Skip to content

Commit 925fd07

Browse files
feat(spec,lint): a bulk action's confirmText is a template slot over count and its params, judged at validate, compile and the publish gate (#24947)
Fixes #24798 Clause-②: yes (widening) Metadata-protocol: semantic The spec half of ruling B on the decision card, step 1: a list-view bulk action's `confirmText` becomes an ADR-0032 `template` slot over a closed scope. The scope's roots are `count` and the action's params by name. Its holes are judged at `objectstack validate`, `objectstack compile` and the publish gate by the template judge the flow text slots already use. ## The ruling this executes (comment 6285948976, items 1–2, verbatim) > 1. `ui/bulk-action.zod.ts:403` `confirmText` becomes a `TemplateExpressionInputSchema` slot (ADR-0032 Decisions 2 and 3): a bare string is shorthand for `{ dialect: 'template', source }`; holes are `{{ path }}` and `{{ path | formatter }}` over the bulk-confirm scope, whose bound roots the spec declares — `count`, the affected-record count, and the action's params by name (the PR fixes the root spelling, `{{ tier }}` or `{{ params.tier }}`, consistent with the flow scope's roots); an unbound root and a single-brace token are refused by name at `objectstack validate`, `compile` and the publish gate through the one judge the flow text slots use (`automation/flow-text-slot-template.ts`); ⛔ no CEL logic inside a hole (an ADR-0032 non-goal); formatters from the existing whitelist. > 2. The console fills the slot through the template engine the flow notifications render with: step 1 `{{count}}` now; step 2 the param roots once the spec PR states the display-value rule (option label against stored value for picklists and lookups; number, date and currency via the whitelist). The renderer half is objectstack-ai/objectui#13183, `pm:blocked` on this card until the spec release is installable there (ruling D on #24591). ## What changed - **The slot.** `bulkActionDefs[].confirmText` (`packages/spec/src/ui/bulk-action.zod.ts`) takes the input `TemplateExpressionInputSchema` takes: a bare string is shorthand for `{ dialect: 'template', source }`, and an envelope must declare `dialect: 'template'`. Like a notify node's `title` / `message`, it is built from the package-internal `templateExpressionInput` constructor, so its refusal prescribes this slot's own hole, `{{ count }}`. The shared `{{record.name}}` would name a root this slot does not bind. A stored sentence with no hole parses as before, its text unchanged. - **The declared roots.** `bulkConfirmTemplateRoots(def)` and `BULK_CONFIRM_COUNT_ROOT` (`@objectstack/spec/ui`) return `count`, then each param's `name` in declaration order. - **Root spelling: bare (`{{ tier }}`), not `{{ params.tier }}`.** The flow text slots' scope writes its roots bare: a flow variable is `{{ v }}`, a node output `{{ lookup.result }}`, and nothing is namespaced. HotCRM's 12 sentences already write `{{count}}` / `{{tier}}`, and ruling item 3 has them stand unchanged. `count` wins over a param named `count`; such a param stays reachable only through its dialog field. - **The display-value rule step 2 waits on**, stated in the slot's `.describe()`. A param hole renders the param's DISPLAY value: - an option-bearing param renders the chosen option's `label`, with a multi-value param's labels joined by `', '`; - a `lookup` or user param renders the picked record's display name; - any other param renders the entered value, so number, date and currency values take a whitelisted formatter (`{{ amount | currency:EUR }}`, `{{ due | date:long }}`). A hole holds no logic. - **One judge, not a fork.** `packages/spec/src/automation/flow-text-slot-template.ts` gains a closed-scope entry, `closedScopeTemplateRefusal(text, { slot, roots })`. It uses the same single-brace token reader, the same hole grammar and the same root rule as `textSlotTemplateRefusal`; the internals are factored to shared helpers (`pathRoot`, `pathHoles`, a predicate on `doubled`). The flow judge's verdicts and sentences are unchanged, with a control pin. The judge's envelope reader is exported as `templateSlotSource`, so a door reads a template slot the way the judge does. - **The doors.** `validateStackExpressions` (`packages/lint/src/validate-expressions.ts`) is the rule `objectstack validate`, `compile` and the publish gate all run. It gains a bulk `confirmText` pass with the same two steps as the flow text slots, in the same order: 1. `closedScopeTemplateRefusal` names a single-brace token (with its `{{ }}` rewrite when the root is bound) and a hole over an unbound root; 2. a text carrying neither is compiled with `validateExpression('template', …)`, which names logic or an unknown formatter in a hole. A blank sentence is named too. The walk covers a view's `list` and `listViews`, a standalone view's flattened list overlay and its ViewItem record (the shapes `PUT /meta/view` stores), and an object's own `listViews`. The publish gate's expression rule now declares `view` in `runtimeTypes`. A view write runs this pass alone, by construction, and an object write also judges its own `listViews`. ## The 「甲」 rule: what passes today, and why every finding is a warning Measured on `origin/main` `b42c621c05`: `confirmText` was `z.string().optional()`. Every string passed, including an unbound `{{ x }}`, a single-brace `{x}`, a hole holding logic (`{{ count * 2 }}`) and a blank `''`. Refusing any of them would be a narrowing that no ADR-0087 conversion carries. So, in 18.x: - every door finding on a bulk `confirmText` is a **warning**, which is an advisory at the publish gate and never a refusal; - the parse still accepts a **blank** string, verbatim, as a second union arm (`z.string().regex(/^\s*$/)`, a `pattern` the JSON Schema carries), while the template arm refuses blank as every template slot does; - a refusal of these texts is its own change, a BREAKING minor under the amended 「甲」 (#6015 `6303865025`), the way #25002 shipped the page-predicate bounds. This PR ships only the advisory; the refusal half is #25205. `f10bc73a0f` took the earlier "refused from protocol 19" promise out of the code, the describe and the changeset. The widening is the envelope form and the declared holes: `Clause-②: yes (widening)`, `minor` for `@objectstack/spec` and `@objectstack/lint`. ## Landing order: steps 1 and 2 have landed; this PR is step 3 ⚠️ Retyping changes the served shape: a parsed `confirmText` is now `{ dialect: 'template', source }`, not a string. objectui used to render it as a React child (`plugin-grid/src/components/BulkActionDialog.tsx`), so the renderer had to move first. It has: 1. objectstack-ai/objectui#13319, the envelope reader, landed as objectui `cd14d8eeffe1`. `BulkActionDef.confirmText` is `string | { dialect?, source }`, and the dialog renders through `bulkConfirmTextSource`. 2. #25193 moved `.objectui-sha` to `cd14d8eeffe1` (main `9057fd41f5`). 3. This PR carries both, through merge `823f523b15`. The fill step (`{{count}}`, then the param roots) stays on objectstack-ai/objectui#13183, as ruling `6285948976` describes. ## Why `Fixes`, not `Refs` The ruling splits the work by repository (ruling D on #24591). This card carries the spec half, and the renderer half is its own card, objectstack-ai/objectui#13183, whose body reads `Blocked-by: #24798`. If this card stayed open until the renderer landed, the renderer card would wait on a card that waits on it. So merging the spec half closes this card. The sibling sweep, #24814, is not addressed here; it stays open and follows serially. The landing-order hazard above is a sequencing condition on merging this PR, not a reason to keep the card open. ## Contract review This is a published `@objectstack/spec` schema, so it needs contract review at `CONTRACT_REVIEW_TIER`. The diff touches no governed surface (`.claude/**`, `docs/adr/**`, `AGENTS.md`, `skills/**` and `.github/workflows/**` are all untouched), so it is neither Tier S nor Tier H. It is under 800 changed lines, under the 3000-line human-merge threshold. ## Tests (final head `823f523b15`, after merging `origin/main` twice: `ef2b46b524`, then `2919ddb875`, which carries #25193) - spec: 708 files / 21498 passed, 1 todo. `test:repo` 74 / 1269 passed. typecheck and `check:generated` exit 0. - lint: 154 files / 7091 passed. typecheck exit 0, with `check:test-typecheck` OK. - cli i18n unit tests (3 files) 50 passed, typecheck exit 0. dogfood `expression-conformance` 8 passed. - Changeset gates green. `check-changeset-no-major --event` against the live body reads `Clause-②: yes (widening)` and `Metadata-protocol: added`, and grades nothing `patch`. - dispatch-gates at `823f523b15`: 115 derived, 114 run at exit 0, 1 NOT MEASURED (`check:dual-build-cjs-loads`, which needs a full build; CI owns it). Earlier rounds (head `9fb508c8a0`): - `pnpm --filter @objectstack/spec test` → `Test Files 704 passed (704)`, `Tests 21379 passed | 1 todo`. - `pnpm --filter @objectstack/lint test` → `Test Files 152 passed (152)`, `Tests 7009 passed`. - `pnpm --filter @objectstack/dogfood exec vitest run test/expression-conformance.test.ts` → 8 passed. The new slot is discovered and classified (`template-bulk-confirm-text`, `experimental` / `unevaluated`: nothing in this checkout fills the holes yet). - `pnpm --filter @objectstack/cli exec vitest run --project unit` on the seven i18n suites that read `confirmText` (`i18n-bulk-action-coverage`, `i18n-coverage`, `i18n-extract`, `i18n-walk-output-parity`, `i18n-declared-surface-gate`, `i18n-duplicate-demand`, `i18n-extract-action-description`) → 100 passed. `pnpm --filter @objectstack/cli typecheck` → OK. The cli `integration` layer is declared to CI. - **Ablation** of the extractor fix (`node scripts/ablation-replace.mjs` on `packages/cli/src/utils/i18n-extract.ts`, `templateSlotSource(def.confirmText)` → `def.confirmText`): the blob went `4c0e88d86ced` → `dc3e90d6ba5f`, and the new envelope pin went red (`Tests 1 failed | 17 passed`). The restore leg brought the blob back to HEAD and `git diff HEAD` was empty. - `pnpm --filter @objectstack/spec --filter @objectstack/lint typecheck` → both `Done`. - `pnpm --filter @objectstack/spec check:generated` → stale `api-surface/`, `export-origins/` and the reference docs, regenerated with `--fix` (exactly the five new exports and the `confirmText` rows). - The derived gate battery (`node scripts/pm/dispatch-gates.mjs --commands`, 115 families at `9fb508c8a0`), reconciled with `--ran`: 114 run at exit 0, and 1 NOT MEASURED: `check:dual-build-cjs-loads` exited 3 (prerequisite not met: it loads the BUILT entry of every package, about 90), which is declared to CI. `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity` and `check:skill-examples` went green after building the closures they name, and `check:type-check-debt` went green on a run without a timeout wrapper. - eslint, narrowed and proven: - population: `eslint.config.mjs` `files: ['**/*.{ts,…}']` minus `NEVER_LINTED`, with `--print-config` confirming each touched file is linted; - `npx eslint --no-inline-config --format json` over the 15 touched TS files → 15 files, 0 errors, 0 warnings; - invariance: the config enables no type-aware linting (no `parserOptions.project`, per its own comment at `eslint.config.mjs:325`), so this diff cannot move a verdict on an untouched file. - **Ablation** of the publish-gate widening, done after committing (`node scripts/ablation-replace.mjs` on `packages/lint/src/authoring-rules.ts`, removing `'view'` from `runtimeTypes`): the anchor went 1 → 0 and the blob `37ae7e87aacf` → `82d615ca2df0`. All 3 gate positives in `validate-expressions.bulk-confirm-text.test.ts` went red (`Tests 3 failed | 11 passed`), and the 3 controls stayed green. The restore leg brought the blob back to HEAD and `git diff HEAD` was empty. No `dist` was involved, since lint's own tests read `src`. ## Acceptance notes - **`os i18n extract` and an envelope-form `confirmText`** (the seat extended the claim to `packages/cli/src/utils/i18n-extract.ts`). `pushBulkActionDefs` now reads the slot through `templateSlotSource(def.confirmText)`, so a `confirmText` written as `{ dialect: 'template', source }` is seeded (`sourceValue` / `inline`) exactly as the bare string. Before this, `authoredEvidence` read a string or an inline locale map only, and the envelope was recorded like an absent key. The new pin is in `packages/cli/test/i18n-bulk-action-coverage.test.ts`. - **Translation bundles.** A translated bulk `confirmText` in a bundle (`objects.OBJ._views.VIEW.bulkActions.DEF.confirmText`) is still a plain string, and nothing here judges its holes. Ruling item 1 binds the def's slot, and the bundle side is the sibling sweep's question. - **A param named `count`** is shadowed by the count root (documented in the slot and in `bulkConfirmTemplateRoots`). No such param exists in the repository corpus. - **Corpus measured** at `b42c621c05`: 6 bulk defs carry a `confirmText` (examples/app-showcase `project.view.ts`), none with a brace, so 0 findings at every door. HotCRM's 12 sentences (`{{count}}`, `{{tier}}`) are pinned clean in both spec and lint tests, the zh-CN form included. Files outside the claim's listed paths, each forced by the slot change and named here: - `packages/spec/src/shared/typed-expression-input.ts`: one docblock line naming the constructor's new caller; - `packages/spec/src/ui/component-props-unknown-members.pin.test.ts`: the envelope's `ast` ledgered under `EXPRESSION_AST`, beside `bulkActionDefs[].visible.ast`; - `packages/spec/src/ui/action-confirm-params-guard.test.ts`: the parsed value is now the envelope; - `packages/qa/dogfood/test/expression-conformance.ledger.ts`: the slot's conformance row; - `packages/lint/src/{authoring-rule-wiring,runtime-gate,runtime-gate.page-visibility-predicate-writes,validate-expressions}.test.ts`: the `view` rule lists, and the receiver table of the declared-keys meta-test; - `packages/cli/src/utils/i18n-extract.ts` and `packages/cli/test/i18n-bulk-action-coverage.test.ts`: the envelope-form seed and its pin (claim extended by the seat); `.changeset/24798-bulk-confirm-template-slot.md` now also bumps `@objectstack/cli`. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f0fdd45 commit 925fd07

29 files changed

Lines changed: 871 additions & 43 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
'@objectstack/cli': minor
5+
---
6+
7+
feat(spec,lint): a bulk action's `confirmText` is a `template` slot over `count` and the action's params
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: semantic
11+
12+
- **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.
13+
- **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.
14+
- **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.
15+
- **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.
16+
- **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.
17+
- **`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.
18+
- **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.
19+
- **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.

‎content/docs/references/ui/bulk-action.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const result = BulkActionDefSchema.parse(data);
4848
| **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: ['<name>']` instead. |
4949
| **patch** | `Record<string, any>` | 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. |
5050
| **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. |
51-
| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. |
51+
| **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. |
5252
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
5353
| **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. |
5454
| **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: ['<name>']` (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. |

‎content/docs/references/ui/component.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -928,7 +928,7 @@ Sort field and direction pair
928928
| **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: ['<name>']` instead. |
929929
| **patch** | `Record<string, any>` | 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. |
930930
| **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. |
931-
| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. |
931+
| **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. |
932932
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
933933
| **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. |
934934
| **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: ['<name>']` (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. |

‎content/docs/references/ui/view.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,7 +1039,7 @@ View filter rule
10391039
| **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: ['<name>']` instead. |
10401040
| **patch** | `Record<string, any>` | 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. |
10411041
| **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. |
1042-
| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. |
1042+
| **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. |
10431043
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
10441044
| **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. |
10451045
| **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: ['<name>']` (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
14161416
| **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: ['<name>']` instead. |
14171417
| **patch** | `Record<string, any>` | 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. |
14181418
| **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. |
1419-
| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. |
1419+
| **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. |
14201420
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
14211421
| **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. |
14221422
| **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: ['<name>']` (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. |

‎packages/cli/src/utils/i18n-extract.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ import {
147147
pageTabItemKey,
148148
walkAddressedPageComponents,
149149
} from '@objectstack/spec/system';
150-
import { FLOW_REGION_SLOTS_BY_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation';
150+
import { FLOW_REGION_SLOTS_BY_TYPE, flowScreenFieldOptionKey, templateSlotSource } from '@objectstack/spec/automation';
151151
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
152152
import { deriveFieldGroupLayout } from '@objectstack/spec/data';
153153
import { APPROVAL_NODE_TYPE } from '@objectstack/spec/automation';
@@ -518,7 +518,12 @@ function pushBulkActionDefs(out: ExpectedEntry[], viewRoot: string[], view: any,
518518
// nobody wrote.
519519
const authoredLabel = inlineText(def.label);
520520
pushDerived(out, [...base, 'label'], authoredLabel ?? humanizeFieldPath(defName), def.label, 'view', { objectName });
521-
pushOptional(out, [...base, 'confirmText'], def.confirmText, 'view', { objectName });
521+
// [#24798] `confirmText` is a `template` slot: a bare string or the
522+
// `{ dialect: 'template', source }` envelope, and either one is the same
523+
// authored sentence. Read it through the template judge's one reader, so
524+
// the envelope form is seeded like the string instead of being recorded
525+
// as an absent key.
526+
pushOptional(out, [...base, 'confirmText'], templateSlotSource(def.confirmText), 'view', { objectName });
522527
pushOptional(out, [...base, 'confirmLabel'], def.confirmLabel, 'view', { objectName });
523528
if (!Array.isArray(def.params)) continue;
524529
for (const param of def.params) {

0 commit comments

Comments
 (0)