You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 925fd07
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
Copy file name to clipboardExpand all lines: content/docs/references/ui/bulk-action.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -48,7 +48,7 @@ const result = BulkActionDefSchema.parse(data);
48
48
|**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. |
49
49
|**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. |
50
50
|**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. |
|**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. |
54
54
|**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. |
Copy file name to clipboardExpand all lines: content/docs/references/ui/component.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -928,7 +928,7 @@ Sort field and direction pair
928
928
|**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. |
929
929
|**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. |
930
930
|**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. |
|**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. |
934
934
|**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. |
Copy file name to clipboardExpand all lines: content/docs/references/ui/view.mdx
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1039,7 +1039,7 @@ View filter rule
1039
1039
|**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. |
1040
1040
|**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. |
1041
1041
|**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. |
|**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. |
1045
1045
|**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
1416
1416
|**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. |
1417
1417
|**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. |
1418
1418
|**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. |
|**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. |
1422
1422
|**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. |
0 commit comments