diff --git a/.changeset/24798-bulk-confirm-template-slot.md b/.changeset/24798-bulk-confirm-template-slot.md new file mode 100644 index 00000000000..6434b55cb8b --- /dev/null +++ b/.changeset/24798-bulk-confirm-template-slot.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +'@objectstack/cli': minor +--- + +feat(spec,lint): a bulk action's `confirmText` is a `template` slot over `count` and the action's params + +Clause-②: yes (widening) +Metadata-protocol: semantic + +- **Before.** `bulkActionDefs[].confirmText` was a plain `z.string()` that declared no hole. A sentence such as `'Update tier on {{count}} account(s) to {{tier}}?'` parsed, nothing judged its holes, and the console showed them as literal text. +- **Now.** `confirmText` is an ADR-0032 `template` slot (Decisions 2–3). A bare string is shorthand for `{ dialect: 'template', source }`, and the envelope form is accepted too. Holes are `{{ path }}` and `{{ path | formatter[:arg] }}`, with formatters from the template engine's whitelist and no logic inside a hole. They read a closed scope, and `bulkConfirmTemplateRoots(def)` (`@objectstack/spec/ui`) declares its roots: `count` (`BULK_CONFIRM_COUNT_ROOT`, the number of records the run covers) and each of the def's params by `name`. The roots are written bare: `{{ tier }}`, never `{{ params.tier }}`. That matches how the flow text slots spell their roots and how shipped sentences already spell them, and `count` wins over a param of that name. A param hole renders the param's display value: the chosen option's `label` (labels joined with `', '` for a multi-value param), the picked record's display name for a lookup or user param, and the entered value otherwise, so number, date and currency values take a formatter. +- **Parsed shape.** A parsed `confirmText` is now the `{ dialect: 'template', source }` envelope rather than a string, as `titleFormat` and a notify node's `title` already are. A reader of the parsed or served def takes the text from `source` (`templateSlotSource` in `@objectstack/spec/automation` reads both forms). A stored sentence with no hole parses as before, its text unchanged. +- **Judged by the one template judge.** `objectstack validate`, `objectstack compile` and the runtime publish gate (now also on `view` writes, and on an object's own `listViews` at an object write) judge each bulk `confirmText` through `closedScopeTemplateRefusal`. That is the closed-scope entry of the judge the flow text slots use (`automation/flow-text-slot-template.ts`). A hole over an unbound root and a single-brace `{x}` token are named, the latter with its `{{ }}` spelling. A text carrying neither is then compiled as a template, which names a hole holding logic or an unknown formatter. +- **A warning, not a refusal.** Each of those texts parsed while `confirmText` was a plain string, so refusing it is a narrowing that no ADR-0087 conversion carries. This release ships every finding above as a **warning**: an advisory at the publish gate, never a refusal, so no command's exit code changes. A blank `confirmText` (`''`) still parses, and the doors warn on it. +- **`os i18n extract`.** A bulk `confirmText` written as the envelope is seeded exactly as the bare string: the extractor reads the slot's text through `templateSlotSource`, so the envelope form no longer records as an absent key with nothing to translate. +- **Served translations.** The catalog overlay on a served view or an object's `listViews` compares a bulk `confirmText` with its packaged base by the slot's text, whichever form either side holds: an unchanged envelope still takes the catalog's sentence, and an edited one keeps the authored sentence. +- **Unchanged.** The `confirm` / `confirmmessage` near-misses still name `confirmText`. Every other bulk-def key, the default confirm sentence and the flow text slots' verdicts are unchanged. diff --git a/content/docs/references/ui/bulk-action.mdx b/content/docs/references/ui/bulk-action.mdx index f7737caf091..067a0e1abe2 100644 --- a/content/docs/references/ui/bulk-action.mdx +++ b/content/docs/references/ui/bulk-action.mdx @@ -48,7 +48,7 @@ const result = BulkActionDefSchema.parse(data); | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | | **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | -| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate, as a warning. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 6fc28aef0f3..10f481ec641 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -928,7 +928,7 @@ Sort field and direction pair | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | | **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | -| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate, as a warning. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 1cb01d57119..a3f5203fbf8 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -1039,7 +1039,7 @@ View filter rule | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | | **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | -| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate, as a warning. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. | @@ -1416,7 +1416,7 @@ View filter rule | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | | **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | -| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate, as a warning. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. | diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index c23f3ab341d..d72f1cb8391 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -147,7 +147,7 @@ import { pageTabItemKey, walkAddressedPageComponents, } from '@objectstack/spec/system'; -import { FLOW_REGION_SLOTS_BY_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation'; +import { FLOW_REGION_SLOTS_BY_TYPE, flowScreenFieldOptionKey, templateSlotSource } from '@objectstack/spec/automation'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { deriveFieldGroupLayout } from '@objectstack/spec/data'; import { APPROVAL_NODE_TYPE } from '@objectstack/spec/automation'; @@ -518,7 +518,12 @@ function pushBulkActionDefs(out: ExpectedEntry[], viewRoot: string[], view: any, // nobody wrote. const authoredLabel = inlineText(def.label); pushDerived(out, [...base, 'label'], authoredLabel ?? humanizeFieldPath(defName), def.label, 'view', { objectName }); - pushOptional(out, [...base, 'confirmText'], def.confirmText, 'view', { objectName }); + // [#24798] `confirmText` is a `template` slot: a bare string or the + // `{ dialect: 'template', source }` envelope, and either one is the same + // authored sentence. Read it through the template judge's one reader, so + // the envelope form is seeded like the string instead of being recorded + // as an absent key. + pushOptional(out, [...base, 'confirmText'], templateSlotSource(def.confirmText), 'view', { objectName }); pushOptional(out, [...base, 'confirmLabel'], def.confirmLabel, 'view', { objectName }); if (!Array.isArray(def.params)) continue; for (const param of def.params) { diff --git a/packages/cli/test/i18n-bulk-action-coverage.test.ts b/packages/cli/test/i18n-bulk-action-coverage.test.ts index 3db1a21c655..85560f2a51c 100644 --- a/packages/cli/test/i18n-bulk-action-coverage.test.ts +++ b/packages/cli/test/i18n-bulk-action-coverage.test.ts @@ -102,6 +102,20 @@ describe('the extractor scaffolds a key for every string the selection bar draws expect(confirm?.inline).toBe('Set these labels on every selected project?'); }); + it('seeds a `confirmText` written as its template envelope exactly as the bare string (#24798)', () => { + // `confirmText` is a `template` slot, so `{ dialect: 'template', source }` + // is the same authored sentence; without reading the envelope it was + // recorded like an absent key, with nothing to translate. + const source = 'Set {{ count }} project(s)?'; + const seedOf = (confirmText: unknown) => bulkEntries(objectConfig({ + bulkActionDefs: [{ name: 'set_labels', operation: 'update', confirmText }], + })).find((e) => e.path.at(-1) === 'confirmText'); + const fromEnvelope = seedOf({ dialect: 'template', source }); + expect(fromEnvelope?.sourceValue).toBe(source); + expect(fromEnvelope?.inline).toBe(source); + expect(fromEnvelope).toEqual(seedOf(source)); + }); + it('records an unauthored optional key without seeding it', () => { // The other half of the contract above: the key exists so a bundle that // authors it is recognised, but nothing is scaffolded and coverage demands diff --git a/packages/lint/src/authoring-rule-wiring.test.ts b/packages/lint/src/authoring-rule-wiring.test.ts index 30beb2a6dee..ea36936cf69 100644 --- a/packages/lint/src/authoring-rule-wiring.test.ts +++ b/packages/lint/src/authoring-rule-wiring.test.ts @@ -505,7 +505,10 @@ describe('authoring-rule registry wiring (#4409)', () => { // suite runs exactly its two list-view FIELD members on a view snapshot // (`runtime-gate.view-writes.test.ts` pins that member surface). Both // are named here rather than folded into the family. + // `validateStackExpressions` (#24798) runs ONE pass on a view write — + // the bulk `confirmText` template slots — registered ahead of the rest. expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual([ + 'validateStackExpressions', 'validatePresetComparands', 'validateReferenceIntegrity', ...FAMILY, diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 2ec7fff54b1..8725846c323 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -716,8 +716,22 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // entry-level change: it rides the object crossing above. MEASURED first: // 0 in-repo producers reference either family table, against 45 files // that reference an ordinary platform table. + // + // [#24798, ruling B] `view` joins, for ONE pass: a bulk action def's + // `confirmText`, now an ADR-0032 `template` slot over `count` and the + // def's params. On a view write `runStackExpressionPasses` runs that pass + // alone (by construction, as for an object write), and on an object write + // it also judges the object's own `listViews`. Every finding is a + // `warning` — an advisory here, never a refusal — because every such + // sentence parsed before the slot was typed; its refusal, a narrowing no + // conversion carries, is its own change (the amended 「甲」, 6303865025). + // MEASURED first over the shipped corpus at `b42c621c05`: 6 bulk defs + // carry a `confirmText` (examples/app-showcase `project.view.ts`), none + // with a brace → 0 findings, against a warning for each of an unbound + // hole, a single-brace token, a hole holding logic and a blank sentence + // in `validate-expressions.test.ts`. surfaces: CLI_AND_RUNTIME, - runtimeTypes: ['flow', 'action', 'hook', 'object'], + runtimeTypes: ['flow', 'action', 'hook', 'object', 'view'], run: (stack, ctx) => runStackExpressionPasses(stack, { runtimeWriteType: ctx.runtimeWriteType }).map((i) => ({ severity: i.severity ?? 'error', diff --git a/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts b/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts index 01d9443f9bc..52f80ea4fbe 100644 --- a/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts +++ b/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts @@ -184,7 +184,10 @@ describe('#24491 — the page door gives the build\'s visibility-predicate verdi }); it('the `view` control: a view write is judged exactly as before', () => { + // `validateStackExpressions` joined for its bulk `confirmText` pass alone + // (#24798) — no visibility predicate is judged by it on a view write. expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual([ + 'validateStackExpressions', 'validatePresetComparands', 'validateReferenceIntegrity', ...FAMILY, diff --git a/packages/lint/src/runtime-gate.test.ts b/packages/lint/src/runtime-gate.test.ts index 491183859a4..b58bf486dbd 100644 --- a/packages/lint/src/runtime-gate.test.ts +++ b/packages/lint/src/runtime-gate.test.ts @@ -327,6 +327,9 @@ describe('the views[] visibility-predicate family at the runtime publish gate (# expect(runtimeGatedTypes()).toContain('view'); expect(stackKeyForType('view')).toBe('views'); expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual([ + // #24798 — the expression rule runs ONE pass on a view write: the bulk + // `confirmText` template slots, warnings only in 18.x. + 'validateStackExpressions', // #8793 — filter comparands, not predicates; registered ahead of the // family and dispatched for `view` because list-view filter rules are // one of the three shapes it judges. @@ -533,6 +536,7 @@ describe('the views[] visibility-predicate family at the runtime publish gate (# expect(result.advisories, JSON.stringify(result.advisories)).toEqual([]); // "clean" and "nothing ran" must stay distinguishable. expect(result.rulesRun).toEqual([ + 'validateStackExpressions', // #24798 — its bulk `confirmText` pass alone, clean here 'validatePresetComparands', // #8793 — dispatched for `view`, clean here 'validateReferenceIntegrity', // #9313 — its two list-view field members, clean here 'validateVisibilityPredicates', @@ -627,6 +631,7 @@ describe('the publish gate judges a schema-bound form at its own layer (#7815)', expect(result.advisories, JSON.stringify(result.advisories)).toEqual([]); // "clean" and "nothing ran" must stay distinguishable. expect(result.rulesRun).toEqual([ + 'validateStackExpressions', // #24798 — its bulk `confirmText` pass alone, clean here 'validatePresetComparands', // #8793 — dispatched for `view`, clean here 'validateReferenceIntegrity', // #9313 — its two list-view field members, clean here 'validateVisibilityPredicates', diff --git a/packages/lint/src/validate-expressions.bulk-confirm-text.test.ts b/packages/lint/src/validate-expressions.bulk-confirm-text.test.ts new file mode 100644 index 00000000000..0dcbceeeda2 --- /dev/null +++ b/packages/lint/src/validate-expressions.bulk-confirm-text.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #24798 (ruling B) — a bulk action's `confirmText` is an ADR-0032 `template` +// slot over the closed scope `bulkConfirmTemplateRoots(def)` declares (`count` +// and the def's params by name). `validateStackExpressions` — the rule +// `objectstack validate`, `objectstack compile` and the runtime publish gate +// all run — judges it through the template judge's closed-scope entry, then +// compiles it as a template. Every finding is a WARNING: each of these +// sentences parsed before the slot was typed, so a refusal is a narrowing no +// conversion carries, and it is its own change, not this one. +// +// The gate half drives the REAL dispatch (`runRuntimeAuthoringRules`) with the +// three shapes a view write carries — the container's `list`, the flattened +// list overlay and the ViewItem record — because a `runtimeTypes` widening +// whose walk reads none of them is a no-op that reads as coverage. + +import { describe, expect, it } from 'vitest'; + +import { runStackExpressionPasses, validateStackExpressions } from './validate-expressions.js'; +import { runRuntimeAuthoringRules } from './runtime-gate.js'; + +const objects = [{ name: 'crm_account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } }]; + +const updateTier = (confirmText: unknown) => ({ + name: 'update_tier', + label: 'Update Tier', + operation: 'update', + confirmText, + params: [{ name: 'tier', type: 'select', options: [{ label: 'Gold', value: 'gold' }] }], +}); + +const stackWith = (confirmText: unknown) => ({ + objects, + views: [{ name: 'crm_account', object: 'crm_account', list: { type: 'grid', columns: ['name'], bulkActionDefs: [updateTier(confirmText)] } }], +}); + +const confirmIssues = (stack: Record) => + validateStackExpressions(stack).filter((i) => i.where.includes('confirmText')); + +describe('validateStackExpressions — a bulk confirmText is judged as the template slot it is (#24798)', () => { + it('passes the HotCRM sentences, a formatter, plain text — bare or as the parsed envelope', () => { + for (const text of [ + 'Update tier on {{count}} account(s) to {{tier}}?', + '将 {{count}} 个客户的等级更新为 {{tier}}?', + 'Delete {{ count | number }} account(s)?', + 'Set these labels on every selected project?', + ]) { + expect(confirmIssues(stackWith(text)), text).toEqual([]); + expect(confirmIssues(stackWith({ dialect: 'template', source: text })), text).toEqual([]); + } + }); + + it('warns on a hole over an unbound root, naming it and the roots the def binds', () => { + const [issue, ...rest] = confirmIssues(stackWith('Move {{count}} to {{ owner }}?')); + expect(rest).toEqual([]); + expect(issue!.severity).toBe('warning'); + expect(issue!.where).toBe("view 'crm_account' · list · bulkActionDefs[0] 'update_tier' confirmText"); + expect(issue!.message).toContain('binds only `count`, `tier`'); + expect(issue!.message).toContain('`{{ owner }}`'); + expect(issue!.source).toBe('Move {{count}} to {{ owner }}?'); + }); + + it('warns on a single-brace token, prescribing its `{{ }}` spelling', () => { + const [issue] = confirmIssues(stackWith('Update {count} account(s) to {tier}?')); + expect(issue!.severity).toBe('warning'); + expect(issue!.message).toContain('Write `Update {count} account(s) to {tier}?` as `Update {{ count }} account(s) to {{ tier }}?`.'); + }); + + it('warns on a hole holding logic or an unknown formatter — the template compile step', () => { + for (const text of ['{{ count * 2 }} rows', '{{ count | shout }} rows']) { + const issues = confirmIssues(stackWith(text)); + expect(issues.length, text).toBeGreaterThan(0); + expect(issues.every((i) => i.severity === 'warning'), text).toBe(true); + } + }); + + it('warns on a blank sentence, which the parse still accepts in 18.x', () => { + const [issue] = confirmIssues(stackWith(' ')); + expect(issue!.severity).toBe('warning'); + expect(issue!.message).toMatch(/^A bulk action's `confirmText` is blank/); + }); + + it('raises no error at all: every bulk confirmText finding is a warning', () => { + const stack = stackWith('{x} {{ y }} {{ count * 2 }}'); + expect(validateStackExpressions(stack).filter((i) => i.severity === 'error')).toEqual([]); + }); + + it('judges an object\'s own listViews, and a def with no confirmText not at all', () => { + const stack = { + objects: [{ ...objects[0], listViews: { all: { type: 'grid', columns: ['name'], bulkActionDefs: [updateTier('{{ x }}'), { name: 'del', operation: 'delete' }] } } }], + }; + const issues = confirmIssues(stack); + expect(issues.map((i) => i.where)).toEqual(["object 'crm_account' · listViews.all · bulkActionDefs[0] 'update_tier' confirmText"]); + // The object write runs the pass over the object's own body too. + expect(runStackExpressionPasses(stack, { runtimeWriteType: 'object' }).filter((i) => i.where.includes('confirmText'))).toHaveLength(1); + }); + + it('on a view write runs that pass alone', () => { + const stack = { ...stackWith('{{ x }}'), flows: [{ name: 'f', nodes: [{ id: 'n', type: 'notify', config: { title: '{bad}' } }] }] }; + const issues = runStackExpressionPasses(stack, { runtimeWriteType: 'view' }); + expect(issues).toHaveLength(1); + expect(issues[0]!.where).toContain('confirmText'); + }); +}); + +describe('the runtime publish gate on a view write — an advisory, never a refusal, in 18.x (#24798)', () => { + const gate = (item: unknown) => runRuntimeAuthoringRules({ type: 'view', item, context: { objects } }); + const advisoriesOn = (item: unknown) => { + const { errors, advisories } = gate(item); + return { errors: errors.filter((f) => f.where.includes('confirmText')), advisories: advisories.filter((f) => f.where.includes('confirmText')) }; + }; + const shapes: Array<[string, (confirmText: unknown) => unknown]> = [ + ['a container\'s list', (c) => stackWith(c).views[0]], + ['the flattened list overlay', (c) => ({ name: 'crm_account.tier', object: 'crm_account', viewKind: 'list', type: 'grid', columns: ['name'], bulkActionDefs: [updateTier(c)] })], + ['the ViewItem record', (c) => ({ name: 'crm_account.tier', object: 'crm_account', viewKind: 'list', config: { type: 'grid', columns: ['name'], bulkActionDefs: [updateTier(c)] } })], + ]; + + for (const [label, item] of shapes) { + it(`names an unbound hole on ${label} as an advisory`, () => { + const { errors, advisories } = advisoriesOn(item('Move {{count}} to {{ owner }}?')); + expect(errors).toEqual([]); + expect(advisories, label).toHaveLength(1); + expect(advisories[0]!.message).toContain('`{{ owner }}`'); + }); + + it(`control: the HotCRM sentence on ${label} draws nothing`, () => { + expect(advisoriesOn(item('Update tier on {{count}} account(s) to {{tier}}?'))).toEqual({ errors: [], advisories: [] }); + }); + } +}); diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index cf3f006a361..d1e247f4c77 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -10,6 +10,7 @@ import { EVALUATED_EXPRESSION_SOURCE_REQUIRED, ExpressionInputSchema, ObjectStac import { FieldSchema, ObjectSchema, SelectOptionSchema } from '@objectstack/spec/data'; import { EvalUserSchema } from '@objectstack/spec/identity'; import { SharingRuleSchema } from '@objectstack/spec/security'; +import { BulkActionDefSchema } from '@objectstack/spec/ui'; // [#15137] The published refusal sentence a `value`-slot finding must lead // with — asserted from the spec's own export, never re-spelled in a test. import { @@ -3291,12 +3292,20 @@ const flowShape = () => { * the author a named replacement instead of a bare "unrecognized key" (#4343). * Reading a key to REJECT it is the opposite of reading a key to honour it. */ -const NOT_A_SINGLE_SHAPE = ['cfg', 'startCfg']; +// [#24798] `viewEntry` — one `views[]` entry as the bulk `confirmText` pass +// walks it — is a union too: a `defineView` container (`list` / `listViews`), +// a flattened list overlay (`viewKind: 'list'`, the list view's own keys at +// the top level) or a ViewItem record (`config`). Its reads are recognisers +// for those three shapes, mirrored from `validate-list-view-field-refs.ts`, and +// the bulk defs they reach are read off the tabled `bulkDef` receiver. +const NOT_A_SINGLE_SHAPE = ['cfg', 'startCfg', 'viewEntry']; const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: string; keys: () => string[] }> = [ { receiver: 'stack', - expected: ['actions', 'flows', 'hooks', 'objects', 'sharingRules'], + // `views` joined with #24798: the bulk `confirmText` pass walks the views' + // list containers. + expected: ['actions', 'flows', 'hooks', 'objects', 'sharingRules', 'views'], declaredBy: 'ObjectStackSchema', keys: () => Object.keys(ObjectStackSchema.shape), }, @@ -3305,7 +3314,9 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s // `validationRules` is absent — one of the five #5017 removed. // `attachedOnRead` joined with #22386 (#22211 ruling A): the declared read // attachments, whose block names join the field index. - expected: ['actions', 'attachedOnRead', 'fields', 'name', 'validations'], + // `listViews` joined with #24798: an object's own list views carry bulk + // defs whose `confirmText` is judged. + expected: ['actions', 'attachedOnRead', 'fields', 'listViews', 'name', 'validations'], declaredBy: 'ObjectSchema', keys: () => Object.keys(ObjectSchema.shape), }, @@ -3316,6 +3327,15 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s declaredBy: 'FieldSchema', keys: () => Object.keys(FieldSchema.shape), }, + { + // [#24798] One `bulkActionDefs[]` entry: its `confirmText` template slot, + // and its `name` for the location (its params are read by the spec's + // `bulkConfirmTemplateRoots`, not by name here). + receiver: 'bulkDef', + expected: ['confirmText', 'name'], + declaredBy: 'BulkActionDefSchema', + keys: () => shapeKeysOf(BulkActionDefSchema), + }, { receiver: 'rule', // `expression` / `predicate` / `formula` are absent — the chain that put diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index ca189c104a1..b4e7ac6310b 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -103,7 +103,12 @@ import { flowNodeTextSlotSources, textSlotTemplateRefusal, structuralConditionRefusal, + closedScopeTemplateRefusal, + templateSlotSource, } from '@objectstack/spec/automation'; +// [#24798] A bulk action's `confirmText` is a `template` slot over the closed +// scope the spec declares for it — judged below by the same template judge. +import { bulkConfirmTemplateRoots } from '@objectstack/spec/ui'; // [#15137] The `value`-role half. Same two published primitives the engine // composes at `registerFlow` (`AutomationEngine.valueEnvelopeRefusals`), in the // same order: the SHAPE rule lives in the spec's `FlowValueSlotSchema` (the @@ -1994,6 +1999,12 @@ export interface StackExpressionOptions { * while an object write carries no top-level actions, so the door locates * the same predicate at `object '…' · action …`. * + * [#24798] A sixth judges an object write too: the bulk `confirmText` + * pass over the object's own `listViews` (`bulkConfirmTextIssues`), warnings + * only. On a `view` write that pass is the ONLY one, over the + * written view's `list` and `listViews` — every other pass reads a + * collection a view write does not carry. + * * The passes over the stack's OTHER collections — flows, the top-level * `actions`, sharing rules and hooks — read an empty list on an object * write. None of them is the object's own body, the object write's snapshot @@ -2030,6 +2041,11 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // an empty list under it, so the claim holds by construction rather than by // the shape of the snapshot the gate happens to build today. const objectWrite = options.runtimeWriteType === 'object'; + // [#24798] On a `view` write exactly ONE pass judges — the bulk `confirmText` + // pass over the written view's own list containers ({@link bulkConfirmTextIssues}); + // every other pass reads a collection a view write does not carry, so it + // returns before them by construction rather than by snapshot shape. + if (options.runtimeWriteType === 'view') return bulkConfirmTextIssues(stack, { views: true, objects: false }); const objects = recordsOf(stack.objects); const fieldIndex = buildFieldIndex(objects); const attachedOnReadIndex = buildAttachedOnReadIndex(objects); @@ -3447,5 +3463,123 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression issues.push(...kept); } + // [#24798] The bulk `confirmText` template slots: an object's own + // `listViews` on every run (an object write included — they are the + // object's own body), a view's list containers off an object write. + issues.push(...bulkConfirmTextIssues(stack, { views: !objectWrite, objects: true })); + + return issues; +} + +/** + * Why a finding on a bulk `confirmText` is a warning, said once at the end of + * each: `confirmText` was a plain string before it became a `template` slot, + * so every one of these sentences parsed — refusing it is a narrowing no + * conversion carries. This change ships the advisory; the refusal is its own + * change, not this one, and the sentence promises no release for it. + */ +const BULK_CONFIRM_TEXT_WARNING_ONLY = + 'A warning, not a refusal: this text parsed before `confirmText` became a template slot.'; + +/** The slot as {@link closedScopeTemplateRefusal}'s sentences name it. */ +const BULK_CONFIRM_TEXT_SLOT = "A bulk action's `confirmText`"; + +/** A blank sentence — `trim()`'s whitespace set is exactly `\s`. */ +const BLANK_TEXT = /^\s*$/; + +/** The label segment for a named list view (`listViews.`). */ +const LIST_VIEWS_LABEL = 'listViews'; + +/** + * [#24798, ruling B] Judge every bulk action def's `confirmText` — the + * ADR-0032 `template` slot `ui/bulk-action.zod.ts` declares over a CLOSED + * scope, `bulkConfirmTemplateRoots(def)` (`count` and the def's params by + * name). The same two steps the flow text slots take above, in the same + * order, through the same judge: a single-brace token or a hole over an + * unbound root is named by `closedScopeTemplateRefusal` (the template judge's + * closed-scope entry), and a text carrying neither is compiled as the + * `template` it is (`validateExpression('template', …)`), which names a hole + * holding logic or an unknown formatter. A blank sentence is named too. + * + * ⚠️ Every finding is a `warning` here, never an `error`: each of these + * texts parsed before the slot was typed, so a refusal is a narrowing no + * ADR-0087 conversion carries. Under the amended 「甲」 (record 6303865025) + * such a refusal ships in 18.x as a BREAKING minor, as its own change (the + * `validatePagePredicateBounds` precedent: advisory first, refusal after); + * this pass is the advisory. The containers walked: a view's `list` and each + * `listViews.`, a standalone list view (the flattened overlay or the + * ViewItem record), and an object's `listViews.`. + */ +function bulkConfirmTextIssues( + stack: AnyRec, + { views: judgeViews, objects: judgeObjects }: { views: boolean; objects: boolean }, +): ExprIssue[] { + const issues: ExprIssue[] = []; + // Every metadata read below is a literal read off a receiver the #5017 + // meta-test tables (`stack`, `obj`, `bulkDef`, and `viewEntry`, a union of + // shapes): a `.length` or a label string spelling `listViews.` would read + // as one more receiver to it, so the loops index by hand and the label is + // joined from LIST_VIEWS_LABEL. + const judgeContainer = (container: unknown, at: string): void => { + if (!container || typeof container !== 'object') return; + const listed = (container as AnyRec).bulkActionDefs; + if (!Array.isArray(listed)) return; + let di = -1; + for (const bulkDef of listed as AnyRec[]) { + di += 1; + if (!bulkDef || typeof bulkDef !== 'object' || bulkDef.confirmText === undefined) continue; + const confirmSource = templateSlotSource(bulkDef.confirmText); + if (confirmSource === undefined) continue; // neither a string nor a template envelope: the parse refuses its shape + const label = typeof bulkDef.name === 'string' ? ` '${bulkDef.name}'` : ''; + const where = `${at} · bulkActionDefs[${di}]${label} confirmText`; + const warn = (message: string, quoted: string): void => { + issues.push({ where, message: `${message} ${BULK_CONFIRM_TEXT_WARNING_ONLY}`, source: quoted, severity: 'warning' }); + }; + if (BLANK_TEXT.test(confirmSource)) { + warn( + `${BULK_CONFIRM_TEXT_SLOT} is blank, so the confirm step shows an empty sentence where the default one names ` + + 'the record count: write the sentence, or remove the key to show the default.', + confirmSource, + ); + continue; + } + const refusal = closedScopeTemplateRefusal(confirmSource, { slot: BULK_CONFIRM_TEXT_SLOT, roots: bulkConfirmTemplateRoots(bulkDef) }); + if (refusal !== undefined) { + warn(refusal, confirmSource); + continue; + } + for (const e of validateExpression('template', confirmSource).errors) warn(e.message, e.source); + } + }; + const judgeListViews = (named: unknown, owner: string): void => { + if (!named || typeof named !== 'object' || Array.isArray(named)) return; + for (const [key, lv] of Object.entries(named as AnyRec)) judgeContainer(lv, `${owner} · ${LIST_VIEWS_LABEL}.${key}`); + }; + if (judgeViews) { + let vi = -1; + for (const viewEntry of recordsOf(stack.views)) { + vi += 1; + const viewName = typeof viewEntry.name === 'string' ? viewEntry.name : typeof viewEntry.object === 'string' ? viewEntry.object : `#${vi}`; + // The two standalone shapes `PUT /api/v1/meta/view` carries and the + // publish gate snapshots as `views: [item]` — recognised as the list-view + // field rules recognise them (`validate-list-view-field-refs.ts`): the + // flattened list overlay (the `ListView` at the top level) and the + // ViewItem record (one level down, in `config`). Without them a view + // write would run this pass over nothing. + if (viewEntry.viewKind === 'list') { + const record = viewEntry.config !== null && typeof viewEntry.config === 'object' && !Array.isArray(viewEntry.config); + judgeContainer(record ? viewEntry.config : viewEntry, `view '${viewName}' (${record ? 'ViewItem record' : 'flattened list overlay'})`); + } + judgeContainer(viewEntry.list, `view '${viewName}' · list`); + judgeListViews(viewEntry.listViews, `view '${viewName}'`); + } + } + if (judgeObjects) { + let oi = -1; + for (const obj of recordsOf(stack.objects)) { + oi += 1; + judgeListViews(obj.listViews, `object '${typeof obj.name === 'string' ? obj.name : `#${oi}`}'`); + } + } return issues; } diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 87301d384e0..0e88e1698e2 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -628,6 +628,16 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ covers: ['data/object.zod.ts:ObjectSchemaBase.titleFormat'], note: 'EXPERIMENTAL, and the state is a deliberate split between two questions. `packages/spec/liveness/object.json` classifies the KEY `live` with the note "objectui ({{record.field}} interpolation)" — that ledger asks whether anything READS the key, and the answer is yes. THIS ledger asks what EVALUATES the expression and under which fail policy, and the only interpolation site named is in the sibling repo objectui, which is not in this checkout: ⛔ NOT measured here, so it is not written into `enforcement` as if it had been. Marking the row `enforced` on a second-hand reading is exactly the invented cell this ledger exists to prevent; marking it `removed` would contradict a governed ledger that measured more than I could. Re-state as `enforced` when someone measures the objectui site — or as `removed` when ADR-0079 retires the key.', }, + { + // #24798 (ruling B, the spec half): `confirmText` became a `template` slot over a closed scope. + id: 'template-bulk-confirm-text', + summary: 'list-view bulk action confirm sentence (BulkActionDef.confirmText) — `{{ count }}` and `{{ }}` template holes over a closed scope', + dialect: 'template', mode: 'interpret', state: 'experimental', failPolicy: 'unevaluated', + enforcement: + 'PARSE ONLY in this repo — `ui/bulk-action.zod.ts` types the slot with `templateExpressionInput` (the input `TemplateExpressionInputSchema` takes, with refusals prescribing `{{ count }}`), normalizing a bare string to `{dialect:"template",source}` and, for 18.x, still accepting a blank string as written. NO EVALUATOR FOUND here: the sentence is drawn by the console\'s bulk dialog in the sibling objectui repo, not in this checkout, and filling its holes is the renderer half of the ruling. At build and at the publish gate `validateStackExpressions` judges it — `closedScopeTemplateRefusal` over the roots `bulkConfirmTemplateRoots(def)` declares (`count` and the def\'s params by name), then `validateExpression(\'template\', …)` — and every finding is a WARNING (an advisory at the gate), because each such text parsed while the slot was a plain string', + covers: ['ui/bulk-action.zod.ts:BulkActionDefSchema.confirmText'], + note: 'EXPERIMENTAL until the console fills the holes. The spec half declares the slot, its closed scope and the display-value rule a param hole renders; the console half — the dialog rendering the slot through the formula template engine, `count` first and the param roots second — lands in objectui, and this row is re-stated as `enforced` with the fail policy that renderer has once it is measured there. Until then a hole reaches the screen as literal text, as it did before the slot was typed.', + }, { id: 'template-notify-content', summary: 'flow `notify` node inline content (NotifyConfig.title, .message) — `{{ }}` template holes rendered per run by the formula template engine', diff --git a/packages/rest/src/meta-view-container-i18n.test.ts b/packages/rest/src/meta-view-container-i18n.test.ts index 575f140240a..4a42ed12c11 100644 --- a/packages/rest/src/meta-view-container-i18n.test.ts +++ b/packages/rest/src/meta-view-container-i18n.test.ts @@ -186,6 +186,22 @@ describe('#22614 — a view container is translated as a container', () => { ]); }); + it('translates an envelope-form bulk confirmText that matches its packaged base (#24798)', async () => { + const container = shipped({ + listViews: { + bulk: grid('Bulk', { + bulkActionDefs: [{ name: 'close_all', label: 'Close all', confirmText: { dialect: 'template', source: 'Sure?' } }], + }), + }, + }); + const [packagedBulk] = expandViewContainer(OBJ, container); + const protocol = protocolOf({ [OBJ]: container, [`${OBJ}.bulk`]: packagedBulk }); + const out = await read(structuredClone(container), 'zh-CN', protocol); + expect(out.listViews.bulk.bulkActionDefs).toEqual([ + { name: 'close_all', label: '全部关闭', confirmText: '确定关闭?' }, + ]); + }); + it("looks each view's packaged base up by its qualified name, so an explicit override beats the catalog", async () => { const packaged = shipped({ list: grid('All Notes'), listViews: { mine: grid('My Notes') } }); const [packagedDefault, packagedMine] = expandViewContainer(OBJ, packaged).sort((a, b) => a.name.localeCompare(b.name)); diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 1103c92fa72..d43954f8997 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -70,6 +70,7 @@ "Checkpoint (type)", "CheckpointParsed (type)", "CheckpointSchema (const)", + "ClosedTemplateScope (interface)", "ConcurrencyPolicy (type)", "ConcurrencyPolicyParsed (type)", "ConcurrencyPolicySchema (const)", @@ -283,6 +284,7 @@ "builtinNodeConfigKeysJudged (function)", "canonicalApproverType (function)", "classifyFlowFilterOperandString (function)", + "closedScopeTemplateRefusal (function)", "collectFlowGraphs (function)", "defineActionDescriptor (function)", "defineFlow (function)", @@ -312,6 +314,7 @@ "resolveFlowTriggerKind (function)", "resolveScheduleOrganization (function)", "structuralConditionRefusal (function)", + "templateSlotSource (function)", "textSlotTemplateRefusal (function)", "validateControlFlow (function)", "valueSlotTemplateRefusals (function)" diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index c8211de9452..c302c6d1d30 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -62,6 +62,7 @@ "AssembledViewArtifactParsed (type)", "AssembledViewArtifactSchema (const)", "AssembledViewPartition (interface)", + "BULK_CONFIRM_COUNT_ROOT (const)", "BulkActionDef (type)", "BulkActionDefParsed (type)", "BulkActionDefSchema (const)", @@ -494,6 +495,7 @@ "anonymousFormIntakeSlug (function)", "anonymousFormIntakeSlugs (function)", "appForm (const)", + "bulkConfirmTemplateRoots (function)", "chartAggregateCategoryKey (function)", "chartAggregateResultKeys (function)", "chartAggregateValueKey (function)", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 4abe3af59b2..b6938167814 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -68,6 +68,7 @@ "Checkpoint": "src/automation/execution.zod.ts#Checkpoint (type)", "CheckpointParsed": "src/automation/execution.zod.ts#CheckpointParsed (type)", "CheckpointSchema": "src/automation/execution.zod.ts#CheckpointSchema (const)", + "ClosedTemplateScope": "src/automation/flow-text-slot-template.ts#ClosedTemplateScope (interface)", "ConcurrencyPolicy": "src/automation/execution.zod.ts#ConcurrencyPolicy (type)", "ConcurrencyPolicyParsed": "src/automation/execution.zod.ts#ConcurrencyPolicyParsed (type)", "ConcurrencyPolicySchema": "src/automation/execution.zod.ts#ConcurrencyPolicySchema (const)", @@ -277,6 +278,7 @@ "builtinNodeConfigKeysJudged": "src/automation/flow-node-config-refusals.ts#builtinNodeConfigKeysJudged (function)", "canonicalApproverType": "src/automation/approval.zod.ts#canonicalApproverType (function)", "classifyFlowFilterOperandString": "src/automation/flow-value-slot-template.ts#classifyFlowFilterOperandString (function)", + "closedScopeTemplateRefusal": "src/automation/flow-text-slot-template.ts#closedScopeTemplateRefusal (function)", "collectFlowGraphs": "src/automation/control-flow.zod.ts#collectFlowGraphs (function)", "defineActionDescriptor": "src/automation/node-executor.zod.ts#defineActionDescriptor (function)", "defineFlow": "src/automation/flow.zod.ts#defineFlow (function)", @@ -306,6 +308,7 @@ "resolveFlowTriggerKind": "src/automation/flow-trigger-kind.ts#resolveFlowTriggerKind (function)", "resolveScheduleOrganization": "src/automation/schedule-organization.zod.ts#resolveScheduleOrganization (function)", "structuralConditionRefusal": "src/automation/flow-node-expression-paths.ts#structuralConditionRefusal (function)", + "templateSlotSource": "src/automation/flow-text-slot-template.ts#templateSlotSource (function)", "textSlotTemplateRefusal": "src/automation/flow-text-slot-template.ts#textSlotTemplateRefusal (function)", "validateControlFlow": "src/automation/control-flow.zod.ts#validateControlFlow (function)", "valueSlotTemplateRefusals": "src/automation/flow-value-slot-template.ts#valueSlotTemplateRefusals (function)" diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index d77feb6e84c..ad631f70cf8 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -59,6 +59,7 @@ "AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)", "AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)", "AssembledViewPartition": "src/ui/assembled-views.zod.ts#AssembledViewPartition (interface)", + "BULK_CONFIRM_COUNT_ROOT": "src/ui/bulk-action.zod.ts#BULK_CONFIRM_COUNT_ROOT (const)", "BulkActionDef": "src/ui/bulk-action.zod.ts#BulkActionDef (type)", "BulkActionDefParsed": "src/ui/bulk-action.zod.ts#BulkActionDefParsed (type)", "BulkActionDefSchema": "src/ui/bulk-action.zod.ts#BulkActionDefSchema (const)", @@ -479,6 +480,7 @@ "anonymousFormIntakeSlug": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeSlug (function)", "anonymousFormIntakeSlugs": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeSlugs (function)", "appForm": "src/ui/app.form.ts#appForm (const)", + "bulkConfirmTemplateRoots": "src/ui/bulk-action.zod.ts#bulkConfirmTemplateRoots (function)", "chartAggregateCategoryKey": "src/ui/chart-aggregate.ts#chartAggregateCategoryKey (function)", "chartAggregateResultKeys": "src/ui/chart-aggregate.ts#chartAggregateResultKeys (function)", "chartAggregateValueKey": "src/ui/chart-aggregate.ts#chartAggregateValueKey (function)", diff --git a/packages/spec/src/automation/flow-text-slot-template.test.ts b/packages/spec/src/automation/flow-text-slot-template.test.ts index 671c1c78ed9..e8ad28a0969 100644 --- a/packages/spec/src/automation/flow-text-slot-template.test.ts +++ b/packages/spec/src/automation/flow-text-slot-template.test.ts @@ -15,7 +15,9 @@ import { describe, expect, it } from 'vitest'; import { FLOW_NODE_TEXT_SLOTS, TEXT_SLOT_TEMPLATE_REFUSAL, + closedScopeTemplateRefusal, flowNodeTextSlotSources, + templateSlotSource, textSlotTemplateRefusal, } from './flow-text-slot-template'; import { valueSlotTemplateRefusals } from './flow-value-slot-template'; @@ -244,6 +246,64 @@ describe('textSlotTemplateRefusal — a `{{ }}` hole over a `$` root the engine }); }); +describe('closedScopeTemplateRefusal — the same judge over a CLOSED scope (#24798)', () => { + const scope = { slot: "A bulk action's `confirmText`", roots: ['count', 'tier'] }; + + it('passes plain text, holes over bound roots, a formatter, an index and a sub-path', () => { + for (const text of ['Delete every selected record?', 'Update {{count}} to {{tier}}?', '{{ count | number }} rows', '{{ tier[0] }}', '{{ tier.label | upper }}']) { + expect(closedScopeTemplateRefusal(text, scope), text).toBeUndefined(); + } + }); + + it('names every hole over an unbound root once, a `$` root included, and the roots it binds', () => { + const message = closedScopeTemplateRefusal('{{ x }} {{ x }} {{ $User.Id }} {{ record.name }}', scope)!; + expect(message).toMatch(/^A bulk action's `confirmText` binds only `count`, `tier`/); + expect(message).toContain('`{{ x }}`, `{{ $User.Id }}`, `{{ record.name }}`'); + expect(message.match(/\{\{ x \}\}/g)).toHaveLength(1); + }); + + it('reads the single brace exactly as the flow judge does: a bound path is rewritten as its hole', () => { + const message = closedScopeTemplateRefusal('Update {count} to {tier}', scope)!; + expect(message.startsWith("A bulk action's `confirmText` reads `{{ }}` template holes (ADR-0032 §3)")).toBe(true); + expect(message).toContain('Write `Update {count} to {tier}` as `Update {{ count }} to {{ tier }}`.'); + // The same tokens the flow judge finds — a token touching a brace is the compile step's. + expect(closedScopeTemplateRefusal('Total {{ count }', scope)).toBeUndefined(); + expect(textSlotTemplateRefusal('Total {{ count }')).toBeUndefined(); + }); + + it('prescribes no rewrite for a token no bound hole spells — an unbound path, logic, a macro', () => { + const message = closedScopeTemplateRefusal('{foo} {count * 2} {NOW()}', scope)!; + expect(message).not.toContain('Write `'); + expect(message).toContain('`{foo}` names `foo`, which this slot does not bind'); + expect(message).toContain('`{count * 2}` is logic'); + expect(message).toContain('`{NOW()}` is not one of the roots this slot binds'); + }); + + it('judges both in one text, single brace first', () => { + const message = closedScopeTemplateRefusal('{count} and {{ y }}', scope)!; + expect(message.indexOf('reads `{{ }}`')).toBeLessThan(message.indexOf('binds only')); + }); + + it('leaves a hole that does not compile as a path to the compile step', () => { + expect(closedScopeTemplateRefusal('{{ count * 2 }}', scope)).toBeUndefined(); + }); + + it('control: the flow judge is unchanged — an open scope names no ordinary root', () => { + expect(textSlotTemplateRefusal('{{ x }} {{ record.name }}')).toBeUndefined(); + expect(textSlotTemplateRefusal('{{ $User.Id }}')).toBeDefined(); + }); +}); + +describe('templateSlotSource — one reading of a template slot value', () => { + it('reads a string, the source of a template envelope, and nothing else', () => { + expect(templateSlotSource('a {{ b }}')).toBe('a {{ b }}'); + expect(templateSlotSource({ dialect: 'template', source: 'a' })).toBe('a'); + expect(templateSlotSource({ dialect: 'cel', source: 'a' })).toBeUndefined(); + expect(templateSlotSource(['a'])).toBeUndefined(); + expect(templateSlotSource(null)).toBeUndefined(); + }); +}); + describe('flowNodeTextSlotSources — the text slots one node config carries', () => { it('reads the notify pair as a bare string or a template envelope, its link as a string, and nothing else on the node', () => { expect(flowNodeTextSlotSources('notify', { diff --git a/packages/spec/src/automation/flow-text-slot-template.ts b/packages/spec/src/automation/flow-text-slot-template.ts index bbe4135ef7e..722a0e505b2 100644 --- a/packages/spec/src/automation/flow-text-slot-template.ts +++ b/packages/spec/src/automation/flow-text-slot-template.ts @@ -106,6 +106,15 @@ * `{{ v }}`. ⛔ The template engine does not learn `$User` * (or any new `$` root) instead — that would widen the flow's variable set * with no declaration behind it. + * + * ## A closed scope reads the same grammar (#24798) + * + * A template text slot outside a flow whose renderer binds a FIXED set of + * roots — today a bulk action's `confirmText`, over `count` and the def's + * params — is judged here too, by {@link closedScopeTemplateRefusal}: the same + * single-brace reading, the same hole grammar, the same root rule, with the + * scope's roots in place of the flow's open one. ⛔ Never a second reading of + * the dialect for such a slot; give it a {@link ClosedTemplateScope}. */ import { @@ -225,10 +234,12 @@ export const FLOW_NODE_TEXT_SLOTS: readonly FlowNodeTextSlot[] = [ /** * The text a text-slot value carries — the string itself, or the `source` of a * `{ dialect: 'template', source }` envelope (what the `tmpl` helper builds - * for the notify pair). Anything else carries no text this judge reads: the - * slot's own contract refuses its shape. + * for the notify pair, and what a parsed template slot holds). Anything else + * carries no text this judge reads: the slot's own contract refuses its shape. + * Exported (#24798) so a door judging a template slot outside a flow reads the + * slot the way this module does — one reading of the envelope, not two. */ -function textSlotSource(value: unknown): string | undefined { +export function templateSlotSource(value: unknown): string | undefined { if (typeof value === 'string') return value; if (value !== null && typeof value === 'object' && !Array.isArray(value)) { const envelope = value as { dialect?: unknown; source?: unknown }; @@ -266,17 +277,48 @@ const HOLE = /\{\{([^}]*)\}\}/g; const HOLE_PATH = /^[\w$.[\]]+$/; /** - * The `$` root of a variable path when the engine does not bind it, else - * `undefined`. The root is the path's first segment as the engine resolves it + * The root of a variable path — its first segment as the engine resolves it * (`[i]` read as `.i`), so `$User.Id`, `$User[0]` and `$User` all root at - * `$User`. + * `$User`, and `tier.label` at `tier`. + */ +function pathRoot(path: string): string | undefined { + return path.replace(/\[(\w+)\]/g, '.$1').split('.').find((segment) => segment !== ''); +} + +/** + * The `$` root of a variable path when the engine does not bind it, else + * `undefined`. */ function unboundEngineRoot(path: string): string | undefined { - const root = path.replace(/\[(\w+)\]/g, '.$1').split('.').find((segment) => segment !== ''); + const root = pathRoot(path); if (root === undefined || !root.startsWith('$') || ENGINE_VARIABLE_SET.has(root)) return undefined; return root; } +/** One `{{ }}` hole of a text whose path compiles, as written and as its trimmed path. */ +interface PathHole { + /** The hole as written (`{{ count | number }}`). */ + readonly text: string; + /** Its variable path, trimmed (`count`). */ + readonly path: string; +} + +/** + * Every hole of `text` whose path compiles ({@link HOLE_PATH}), in order — a + * formatter after `|` is not part of the path. A hole that does not compile is + * the compile step's to name, so it is not returned. + */ +function pathHoles(text: string): PathHole[] { + const out: PathHole[] = []; + for (const match of text.matchAll(HOLE)) { + const inner = match[1]!; + const pipe = inner.indexOf('|'); + const path = (pipe === -1 ? inner : inner.slice(0, pipe)).trim(); + if (HOLE_PATH.test(path)) out.push({ text: match[0], path }); + } + return out; +} + /** One `{{ }}` hole of a text slot whose root is a `$` name the engine does not bind. */ interface UnboundRootHole { /** The hole as written (`{{ $User.Id }}`). */ @@ -290,13 +332,9 @@ interface UnboundRootHole { /** Every hole of `text` that compiles as a path and roots at a `$` name the engine does not bind, in order. */ function unboundRootHoles(text: string): UnboundRootHole[] { const out: UnboundRootHole[] = []; - for (const match of text.matchAll(HOLE)) { - const inner = match[1]!; - const pipe = inner.indexOf('|'); - const path = (pipe === -1 ? inner : inner.slice(0, pipe)).trim(); - if (!HOLE_PATH.test(path)) continue; - const root = unboundEngineRoot(path); - if (root !== undefined) out.push({ text: match[0], path, root }); + for (const hole of pathHoles(text)) { + const root = unboundEngineRoot(hole.path); + if (root !== undefined) out.push({ ...hole, root }); } return out; } @@ -321,18 +359,24 @@ function unboundRootOf(token: TemplateToken): string | undefined { return token.kind === 'path' ? unboundEngineRoot(token.inner) : undefined; } +/** A flow text slot's spellable token: a path not rooted at a `$` name the engine does not bind. */ +function flowSpellable(token: TemplateToken): boolean { + return token.kind === 'path' && unboundRootOf(token) === undefined; +} + /** - * `text` with each single-brace PATH token written as a hole — the spelling a - * refusal prescribes for it. A path over a `$` root the engine does not bind - * stays as written: its hole would be refused too, so its remedy is - * {@link unboundRootRemedy}, never a rewrite. + * `text` with each single-brace token `spellable` admits written as a hole — + * the spelling a refusal prescribes for it. Every other token stays as + * written: its hole would be refused too, so its remedy is its own sentence, + * never a rewrite. In a flow text slot the spellable tokens are the PATH + * tokens not rooted at a `$` name the engine does not bind + * ({@link flowSpellable}); in a closed scope, the path tokens over a bound root. */ -function doubled(text: string, tokens: readonly TemplateToken[]): string { +function doubled(text: string, tokens: readonly TemplateToken[], spellable: (token: TemplateToken) => boolean): string { let out = ''; let at = 0; for (const token of tokens) { - const spellable = token.kind === 'path' && unboundRootOf(token) === undefined; - out += text.slice(at, token.index) + (spellable ? `{{ ${token.inner} }}` : token.text); + out += text.slice(at, token.index) + (spellable(token) ? `{{ ${token.inner} }}` : token.text); at = token.index + token.text.length; } return out + text.slice(at); @@ -377,8 +421,8 @@ function singleBraceRefusal(text: string): string | undefined { const tokens = singleBraceTokens(text); if (tokens.length === 0) return undefined; const parts: string[] = []; - if (tokens.some((token) => token.kind === 'path' && unboundRootOf(token) === undefined)) { - parts.push(`Write \`${text}\` as \`${doubled(text, tokens)}\`.`); + if (tokens.some(flowSpellable)) { + parts.push(`Write \`${text}\` as \`${doubled(text, tokens, flowSpellable)}\`.`); } const seen = new Set(); for (const token of tokens) { @@ -466,13 +510,113 @@ export function flowNodeTextSlotSources(nodeType: string, config: unknown): Flow if (slot.eachValue) { if (value === null || typeof value !== 'object' || Array.isArray(value)) continue; for (const [name, entry] of Object.entries(value as Record)) { - const source = textSlotSource(entry); + const source = templateSlotSource(entry); if (source !== undefined) out.push({ path: `${slot.key}.${name}`, label: slot.label, source }); } continue; } - const source = textSlotSource(value); + const source = templateSlotSource(value); if (source !== undefined) out.push({ path: slot.key, label: slot.label, source }); } return out; } + +// ─── A CLOSED scope (#24798) ──────────────────────────────────────────────── + +/** + * A template text slot whose holes read a CLOSED scope — a fixed set of roots + * the slot's renderer binds, and nothing else. A flow text slot's scope is + * open (any flow variable, any node output, so only a `$` root is judged); + * a closed slot binds a handful of names its contract declares, so any other + * root renders a blank fragment and is judged by name. + * + * Today one slot reads a closed scope: a bulk action's `confirmText` + * (`ui/bulk-action.zod.ts`, ADR-0032 Decisions 2–3), whose roots are + * `bulkConfirmTemplateRoots(def)` — `count` and the def's params by name. + */ +export interface ClosedTemplateScope { + /** The slot as a sentence names it, capitalised: ``A bulk action's `confirmText` ``. */ + readonly slot: string; + /** Every root a hole may name, in the order a refusal lists them. */ + readonly roots: readonly string[]; +} + +/** `roots`, each in backticks, joined for a sentence. */ +function rootList(roots: readonly string[]): string { + return roots.map((root) => `\`${root}\``).join(', '); +} + +/** The remedy for one single-brace token a closed scope cannot spell as a hole. */ +function closedUnspellableRemedy(token: TemplateToken, scope: ClosedTemplateScope): string { + if (token.kind === 'path') { + return ( + `\`${token.text}\` names \`${pathRoot(token.inner) ?? token.inner}\`, which this slot does not bind, so no hole ` + + `spells it: name one of ${rootList(scope.roots)}, or reword the sentence.` + ); + } + if (token.kind === 'expression') { + return ( + `\`${token.text}\` is logic, and a hole is a bound path with an optional whitelisted formatter ` + + '(`{{ count | number }}`), never logic (ADR-0032 §3): reword the sentence around a bound hole.' + ); + } + return ( + `\`${token.text}\` is not one of the roots this slot binds (${rootList(scope.roots)}), so no hole spells it: ` + + 'delete it or reword the sentence (a text slot has no escape for literal braces).' + ); +} + +/** + * Why `text` — the template of a slot that reads `scope` — is refused, or + * `undefined` when it carries neither a single-brace `{…}` token nor a hole + * over a root `scope` does not bind. + * + * The same reading as {@link textSlotTemplateRefusal}, over the same token and + * hole grammar: a single-brace token is found exactly as there (a token + * touching another brace is left to the compile step), and a hole's root is + * its path's first segment, a formatter not included. What differs is the + * scope: a path token over a bound root is prescribed its `{{ }}` spelling, + * any other token its own remedy, and every hole whose root is not in + * `scope.roots` is named. A hole that does not compile as a path (logic, an + * unknown formatter) is left to `validateExpression('template', …)`, which + * every door runs next. + */ +export function closedScopeTemplateRefusal(text: string, scope: ClosedTemplateScope): string | undefined { + const bound = new Set(scope.roots); + const spellable = (token: TemplateToken): boolean => { + if (token.kind !== 'path') return false; + const root = pathRoot(token.inner); + return root !== undefined && bound.has(root); + }; + const refusals: string[] = []; + + const tokens = singleBraceTokens(text); + if (tokens.length > 0) { + const parts = [ + `${scope.slot} reads \`{{ }}\` template holes (ADR-0032 §3), not the single-brace \`{…}\` dialect: a \`{…}\` ` + + 'token there is no placeholder and is shown as literal text.', + ]; + if (tokens.some(spellable)) parts.push(`Write \`${text}\` as \`${doubled(text, tokens, spellable)}\`.`); + const seen = new Set(); + for (const token of tokens) { + if (spellable(token) || seen.has(token.text)) continue; + seen.add(token.text); + parts.push(closedUnspellableRemedy(token, scope)); + } + refusals.push(parts.join(' ')); + } + + const unbound: string[] = []; + for (const hole of pathHoles(text)) { + const root = pathRoot(hole.path); + if (root === undefined || bound.has(root) || unbound.includes(hole.text)) continue; + unbound.push(hole.text); + } + if (unbound.length > 0) { + refusals.push( + `${scope.slot} binds only ${rootList(scope.roots)}, so a hole over any other root renders a blank fragment: ` + + `${unbound.map((hole) => `\`${hole}\``).join(', ')} — name one of those roots, or reword the sentence.`, + ); + } + return refusals.length === 0 ? undefined : refusals.join(' '); +} diff --git a/packages/spec/src/shared/typed-expression-input.ts b/packages/spec/src/shared/typed-expression-input.ts index 2c8a463eb68..8748f69a5a6 100644 --- a/packages/spec/src/shared/typed-expression-input.ts +++ b/packages/spec/src/shared/typed-expression-input.ts @@ -9,8 +9,9 @@ * ## ⛔ Package-internal — NOT a public export * * Deliberately absent from `shared/index.ts` and from the root barrel, like - * `./refinement-projection.ts`: its callers are `./expression.zod.ts` and the - * notify node config (`../automation/io-node-config.zod.ts`), both inside + * `./refinement-projection.ts`: its callers are `./expression.zod.ts`, the + * notify node config (`../automation/io-node-config.zod.ts`) and a bulk + * action's `confirmText` (`../ui/bulk-action.zod.ts`), all inside * `@objectstack/spec`, and a published factory with no consumer outside the * package would widen the public face for nothing. `api-surface/` and * `export-origins/` must not move for it. diff --git a/packages/spec/src/system/i18n-resolver.object-list-views.test.ts b/packages/spec/src/system/i18n-resolver.object-list-views.test.ts index c00b3370664..e93ec0b0ffd 100644 --- a/packages/spec/src/system/i18n-resolver.object-list-views.test.ts +++ b/packages/spec/src/system/i18n-resolver.object-list-views.test.ts @@ -252,6 +252,16 @@ describe('translateObject — embedded listViews: description and bulk-action co expect(def(out).params[0].label).toBe('备注'); }); + it('an envelope-form confirmText is judged by its source on the embedded view too (#24798)', () => { + const envelope = (source: string) => ({ dialect: 'template', source }); + const base = clone(WITH_COPY); + def(base).confirmText = envelope(def(base).confirmText); + expect(def(translate(clone(base), base)).confirmText).toBe('确定将所选账户标记为已复核吗?'); + const edited = clone(base); + def(edited).confirmText = envelope('Review {{ count }} accounts?'); + expect(def(translate(edited, base)).confirmText).toEqual(envelope('Review {{ count }} accounts?')); + }); + it('an edited description beats the catalog; the label beside it stays translated', () => { const doc = clone(WITH_COPY); doc.listViews.mine.description = 'Our accounts'; diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 1dc3df8da70..b9a2fe575be 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -5018,6 +5018,42 @@ describe('translateView — the catalog loses to an explicit override (ADR-0029 expect(def(out, 'skip').label).toBe('跳过'); }); + // #24798: `confirmText` is a template slot, so a served view and its + // packaged base may each hold the parsed `{ dialect: 'template', source }` + // envelope. Both sides are compared by their source, never by reference. + const envelope = (source: string) => ({ dialect: 'template' as const, source }); + const withEnvelope = (view: any): any => { + const out = clone(view); + const markDone = def(out, 'mark_done'); + markDone.confirmText = envelope(markDone.confirmText); + return out; + }; + + it('an unedited envelope-form confirmText still gets the catalog (#24798)', () => { + const base = withEnvelope(PACKAGED); + const out: any = translateView(clone(base), BUNDLE, { locale: 'zh-CN', packagedBase: base }); + expect(def(out, 'mark_done').confirmText).toBe('确定将所选任务标记为完成吗?'); + }); + + it('a diverged envelope-form confirmText keeps the authored sentence (#24798)', () => { + const base = withEnvelope(PACKAGED); + const doc = clone(base); + def(doc, 'mark_done').confirmText = envelope('Close {{ count }} tasks?'); + const out: any = translateView(doc, BUNDLE, { locale: 'zh-CN', packagedBase: base }); + expect(def(out, 'mark_done').confirmText).toEqual(envelope('Close {{ count }} tasks?')); + // The other strings on the def are judged on their own and stay translated. + expect(def(out, 'mark_done').label).toBe('标记完成'); + }); + + it('a bare string and an envelope with the same source are the same sentence (#24798)', () => { + // The served side bare, the packaged side parsed — and the other way round. + const parsedBase = withEnvelope(PACKAGED); + const fromBare: any = translateView(clone(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: parsedBase }); + expect(def(fromBare, 'mark_done').confirmText).toBe('确定将所选任务标记为完成吗?'); + const fromParsed: any = translateView(withEnvelope(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED }); + expect(def(fromParsed, 'mark_done').confirmText).toBe('确定将所选任务标记为完成吗?'); + }); + it('a def or param the package does not ship was authored after the fact and keeps its strings', () => { const doc = clone(PACKAGED); doc.config.bulkActionDefs.push({ name: 'added_def', label: 'Added by the org', operation: 'update', patch: {} }); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index e21445ef8d3..2956a32125a 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -76,6 +76,7 @@ import { mapFlowNodeList } from '../conversions/walk.js'; import { flowScreenFieldOptionKey } from '../automation/flow-screen-option-key.js'; import { pageComponentSlotPositions } from '../ui/component.zod.js'; +import { templateSlotSource } from '../automation/flow-text-slot-template.js'; import type { PlatformTranslationBundle, @@ -158,7 +159,11 @@ export interface BulkActionDefLike { /** `BulkActionDefSchema.name` — the `bulkActions` key. */ name?: string; label?: string; - confirmText?: string; + /** + * A `template` slot (#24798): the bare string, or the parsed + * `{ dialect: 'template', source }` envelope a served view carries. + */ + confirmText?: string | { dialect: 'template'; source: string }; confirmLabel?: string; params?: Array; [key: string]: unknown; @@ -958,7 +963,16 @@ function translateBulkActionDefs( : lookupBulkActionText(bundle, objectName, viewKey, defName, pick, opts); const label = text(packagedDef, 'label', def.label, (n) => n.label); - const confirmText = text(packagedDef, 'confirmText', def.confirmText, (n) => n.confirmText); + // `confirmText` is a template slot (#24798): the authored side and the + // packaged side may each be a bare string or the parsed envelope, so both + // are compared by their `source` — an unchanged envelope still takes the + // catalog, and a diverged one keeps the authored sentence. + const confirmText = text( + packagedDef ? { confirmText: templateSlotSource(packagedDef.confirmText) } : undefined, + 'confirmText', + templateSlotSource(def.confirmText), + (n) => n.confirmText, + ); const confirmLabel = text(packagedDef, 'confirmLabel', def.confirmLabel, (n) => n.confirmLabel); let params = def.params; diff --git a/packages/spec/src/ui/action-confirm-params-guard.test.ts b/packages/spec/src/ui/action-confirm-params-guard.test.ts index 16b14febb30..21bfc781ae2 100644 --- a/packages/spec/src/ui/action-confirm-params-guard.test.ts +++ b/packages/spec/src/ui/action-confirm-params-guard.test.ts @@ -195,8 +195,10 @@ describe('the pair guard is scoped to ActionSchema by SCHEMA BOUNDARY', () => { }); expect(result.success).toBe(true); + // [#24798] The bulk def's `confirmText` is a `template` slot: the bare + // string parses to its envelope, text unchanged. expect(result.success && result.data.confirmText) - .toBe('Set these labels on every selected project?'); + .toEqual({ dialect: 'template', source: 'Set these labels on every selected project?' }); expect(result.success && result.data.params?.length).toBe(1); }); diff --git a/packages/spec/src/ui/bulk-action.zod.ts b/packages/spec/src/ui/bulk-action.zod.ts index 5074f748755..49e805485ab 100644 --- a/packages/spec/src/ui/bulk-action.zod.ts +++ b/packages/spec/src/ui/bulk-action.zod.ts @@ -3,7 +3,8 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; -import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; +import { EvaluatedExpressionInputSchema, ExpressionSchema } from '../shared/expression.zod'; +import { templateExpressionInput } from '../shared/typed-expression-input'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { FieldType } from '../data/field.zod'; @@ -329,6 +330,70 @@ export const BulkActionParamSchema = lazySchema(() => strictObject( })); export type BulkActionParam = z.input; +/** + * [#24798, ADR-0032 Decisions 2–3] The root a bulk `confirmText` hole names + * for the affected-record count — the number of selected records the run will + * cover (the dialog's own `rows.length`, after the def's `visible` narrowed + * the selection). The platform's default confirm sentence already says it; + * this is the spelling an authored sentence uses: `{{ count }}`. + */ +export const BULK_CONFIRM_COUNT_ROOT = 'count'; + +/** + * The roots a bulk action's `confirmText` holes may name — its CLOSED scope: + * {@link BULK_CONFIRM_COUNT_ROOT}, then each of the def's params by `name`, in + * declaration order. A hole over any other root renders a blank fragment, so + * the doors (`objectstack validate`, `objectstack compile`, the publish gate) + * name it through the one template-slot judge + * (`automation/flow-text-slot-template.ts`, `closedScopeTemplateRefusal`). + * + * The roots are BARE — `{{ tier }}`, never `{{ params.tier }}` — because that + * is how the flow text slots' scope spells its roots (a flow variable is + * `{{ v }}`, a node output `{{ lookup.result }}`, with no namespace in front), + * and how the shipped confirm sentences already spell them. `count` comes + * first and wins: a param named `count` is reachable only through the dialog + * field, never through a hole. + */ +export function bulkConfirmTemplateRoots(def: { readonly params?: unknown }): string[] { + const roots = [BULK_CONFIRM_COUNT_ROOT]; + if (!Array.isArray(def.params)) return roots; + for (const param of def.params) { + const name = (param as { name?: unknown } | null)?.name; + if (typeof name === 'string' && name.length > 0 && !roots.includes(name)) roots.push(name); + } + return roots; +} + +/** + * The bulk `confirmText` slot: ADR-0032's `template` dialect, the input + * `TemplateExpressionInputSchema` takes — a bare string is shorthand for + * `{ dialect: 'template', source }`, an envelope must declare + * `dialect: 'template'` — built from the package-internal constructor (as a + * notify node's `title` / `message` are) so its refusal prescribes this slot's + * own hole, `{{ count }}`, rather than `{{record.name}}`, a root it does not + * bind. + * + * ⚠️ One arm wider than a template slot, on purpose: a BLANK string parses as + * itself. `confirmText` was a plain `z.string()`, so `''` passed, and refusing + * it is a narrowing no conversion carries. The doors raise it as a warning + * with the hole findings; refusing it here is its own change (the amended + * 「甲」, record 6303865025), not this one. + */ +const BULK_CONFIRM_TEXT_DIALECT_ONLY = + 'A bulk action\'s `confirmText` is a template-typed slot: it accepts a bare template string or an envelope ' + + 'declaring `dialect: \'template\'` only. Write `\'Update {{ count }} record(s)?\'` or ' + + '`{ dialect: \'template\', source: \'Update {{ count }} record(s)?\' }`.'; + +const BulkConfirmTextSchema = z.union([ + templateExpressionInput(ExpressionSchema, { + sourceRequired: BULK_CONFIRM_TEXT_DIALECT_ONLY, + dialectOnly: BULK_CONFIRM_TEXT_DIALECT_ONLY, + }), + // `trim()`'s whitespace set is exactly `\s` (ECMA-262), so this arm takes the blank strings + // `NON_BLANK_STRING` refuses — as a `pattern` the JSON Schema carries, not a dropped refinement. + z.string().regex(/^\s*$/), +], { error: () => BULK_CONFIRM_TEXT_DIALECT_ONLY }); + /** * Rich, schema-driven definition of one button in the multi-select bar. * @@ -400,7 +465,7 @@ export const BulkActionDefSchema = lazySchema(() => strictObject( execution: BulkActionExecutionSchema.optional().describe("For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead."), patch: z.record(z.string(), z.unknown()).optional().describe("For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog."), params: z.array(BulkActionParamSchema).optional().describe('Inputs collected once before the run. Omit to skip the params step and go straight to confirm.'), - confirmText: z.string().optional().describe('Confirmation text shown above the affected-record summary.'), + confirmText: BulkConfirmTextSchema.optional().describe("Confirmation text shown above the affected-record summary — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path | formatter[:arg] }}` over two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount | currency:EUR }}`, `{{ due | date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on {{ count }} account(s) to {{ tier }}?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate, as a warning."), confirmLabel: z.string().optional().describe('Custom Confirm button label (default: "Run").'), visible: EvaluatedExpressionInputSchema.optional().describe('Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record.'), requiredPermissions: z.array(z.string()).optional().describe("[ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks."), diff --git a/packages/spec/src/ui/bulk-confirm-template.test.ts b/packages/spec/src/ui/bulk-confirm-template.test.ts new file mode 100644 index 00000000000..c10f9194a4e --- /dev/null +++ b/packages/spec/src/ui/bulk-confirm-template.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #24798 (ruling B) — a bulk action's `confirmText` is an ADR-0032 `template` + * slot over a CLOSED scope: `count` and the def's params by name. These pins + * hold the slot's accept set (a bare string is shorthand for the envelope, a + * holeless sentence still parses, a blank one still parses in 18.x) and the + * roots the spec declares. The holes themselves are judged at the doors + * through `closedScopeTemplateRefusal` (`automation/flow-text-slot-template.ts`, + * pinned beside the flow judge) — `@objectstack/lint`'s `validate-expressions` + * pins the door. + */ +import { describe, expect, it } from 'vitest'; + +import { BULK_CONFIRM_COUNT_ROOT, BulkActionDefSchema, bulkConfirmTemplateRoots } from './bulk-action.zod'; +import { closedScopeTemplateRefusal } from '../automation/flow-text-slot-template'; + +const def = (confirmText: unknown) => ({ + name: 'update_tier', + label: 'Update Tier', + operation: 'update', + confirmText, + params: [{ name: 'tier', type: 'select', options: [{ label: 'Gold', value: 'gold' }] }], +}); + +const parsed = (confirmText: unknown) => { + const r = BulkActionDefSchema.safeParse(def(confirmText)); + if (!r.success) throw new Error(r.error.issues.map((i) => i.message).join('\n')); + return r.data.confirmText; +}; + +const refusal = (confirmText: unknown) => { + const r = BulkActionDefSchema.safeParse(def(confirmText)); + return r.success ? undefined : r.error.issues; +}; + +describe('BulkActionDef.confirmText — a template slot (ADR-0032 Decisions 2–3)', () => { + it('reads a bare string as shorthand for `{ dialect: \'template\', source }` — the HotCRM sentence included', () => { + const source = 'Update tier on {{count}} account(s) to {{tier}}?'; + expect(parsed(source)).toEqual({ dialect: 'template', source }); + }); + + it('accepts the envelope itself', () => { + expect(parsed({ dialect: 'template', source: '{{ count | number }} selected' })) + .toEqual({ dialect: 'template', source: '{{ count | number }} selected' }); + }); + + it('still parses a stored sentence with no hole — as the template it now is, text unchanged', () => { + expect(parsed('Set these labels on every selected project?')) + .toEqual({ dialect: 'template', source: 'Set these labels on every selected project?' }); + }); + + it('still parses a blank string, verbatim: refusing it is a narrowing, a warning at the doors', () => { + // `confirmText` was `z.string()`, so `''` passed. The shared template + // input refuses a blank source; this slot keeps the accept instead. + expect(parsed('')).toBe(''); + expect(parsed(' \t')).toBe(' \t'); + }); + + it('refuses a foreign-dialect envelope and a non-string with the slot\'s own sentence, prescribing `{{ count }}`', () => { + for (const value of [{ dialect: 'cel', source: 'record.amount > 1' }, { dialect: 'cron', source: '0 9 * * *' }, 42]) { + const issues = refusal(value); + expect(issues, JSON.stringify(value)).toHaveLength(1); + expect(issues![0]!.path).toEqual(['confirmText']); + expect(issues![0]!.code).toBe('invalid_union'); + expect(issues![0]!.message).toMatch(/^A bulk action's `confirmText` is a template-typed slot/); + expect(issues![0]!.message).toContain('{{ count }}'); + expect(issues![0]!.message).not.toContain('record.name'); + } + }); + + it('judges no hole at parse: an unbound hole and a single-brace token both parse (the doors name them)', () => { + expect(parsed('Update {{ x }}')).toEqual({ dialect: 'template', source: 'Update {{ x }}' }); + expect(parsed('Update {count}')).toEqual({ dialect: 'template', source: 'Update {count}' }); + }); + + it('keeps the `confirm` / `confirmmessage` near-misses pointing at `confirmText`', () => { + for (const key of ['confirm', 'confirmmessage']) { + const { confirmText: _drop, ...rest } = def('x'); + const r = BulkActionDefSchema.safeParse({ ...rest, [key]: 'Sure {{ count }}?' }); + expect(r.success, key).toBe(false); + const issue = r.success ? undefined : r.error.issues[0]; + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.message).toContain(`\`${key}\` → \`confirmText\``); + } + }); +}); + +describe('bulkConfirmTemplateRoots — the closed scope the spec declares', () => { + it('is `count`, then each param by name, in declaration order, bare', () => { + expect(BULK_CONFIRM_COUNT_ROOT).toBe('count'); + expect(bulkConfirmTemplateRoots({ params: [{ name: 'tier' }, { name: 'owner_id' }] })).toEqual(['count', 'tier', 'owner_id']); + }); + + it('is `count` alone on a def with no params', () => { + expect(bulkConfirmTemplateRoots({})).toEqual(['count']); + expect(bulkConfirmTemplateRoots({ params: 'nonsense' })).toEqual(['count']); + }); + + it('lets `count` win over a param of that name, and skips a nameless param', () => { + expect(bulkConfirmTemplateRoots({ params: [{ name: 'count' }, {}, null, { name: 'tier' }] })).toEqual(['count', 'tier']); + }); + + it('binds every hole the HotCRM sentences write, and nothing else', () => { + const roots = bulkConfirmTemplateRoots(def('x')); + const scope = { slot: "A bulk action's `confirmText`", roots }; + expect(closedScopeTemplateRefusal('Update tier on {{count}} account(s) to {{tier}}?', scope)).toBeUndefined(); + expect(closedScopeTemplateRefusal('将 {{count}} 个客户的等级更新为 {{tier}}?', scope)).toBeUndefined(); + expect(closedScopeTemplateRefusal('Transfer {{count}} account(s) to {{ owner }}?', scope)).toContain('`{{ owner }}`'); + }); +}); diff --git a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts index efb930715c7..a9ecaf0c7ac 100644 --- a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts +++ b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts @@ -265,7 +265,9 @@ on(['action:group', 'action:menu'], ['actions[].visible.ast', 'actions[].disable on(['action:button', 'action:icon'], ['visible.ast', 'disabled.ast'], EXPRESSION_AST); on(['record:line_items'], ['columns[].readonlyWhen.ast', 'columns[].requiredWhen.ast'], EXPRESSION_AST); on(['object-master-detail-form'], ['details[].columns[].readonlyWhen.ast', 'details[].columns[].requiredWhen.ast'], EXPRESSION_AST); -on(['object-grid', 'list-view'], ['conditionalFormatting[].condition.ast', 'bulkActionDefs[].visible.ast'], EXPRESSION_AST); +// [#24798] A bulk def's `confirmText` is a `template` slot, so its envelope +// carries the same engine AST beside `source`. +on(['object-grid', 'list-view'], ['conditionalFormatting[].condition.ast', 'bulkActionDefs[].visible.ast', 'bulkActionDefs[].confirmText.ast'], EXPRESSION_AST); on(['object-kanban'], ['conditionalFormatting[].condition.ast'], EXPRESSION_AST); // The S-forms stage: an inline form field (`customFields[]`, and a section's // inline entry) and the form view's `{ field }` entry carry the three `*When`