diff --git a/.changeset/23332-page-action-row-operation.md b/.changeset/23332-page-action-row-operation.md new file mode 100644 index 00000000000..316b636f735 --- /dev/null +++ b/.changeset/23332-page-action-row-operation.md @@ -0,0 +1,32 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: a page action row declares `ActionSchema`'s own `operation` enum and refuses `onSuccess` beside `operation: 'update'`, with the action's text + +Clause-②: no (narrowing) + + + +**BREAKING**: an accept-set narrowing on two keys of 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` to the console's action runner, which reads it before any executor: `operation: 'update'` performs the declarative write through the platform action route and runs no executor of its own. The rows typed `operation` as an open value and never carried `ActionSchema`'s refusal of `onSuccess` beside `operation: 'update'`, so a row with an `api` or `script` executor, the write and a hop parsed while the same action was refused. Now: + +- a row's `operation` is `ActionSchema`'s own enum (one member, `'update'`); `'delete'` and `'custom'` get the action's "bulk-def operation with no row-level form" refusal, and any other value is refused at `operation`; +- a row's `onSuccess` beside `operation: 'update'` is refused whatever executor the row names (or none), with ONE text shared with `ActionSchema`: the hop would follow the write, whose success carries no result `${result.*}` could read. + +`ActionSchema`'s reach is unchanged. Its message for the pairing used to open "`onSuccess` is not defined for an `operation: 'update'` action yet"; it now states what the runner does (reads `operation` before any executor, writes, then hops) and keeps the prescription "Drop `onSuccess`". + +### FROM → TO + +| before | what to write instead | +| --- | --- | +| `{ actionType: 'api', operation: 'update', patch: { status: 'done' }, onSuccess: { navigate: '/done' } }` (or `type` on a member, or no executor) | the same row without `onSuccess`: the runner performs the write before any executor, so the hop followed a write whose success declares no result | +| `{ operation: 'delete', … }` / `{ operation: 'custom', … }` on a row | no `operation`: a row delete is the object's own delete affordance, and a row is already the action a bulk def's `'custom'` would name | +| any other `operation` value | `operation: 'update'` with a `patch`, or no `operation` (the runner ran nothing for it) | + +**The one-line fix: on a page action row, write `operation` only as `'update'`, and never beside `onSuccess`.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-operation-declared`; the rewrite is by hand. + +**What an author now sees.** `os validate`'s props gate reports the refusal at the row's `operation` or `onSuccess` (`properties.…`, or `properties.actions.N.…` for a member). For example, `{ actionType: 'api', operation: 'update', patch: { status: 'done' }, onSuccess: { navigate: '/done' } }` on an `action:button` gets: ``An `action:button`'s `onSuccess` (`operation: 'update'`) would follow a result the contract does not declare: the console's action runner reads `operation` before any executor, …``. The same row without `onSuccess` parses unchanged. + +**Measured producers.** None in this repository. In objectui (at its pin `1fb1ced36243` and its main `b9814b3`), the action renderers' own tests carry `operation: 'update'` rows without `onSuccess`, which stay accepted; no row carries another `operation` value or the pairing. objectui's metadata-admin form already clears `onSuccess` when an author switches an action into an update. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 33e7212fc8f..53e78ea744a 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -43,7 +43,7 @@ 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** | `any` | optional | Declarative single-record write, forwarded to the runner together with `patch` | +| **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 | | **confirmText** | `string` | optional | Confirmation question asked before the action runs | | **successMessage** | `string` | optional | Toast shown when the action succeeds | @@ -54,7 +54,7 @@ const result = ActionButtonPropsSchema.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Action locations, forwarded to the runner — the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | | **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | -| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | 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`, and on a row that names no 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` | +| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | 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** | `string` | optional | 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 | ### Nested Shape: `ActionButtonProps.onSuccess` @@ -101,7 +101,7 @@ 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** | `any` | optional | Declarative single-record write, forwarded to the runner together with `patch` | +| **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 | | **confirmText** | `string` | optional | Confirmation question asked before the action runs | | **successMessage** | `string` | optional | Toast shown when the action succeeds | @@ -110,7 +110,7 @@ const result = ActionButtonPropsSchema.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Action locations, forwarded to the runner — an `action:group` with a `location` draws only the members that list it, and the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | | **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | -| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | 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`, and on a row that names no 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` | +| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | 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** | `string` | optional | 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 | | **size** | `Enum<'default' \| 'sm' \| 'lg' \| 'icon' \| 'md'>` | optional | 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 | @@ -137,7 +137,7 @@ 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** | `any` | optional | Declarative single-record write, forwarded to the runner together with `patch` | +| **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 | | **confirmText** | `string` | optional | Confirmation question asked before the action runs | | **successMessage** | `string` | optional | Toast shown when the action succeeds | @@ -146,7 +146,7 @@ const result = ActionButtonPropsSchema.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Action locations, forwarded to the runner — the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | | **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | -| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | 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`, and on a row that names no 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` | +| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | 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** | `string` | optional | 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 | ### Nested Shape: `ActionIconProps.onSuccess` @@ -191,7 +191,7 @@ 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** | `any` | optional | Declarative single-record write, forwarded to the runner together with `patch` | +| **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 | | **confirmText** | `string` | optional | Confirmation question asked before the action runs | | **successMessage** | `string` | optional | Toast shown when the action succeeds | @@ -200,7 +200,7 @@ const result = ActionButtonPropsSchema.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Action locations, forwarded to the runner — an `action:group` with a `location` draws only the members that list it, and the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | | **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | -| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | 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`, and on a row that names no 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` | +| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | 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** | `string` | optional | 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 | 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 589b19405a8..65e3d55bba5 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 @@ -108,3 +108,29 @@ describe("the props gate refuses a page action row's `onSuccess` beside an execu expect(validateComponentProps(stackWith(node({ [key]: 'form', target: 'task_form' })))).toEqual([]); }); }); + +// `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. + +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' } }))); + 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)("REFUSAL: %s with `operation: 'delete'` is one finding at `operation`", (_row, node, at) => { + const findings = validateComponentProps(stackWith(node({ operation: 'delete', patch: { status: 'done' } }))); + expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_INVALID, `${at}.operation`]]); + expect(findings[0].message).toContain("`operation: 'delete'` is a bulk-def operation with no row-level form"); + }); +}); 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 new file mode 100644 index 00000000000..23a1eecb7a9 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-operation-declared.ts @@ -0,0 +1,32 @@ +// 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-operation-declared', + surface: 'the `operation` of a page action row (`properties.operation` on an `action:button` or `action:icon`, ' + + 'and `operation` on each `action:group` / `action:menu` member of `properties.actions`): any value but ' + + '`\'update\'`. Also the `onSuccess` of a page action row that declares `operation: \'update\'`, whatever ' + + 'executor the row names (`actionType` on a node, `type` on a member, or none)', + replacement: '`operation: \'update\'` with a `patch` for the declarative single-record write, or no `operation`. ' + + 'A row delete is the object\'s own delete affordance; a bulk def\'s `\'custom\'` has no row form, because the ' + + 'row is already the action. Beside `operation: \'update\'`, no `onSuccess`. ⛔ NOT mechanically convertible: ' + + 'whether a row meant the write or the hop is the author\'s call.', + reason: + 'Every page action row forwards `operation` to the console\'s action runner beside its executor, and the runner ' + + 'reads it before any executor: `operation === \'update\'` performs the declarative write through the platform ' + + 'action route, and any other value falls through to the executor and does nothing. The rows typed the key as ' + + 'an open value, so such a value parsed clean, and they never carried `ActionSchema`\'s refusal of `onSuccess` ' + + 'beside `operation: \'update\'`: a row with an `api` or `script` executor, the write and a hop parsed, while ' + + 'the same action was refused. The rows now declare `ActionSchema`\'s own `operation` enum and refuse the ' + + 'pairing with the action\'s own text: the hop would follow the write, which runs no handler and no executor, ' + + 'so its success carries no result `${result.*}` could read. 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 the ' + + 'refusal at the row\'s `operation` or `onSuccess`. Measured producers: none in this repository; none in ' + + 'objectui\'s action rows at its pin or main.', + acceptanceCriteria: + '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.', +}; diff --git a/packages/spec/src/ui/action-on-success-executor.ts b/packages/spec/src/ui/action-on-success-executor.ts index 4efaf2fe8a9..3e7f331d2bf 100644 --- a/packages/spec/src/ui/action-on-success-executor.ts +++ b/packages/spec/src/ui/action-on-success-executor.ts @@ -34,6 +34,16 @@ * app-shell `utils/flowResponse.ts`), so the hop fires while that screen or * the refusal notice is still open. * + * `operation: 'update'` is read BEFORE any executor (`ActionRunner.execute`, + * `:1384-1388` at the pin): the runner performs the declarative write through + * the platform action route, runs no handler and no executor of its own, and + * then hands the action to `handlePostExecution` like every other branch. So a + * hop beside it follows the write, whose success carries neither a handler's + * return nor a call's response for `${result.*}` to read. + * `ActionSchema` refuses `onSuccess` beside `operation: 'update'` on every + * `type`, and the page action rows refuse it the same way, with the one text + * {@link onSuccessBesideUpdateOperationRefusal} prints. + * * A page action row adds one case `ActionSchema` cannot have, because the * action's `type` defaults to `script`: a row that names no executor. The * runner dispatches it on nothing, so no executor's success is there for the @@ -113,3 +123,19 @@ export function onSuccessBesideExecutorRefusal( + `success carries none, and ${HOPS_AFTER_EVERY_EXECUTOR}. ${DECLARED_BESIDE} ${MOVE_THE_HOP}`; } } + +/** + * The refusal of an `onSuccess` beside `operation: 'update'`, on `ActionSchema` + * and on a page action row alike: the runner reads `operation` before any + * executor, so the hop would follow the declarative write, and whatever + * executor the action or row names is never run. `subject` opens it ("An + * `operation: 'update'` action's `onSuccess`"). + */ +export function onSuccessBesideUpdateOperationRefusal(subject: string): string { + return `${subject} would follow a result the contract does not declare: the console's action runner reads ` + + "`operation` before any executor, so an `operation: 'update'` action runs no handler and no executor of its " + + 'own (the platform action route writes `patch` to the current record as the caller), and the runner then ' + + 'performs the post-success hop after that write succeeds. `${result.*}` reads a handler\'s return or a ' + + "call's response, and the declarative write declares neither. Drop `onSuccess`; post-success navigation " + + 'after the declarative write is a spec proposal, not a silent key.'; +} diff --git a/packages/spec/src/ui/action-row-update.test.ts b/packages/spec/src/ui/action-row-update.test.ts index 6cb1a0256b0..8e276b541d2 100644 --- a/packages/spec/src/ui/action-row-update.test.ts +++ b/packages/spec/src/ui/action-row-update.test.ts @@ -215,7 +215,7 @@ describe('mixing rule: one refusal per contradiction, at the contradicting key', ['bodyShape', { wrap: 'data' }, /^`bodyShape` shapes a `type: 'api'` request body — an `operation: 'update'` action sends no request body of its own/], ['recordIdParam', 'id', /^`recordIdParam` injects the row id into a request body — an `operation: 'update'` action binds the current record from the invocation context/], ['recordIdField', 'token', /^`recordIdField` seeds `recordIdParam` — an `operation: 'update'` action binds the current record from the invocation context/], - ['onSuccess', { navigate: '/tasks' }, /^`onSuccess` is not defined for an `operation: 'update'` action yet/], + ['onSuccess', { navigate: '/tasks' }, /^An `operation: 'update'` action's `onSuccess` would follow a result the contract does not declare: the console's action runner reads `operation` before any executor, so an `operation: 'update'` action runs no handler and no executor of its own/], ['opensInNewTab', true, /^`opensInNewTab` pre-opens a tab for a handler-returned `\{ redirectUrl \}` — an `operation: 'update'` action has no handler/], ['newTabUrl', '/open/{recordId}', /^`newTabUrl` is the zero-roundtrip target of the pre-opened-tab flow — an `operation: 'update'` action has no such flow/], ]; @@ -232,6 +232,17 @@ describe('mixing rule: one refusal per contradiction, at the contradicting key', }); } + it('refuses `onSuccess` beside `operation: \'update\'` on every `type` it parses with, ONE issue at `onSuccess` (negative control: the same action without it parses)', () => { + for (const typed of [{}, { type: 'script' as const }]) { + const refused = ActionSchema.safeParse({ ...duly, ...typed, onSuccess: { navigate: '/tasks' } }); + expect(paths(refused), JSON.stringify(typed)).toEqual(['onSuccess']); + expect(issueAt(refused, 'onSuccess')?.code).toBe('custom'); + expect(issueAt(refused, 'onSuccess')?.message).toContain('Drop `onSuccess`'); + const control = ActionSchema.safeParse({ ...duly, ...typed }); + expect(control.success, JSON.stringify(typed)).toBe(true); + } + }); + it('refuses `patch` on an action WITHOUT `operation: \'update\'` — it would be silently dropped', () => { for (const base of [ { name: 'x', label: 'X', target: 'doThing' }, diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 3d34ee62616..048c7bd82f4 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -35,7 +35,11 @@ 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 } from './action-on-success-executor'; +import { + ON_SUCCESS_EXECUTORS, + onSuccessBesideExecutorRefusal, + onSuccessBesideUpdateOperationRefusal, +} from './action-on-success-executor'; import { navigationTargetRefusal } from './navigation-target'; /** @@ -1885,10 +1889,9 @@ const DECLARATIVE_UPDATE_REFUSED_KEYS: ReadonlyArray [i.code, i.path])).toEqual([['custom', [...row.at, 'onSuccess']]]); }); }); + +// =========================================================================== +// 4. operation — ActionSchema's own enum, and its `onSuccess` rule +// =========================================================================== + +/** A declarative update row: `operation: 'update'` with a static `patch`. */ +const UPDATE = { operation: 'update', patch: { status: 'done' } } as const; + +/** `ActionSchema`'s refusal of `onSuccess` beside `operation: 'update'`, and the subject it opens with. */ +const ACTION_UPDATE_SUBJECT = "An `operation: 'update'` action's `onSuccess`"; +const actionUpdateOnSuccessMessage = (): string => { + const issues = issuesOf(ActionSchema.safeParse({ + name: 'close_task', label: 'Close', ...UPDATE, onSuccess: HOP, + })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', ['onSuccess']]]); + expect(issues[0].message.startsWith(ACTION_UPDATE_SUBJECT)).toBe(true); + return issues[0].message; +}; + +describe("a page action row's `operation` is `ActionSchema`'s own enum", () => { + it('NO SECOND VOCABULARY: every row holds the very enum instance `ActionSchema.operation` declares', () => { + const operation = ActionSchema.in.shape.operation.unwrap(); + type Shape = { shape: Record> }; + const memberOf = (props: unknown) => + ((props as Shape).shape.actions.unwrap() as unknown as z.ZodArray).element as unknown as Shape; + expect((ActionButtonPropsSchema as unknown as Shape).shape.operation.unwrap()).toBe(operation); + expect((ActionIconPropsSchema as unknown as Shape).shape.operation.unwrap()).toBe(operation); + expect(memberOf(GROUP).shape.operation.unwrap()).toBe(operation); + expect(memberOf(MENU).shape.operation.unwrap()).toBe(operation); + }); + + it.each(ROWS)("REFUSAL: $name with `operation: 'delete'` is refused at `operation` with the action's own text", (row) => { + const issues = issuesOf(row.parse({ operation: 'delete', patch: { status: 'done' } })); + expect(issues.map((i) => i.path)).toEqual([[...row.at, 'operation']]); + const action = issuesOf(ActionSchema.safeParse({ name: 'x', label: 'X', operation: 'delete', patch: { status: 'done' } })) + .find((i) => i.path.join('.') === 'operation'); + expect(issues[0].message).toBe(action?.message); + expect(issues[0].message.startsWith("`operation: 'delete'` is a bulk-def operation with no row-level form")).toBe(true); + }); + + it.each(ROWS)("REFUSAL: $name with `operation: 'create'` (no member) is refused at `operation`", (row) => { + const issues = issuesOf(row.parse({ operation: 'create', patch: { status: 'done' } })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['invalid_value', [...row.at, 'operation']]]); + }); + + it.each(ROWS)("ACCEPTED CONTROL: $name with `operation: 'update'` and a `patch` parses unchanged", (row) => { + const r = row.parse({ ...UPDATE }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + expect(row.parsed(r.data).operation).toBe('update'); + }); +}); + +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; + 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'`"); + + it.each(ROWS.flatMap((row) => BESIDE.map((executor) => [row.name, String(executor), row, executor] as const)))( + 'REFUSAL: %s with executor %s, `operation: \'update\'` and `onSuccess` is refused at `onSuccess`, ONE issue', + (_name, _label, row, executor) => { + const issues = issuesOf(updateRow(row, executor, { onSuccess: HOP })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, 'onSuccess']]]); + expect(firstSentence(issues[0].message)).toBe( + `${updateSubject(row)} would follow a result the contract does not declare: the console's action runner reads ` + + "`operation` before any executor, so an `operation: 'update'` action runs no handler and no executor of its " + + 'own (the platform action route writes `patch` to the current record as the caller), and the runner then ' + + 'performs the post-success hop after that write succeeds.', + ); + }, + ); + + it.each(ROWS.flatMap((row) => BESIDE.map((executor) => [row.name, String(executor), row, executor] as const)))( + 'NEGATIVE CONTROL: %s with executor %s and `operation: \'update\'` and no `onSuccess` parses', + (_name, _label, row, executor) => { + const r = updateRow(row, executor); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + }, + ); + + 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; + 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 cc2da0fa54a..025f56e848d 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 @@ -242,8 +242,9 @@ const on = (types: readonly string[], paths: readonly string[], reason: Reason): }; // `onSuccess` left this list when the rows took `ActionSchema`'s own -// post-success block (`component-action-row-on-success-executor.test.ts`). -const ACTION_RUNNER_PATHS = ['params', 'bodyExtra', 'bodyShape', 'operation', 'patch', 'toast', 'resultDialog']; +// 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']; const VIEW_DATA_TYPES = ['object-grid', 'object-map', 'object-gantt', 'object-tree']; // Composition slots. diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 42ffdc3eb2c..6bc268a6c18 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -59,7 +59,11 @@ 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 } from './action-on-success-executor'; +import { + ON_SUCCESS_EXECUTORS, + onSuccessBesideExecutorRefusal, + onSuccessBesideUpdateOperationRefusal, +} from './action-on-success-executor'; import { navigationTargetRefusal } from './navigation-target'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; // [#21464] `object-metric.aggregate.groupBy` is the chart aggregate's own @@ -4187,6 +4191,27 @@ function refuseRowUrlExecutorAlias(executorKey: 'actionType' | 'type', subject: */ const actionOnSuccessBlock = () => ActionSchema.in.shape.onSuccess.unwrap(); +/** + * A page action row's `operation`: `ActionSchema`'s own enum, by reference, + * read off the object half of the action's pipe as {@link actionOnSuccessBlock} + * reads `onSuccess`. ⛔ No second vocabulary: the one member is `'update'`, + * and `'delete'` / `'custom'` get the action's own "bulk-def operation with no + * row-level form" refusal. + * + * Every row forwards `operation` to the runner beside `type` + * (`action-button.tsx:322` at the pin; `action-icon.tsx`, `action-group.tsx` + * and `action-menu.tsx` forward it too), and the runner reads it BEFORE any + * executor (`ActionRunner.ts:1384-1388`): `isUpdateOperationAction` tests + * `operation === 'update'`, so any other value fell through to the executor + * and did nothing. Until this, the rows declared `z.unknown()`, and such a + * 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. + */ +const actionOperation = () => ActionSchema.in.shape.operation.unwrap(); + /** * The console runner's built-in executors: `ActionType`'s six, the members its * `builtinExecutors` table is typed by (`Record`, so the @@ -4222,16 +4247,29 @@ function rowOnSuccessRefused(executor: string | undefined): boolean { * an `action:group` / `action:menu` member. The refusal text is the one * `ActionSchema` prints for the same executor (`action-on-success-executor.ts`), * with the subject naming the row and the spelling that resolved its executor, - * as {@link refuseUnopenableRowUrlTarget} names it. The issue lands at + * 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`. */ function refuseRowOnSuccessBesideExecutor(executorKey: 'actionType' | 'type', owner: string) { return ( - row: { actionType?: string; type?: string; name?: string; onSuccess?: unknown }, + 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; + } const executor = effectiveRowExecutor(row, executorKey); if (!rowOnSuccessRefused(executor)) return; const key = row[executorKey] === '' ? `an empty \`${executorKey}\`` : `no \`${executorKey}\``; @@ -4361,7 +4399,7 @@ 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: z.unknown().optional().describe('Declarative single-record write, forwarded to the runner together with `patch`'), + 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'), confirmText: z.string().optional().describe('Confirmation question asked before the action runs'), successMessage: z.string().optional().describe('Toast shown when the action succeeds'), @@ -4374,7 +4412,7 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ toast: z.unknown().optional().describe('Toast behaviour, forwarded to the runner'), resultDialog: z.unknown().optional().describe('One-shot result dialog for a value the response shows exactly once, forwarded to the runner'), onSuccess: actionOnSuccessBlock().optional() - .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`, and on a row that names no 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`'), + .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")) @@ -4462,7 +4500,7 @@ 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: z.unknown().optional().describe('Declarative single-record write, forwarded to the runner together with `patch`'), + 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'), confirmText: z.string().optional().describe('Confirmation question asked before the action runs'), successMessage: z.string().optional().describe('Toast shown when the action succeeds'), @@ -4473,7 +4511,7 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ toast: z.unknown().optional().describe('Toast behaviour, forwarded to the runner'), resultDialog: z.unknown().optional().describe('One-shot result dialog for a value the response shows exactly once, forwarded to the runner'), onSuccess: actionOnSuccessBlock().optional() - .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`, and on a row that names no 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`'), + .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")) @@ -4652,7 +4690,7 @@ 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: z.unknown().optional().describe('Declarative single-record write, forwarded to the runner together with `patch`'), + 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'), confirmText: z.string().optional().describe('Confirmation question asked before the action runs'), successMessage: z.string().optional().describe('Toast shown when the action succeeds'), @@ -4663,7 +4701,7 @@ function actionContainerMemberShape() { toast: z.unknown().optional().describe('Toast behaviour, forwarded to the runner'), resultDialog: z.unknown().optional().describe('One-shot result dialog for a value the response shows exactly once, forwarded to the runner'), onSuccess: actionOnSuccessBlock().optional() - .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`, and on a row that names no 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`'), + .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'), };