diff --git a/.changeset/23427-page-action-row-declarative-update-rules.md b/.changeset/23427-page-action-row-declarative-update-rules.md new file mode 100644 index 00000000000..6506ebc96ad --- /dev/null +++ b/.changeset/23427-page-action-row-declarative-update-rules.md @@ -0,0 +1,43 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: a page action row is judged by `ActionSchema`'s declarative-update rules, from the one judge both read + +Clause-②: no (narrowing) + + + +**BREAKING**: an accept-set narrowing on four published page component rows, shipped as `minor` under the launch-window convention for accept-set narrowings. + +The page action rows are `action:button` and `action:icon` (`properties`), and each member of an `action:group` or `action:menu` (`properties.actions[]`). Each forwards `operation`, `patch`, `params`, `target`, its executor key and `locations` to the console's action runner, which reads `operation` before any executor: `operation: 'update'` posts the write through the platform action route and runs no executor of its own. `ActionSchema` refused the shapes below on an action, and the rows accepted them. The rows now apply the same judge (`ActionSchema`'s texts are unchanged), with the executor spelled as the row spells it (`actionType` on a node, `type` on a member): + +- `patch` without `operation: 'update'` is refused at `patch` (it is never written); +- beside `operation: 'update'`: + - an executor other than `'script'` is refused at the executor key; + - `target`, `method`, `bodyExtra`, `bodyShape` and, on `action:button`, `recordIdField` are each refused at their own key; + - a row with nothing to write is refused at `patch`; + - a `list_toolbar` location is refused at `locations.N`; +- a row's `patch` is `ActionSchema`'s own record of field values: a string, array or number `patch` is refused at `patch` (the runner dropped it before the write). + +On `action:button` and `action:icon`, a non-empty object `params` (the node's static values, which the write merges over `patch`) counts as something to write. On a container member it does not. Beside the operation, the rows' url-target and `'navigation'` rename refusals stand down, because the update's refusal of `target` and of the executor covers both keys. + +### FROM → TO + +| before (on a row) | what to write instead | +| --- | --- | +| `{ actionType: 'script', patch: { status: 'done' } }` (no `operation`) | `{ operation: 'update', patch: { status: 'done' } }` for the write, or drop `patch` | +| `{ operation: 'update', patch: { status: 'done' }, target: 'close_task' }` | the same row without `target`: the platform action route performs the write | +| `{ operation: 'update', patch: { status: 'done' }, actionType: 'url' }` (or `type` on a member, or any other executor) | the same row with no executor key, or `'script'` | +| `{ operation: 'update', patch: { … }, method: 'PATCH' }` (likewise `bodyExtra`, `bodyShape`, `recordIdField`) | the same row without that key (static field values go in `patch`) | +| `{ operation: 'update' }` (no `patch`, empty `params`) | add `patch: { … }`, or `params` that supply the values | +| `{ operation: 'update', patch: { … }, locations: ['list_toolbar'] }` | a record location (`list_item`, `record_header`, `record_more`, `record_section`), or a list view `bulkActionDefs` entry for the selection bar | +| `{ operation: 'update', patch: 'done' }` | `patch: { status: 'done' }` | + +A row that meant a call of its own keeps its executor and `target`, and drops `operation` and `patch` instead. + +**The one-line fix: write a page action row's declarative update as `ActionSchema` accepts it, with `operation: 'update'` and a `patch` record, no executor or `'script'`, no dispatch keys, and record locations only.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-declarative-update-rules`; the rewrite is by hand. + +**What an author now sees.** `os validate`'s props gate reports each refusal at the row's own key (`properties.…`, or `properties.actions.N.…` for a member), with `ActionSchema`'s text. For example, `{ operation: 'update', patch: { status: 'done' }, actionType: 'url' }` on an `action:button` gets ``… so `actionType` names that route or nothing; leave it off. …`` at `properties.actionType`. + +**Measured producers.** None in this repository: its examples, the CLI corpus, the dogfood fixtures and the package tests carry none, apart from these rows' own pins. Two landed pins put `api` or a host handler beside the update, and they now name `'script'`. In objectui, at its pin `b9814b334` and its main `524136e`, no page action row with these shapes is parsed by the spec. One renderer-test control mounts an `action:button` with `patch` and no `operation`, to show nothing is written. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 447d60505ab..ce78074e014 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -43,8 +43,8 @@ const result = ActionButtonPropsSchema.parse(data); | **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner | | **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner | | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | -| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own | -| **patch** | `any` | optional | Field values the declarative `operation` writes, forwarded to the runner | +| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape`, `recordIdField` and `onSuccess` are refused, a `patch` or `params` gives the values to write, and `list_toolbar` is not a location | +| **patch** | `Record` | optional | Static field values the declarative `operation: 'update'` writes, forwarded to the runner: `ActionSchema`'s own record of field values, merged UNDER the values `params` supplies. Refused on a row without `operation: 'update'` (it would be silently dropped) | | **confirmText** | `string` | optional | Confirmation question asked before the action runs | | **successMessage** | `string` | optional | Toast shown when the action succeeds | | **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error | @@ -101,8 +101,8 @@ const result = ActionButtonPropsSchema.parse(data); | **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner | | **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner | | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | -| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own | -| **patch** | `any` | optional | Field values the declarative `operation` writes, forwarded to the runner | +| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, a `patch` or `params` gives the values to write, and `list_toolbar` is not a location | +| **patch** | `Record` | optional | Static field values the declarative `operation: 'update'` writes, forwarded to the runner: `ActionSchema`'s own record of field values, merged UNDER the values `params` supplies. Refused on a row without `operation: 'update'` (it would be silently dropped) | | **confirmText** | `string` | optional | Confirmation question asked before the action runs | | **successMessage** | `string` | optional | Toast shown when the action succeeds | | **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error | @@ -137,8 +137,8 @@ const result = ActionButtonPropsSchema.parse(data); | **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner | | **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner | | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | -| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own | -| **patch** | `any` | optional | Field values the declarative `operation` writes, forwarded to the runner | +| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, a `patch` or `params` gives the values to write, and `list_toolbar` is not a location | +| **patch** | `Record` | optional | Static field values the declarative `operation: 'update'` writes, forwarded to the runner: `ActionSchema`'s own record of field values, merged UNDER the values `params` supplies. Refused on a row without `operation: 'update'` (it would be silently dropped) | | **confirmText** | `string` | optional | Confirmation question asked before the action runs | | **successMessage** | `string` | optional | Toast shown when the action succeeds | | **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error | @@ -191,8 +191,8 @@ const result = ActionButtonPropsSchema.parse(data); | **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner | | **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner | | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | -| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own | -| **patch** | `any` | optional | Field values the declarative `operation` writes, forwarded to the runner | +| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, a `patch` or `params` gives the values to write, and `list_toolbar` is not a location | +| **patch** | `Record` | optional | Static field values the declarative `operation: 'update'` writes, forwarded to the runner: `ActionSchema`'s own record of field values, merged UNDER the values `params` supplies. Refused on a row without `operation: 'update'` (it would be silently dropped) | | **confirmText** | `string` | optional | Confirmation question asked before the action runs | | **successMessage** | `string` | optional | Toast shown when the action succeeds | | **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error | diff --git a/packages/lint/src/validate-component-props-row-on-success.test.ts b/packages/lint/src/validate-component-props-row-on-success.test.ts index 65e3d55bba5..16b18871eda 100644 --- a/packages/lint/src/validate-component-props-row-on-success.test.ts +++ b/packages/lint/src/validate-component-props-row-on-success.test.ts @@ -111,21 +111,24 @@ describe("the props gate refuses a page action row's `onSuccess` beside an execu // `operation` on a page action row: `ActionSchema`'s own enum, and its refusal // of `onSuccess` beside `operation: 'update'` (the runner performs the write -// before any executor), whatever executor the row names. +// before any executor). Beside the operation a row names no executor or +// `script`, the route the write is posted to; every other executor there is +// refused at the executor key (`@objectstack/spec`'s +// `component-action-row-declarative-update.test.ts`). describe("the props gate judges a page action row's `operation` as `ActionSchema` does", () => { const UPDATE = { operation: 'update', patch: { status: 'done' } }; - it.each(ROWS)("REFUSAL: %s with `api`, `operation: 'update'` and `onSuccess` is one finding at `onSuccess`", (_row, node, at, key, subject) => { - const findings = validateComponentProps(stackWith(node({ [key]: 'api', ...UPDATE, onSuccess: { navigate: '/tasks' } }))); + it.each(ROWS)("REFUSAL: %s with `script`, `operation: 'update'` and `onSuccess` is one finding at `onSuccess`", (_row, node, at, key, subject) => { + const findings = validateComponentProps(stackWith(node({ [key]: 'script', ...UPDATE, onSuccess: { navigate: '/tasks' } }))); expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_INVALID, `${at}.onSuccess`]]); expect(findings[0].message).toContain( `${ownerOf(subject)} \`onSuccess\` (\`operation: 'update'\`) would follow a result the contract does not declare:`, ); }); - it.each(ROWS)("ACCEPTED CONTROL: %s with `api` and `operation: 'update'` and no `onSuccess` reports nothing", (_row, node, _at, key) => { - expect(validateComponentProps(stackWith(node({ [key]: 'api', ...UPDATE })))).toEqual([]); + it.each(ROWS)("ACCEPTED CONTROL: %s with `script` and `operation: 'update'` and no `onSuccess` reports nothing", (_row, node, _at, key) => { + expect(validateComponentProps(stackWith(node({ [key]: 'script', ...UPDATE })))).toEqual([]); }); it.each(ROWS)("REFUSAL: %s with `operation: 'delete'` is one finding at `operation`", (_row, node, at) => { diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-declarative-update-rules.ts b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-declarative-update-rules.ts new file mode 100644 index 00000000000..14625979538 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-declarative-update-rules.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ui-page-action-row-declarative-update-rules', + surface: 'a page action row (`properties` of an `action:button` or `action:icon`, and each `action:group` / ' + + '`action:menu` member of `properties.actions`) that carries `patch` without `operation: \'update\'`, or ' + + 'that carries `operation: \'update\'` beside an executor other than `\'script\'` (`actionType` on a node, ' + + '`type` on a member), beside `target`, `method`, `bodyExtra`, `bodyShape` or (on `action:button`) ' + + '`recordIdField`, with nothing to write (no `patch`, and no non-empty `params`), or with `list_toolbar` in ' + + '`locations`. Also a row `patch` that is not a record of field values', + replacement: 'Write the row as `ActionSchema` accepts the same action: `operation: \'update\'` with a `patch` ' + + 'record (or `params` that supply the values), no executor key or `\'script\'`, none of the dispatch keys, ' + + 'and record locations only. A row that meant a call of its own keeps its executor and its `target` and ' + + 'drops `operation` and `patch` instead. ⛔ NOT mechanically convertible: whether a row meant the ' + + 'declarative write or the executor is the author\'s call.', + reason: + 'Every page action row forwards `operation`, `patch`, `params`, `target`, its executor key and `locations` ' + + 'to the console\'s action runner, and the runner reads `operation` before any executor: it posts the write ' + + 'through its `script` dispatch and runs no executor of its own, so beside it the executor and the keys ' + + 'that bind one are never read, a `patch` without it is never written, and a `patch` that is not a record ' + + 'is dropped before the write. `ActionSchema` refused each of these shapes on an action; the rows accepted ' + + 'them. The rows now apply the same judge, read from one place, with the action\'s own texts (the executor ' + + 'spelled as the row spells it), and take `ActionSchema`\'s own `patch` record by reference. A page\'s ' + + '`properties` is not parsed on the metadata save or load path; `os validate`\'s props gate parses it ' + + 'against these same rows and reports each refusal at the row\'s own key. Measured producers: none in this ' + + 'repository\'s examples, CLI corpus or tests outside these rows\' own pins; in objectui, at its pin and ' + + 'main, one renderer-test control mounts an `action:button` with `patch` and no `operation` to show nothing ' + + 'is written, and no spec parse judges it.', + acceptanceCriteria: + 'No page action row carries `patch` without `operation: \'update\'`; every row with `operation: \'update\'` ' + + 'names no executor or `\'script\'`, carries none of `target`, `method`, `bodyExtra`, `bodyShape` and ' + + '`recordIdField`, has a `patch` record or non-empty `params`, and lists no `list_toolbar` location; ' + + '`os validate` reports no `component-props-invalid` finding at `properties.KEY` or ' + + '`properties.actions.N.KEY` for those keys, and every such row parses as before.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-operation-declared.ts b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-operation-declared.ts index 23a1eecb7a9..a4213034b01 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-operation-declared.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-operation-declared.ts @@ -28,5 +28,5 @@ export const entry: SemanticMigration = { 'Every page action row\'s `operation` is absent or `\'update\'`, and no row with `operation: \'update\'` carries ' + '`onSuccess`; `os validate` reports no `component-props-invalid` finding at `properties.operation`, ' + '`properties.actions.N.operation`, or at the `onSuccess` of an `operation: \'update\'` row, and every such row ' - + 'parses as before.', + + 'parses as before unless `ui-page-action-row-declarative-update-rules` refuses it.', }; diff --git a/packages/spec/src/ui/action-declarative-update.ts b/packages/spec/src/ui/action-declarative-update.ts new file mode 100644 index 00000000000..3508c7554d6 --- /dev/null +++ b/packages/spec/src/ui/action-declarative-update.ts @@ -0,0 +1,234 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { z } from 'zod'; +import { onSuccessBesideUpdateOperationRefusal } from './action-on-success-executor'; + +/** + * The mixing rule between the declarative single-record field write, + * `operation: 'update'` (#14092), and the rest of the action vocabulary: ONE + * judge and ONE set of refusal texts, read by `ActionSchema` (`action.zod.ts`) + * and by the page action rows (`component.zod.ts`: `action:button`, + * `action:icon` and each `action:group` / `action:menu` member). Designed with + * the PR that landed the operation and pinned in `action-row-update.test.ts`; + * the rows' half is pinned in `component-action-row-declarative-update.test.ts`. + * + * Both runtimes read `operation` before the executor. The console's action + * runner (`ActionRunner.execute`, `core/src/actions/ActionRunner.ts:1384-1388` + * at the `.objectui-sha` pin `b9814b334`, byte-identical at objectui `main` + * `524136e`) posts the write through its registered `script` dispatch and runs + * no executor of its own, whatever executor the action or row names. The + * platform action route treats an action carrying `operation: 'update'` as the + * write "whatever `type` says" (`isDeclarativeUpdateAction`, + * `packages/runtime/src/action-execution.ts`). So beside the operation, the + * executor and every key that binds or shapes an executor's dispatch are never + * read. The rules, in the order the issues are raised: + * + * 1. `patch` without `operation: 'update'` is refused (it would be silently + * dropped — ADR-0078). + * 2. Beside `operation: 'update'`, an executor other than `'script'` — the + * route the write is posted to — is refused at the executor key + * ({@link updateOperationExecutorRefusal}). An explicit `'script'` names + * that route and is accepted; on `ActionSchema` it is also + * indistinguishable from the materialized default (a `.default()` has + * already run by the time a refinement sees the object — the #13897 + * asymmetry). + * 3. Every key that names or shapes a dispatch the update does not make is + * refused at its own path ({@link DECLARATIVE_UPDATE_REFUSED_KEYS}). + * 4. `operation: 'update'` with nothing to write is refused at `patch`. + * 5. A `list_toolbar` location is refused: no current record there. + * + * Deliberately NOT judged here: `objectName`. `ActionSchema` parses an action + * embedded on an object (object implicit) and a standalone one in + * `stack.actions` (only `objectName` can say), so the standalone form is judged + * where standalone-ness is knowable — `defineStack`'s cross-reference walk + * (`stack.zod.ts`, `collectGlobalUpdateActionErrors`). A page action row acts on + * its own `objectName` or on the page's object, which the row cannot see either. + * + * What differs between an action and a row is only what a + * {@link DeclarativeUpdateSurface} says: the key that spells the executor, + * whether that key has a default, the subject the `onSuccess` refusal opens + * with, and whether `params` has an object form the write reads. Every rule and + * every text is the same function of it. On `ActionSchema`'s surface each text + * is byte-for-byte the action's own. + * + * Why a module of its own: `action-on-success-executor.ts`'s reason. The two + * schema files must apply the SAME judge; `component.zod.ts` already imports + * `action.zod.ts`, so the judge cannot live in the rows' file, and exported from + * `action.zod.ts` it would ride the `ui` barrel into the published API. Reached + * by relative import only. + */ + +/** What one surface tells the declarative-update judge. */ +export interface DeclarativeUpdateSurface { + /** + * The key that names the executor: `type` on an action and on an + * `action:group` / `action:menu` member, `actionType` on a page node. + */ + readonly executorKey: 'type' | 'actionType'; + /** + * `true` on `ActionSchema`, whose `type` defaults to `'script'`, the route the + * write is posted to. A row's executor key has no default. + */ + readonly executorDefaultsToScript: boolean; + /** Opens the refusal of `onSuccess` beside the operation ({@link onSuccessBesideUpdateOperationRefusal}). */ + readonly onSuccessSubject: string; + /** + * `true` where a non-empty OBJECT `params` holds static field values the + * write merges in, besides the array the dialog collects: an `action:button` + * / `action:icon` node, whose `params` IS `properties.params`, the static + * values the renderer forwards (`readStaticParamValues`) and the runner's + * update merges OVER `patch` (`executeUpdateOperation`). An action's + * `params` is the input list only, and so is a container member's, whose + * object `params` never reaches a non-`api` executor. + */ + readonly staticParams: boolean; +} + +/** `` `type: 'api'` `` on an action or a member, `` `actionType: 'api'` `` on a page node. */ +const spell = (surface: DeclarativeUpdateSurface, executor: string): string => + `\`${surface.executorKey}: '${executor}'\``; + +/** {@link spell} with its indefinite article: `actionType` takes `an`. */ +const aSpelled = (surface: DeclarativeUpdateSurface, executor: string): string => + `${surface.executorKey === 'actionType' ? 'an' : 'a'} ${spell(surface, executor)}`; + +/** {@link aSpelled}, opening a sentence. */ +const ASpelled = (surface: DeclarativeUpdateSurface, executor: string): string => + `${surface.executorKey === 'actionType' ? 'An' : 'A'} ${spell(surface, executor)}`; + +/** + * The refusal of an executor other than `'script'` beside `operation: + * 'update'`, at the executor key. On `ActionSchema` the key stays at its + * default; a row's key has none, so it names that route or nothing. + */ +function updateOperationExecutorRefusal(surface: DeclarativeUpdateSurface, executor: string): string { + const keep = surface.executorDefaultsToScript + ? `\`${surface.executorKey}\` stays at its default \`'script'\`` + : `\`${surface.executorKey}\` names that route or nothing`; + return "`operation: 'update'` is performed by the platform action route — the route " + + `${aSpelled(surface, 'script')} action posts to — so ${keep}; leave it off. ` + + `${ASpelled(surface, executor)} action dispatches on \`target\` (\`api\` makes the browser call the endpoint itself) ` + + 'and never performs the write.'; +} + +/** + * The keys an `operation: 'update'` action must not carry, each with the + * sentence that names the executor the key belongs to. A table rather than a + * chain of `.refine()`s so every combination gets ONE issue at ITS key's path + * — the same shape `BulkActionDefSchema`'s refinements use for the def side. + * + * A key a surface does not declare never reaches the judge: a page action row + * is a strict object, so it refuses `body`, `recordIdParam`, + * `outcomeMessages`, `opensInNewTab` and `newTabUrl` (and `recordIdField` on + * every row but `action:button`) as unknown keys before any refinement runs. + */ +const DECLARATIVE_UPDATE_REFUSED_KEYS: ReadonlyArray< + readonly [key: string, why: (surface: DeclarativeUpdateSurface) => string] +> = [ + ['target', (s) => + "`target` names a handler, URL, page, flow or endpoint — an `operation: 'update'` action dispatches on " + + 'none of them: the platform action route writes `patch` to the current record. Drop `target`; to call ' + + `an endpoint of your own instead, use ${spell(s, 'api')} without \`operation\`.`], + ['body', (s) => + `\`body\` is the inline handler of ${aSpelled(s, 'script')} action — an \`operation: 'update'\` action has no ` + + 'handler: the write IS the `patch`, performed by the platform as the caller. Drop `body`; to write ' + + 'through a handler of your own (system-elevated `ctx.api`), drop `operation` and `patch` instead.'], + ['method', (s) => + `\`method\` is the HTTP verb of ${aSpelled(s, 'api')} request — an \`operation: 'update'\` action makes no request ` + + 'of its own: the platform performs the write on the data plane. Drop `method`.'], + ['bodyExtra', (s) => + `\`bodyExtra\` is the static request body of ${aSpelled(s, 'api')} action — on an \`operation: 'update'\` action ` + + 'the static field values go in `patch`. Move them there and drop `bodyExtra`.'], + ['bodyShape', (s) => + `\`bodyShape\` shapes ${aSpelled(s, 'api')} request body — an \`operation: 'update'\` action sends no request body ` + + 'of its own. Drop `bodyShape`.'], + ['recordIdParam', () => + "`recordIdParam` injects the row id into a request body — an `operation: 'update'` action binds the " + + 'current record from the invocation context and has no body to inject into. Drop `recordIdParam`.'], + ['recordIdField', () => + "`recordIdField` seeds `recordIdParam` — an `operation: 'update'` action binds the current record from " + + 'the invocation context and injects nothing. Drop `recordIdField`.'], + // One text with the executor half of the rows' `onSuccess` rule + // (`action-on-success-executor.ts`). + ['onSuccess', (s) => onSuccessBesideUpdateOperationRefusal(s.onSuccessSubject)], + ['outcomeMessages', () => + "`outcomeMessages` picks success copy by the `outcome` a handler returns — an `operation: 'update'` " + + 'action has no handler: the platform performs the write and reports no outcome, so no entry could ' + + 'ever be chosen. Drop it; a fixed confirmation for the write is `successMessage`.'], + ['opensInNewTab', () => + "`opensInNewTab` pre-opens a tab for a handler-returned `{ redirectUrl }` — an `operation: 'update'` " + + 'action has no handler and returns no redirect. Drop it.'], + ['newTabUrl', () => + "`newTabUrl` is the zero-roundtrip target of the pre-opened-tab flow — an `operation: 'update'` action " + + 'has no such flow. Drop it.'], +]; + +/** Whether `params` gives the write field values on `surface` (rule 4). */ +function paramsWrite(surface: DeclarativeUpdateSurface, params: unknown): boolean { + if (Array.isArray(params)) return params.length > 0; + return surface.staticParams && params !== null && typeof params === 'object' && Object.keys(params).length > 0; +} + +/** + * The declarative-update judge for `surface`: the five rules above, as one + * refinement. `ActionSchema` and each page action row pass it to + * `superRefine` with their own {@link DeclarativeUpdateSurface}. + */ +export function refuseDeclarativeUpdateContradictions(surface: DeclarativeUpdateSurface) { + return (data: Record, ctx: z.core.$RefinementCtx): void => { + if (data.operation !== 'update') { + if (data.patch !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['patch'], + message: + "`patch` only applies to `operation: 'update'` — without it the action never writes fields, so " + + "these values would be silently dropped. Add `operation: 'update'`, or for the static request " + + `body of ${aSpelled(surface, 'api')} action use \`bodyExtra\`.`, + }); + } + return; + } + + // An empty row executor key names nothing, as the rows read it everywhere + // else (`effectiveRowExecutor`); `ActionSchema`'s `type` is never empty. + const executor = data[surface.executorKey]; + if (typeof executor === 'string' && executor !== '' && executor !== 'script') { + ctx.addIssue({ + code: 'custom', + path: [surface.executorKey], + message: updateOperationExecutorRefusal(surface, executor), + }); + } + + for (const [key, why] of DECLARATIVE_UPDATE_REFUSED_KEYS) { + if (data[key] !== undefined) { + ctx.addIssue({ code: 'custom', path: [key], message: why(surface) }); + } + } + + if (data.patch === undefined && !paramsWrite(surface, data.params)) { + ctx.addIssue({ + code: 'custom', + path: ['patch'], + message: + "An `operation: 'update'` action has nothing to write: declare a static `patch` (`{ status: 'done' }`), " + + 'or `params` so the dialog collects the values, or both — the patch is merged UNDER the collected params.', + }); + } + + if (Array.isArray(data.locations)) { + data.locations.forEach((location, index) => { + if (location !== 'list_toolbar') return; + ctx.addIssue({ + code: 'custom', + path: ['locations', index], + message: + "`list_toolbar` has no current record — an `operation: 'update'` action writes ONE record, the one it " + + 'runs on. Use a record location (`list_item`, `record_header`, `record_more`, `record_section`) or, ' + + "for the selection bar, a `bulkActionDefs` entry on the list view (`{ operation: 'update', patch }`).", + }); + }); + } + }; +} diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 9defb530cc8..77db3452d9f 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -35,11 +35,8 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { lazySchema } from '../shared/lazy-schema'; import { aiJsonSchemaSlot } from '../shared/ai-json-schema-slot'; import { ACTION_TARGET_ALIASES } from './action-target-aliases'; -import { - ON_SUCCESS_EXECUTORS, - onSuccessBesideExecutorRefusal, - onSuccessBesideUpdateOperationRefusal, -} from './action-on-success-executor'; +import { ON_SUCCESS_EXECUTORS, onSuccessBesideExecutorRefusal } from './action-on-success-executor'; +import { refuseDeclarativeUpdateContradictions, type DeclarativeUpdateSurface } from './action-declarative-update'; import { navigationTargetRefusal } from './navigation-target'; /** @@ -1863,147 +1860,29 @@ const actionObject = () => strictObject({ }); /** - * The keys an `operation: 'update'` action must not carry, each with the - * sentence that names the executor the key belongs to. A table rather than a - * chain of `.refine()`s so every combination gets ONE issue at ITS key's path - * — the same shape `BulkActionDefSchema`'s refinements use for the def side. + * `ActionSchema`'s surface of the declarative-update judge + * (`action-declarative-update.ts`, which carries the rule and every text, and + * applies the same judge to the page action rows). Its five rules, in brief: + * `patch` only beside `operation: 'update'`; beside it, `type` stays at its + * default `'script'` (the platform action route), every key that binds or + * shapes a dispatch the update does not make is refused at its own path, there + * is a `patch` or a non-empty `params` to write, and no `list_toolbar` + * location. `objectName` is judged by `defineStack`'s walk instead + * (`stack.zod.ts`, `collectGlobalUpdateActionErrors`). */ -const DECLARATIVE_UPDATE_REFUSED_KEYS: ReadonlyArray = [ - ['target', - "`target` names a handler, URL, page, flow or endpoint — an `operation: 'update'` action dispatches on " - + 'none of them: the platform action route writes `patch` to the current record. Drop `target`; to call ' - + "an endpoint of your own instead, use `type: 'api'` without `operation`."], - ['body', - "`body` is the inline handler of a `type: 'script'` action — an `operation: 'update'` action has no " - + 'handler: the write IS the `patch`, performed by the platform as the caller. Drop `body`; to write ' - + 'through a handler of your own (system-elevated `ctx.api`), drop `operation` and `patch` instead.'], - ['method', - "`method` is the HTTP verb of a `type: 'api'` request — an `operation: 'update'` action makes no request " - + 'of its own: the platform performs the write on the data plane. Drop `method`.'], - ['bodyExtra', - "`bodyExtra` is the static request body of a `type: 'api'` action — on an `operation: 'update'` action " - + 'the static field values go in `patch`. Move them there and drop `bodyExtra`.'], - ['bodyShape', - "`bodyShape` shapes a `type: 'api'` request body — an `operation: 'update'` action sends no request body " - + 'of its own. Drop `bodyShape`.'], - ['recordIdParam', - "`recordIdParam` injects the row id into a request body — an `operation: 'update'` action binds the " - + 'current record from the invocation context and has no body to inject into. Drop `recordIdParam`.'], - ['recordIdField', - "`recordIdField` seeds `recordIdParam` — an `operation: 'update'` action binds the current record from " - + 'the invocation context and injects nothing. Drop `recordIdField`.'], - // One text with the page action rows' refusal of the same pairing - // (`action-on-success-executor.ts`). - ['onSuccess', onSuccessBesideUpdateOperationRefusal("An `operation: 'update'` action's `onSuccess`")], - ['outcomeMessages', - "`outcomeMessages` picks success copy by the `outcome` a handler returns — an `operation: 'update'` " - + 'action has no handler: the platform performs the write and reports no outcome, so no entry could ' - + 'ever be chosen. Drop it; a fixed confirmation for the write is `successMessage`.'], - ['opensInNewTab', - "`opensInNewTab` pre-opens a tab for a handler-returned `{ redirectUrl }` — an `operation: 'update'` " - + 'action has no handler and returns no redirect. Drop it.'], - ['newTabUrl', - "`newTabUrl` is the zero-roundtrip target of the pre-opened-tab flow — an `operation: 'update'` action " - + 'has no such flow. Drop it.'], -]; - -/** - * The mixing rule between the declarative single-record field write and the - * rest of the action vocabulary (#14092), designed with the PR that landed it - * and pinned in `action-row-update.test.ts`: - * - * 1. `operation: 'update'` keeps `type` at its default `'script'` — the - * platform action route — and any OTHER explicit `type` is refused. An - * explicit `type: 'script'` is indistinguishable from the materialized - * default at object level (a `.default()` has already run by the time a - * refinement sees the object — the #13897 asymmetry) and means the same - * thing, so it is accepted. - * 2. Every key that names or shapes a dispatch the update does not make is - * refused at its own path ({@link DECLARATIVE_UPDATE_REFUSED_KEYS}). - * 3. `patch` without `operation: 'update'` is refused (it would be silently - * dropped — ADR-0078); `operation: 'update'` with neither `patch` nor - * `params` is refused (nothing to write). - * 4. `list_toolbar` is refused: no current record there. - * - * Deliberately NOT judged here: `objectName`. The same schema parses an - * action embedded on an object (object implicit) and a standalone one in - * `stack.actions` (only `objectName` can say), so the standalone form is - * judged where standalone-ness is knowable — `defineStack`'s cross-reference - * walk (`stack.zod.ts`, `collectGlobalUpdateActionErrors`). - */ -function refuseDeclarativeUpdateContradictions( - data: { - type?: string; - operation?: string; - patch?: unknown; - params?: unknown; - locations?: unknown; - } & Record, - ctx: z.core.$RefinementCtx, -): void { - if (data.operation !== 'update') { - if (data.patch !== undefined) { - ctx.addIssue({ - code: 'custom', - path: ['patch'], - message: - "`patch` only applies to `operation: 'update'` — without it the action never writes fields, so " - + "these values would be silently dropped. Add `operation: 'update'`, or for the static request " - + "body of a `type: 'api'` action use `bodyExtra`.", - }); - } - return; - } - - if (data.type !== undefined && data.type !== 'script') { - ctx.addIssue({ - code: 'custom', - path: ['type'], - message: - "`operation: 'update'` is performed by the platform action route — the route a `type: 'script'` " - + "action posts to — so `type` stays at its default `'script'`; leave it off. A `type: '" - + `${data.type}'\` action dispatches on \`target\` (\`api\` makes the browser call the endpoint itself) ` - + 'and never performs the write.', - }); - } - - for (const [key, why] of DECLARATIVE_UPDATE_REFUSED_KEYS) { - if (data[key] !== undefined) { - ctx.addIssue({ code: 'custom', path: [key], message: why }); - } - } - - const collectsParams = Array.isArray(data.params) && data.params.length > 0; - if (data.patch === undefined && !collectsParams) { - ctx.addIssue({ - code: 'custom', - path: ['patch'], - message: - "An `operation: 'update'` action has nothing to write: declare a static `patch` (`{ status: 'done' }`), " - + 'or `params` so the dialog collects the values, or both — the patch is merged UNDER the collected params.', - }); - } - - if (Array.isArray(data.locations)) { - data.locations.forEach((location, index) => { - if (location !== 'list_toolbar') return; - ctx.addIssue({ - code: 'custom', - path: ['locations', index], - message: - "`list_toolbar` has no current record — an `operation: 'update'` action writes ONE record, the one it " - + 'runs on. Use a record location (`list_item`, `record_header`, `record_more`, `record_section`) or, ' - + "for the selection bar, a `bulkActionDefs` entry on the list view (`{ operation: 'update', patch }`).", - }); - }); - } -} +const ACTION_DECLARATIVE_UPDATE: DeclarativeUpdateSurface = { + executorKey: 'type', + executorDefaultsToScript: true, + onSuccessSubject: "An `operation: 'update'` action's `onSuccess`", + staticParams: false, +}; /** * The two places `outcomeMessages` (#21095) would parse clean and never be * read — refused at the key's own path, each with the rewrite. The third, - * `operation: 'update'`, lives in {@link DECLARATIVE_UPDATE_REFUSED_KEYS} - * beside `onSuccess`, the sibling key it is refused for the same reason as. + * `operation: 'update'`, lives in the declarative-update judge's refused-key + * table (`action-declarative-update.ts`) beside `onSuccess`, the sibling key it + * is refused for the same reason as. * * 1. A type with no server response. Only `type: 'api'` (the `target` call's * response body) and `type: 'script'` (the handler's return value) hand @@ -2326,7 +2205,7 @@ export const ActionSchema = lazySchema(() => actionObject().refine((data) => { + "`operation: 'update'` with a `patch` for a declarative single-record write, or make this a " + "`type: 'api'` action calling an endpoint of your own — otherwise drop `undoable`.", path: ['undoable'], -}).superRefine(refuseDeclarativeUpdateContradictions) +}).superRefine(refuseDeclarativeUpdateContradictions(ACTION_DECLARATIVE_UPDATE)) .superRefine(refuseInertOutcomeMessages) // Grade gate first, feature gate last: `requiresFeature` composes onto // whatever `visible` it finds, so this order keeps `features.*` the final diff --git a/packages/spec/src/ui/component-action-row-declarative-update.test.ts b/packages/spec/src/ui/component-action-row-declarative-update.test.ts new file mode 100644 index 00000000000..7017029d70e --- /dev/null +++ b/packages/spec/src/ui/component-action-row-declarative-update.test.ts @@ -0,0 +1,340 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The page action rows — `action:button`, `action:icon` and an `action:group` / + * `action:menu` member — judged by `ActionSchema`'s declarative-update rules, + * from the one judge both read (`action-declarative-update.ts`). Every row + * forwards `operation`, `patch`, `params`, `target`, the executor key and + * `locations` to the console's action runner, which reads `operation` before + * any executor; so a row the action would refuse is refused here too, at the + * same key, with the action's text (the executor spelled as the row spells it). + * + * - §1 THE FIVE MEMBERS the family's closing card names, on every row: each is + * one issue at its key, and the same row made valid parses. + * - §2 ONE TEXT: a member's refusal is `ActionSchema`'s, derived from the + * action's own parse rather than restated here. + * - §3 THE REST OF THE SET: the refused-key table's other row keys, the keys a + * row does not declare (refused as unknown before the judge), the executor + * rule's edges, `params` as a source of values, and `patch` as the action's + * own record. + * - §4 OPERATION FIRST: beside the operation, the rows' url-target and + * executor-alias judgments stand down, so a row is never told to fix a key + * it must drop. + * + * `ActionSchema`'s own pins are `action-row-update.test.ts`, unchanged. A + * page's `properties` bag is outside the stack parse; `os validate`'s props + * gate parses it against these same rows. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; + +import { ActionSchema } from './action.zod'; +import { ActionButtonPropsSchema, ActionIconPropsSchema, ComponentPropsMap } from './component.zod'; + +type Issue = { code: string; path: PropertyKey[]; message: string }; +type Parsed = { success: boolean; data?: unknown; error?: { issues: unknown[] } }; +const issuesOf = (r: Parsed): Issue[] => (r.success ? [] : (r.error?.issues ?? [])) as Issue[]; + +type Parser = { safeParse: (value: unknown) => Parsed }; +const GROUP = ComponentPropsMap['action:group'] as unknown as Parser; +const MENU = ComponentPropsMap['action:menu'] as unknown as Parser; + +interface Row { + readonly name: string; + readonly parse: (action: Record) => Parsed; + /** The issue path prefix of the row's own keys. */ + readonly at: readonly PropertyKey[]; + readonly executorKey: 'actionType' | 'type'; + /** A page node (`action:button` / `action:icon`), whose object `params` is its static values. */ + readonly node: boolean; +} + +const ROWS: readonly Row[] = [ + { + name: 'action:button', + parse: (action) => ActionButtonPropsSchema.safeParse({ label: 'Close', ...action }), + at: [], + executorKey: 'actionType', + node: true, + }, + { + name: 'action:icon', + parse: (action) => ActionIconPropsSchema.safeParse({ icon: 'check', label: 'Close', ...action }), + at: [], + executorKey: 'actionType', + node: true, + }, + { + name: 'action:group member', + parse: (action) => GROUP.safeParse({ actions: [{ label: 'Close', ...action }] }), + at: ['actions', 0], + executorKey: 'type', + node: false, + }, + { + name: 'action:menu member', + parse: (action) => MENU.safeParse({ actions: [{ label: 'Close', ...action }] }), + at: ['actions', 0], + executorKey: 'type', + node: false, + }, +]; + +/** `ActionSchema`'s issues for the same keys, on a registered action. */ +const actionIssues = (keys: Record): Issue[] => + issuesOf(ActionSchema.safeParse({ name: 'close_task', label: 'Close', ...keys })); + +/** + * `ActionSchema`'s issue at `path` for the same keys. The action carries rules + * a row does not (a bare `script` action needs a `body` or a `target`, a `url` + * action a `target`), so its issue is found by path, not by position. + */ +const actionIssueAt = (keys: Record, path: readonly PropertyKey[]): Issue => { + const issue = actionIssues(keys).find((i) => i.path.join('.') === path.join('.')); + expect(issue, `ActionSchema raises nothing at ${path.join('.')}`).toBeDefined(); + return issue as Issue; +}; + +/** + * The action's text as a row spells its executor: the same text on a member, + * whose executor key is `type` as the action's is; `actionType: '…'` on a node. + */ +const asRowSpells = (row: Row, actionText: string): string => + row.executorKey === 'type' + ? actionText + : actionText.replaceAll("a `type: '", "an `actionType: '").replaceAll("`type: '", "`actionType: '"); + +const UPDATE = { operation: 'update', patch: { status: 'done' } } as const; +const STATUS_PARAM = [{ name: 'status', type: 'text', label: 'Status' }]; + +// =========================================================================== +// §1 + §2. The five members, on every row +// =========================================================================== + +interface Member { + readonly title: string; + /** The row's keys; `k` is the row's executor key. */ + readonly keys: (k: string) => Record; + /** Where the one issue lands, relative to the row. */ + readonly at: (k: string) => PropertyKey[]; + /** The same row made valid. */ + readonly control: (k: string) => Record; +} + +const MEMBERS: readonly Member[] = [ + { + title: '1. `patch` without `operation`', + keys: () => ({ patch: { status: 'done' } }), + at: () => ['patch'], + control: () => ({ ...UPDATE }), + }, + { + title: "2. `operation: 'update'` + `target`", + keys: () => ({ ...UPDATE, target: 'close_task_handler' }), + at: () => ['target'], + control: () => ({ ...UPDATE }), + }, + { + title: "3. `operation: 'update'` + a `url` executor", + keys: (k) => ({ ...UPDATE, [k]: 'url' }), + at: (k) => [k], + control: (k) => ({ ...UPDATE, [k]: 'script' }), + }, + { + title: "4. `operation: 'update'` with no `patch` and no `params`", + keys: () => ({ operation: 'update' }), + at: () => ['patch'], + control: () => ({ operation: 'update', params: STATUS_PARAM }), + }, + { + title: "5. `operation: 'update'` + a `list_toolbar` location", + keys: () => ({ ...UPDATE, locations: ['record_header', 'list_toolbar'] }), + at: () => ['locations', 1], + control: () => ({ ...UPDATE, locations: ['record_header'] }), + }, +]; + +const ROW_MEMBERS = ROWS.flatMap((row) => MEMBERS.map((member) => [row.name, member.title, row, member] as const)); + +describe('the five declarative-update shapes the rows accepted while `ActionSchema` refused them', () => { + it.each(ROW_MEMBERS)('REFUSAL: %s, member %s, is ONE issue at its key', (_name, _title, row, member) => { + const issues = issuesOf(row.parse(member.keys(row.executorKey))); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, ...member.at(row.executorKey)]]]); + }); + + it.each(ROW_MEMBERS)('CONTROL: %s, member %s, made valid, parses', (_name, _title, row, member) => { + const r = row.parse(member.control(row.executorKey)); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + }); + + it.each(MEMBERS)('the action refuses member $title at the same key (the family premise)', (member) => { + expect(actionIssueAt(member.keys('type'), member.at('type')).code).toBe('custom'); + }); +}); + +describe("ONE TEXT: a row's refusal is `ActionSchema`'s, with the executor spelled as the row spells it", () => { + // Member 3's text is the one with a row clause (a row's key has no default); + // it is judged on its own below. + const SAME_TEXT = MEMBERS.filter((member) => !member.title.startsWith('3.')); + + it.each(ROWS.flatMap((row) => SAME_TEXT.map((member) => [row.name, member.title, row, member] as const)))( + '%s, member %s', + (_name, _title, row, member) => { + const [rowIssue] = issuesOf(row.parse(member.keys(row.executorKey))); + const actionIssue = actionIssueAt(member.keys('type'), member.at('type')); + expect(rowIssue.message).toBe(asRowSpells(row, actionIssue.message)); + }, + ); + + it.each(ROWS)("$name: member 3 names the row's executor spelling, and no default the row does not have", (row) => { + const [issue] = issuesOf(row.parse({ ...UPDATE, [row.executorKey]: 'url' })); + expect(issue.message).toContain(`\`${row.executorKey}: 'url'\``); + expect(issue.message).toContain(`\`${row.executorKey}: 'script'\``); + expect(issue.message).not.toContain('default'); + // The action's own text keeps its default clause (`action-row-update.test.ts`). + expect(actionIssueAt({ ...UPDATE, type: 'url' }, ['type']).message).toContain("stays at its default `'script'`"); + }); + + it.each(ROWS)('$name: on a node the spelling is `actionType`, never `type`', (row) => { + const [issue] = issuesOf(row.parse({ ...UPDATE, target: 'close_task_handler' })); + if (row.executorKey === 'actionType') { + expect(issue.message).toContain("`actionType: 'api'`"); + expect(issue.message).not.toContain("`type: '"); + } else { + expect(issue.message).toContain("`type: 'api'`"); + } + }); +}); + +// =========================================================================== +// §3. The rest of the set +// =========================================================================== + +/** The refused-key table's keys each row declares, with a value of the key's own shape. */ +const DECLARED_REFUSED: ReadonlyArray = [ + ['method', 'PATCH', undefined], + ['bodyExtra', { status: 'done' }, undefined], + ['bodyShape', { wrap: 'data' }, undefined], + ['recordIdField', 'token', 'action:button'], +]; + +describe("the refused-key table's other row keys, beside `operation: 'update'`", () => { + const cases = ROWS.flatMap((row) => + DECLARED_REFUSED.filter(([, , only]) => only === undefined || only === row.name) + .map(([key, value]) => [row.name, key, row, key, value] as const)); + + it.each(cases)('REFUSAL: %s with `%s` is ONE issue at that key, with the action\'s text', (_name, _key, row, key, value) => { + const issues = issuesOf(row.parse({ ...UPDATE, [key]: value })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, key]]]); + expect(issues[0].message).toBe(asRowSpells(row, actionIssueAt({ ...UPDATE, [key]: value }, [key]).message)); + }); + + it.each(cases)('CONTROL: %s with `%s` and no `operation` parses', (_name, _key, row, key, value) => { + const r = row.parse({ [row.executorKey]: 'api', target: '/api/v1/close', [key]: value }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + }); + + it.each(ROWS)('$name: a table key the row does not declare is refused as unknown, before the judge runs', (row) => { + const undeclared = ['body', 'recordIdParam', 'outcomeMessages', 'opensInNewTab', 'newTabUrl', + ...(row.name === 'action:button' ? [] : ['recordIdField'])]; + for (const key of undeclared) { + const issues = issuesOf(row.parse({ ...UPDATE, [key]: 'x' })); + expect(issues.map((i) => [i.code, i.path]), key).toEqual([['unrecognized_keys', [...row.at]]]); + } + }); +}); + +describe("the executor rule's edges on a row", () => { + it.each(ROWS)("$name: `'script'`, an empty key and no key are accepted beside the operation", (row) => { + for (const executor of ['script', '', undefined]) { + const r = row.parse({ ...UPDATE, ...(executor === undefined ? {} : { [row.executorKey]: executor }) }); + expect(r.success, `${String(executor)}: ${JSON.stringify(issuesOf(r))}`).toBe(true); + } + }); + + it.each(ROWS)('$name: every other executor is refused at the key, a host handler included', (row) => { + for (const executor of ['url', 'modal', 'flow', 'api', 'form', 'run']) { + const issues = issuesOf(row.parse({ ...UPDATE, [row.executorKey]: executor })); + expect(issues.map((i) => [i.code, i.path]), executor).toEqual([['custom', [...row.at, row.executorKey]]]); + } + }); +}); + +describe('`params` as the source of the values to write', () => { + it.each(ROWS)('$name: a non-empty input list counts; an empty one does not', (row) => { + expect(row.parse({ operation: 'update', params: STATUS_PARAM }).success).toBe(true); + const issues = issuesOf(row.parse({ operation: 'update', params: [] })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, 'patch']]]); + }); + + it.each(ROWS.filter((row) => row.node))( + '$name: a non-empty static-values object counts (it IS `properties.params`, merged by the write); an empty one does not', + (row) => { + const r = row.parse({ operation: 'update', params: { status: 'done' } }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + const issues = issuesOf(row.parse({ operation: 'update', params: {} })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, 'patch']]]); + }, + ); + + it.each(ROWS.filter((row) => !row.node))( + '$name: an object `params` does not count — the container never forwards it to a non-`api` executor', + (row) => { + const issues = issuesOf(row.parse({ operation: 'update', params: { status: 'done' } })); + expect(issues.map((i) => [i.code, i.path]).sort()).toEqual([ + ['custom', [...row.at, 'params']], + ['custom', [...row.at, 'patch']], + ].sort()); + }, + ); +}); + +describe("a row's `patch` is `ActionSchema`'s own record of field values", () => { + type Shape = { shape: Record> }; + const memberOf = (props: unknown) => + ((props as Shape).shape.actions.unwrap() as unknown as z.ZodArray).element as unknown as Shape; + + it('NO SECOND SHAPE: every row holds the very schema instance `ActionSchema.patch` declares', () => { + const patch = ActionSchema.in.shape.patch.unwrap(); + expect((ActionButtonPropsSchema as unknown as Shape).shape.patch.unwrap()).toBe(patch); + expect((ActionIconPropsSchema as unknown as Shape).shape.patch.unwrap()).toBe(patch); + expect(memberOf(GROUP).shape.patch.unwrap()).toBe(patch); + expect(memberOf(MENU).shape.patch.unwrap()).toBe(patch); + }); + + it.each(ROWS)('$name: a `patch` that is not a record is refused at `patch` — the runner drops it before the write', (row) => { + for (const patch of ['done', ['status', 'done'], 1]) { + const issues = issuesOf(row.parse({ operation: 'update', patch })); + expect(issues.map((i) => [i.code, i.path]), JSON.stringify(patch)).toEqual([['invalid_type', [...row.at, 'patch']]]); + } + }); +}); + +// =========================================================================== +// §4. Operation first: the rows' executor-dependent judgments stand down +// =========================================================================== + +describe('beside the operation, the url-target and executor-alias judgments stand down', () => { + it.each(ROWS)('$name: a `url` executor and a `javascript:` target are refused as the update refuses them, not as a url target', (row) => { + const issues = issuesOf(row.parse({ ...UPDATE, [row.executorKey]: 'url', target: 'javascript:alert(1)' })); + expect(issues.map((i) => [i.code, i.path])).toEqual([ + ['custom', [...row.at, row.executorKey]], + ['custom', [...row.at, 'target']], + ]); + expect(issues[1].message).toBe(asRowSpells(row, actionIssueAt({ ...UPDATE, target: 'javascript:alert(1)' }, ['target']).message)); + }); + + it.each(ROWS)("$name: `'navigation'` is ONE issue, the update's, never a rename onto `'url'`", (row) => { + const issues = issuesOf(row.parse({ ...UPDATE, [row.executorKey]: 'navigation' })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, row.executorKey]]]); + expect(issues[0].message).toContain(`\`${row.executorKey}: 'navigation'\``); + }); + + it.each(ROWS)('$name: CONTROL — without the operation both judgments still fire', (row) => { + const target = issuesOf(row.parse({ [row.executorKey]: 'url', target: 'javascript:alert(1)' })); + expect(target.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, 'target']]]); + const alias = issuesOf(row.parse({ [row.executorKey]: 'navigation', target: '/tasks' })); + expect(alias.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, row.executorKey]]]); + }); +}); diff --git a/packages/spec/src/ui/component-action-row-on-success-executor.test.ts b/packages/spec/src/ui/component-action-row-on-success-executor.test.ts index 856f010cbee..13f511e471a 100644 --- a/packages/spec/src/ui/component-action-row-on-success-executor.test.ts +++ b/packages/spec/src/ui/component-action-row-on-success-executor.test.ts @@ -390,8 +390,13 @@ describe("a page action row's `operation` is `ActionSchema`'s own enum", () => { }); describe("a page action row's `onSuccess` is refused beside `operation: 'update'`, as `ActionSchema` refuses it", () => { - /** No executor, the two the hop is declared beside, and a host handler: the write runs first on each. */ - const BESIDE = [undefined, 'api', 'script', 'run'] as const; + /** + * The two executors a row may name beside `operation: 'update'`: none, and + * `'script'`, the route the write is posted to. Every other executor beside + * it is refused at the executor key by the declarative-update judge, pinned + * in `component-action-row-declarative-update.test.ts`. + */ + const BESIDE = [undefined, 'script'] as const; const updateRow = (row: Row, executor: string | undefined, extra: Record = {}) => row.parse({ ...(executor === undefined ? {} : { [row.executorKey]: executor }), ...UPDATE, ...extra }); const updateSubject = (row: Row) => onSuccessSubject(row, "`operation: 'update'`"); @@ -419,7 +424,7 @@ describe("a page action row's `onSuccess` is refused beside `operation: 'update' ); it.each(ROWS)("ONE TEXT: $name gives `ActionSchema`'s own refusal of the pairing, after the subject", (row) => { - const rowMessage = issuesOf(updateRow(row, 'api', { onSuccess: HOP }))[0].message; + const rowMessage = issuesOf(updateRow(row, 'script', { onSuccess: HOP }))[0].message; expect(rowMessage.slice(updateSubject(row).length)) .toBe(actionUpdateOnSuccessMessage().slice(ACTION_UPDATE_SUBJECT.length)); }); 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 025f56e848d..4a077bd23c7 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 @@ -243,8 +243,11 @@ const on = (types: readonly string[], paths: readonly string[], reason: Reason): // `onSuccess` left this list when the rows took `ActionSchema`'s own // post-success block, and `operation` when they took the action's own -// `operation` enum (`component-action-row-on-success-executor.test.ts`). -const ACTION_RUNNER_PATHS = ['params', 'bodyExtra', 'bodyShape', 'patch', 'toast', 'resultDialog']; +// `operation` enum (`component-action-row-on-success-executor.test.ts`); +// `patch` left it when they took the action's own record of field values, +// whose values are a `records` line below +// (`component-action-row-declarative-update.test.ts`). +const ACTION_RUNNER_PATHS = ['params', 'bodyExtra', 'bodyShape', 'toast', 'resultDialog']; const VIEW_DATA_TYPES = ['object-grid', 'object-map', 'object-gantt', 'object-tree']; // Composition slots. @@ -292,6 +295,10 @@ on(['object-kanban', 'object-timeline'], ['data[]'], RECORDS); on(['object-calendar'], ['data[]', 'staticData[]'], RECORDS); on(['object-form', 'object-master-detail-form'], ['initialValues{}', 'initialData{}'], RECORDS); on(['object-grid'], ['bulkActionDefs[].patch{}', 'bulkActionDefs[].params[].default'], RECORDS); +// A page action row's `patch` is `ActionSchema`'s record of static field +// values, by reference: each value is one field's. +on(['action:button', 'action:icon'], ['patch{}'], RECORDS); +on(['action:group', 'action:menu'], ['actions[].patch{}'], RECORDS); // A static board's card is a record row: `id` and `title` are typed, the rest // is the row's own values (`plugin-kanban/src/index.tsx:155`, kept verbatim). on(['object-kanban'], ['columns[].cards[].*'], RECORDS); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index fa2fcbf2ecf..54e3895c3d8 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -59,11 +59,8 @@ import { AggregationFunction } from '../data/query.zod'; import { ListViewExportOptionsSchema } from './list-view-export-options'; import { ActionSchema, ActionType, InlineActionSchema, ActionLocationSchema } from './action.zod'; import { ACTION_TARGET_ALIASES } from './action-target-aliases'; -import { - ON_SUCCESS_EXECUTORS, - onSuccessBesideExecutorRefusal, - onSuccessBesideUpdateOperationRefusal, -} from './action-on-success-executor'; +import { ON_SUCCESS_EXECUTORS, onSuccessBesideExecutorRefusal } from './action-on-success-executor'; +import { refuseDeclarativeUpdateContradictions } from './action-declarative-update'; import { navigationTargetRefusal } from './navigation-target'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; // [#21464] `object-metric.aggregate.groupBy` is the chart aggregate's own @@ -4143,12 +4140,18 @@ const ROW_URL_NAVIGATOR_EXECUTORS: ReadonlySet = new Set(['url', 'naviga * the spelling that resolved the executor, so an `actionType: 'url'` row reads * as it always has, and a name-leg row learns that declaring the executor key * is how a `name` stops choosing the url navigator. + * + * Beside `operation: 'update'` the runner runs no executor, so no url + * navigator reads `target`: the declarative-update judge + * ({@link rowDeclarativeUpdate}) refuses `target` there whole, and this + * judgment stands down rather than prescribe a scheme for a key to be dropped. */ function refuseUnopenableRowUrlTarget(executorKey: 'actionType' | 'type', owner: string) { return ( - row: { actionType?: string; type?: string; name?: string; target?: string }, + row: { actionType?: string; type?: string; name?: string; target?: string; operation?: unknown }, ctx: z.RefinementCtx, ): void => { + if (row.operation === 'update') return; const executor = effectiveRowExecutor(row, executorKey); if (executor === undefined || !ROW_URL_NAVIGATOR_EXECUTORS.has(executor) || typeof row.target !== 'string') return; const spelled = row[executorKey] @@ -4184,10 +4187,15 @@ function refuseUnopenableRowUrlTarget(executorKey: 'actionType' | 'type', owner: * refusal. The `element:button` inline action keeps its own declared fold of * `type: 'navigation'` onto `'url'` (`normalizeInlineAction`, `action.zod.ts`), * a bridge for its measured producers. + * + * Beside `operation: 'update'` the declarative-update judge + * ({@link rowDeclarativeUpdate}) refuses every executor but `'script'`, the + * alias included, with "leave it off"; this rename onto `'url'` stands down + * there, so the row is never told both to write `'url'` and to drop the key. */ function refuseRowUrlExecutorAlias(executorKey: 'actionType' | 'type', subject: string) { - return (row: { actionType?: string; type?: string }, ctx: z.RefinementCtx): void => { - if (row[executorKey] !== 'navigation') return; + return (row: { actionType?: string; type?: string; operation?: unknown }, ctx: z.RefinementCtx): void => { + if (row.operation === 'update' || row[executorKey] !== 'navigation') return; ctx.addIssue({ code: 'custom', path: [executorKey], @@ -4248,11 +4256,26 @@ const actionOnSuccessBlock = () => ActionSchema.in.shape.onSuccess.unwrap(); * value parsed clean. * * What the enum does not bring: `ActionSchema`'s rules ABOUT `operation` are - * refinements of the action. The one about `onSuccess` is carried to the rows - * ({@link refuseRowOnSuccessBesideExecutor}), with the action's own text. + * refinements of the action. They are carried to the rows whole, by the one + * judge both read ({@link rowDeclarativeUpdate}), with the action's own texts. */ const actionOperation = () => ActionSchema.in.shape.operation.unwrap(); +/** + * A page action row's `patch`: `ActionSchema`'s own record of static field + * values, by reference, read off the object half of the action's pipe as + * {@link actionOperation} reads `operation`. ⛔ No second shape. + * + * Every row forwards `patch` to the runner beside `operation`, and the runner's + * update merges it only when it is a plain object (`executeUpdateOperation`, + * `ActionRunner.ts` at the pin: anything else becomes `{}`), as the platform + * route's write bag does (`declarativeUpdateWrite`). Until this, the rows + * declared `z.unknown()`, so a string, array or number `patch` parsed clean and + * was dropped before the write; and the declarative-update judge reads a + * present `patch` as something to write, which holds only for a record. + */ +const actionPatch = () => ActionSchema.in.shape.patch.unwrap(); + /** * The console runner's built-in executors: `ActionType`'s six, the members its * `builtinExecutors` table is typed by (`Record`, so the @@ -4290,27 +4313,22 @@ function rowOnSuccessRefused(executor: string | undefined): boolean { * with the subject naming the row and the spelling that resolved its executor, * as {@link refuseUnopenableRowUrlTarget} names it. Beside `operation: * 'update'` it is refused whatever the executor, as `ActionSchema` refuses it - * on every `type`, with that refusal's text. The issue lands at - * `onSuccess`, so `os validate`'s props gate reports it at `properties.onSuccess` - * or `properties.actions.N.onSuccess`. + * on every `type`, with that refusal's text — by the declarative-update judge + * ({@link rowDeclarativeUpdate}), whose refused-key table carries the pairing + * for the action and the rows alike, so this executor half stands down there. + * The issue lands at `onSuccess`, so `os validate`'s props gate reports it at + * `properties.onSuccess` or `properties.actions.N.onSuccess`. */ function refuseRowOnSuccessBesideExecutor(executorKey: 'actionType' | 'type', owner: string) { return ( row: { actionType?: string; type?: string; name?: string; operation?: unknown; onSuccess?: unknown }, ctx: z.RefinementCtx, ): void => { - if (row.onSuccess === undefined) return; // `operation: 'update'` first, as the runner reads it: the write runs // before any executor, and the row's executor key is never consulted. - // ONE issue, `ActionSchema`'s text for the same pairing. - if (row.operation === 'update') { - ctx.addIssue({ - code: 'custom', - path: ['onSuccess'], - message: onSuccessBesideUpdateOperationRefusal(`${owner} \`onSuccess\` (\`operation: 'update'\`)`), - }); - return; - } + // The pairing is the declarative-update judge's (ONE issue, the action's + // text), so nothing is judged here beside it. + if (row.onSuccess === undefined || row.operation === 'update') return; const executor = effectiveRowExecutor(row, executorKey); if (!rowOnSuccessRefused(executor)) return; const key = row[executorKey] === '' ? `an empty \`${executorKey}\`` : `no \`${executorKey}\``; @@ -4327,6 +4345,48 @@ function refuseRowOnSuccessBesideExecutor(executorKey: 'actionType' | 'type', ow }; } +/** + * `ActionSchema`'s declarative-update rules on a page action row: the one judge + * both read (`action-declarative-update.ts`), on the row's surface. ⛔ No + * second copy of a rule or a text. Every row forwards `operation`, `patch`, + * `params`, `target`, `method`, `bodyExtra`, `bodyShape`, `locations` and + * `onSuccess` to the console's action runner (and `action:button` its + * `recordIdField`), and the runner reads `operation` before any executor: the + * write is posted through its `script` dispatch, so beside it the row's + * executor and the keys that bind one are never read. So each rule lands on the + * row at the same path, with the action's text: + * + * - `patch` without `operation: 'update'` — refused at `patch`; + * - beside it, an executor key other than `'script'` — refused at that key + * (`actionType` on a node, `type` on a member). The row's key has no default, + * so the text says it names that route or nothing; an empty key names + * nothing, as {@link effectiveRowExecutor} reads it; + * - beside it, `target`, `method`, `bodyExtra`, `bodyShape`, `onSuccess` and + * (`action:button` only) `recordIdField` — each refused at its own path. The + * table's other keys are not row keys, so the strict row refuses them as + * unknown before this runs; + * - beside it, nothing to write — refused at `patch`. A node's non-empty + * OBJECT `params` counts: on `action:button` / `action:icon` it IS + * `properties.params`, whose static values the runner's update merges over + * `patch`. A member's does not (refused off `api` by + * {@link actionContainerMemberParamsFitType}, and never forwarded); + * - beside it, a `list_toolbar` location — refused at `locations.N`. + * + * Applied first on each row, as the runner reads `operation` first: the row's + * url-target, executor-alias and `onSuccess`-executor judgments stand down + * beside the operation, since each key they judge is refused here whole. + * `os validate`'s props gate parses `properties` against these same rows, so + * it reports each refusal at `properties.KEY` or `properties.actions.N.KEY`. + */ +function rowDeclarativeUpdate(executorKey: 'actionType' | 'type', owner: string) { + return refuseDeclarativeUpdateContradictions({ + executorKey, + executorDefaultsToScript: false, + onSuccessSubject: `${owner} \`onSuccess\` (\`operation: 'update'\`)`, + staticParams: executorKey === 'actionType', + }); +} + /** * Read-but-not-authorable keys shared by `action:button` and `action:icon`, * each read by the renderer and each refused here with what to write instead. @@ -4440,8 +4500,8 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ method: z.string().optional().describe('HTTP method for an `api` action, forwarded to the runner'), bodyExtra: z.unknown().optional().describe('Static request-body fields for an `api` action, forwarded to the runner'), bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'), - operation: actionOperation().optional().describe('Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`\'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own'), - patch: z.unknown().optional().describe('Field values the declarative `operation` writes, forwarded to the runner'), + operation: actionOperation().optional().describe('Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`\'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape`, `recordIdField` and `onSuccess` are refused, a `patch` or `params` gives the values to write, and `list_toolbar` is not a location'), + patch: actionPatch().optional().describe('Static field values the declarative `operation: \'update\'` writes, forwarded to the runner: `ActionSchema`\'s own record of field values, merged UNDER the values `params` supplies. Refused on a row without `operation: \'update\'` (it would be silently dropped)'), confirmText: z.string().optional().describe('Confirmation question asked before the action runs'), successMessage: z.string().optional().describe('Toast shown when the action succeeds'), errorMessage: z.string().optional().describe('Toast shown when the action fails, in place of the raw error'), @@ -4456,7 +4516,8 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ .describe('Post-success navigation, forwarded to the runner: `ActionSchema`\'s own `{ navigate, openIn }` block. Taken beside an `api` or `script` executor, or a handler a host registers under another name; refused beside the built-ins `url`, `navigation`, `modal`, `flow` and `form`, on a row that names no executor, and beside `operation: \'update\'`, whose write the runner performs before any executor. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab`'), objectName: z.string().optional() .describe('Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page\'s object. Omit to act on the page\'s object'), -}).superRefine(refuseUnopenableRowUrlTarget('actionType', "An `action:button`'s")) +}).superRefine(rowDeclarativeUpdate('actionType', "An `action:button`'s")) + .superRefine(refuseUnopenableRowUrlTarget('actionType', "An `action:button`'s")) .superRefine(refuseRowUrlExecutorAlias('actionType', "An `action:button`'s `actionType: 'navigation'`")) .superRefine(refuseRowOnSuccessBesideExecutor('actionType', "An `action:button`'s"))); /** Author state (ADR-0122: the bare name is the author state). */ @@ -4541,8 +4602,8 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ method: z.string().optional().describe('HTTP method for an `api` action, forwarded to the runner'), bodyExtra: z.unknown().optional().describe('Static request-body fields for an `api` action, forwarded to the runner'), bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'), - operation: actionOperation().optional().describe('Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`\'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own'), - patch: z.unknown().optional().describe('Field values the declarative `operation` writes, forwarded to the runner'), + operation: actionOperation().optional().describe('Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`\'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, a `patch` or `params` gives the values to write, and `list_toolbar` is not a location'), + patch: actionPatch().optional().describe('Static field values the declarative `operation: \'update\'` writes, forwarded to the runner: `ActionSchema`\'s own record of field values, merged UNDER the values `params` supplies. Refused on a row without `operation: \'update\'` (it would be silently dropped)'), confirmText: z.string().optional().describe('Confirmation question asked before the action runs'), successMessage: z.string().optional().describe('Toast shown when the action succeeds'), errorMessage: z.string().optional().describe('Toast shown when the action fails, in place of the raw error'), @@ -4555,7 +4616,8 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ .describe('Post-success navigation, forwarded to the runner: `ActionSchema`\'s own `{ navigate, openIn }` block. Taken beside an `api` or `script` executor, or a handler a host registers under another name; refused beside the built-ins `url`, `navigation`, `modal`, `flow` and `form`, on a row that names no executor, and beside `operation: \'update\'`, whose write the runner performs before any executor. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab`'), objectName: z.string().optional() .describe('Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page\'s object. Omit to act on the page\'s object'), -}).superRefine(refuseUnopenableRowUrlTarget('actionType', "An `action:icon`'s")) +}).superRefine(rowDeclarativeUpdate('actionType', "An `action:icon`'s")) + .superRefine(refuseUnopenableRowUrlTarget('actionType', "An `action:icon`'s")) .superRefine(refuseRowUrlExecutorAlias('actionType', "An `action:icon`'s `actionType: 'navigation'`")) .superRefine(refuseRowOnSuccessBesideExecutor('actionType', "An `action:icon`'s"))); /** Author state (ADR-0122: the bare name is the author state). */ @@ -4734,8 +4796,8 @@ function actionContainerMemberShape() { method: z.string().optional().describe('HTTP method for an `api` action, forwarded to the runner'), bodyExtra: z.unknown().optional().describe('Static request-body fields for an `api` action, forwarded to the runner'), bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'), - operation: actionOperation().optional().describe('Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`\'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own'), - patch: z.unknown().optional().describe('Field values the declarative `operation` writes, forwarded to the runner'), + operation: actionOperation().optional().describe('Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`\'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, a `patch` or `params` gives the values to write, and `list_toolbar` is not a location'), + patch: actionPatch().optional().describe('Static field values the declarative `operation: \'update\'` writes, forwarded to the runner: `ActionSchema`\'s own record of field values, merged UNDER the values `params` supplies. Refused on a row without `operation: \'update\'` (it would be silently dropped)'), confirmText: z.string().optional().describe('Confirmation question asked before the action runs'), successMessage: z.string().optional().describe('Toast shown when the action succeeds'), errorMessage: z.string().optional().describe('Toast shown when the action fails, in place of the raw error'), @@ -4832,7 +4894,8 @@ function buildActionGroupMember() { ...actionContainerMemberShape(), size: z.enum([...BUTTON_PRIMITIVE_SIZES, 'md']).optional() .describe('Inline button size — the Button primitive\'s vocabulary, plus `md` (drawn as `default`), falling back to the group\'s `size`. A dropdown item reads no size'), - }).superRefine(actionContainerMemberParamsFitType('action:group')) + }).superRefine(rowDeclarativeUpdate('type', "An `action:group` member's")) + .superRefine(actionContainerMemberParamsFitType('action:group')) .superRefine(refuseUnopenableRowUrlTarget('type', "An `action:group` member's")) .superRefine(refuseRowUrlExecutorAlias('type', "An `action:group` member's `type: 'navigation'`")) .superRefine(refuseRowOnSuccessBesideExecutor('type', "An `action:group` member's")); @@ -4854,7 +4917,8 @@ function buildActionMenuMember() { + 'button (the menu\'s own `size`). Remove it, or put the action in an `action:group`, whose inline buttons ' + 'read a member\'s `size`.', }, - }, actionContainerMemberShape()).superRefine(actionContainerMemberParamsFitType('action:menu')) + }, actionContainerMemberShape()).superRefine(rowDeclarativeUpdate('type', "An `action:menu` member's")) + .superRefine(actionContainerMemberParamsFitType('action:menu')) .superRefine(refuseUnopenableRowUrlTarget('type', "An `action:menu` member's")) .superRefine(refuseRowUrlExecutorAlias('type', "An `action:menu` member's `type: 'navigation'`")) .superRefine(refuseRowOnSuccessBesideExecutor('type', "An `action:menu` member's"));