Skip to content

Commit 775ed65

Browse files
fix(spec)!: a page action row declares ActionSchema's operation and refuses onSuccess beside operation: 'update', with the action's text (#23425)
Fixes #23332 Clause-②: no (narrowing) The page action rows (`action:button`, `action:icon`, and each `action:group` / `action:menu` member) and `ActionSchema` now give one answer for `operation: 'update'` plus `onSuccess`. Direction of record: triage `6154972534` (consistent with #23168's answer C, `6148462268`), within the PM's premised ruling. ## Premise check, done first: `ActionSchema`'s refusal is the right half The ruling's premise: no runtime consumer honors `onSuccess` after an `operation: 'update'` write as a declared, tested behavior. It holds. Read at objectui's `.objectui-sha` pin `1fb1ced36243` (the runner file is byte-identical at objectui `main` `b9814b3`): - **What the runner does mechanically.** `ActionRunner.execute` reads `operation` before any executor (`core/src/actions/ActionRunner.ts:1384-1388`). `executeUpdateOperation` posts the write through the registered `script` dispatch, and then `handlePostExecution` runs. That function performs the `onSuccess` hop for every branch on success (`:1639-1642`). So the hop would fire after the write, but only because every branch shares the same post-execution path. - **It is not declared.** The runner's own docblock for `onSuccess` (`:1620-1638`) says the key is "refine-scoped to `type: 'api'` and `type: 'script'`" and that the spec's declared meaning is its only meaning. objectui's metadata-admin form puts `onSuccess` in `UPDATE_OPERATION_REFUSES` (`app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx:336-353`), calls it "a navigation the write has no business doing", and clears it when an author switches an action into an update (`commitOperation`, `:620-628`). - **It is not tested.** No objectui file at the pin pins the pairing. `ActionRunner.onSuccessNavigation.test.ts` has no `operation` in it, and `ActionRunner.updateOperation.test.ts` has no `onSuccess`. Of the 55 files that write `operation: 'update'`, the 10 that also mention `onSuccess` were each read: none puts the two on one action. So the rule stays as `ActionSchema` has it, and the rows take it. ⛔ `ActionSchema` was not widened. ## What changes **1. A row's `operation` is `ActionSchema`'s own enum, by reference** (`component.zod.ts`, `actionOperation()` = `ActionSchema.in.shape.operation.unwrap()`). It is read the same way `actionOnSuccessBlock()` reads `onSuccess`, so there is 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. Before this, the rows typed it `z.unknown()`. The runner tests `operation === 'update'` and lets any other value fall through to the executor, where it did nothing. **2. A row's `onSuccess` beside `operation: 'update'` is refused whatever executor the row names (or none)**, as `ActionSchema` refuses it on every `type`. `refuseRowOnSuccessBesideExecutor` reads `operation` first, the way the runner does, and gives ONE issue at `onSuccess`. Before this: | row | before | after | | --- | --- | --- | | `actionType: 'api'` or `'script'` or a host handler, `operation: 'update'`, `onSuccess` | accepted | refused at `onSuccess`, with the update text | | no executor, `operation: 'update'`, `onSuccess` | refused with the no-executor text ("has no executor to follow" … declare `actionType: 'script'`), which does not fit this shape | refused with the update text | | `operation: 'update'` and no `onSuccess`, on any executor | accepted | accepted (unchanged) | **3. One refusal text, from one place.** The new `onSuccessBesideUpdateOperationRefusal(subject)` lives in `action-on-success-executor.ts`, the module PR #23331 created for this purpose (relative import only, outside the `ui` barrel). `ActionSchema`'s `DECLARATIVE_UPDATE_REFUSED_KEYS` `onSuccess` entry and the rows both read it. The text says what the row does at run time, as the card asks: > 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, 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. On `ActionSchema` the subject is "An `operation: 'update'` action's `onSuccess`". The text used to open "`onSuccess` is not defined for an `operation: 'update'` action yet". Its reach is unchanged, and the "Drop `onSuccess`" prescription is kept. ## Accept-set narrowing (BREAKING, `minor`, ADR-0087 `registered ui-page-action-row-operation-declared`) Values a row accepted before and now refuses: - any `operation` other than `'update'`: `'delete'` and `'custom'` (with the action's prescription), and anything else (`invalid_value`); - `onSuccess` beside `operation: 'update'`, on any executor. **Producers measured:** none in this repository at `339604baf`. I checked `examples/` (the only page action rows are in `examples/app-showcase/src/ui/pages/index.ts` and `src/ui/actions/index.ts`, and neither carries `operation`), the CLI corpus (`packages/cli`, `skills/`) and the dogfood fixtures (`packages/qa`). In objectui at the pin `1fb1ced36243` and at `main` `b9814b3`, the action renderers' tests carry `operation: 'update'` rows without `onSuccess` (accepted, unchanged). No row carries another `operation` value or the pairing. objectui's registration already publishes `operation` as `enum: ['update']` (`action-button.tsx:584-590`), so its `registry-inputs-spec-parity` enum check gets a member the spec accepts. hotcrm: NOT MEASURED (no checkout in this container). Semantic migration entry `packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-operation-declared.ts`, beside the `18.ui-page-action-row-*` family. Changeset `.changeset/23332-page-action-row-operation.md`. ## Pins, each with a negative control - **Row side** (`component-action-row-on-success-executor.test.ts` §4), on all four rows: - REFUSAL: `onSuccess` beside `operation: 'update'` with no executor and with `api` / `script` / `run`. The test asserts ONE issue at `onSuccess` plus the full first sentence. - NEGATIVE CONTROL: the same rows without `onSuccess` parse. - ONE TEXT: the row message after its subject equals `ActionSchema`'s message after its subject. - NO SECOND VOCABULARY: every row holds the very enum instance `ActionSchema.operation` declares. - `'delete'` is refused with the action's exact message, `'create'` gets `invalid_value` at `operation`, and the `'update'` accepted control passes. - **`ActionSchema` side** (`action-row-update.test.ts`): - The mixing-rule pin is flipped to the new opening, a prefix of the text that bears the load. - New: `onSuccess` beside `operation: 'update'` (no `type` and explicit `type: 'script'`) gives exactly one issue, at `onSuccess`, and the same action without it parses. - **The `os validate` door** (`packages/lint/src/validate-component-props-row-on-success.test.ts`): one `component-props-invalid` finding at `…onSuccess` for `api` + update + `onSuccess`, nothing without `onSuccess`, and one finding at `…operation` for `'delete'`. - **Ledger:** `component-props-unknown-members.pin.test.ts` deletes the `operation` line, because a typed member deletes its line. **Pin sweep:** I grepped the old message ("not defined for an `operation: 'update'`") across this repo: one pin, flipped (`action-row-update.test.ts:218`). objectui at the pin and at `main`: zero hits. **Ablations** (committed tree, `scripts/ablation-replace.mjs`; every leg restored to blob == HEAD with `git diff HEAD` empty; spec tests import `src`, so no dist was involved): - The row branch was turned off (`if (row.operation === 'update')` changed to `if (false as boolean)`). Result: 20 red (16 REFUSAL + 4 ONE TEXT), and the negative controls stayed green. - `ActionSchema`'s `onSuccess` entry was deleted. Result: 6 red (both `ActionSchema` pins + 4 ONE TEXT). My first attempt used an empty `--replacement`, which the tool refused as a no-op (count 0 to 0). It was re-run with `--delete`. - The row enum was replaced by `z.unknown()`. Result: 9 red (8 operation refusals + NO SECOND VOCABULARY). ## Verification at `339604baf` (after merging `origin/main` `5656c5638`) - `@objectstack/spec`: - Targeted files after the merge rebuild: 4 files, 289 passed. - `--project local`: 672 of 673 files green before the ledger fix. The 3 failures were all in `component-props-unknown-members.pin.test.ts` (the typed `operation` member's line), and that file is now 69/69. - `test:repo`: 59 files, 1011 passed. - `typecheck` (tsc, scripts, test-typecheck): exit 0. - `check:generated`: green after `gen:docs` (`content/docs/references/ui/component.mdx` regenerated). - `@objectstack/lint`: 138 files, 6706 passed; `typecheck`: exit 0. - Consumer readings (`ComponentPropsMap` parsers): `cloud-connection` and `mcp` `canonical-expression-envelopes.test.ts` 16 and 11 passed; `platform-objects` `pages/canonical-expression-envelopes.test.ts` 15 passed. - `dispatch-gates.mjs --commands`: 111 families, all 111 run on `339604baf`, every one exit 0. `--ran` reconciliation: "111 run, 0 NOT-MEASURED (a DERIVED zero — all 111 recorded an exit code and none of them is 3)". `check:nul-bytes` exit 0. ## Acceptance notes - Out of scope, same family (rows vs `ActionSchema` on the declarative-update mixing rule): the rows carry none of `ActionSchema`'s other `operation: 'update'` refusals. These were measured on the built spec: `patch` without `operation`, `target` beside update, a non-script executor beside update, update with nothing to write, and `list_toolbar` beside update each parse on `action:button` and are refused by `ActionSchema`. Reported to the seat for the family's closing card. Not fixed here. - Not touched: `packages/spec/src/ui/page.zod.ts` (#23357). The sentence at `component.zod.ts:6030` ("a stored page still saves and loads") is far from this diff and still true until #23418 lands, so it was left alone. The new migration entry's sentence "A page's `properties` is not parsed on the metadata save or load path" follows its family's current text, and #23418 rewrites that text in the sibling entries. Whichever PR lands later should align this entry too. - `Clause-②`: the PM's claim declared `yes`, conservatively and conditionally ("if measurement moves the rule on ActionSchema's side, which would widen it"). Measurement did not move it, so this PR declares `no (narrowing)`, as triage's direction records for this outcome. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 823da2f commit 775ed65

10 files changed

Lines changed: 281 additions & 25 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: a page action row declares `ActionSchema`'s own `operation` enum and refuses `onSuccess` beside `operation: 'update'`, with the action's text
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: registered ui-page-action-row-operation-declared -->
10+
11+
**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.
12+
13+
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:
14+
15+
- 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`;
16+
- 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.
17+
18+
`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`".
19+
20+
### FROM → TO
21+
22+
| before | what to write instead |
23+
| --- | --- |
24+
| `{ 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 |
25+
| `{ 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 |
26+
| any other `operation` value | `operation: 'update'` with a `patch`, or no `operation` (the runner ran nothing for it) |
27+
28+
**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.
29+
30+
**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.
31+
32+
**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.

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ const result = ActionButtonPropsSchema.parse(data);
4343
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
4444
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
4545
| **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner |
46-
| **operation** | `any` | optional | Declarative single-record write, forwarded to the runner together with `patch` |
46+
| **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 |
4747
| **patch** | `any` | optional | Field values the declarative `operation` writes, forwarded to the runner |
4848
| **confirmText** | `string` | optional | Confirmation question asked before the action runs |
4949
| **successMessage** | `string` | optional | Toast shown when the action succeeds |
@@ -54,7 +54,7 @@ const result = ActionButtonPropsSchema.parse(data);
5454
| **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 |
5555
| **toast** | `any` | optional | Toast behaviour, forwarded to the runner |
5656
| **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner |
57-
| **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 $&#123;, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` |
57+
| **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 $&#123;, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` |
5858
| **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 |
5959

6060
### Nested Shape: `ActionButtonProps.onSuccess`
@@ -101,7 +101,7 @@ const result = ActionButtonPropsSchema.parse(data);
101101
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
102102
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
103103
| **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner |
104-
| **operation** | `any` | optional | Declarative single-record write, forwarded to the runner together with `patch` |
104+
| **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 |
105105
| **patch** | `any` | optional | Field values the declarative `operation` writes, forwarded to the runner |
106106
| **confirmText** | `string` | optional | Confirmation question asked before the action runs |
107107
| **successMessage** | `string` | optional | Toast shown when the action succeeds |
@@ -110,7 +110,7 @@ const result = ActionButtonPropsSchema.parse(data);
110110
| **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 |
111111
| **toast** | `any` | optional | Toast behaviour, forwarded to the runner |
112112
| **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner |
113-
| **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 $&#123;, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` |
113+
| **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 $&#123;, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` |
114114
| **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 |
115115
| **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 |
116116

@@ -137,7 +137,7 @@ const result = ActionButtonPropsSchema.parse(data);
137137
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
138138
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
139139
| **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner |
140-
| **operation** | `any` | optional | Declarative single-record write, forwarded to the runner together with `patch` |
140+
| **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 |
141141
| **patch** | `any` | optional | Field values the declarative `operation` writes, forwarded to the runner |
142142
| **confirmText** | `string` | optional | Confirmation question asked before the action runs |
143143
| **successMessage** | `string` | optional | Toast shown when the action succeeds |
@@ -146,7 +146,7 @@ const result = ActionButtonPropsSchema.parse(data);
146146
| **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 |
147147
| **toast** | `any` | optional | Toast behaviour, forwarded to the runner |
148148
| **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner |
149-
| **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 $&#123;, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` |
149+
| **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 $&#123;, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` |
150150
| **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 |
151151

152152
### Nested Shape: `ActionIconProps.onSuccess`
@@ -191,7 +191,7 @@ const result = ActionButtonPropsSchema.parse(data);
191191
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
192192
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
193193
| **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner |
194-
| **operation** | `any` | optional | Declarative single-record write, forwarded to the runner together with `patch` |
194+
| **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 |
195195
| **patch** | `any` | optional | Field values the declarative `operation` writes, forwarded to the runner |
196196
| **confirmText** | `string` | optional | Confirmation question asked before the action runs |
197197
| **successMessage** | `string` | optional | Toast shown when the action succeeds |
@@ -200,7 +200,7 @@ const result = ActionButtonPropsSchema.parse(data);
200200
| **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 |
201201
| **toast** | `any` | optional | Toast behaviour, forwarded to the runner |
202202
| **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner |
203-
| **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 $&#123;, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` |
203+
| **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 $&#123;, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` |
204204
| **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 |
205205

206206

‎packages/lint/src/validate-component-props-row-on-success.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,29 @@ describe("the props gate refuses a page action row's `onSuccess` beside an execu
108108
expect(validateComponentProps(stackWith(node({ [key]: 'form', target: 'task_form' })))).toEqual([]);
109109
});
110110
});
111+
112+
// `operation` on a page action row: `ActionSchema`'s own enum, and its refusal
113+
// of `onSuccess` beside `operation: 'update'` (the runner performs the write
114+
// before any executor), whatever executor the row names.
115+
116+
describe("the props gate judges a page action row's `operation` as `ActionSchema` does", () => {
117+
const UPDATE = { operation: 'update', patch: { status: 'done' } };
118+
119+
it.each(ROWS)("REFUSAL: %s with `api`, `operation: 'update'` and `onSuccess` is one finding at `onSuccess`", (_row, node, at, key, subject) => {
120+
const findings = validateComponentProps(stackWith(node({ [key]: 'api', ...UPDATE, onSuccess: { navigate: '/tasks' } })));
121+
expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_INVALID, `${at}.onSuccess`]]);
122+
expect(findings[0].message).toContain(
123+
`${ownerOf(subject)} \`onSuccess\` (\`operation: 'update'\`) would follow a result the contract does not declare:`,
124+
);
125+
});
126+
127+
it.each(ROWS)("ACCEPTED CONTROL: %s with `api` and `operation: 'update'` and no `onSuccess` reports nothing", (_row, node, _at, key) => {
128+
expect(validateComponentProps(stackWith(node({ [key]: 'api', ...UPDATE })))).toEqual([]);
129+
});
130+
131+
it.each(ROWS)("REFUSAL: %s with `operation: 'delete'` is one finding at `operation`", (_row, node, at) => {
132+
const findings = validateComponentProps(stackWith(node({ operation: 'delete', patch: { status: 'done' } })));
133+
expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_INVALID, `${at}.operation`]]);
134+
expect(findings[0].message).toContain("`operation: 'delete'` is a bulk-def operation with no row-level form");
135+
});
136+
});

0 commit comments

Comments
 (0)