From 394b0ef1ee6cfd7a1bbca79f7b4d0a7dd232577f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 18:11:48 +0000 Subject: [PATCH 1/3] feat(spec)!: a page action row's onSuccess is refused beside a runner built-in other than api / script, and beside no executor WIP: the rule, the shared refusal text, and ActionSchema's true reason. Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude --- .../spec/src/ui/action-on-success-executor.ts | 115 ++++++++++++++++++ packages/spec/src/ui/action.zod.ts | 38 +++--- packages/spec/src/ui/component.zod.ts | 108 +++++++++++++--- 3 files changed, 221 insertions(+), 40 deletions(-) create mode 100644 packages/spec/src/ui/action-on-success-executor.ts diff --git a/packages/spec/src/ui/action-on-success-executor.ts b/packages/spec/src/ui/action-on-success-executor.ts new file mode 100644 index 00000000000..4efaf2fe8a9 --- /dev/null +++ b/packages/spec/src/ui/action-on-success-executor.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Which executor an action's post-success hop, `onSuccess`, may sit beside: + * ONE pair and ONE refusal text, 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). + * + * The console's action runner performs the hop after EVERY executor that + * succeeds. Each branch of `ActionRunner.execute` (a host-registered handler, + * a built-in, the declarative `operation: 'update'`) ends in + * `handlePostExecution`, which hands `onSuccess` to `readOnSuccessNavigation` + * and `navigateOnSuccess` (`core/src/actions/ActionRunner.ts:1384-1422` and + * `:1639-1642` at the `.objectui-sha` pin `1fb1ced36243`; the file is + * byte-identical at objectui `main` `72594f29`). So the rule is not that the + * other executors leave the hop unread. None does. The rule is about what the + * hop would follow: + * + * - `api` and `script` succeed with a result, the call's response or the + * handler's return, and `${result.*}` reads it. The hop is declared for + * these two ({@link ON_SUCCESS_EXECUTORS}). + * - `url`, its runner alias `navigation`, and `form` are themselves + * navigations: `executeUrl` and `executeNavigation` hand off to + * `navigateTo`, and `executeForm` routes to the form view's `/forms/NAME` + * page. The hop is a second navigation, away from where the action went. + * - `modal` opens a dialog, and its success is whatever the dialog's host + * answers. The console's dialog (app-shell `useActionModal`) answers when it + * closes: a saved form with the record, an OK with nothing, a dismissal as a + * failure. With no host the runner answers at once. None of it is a result + * the contract declares. + * - `flow` starts a run, and the console's `flow` handler answers success when + * the run starts. A run paused at its first screen, or one that ended + * refused, answers `{ success: true, silent: true }` (`judgeFlowLaunch`, + * app-shell `utils/flowResponse.ts`), so the hop fires while that screen or + * the refusal notice is still open. + * + * 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 + * hop to follow. + * + * A name outside the runner's built-in table is a host-registered handler. The + * runner hops after it too, and only the host knows whether its handler + * navigates, so the rows leave it open, like the executor key itself. That + * half lives in `component.zod.ts`, beside the rows' effective executor. + * + * Why a module of its own: `action-target-aliases.ts`'s reason. The two files + * must read the SAME pair and the same text; `component.zod.ts` already imports + * `action.zod.ts`, so neither can live in the rows' file; and exported from + * `action.zod.ts` they would ride the `ui` barrel into the published API. + * Reached by relative import only. + */ + +/** The two executors the post-success hop is declared beside. */ +export const ON_SUCCESS_EXECUTORS: ReadonlySet = new Set(['api', 'script']); + +/** What the hop is declared beside, and why: the second sentence of every refusal below. */ +const DECLARED_BESIDE = + '`onSuccess` is declared beside `api` and `script` only, whose success carries the result `${result.*}` ' + + "reads: the call's response, the handler's return."; + +/** The runtime fact every refusal below rests on. */ +const HOPS_AFTER_EVERY_EXECUTOR = + "the console's action runner performs the post-success hop after every executor that succeeds"; + +/** The prescription for an executor whose success carries no declared result. */ +const MOVE_THE_HOP = + 'Drop `onSuccess`, or move the hop onto an `api` or `script` action, whose result it reads.'; + +/** + * The refusal of an `onSuccess` beside `executor`: a built-in other than `api` + * / `script`, or `undefined` for a page action row that names no executor. + * `subject` opens it ("A `type: 'url'` action's `onSuccess`"), and + * `executorKey` spells the executor the no-executor prescription asks for + * (`actionType` on a page node, `type` on a member). + */ +export function onSuccessBesideExecutorRefusal( + subject: string, + executor: string | undefined, + executorKey: 'type' | 'actionType', +): string { + switch (executor) { + case 'url': + case 'navigation': { + const itself = executor === 'url' + ? 'a `url` action is itself the navigation' + : "`navigation`, the runner's alias of `url`, is itself the navigation"; + return `${subject} would navigate a second time: ${itself}, and ${HOPS_AFTER_EVERY_EXECUTOR}. ` + + `${DECLARED_BESIDE} Put the destination in \`target\`, with the tab in the top-level \`openIn\`, ` + + 'and drop `onSuccess`.'; + } + case 'form': + return `${subject} would navigate a second time: a \`form\` action is itself a navigation, to the form ` + + `view's \`/forms/\` page, and ${HOPS_AFTER_EVERY_EXECUTOR}, so it would leave the form before it is ` + + `filled in. ${DECLARED_BESIDE} Drop \`onSuccess\`, and declare where a submitted form goes in the form ` + + "view's `submitBehavior`."; + case 'modal': + return `${subject} would follow a result the contract does not declare: a \`modal\` action opens a ` + + `dialog, whose success is whatever the dialog's host answers, and ${HOPS_AFTER_EVERY_EXECUTOR}. ` + + `${DECLARED_BESIDE} ${MOVE_THE_HOP}`; + case 'flow': + return `${subject} would follow the flow's start, not a result: the console answers a \`flow\` action's ` + + 'success when the run starts, even for a run paused at its first screen or one that ended refused, and ' + + `${HOPS_AFTER_EVERY_EXECUTOR}, so the hop fires while that screen or the refusal notice is still open. ` + + `${DECLARED_BESIDE} ${MOVE_THE_HOP}`; + case undefined: + return `${subject} has no executor to follow: the row names none, and the console's action runner ` + + 'performs the post-success hop after the executor it runs succeeds. ' + + `${DECLARED_BESIDE} Declare the executor whose success the hop follows, \`${executorKey}: 'api'\` or ` + + `\`${executorKey}: 'script'\`, or drop \`onSuccess\`.`; + default: + return `${subject} would follow a result the contract does not declare: a \`${executor}\` action's ` + + `success carries none, and ${HOPS_AFTER_EVERY_EXECUTOR}. ${DECLARED_BESIDE} ${MOVE_THE_HOP}`; + } +} diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 3b8ef2ae1e9..3d34ee62616 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -35,6 +35,7 @@ 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 { navigationTargetRefusal } from './navigation-target'; /** @@ -2190,27 +2191,24 @@ export const ActionSchema = lazySchema(() => actionObject().refine((data) => { + 'correct for a param-LESS action, where the confirm IS the only dialog, and for a view\'s ' + '`bulkActionDefs`, where the pair renders one dialog by that schema\'s own contract.', path: ['confirmText'], -}).refine((data) => { - // #9566/#9474 — `onSuccess` is scoped to the two action types that HAVE a - // success event carrying a server response for `${result.*}` to read: - // `api` (the `target` call's response body) and `script` (the handler's - // return value). Same enforcement shape as the `body`-on-non-script - // refinement above (#4352): a declared key the dispatch path never reads is - // the invisible-failure class this file rejects at author time. - if (data.onSuccess && data.type !== 'api' && data.type !== 'script') { - return false; +}).superRefine((data, ctx) => { + // #9566/#9474 — `onSuccess` is scoped to the two action types whose success + // carries a result for `${result.*}` to read: `api` (the `target` call's + // response body) and `script` (the handler's return value). The console's + // action runner performs the hop after EVERY executor that succeeds, so on + // `url` / `modal` / `flow` / `form` the key is read, not dead: it navigates + // a second time, or follows a result the contract does not declare. The + // pair and the refusal text live in `action-on-success-executor.ts`, which + // the page action rows (`component.zod.ts`) read too, so an action and a + // row give one verdict in one wording. `type` is the closed `ActionType` + // enum with a `script` default, so the reach is exactly the four. + if (data.onSuccess && !ON_SUCCESS_EXECUTORS.has(data.type)) { + ctx.addIssue({ + code: 'custom', + path: ['onSuccess'], + message: onSuccessBesideExecutorRefusal(`A \`type: '${data.type}'\` action's \`onSuccess\``, data.type, 'type'), + }); } - return true; -}, { - message: - "`onSuccess` declares POST-SUCCESS navigation for a server-executing action, and only " - + "`type: 'api'` and `type: 'script'` have a success event (a server response) it can ride — " - + "on any other type the key would parse clean and never run (ADR-0078). For a `type:'url'` " - + "action the navigation IS the action: put the destination in `target` and pick the tab with " - + "the top-level `openIn`. For `modal`/`flow`/`form`, the action dispatches on `target` and no " - + 'renderer reads a post-success hop today — if that capability is needed, it is a spec ' - + 'proposal, not a silent key.', - path: ['onSuccess'], }).refine((data) => { // #11519 (maintainer ruling 2026-08-24) — refuse the DOUBLED post-success // navigation channel where the schema can see it. Two channels can name a diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index bc984cb3b55..42ffdc3eb2c 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -57,8 +57,9 @@ import { AggregationFunction } from '../data/query.zod'; // `{ formats }` while the grid reads `.formats` and lifts nothing. Declared // outside the `ui` barrel so the two carriers share it without publishing it. import { ListViewExportOptionsSchema } from './list-view-export-options'; -import { ActionSchema, InlineActionSchema, ActionLocationSchema } from './action.zod'; +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 { navigationTargetRefusal } from './navigation-target'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; // [#21464] `object-metric.aggregate.groupBy` is the chart aggregate's own @@ -4045,7 +4046,9 @@ const ACTION_NODE_ALIASES = { * to `name` at the click, so it falls through here too. ⛔ `name` is not * refused as an executor spelling: the runner declares that leg, and a row * whose `name` names any other handler is that handler's business. This only - * states, once, which executor the contract judges a row's keys against. + * states, once, which executor the contract judges a row's keys against: the + * url-target judgment ({@link refuseUnopenableRowUrlTarget}) and the + * `onSuccess` rule ({@link refuseRowOnSuccessBesideExecutor}) both read it. */ function effectiveRowExecutor( row: { actionType?: string; type?: string; name?: string }, @@ -4176,14 +4179,75 @@ function refuseRowUrlExecutorAlias(executorKey: 'actionType' | 'type', subject: * `navigate` the runner refuses, a misspelled key or a retired callback shape * parsed clean and did nothing after the action succeeded. * - * What the block does not bring: `ActionSchema`'s rules ABOUT `onSuccess` — it - * is refused beside a `type` other than `api` / `script`, and beside - * `opensInNewTab: true` — are refinements of the action, not of the block. The - * rows' executor set is open and the runner runs the hop after every executor, - * a host handler included, so those rules do not carry over as written. + * What the block does not bring: `ActionSchema`'s rules ABOUT `onSuccess` are + * refinements of the action, not of the block. The one about its executor is + * carried to the rows on the vocabulary the two share + * ({@link refuseRowOnSuccessBesideExecutor}); `opensInNewTab` is not a row key, + * so the other has nothing to judge here. */ const actionOnSuccessBlock = () => ActionSchema.in.shape.onSuccess.unwrap(); +/** + * The console runner's built-in executors: `ActionType`'s six, the members its + * `builtinExecutors` table is typed by (`Record`, so the + * table and the enum cannot drift), and its one local alias `navigation` + * (`core/src/actions/ActionRunner.ts:1140-1152` at the `.objectui-sha` pin + * `1fb1ced36243`, byte-identical at objectui `main` `72594f29`). Every other + * name is a handler a host registers. + */ +const ROW_BUILTIN_EXECUTORS: ReadonlySet = new Set([...ActionType.options, ...ROW_URL_NAVIGATOR_EXECUTORS]); + +/** + * Whether a page action row's `onSuccess` is refused beside its effective + * executor ({@link effectiveRowExecutor}): `ActionSchema`'s "`onSuccess` only + * beside `api` / `script`", carried to the rows on exactly the vocabulary the + * action's closed `ActionType` can see. So it is refused beside every runner + * built-in other than those two (`url`, `navigation`, `modal`, `flow`, `form`), + * and beside a row with no effective executor, which the runner dispatches on + * nothing. + * + * A name outside the built-in table is a host-registered handler, and it stays + * open, like the executor key itself: the runner hops after every host + * handler, and only the host knows whether its handler navigates. The trade-off + * is accepted: a host handler that itself navigates, plus `onSuccess`, + * navigates twice, and no contract can see that. + */ +function rowOnSuccessRefused(executor: string | undefined): boolean { + return executor === undefined || (ROW_BUILTIN_EXECUTORS.has(executor) && !ON_SUCCESS_EXECUTORS.has(executor)); +} + +/** + * A page action row's `onSuccess` beside an executor it cannot follow + * ({@link rowOnSuccessRefused}), on `action:button` and `action:icon` and on + * 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 + * `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 }, + ctx: z.RefinementCtx, + ): void => { + if (row.onSuccess === undefined) return; + const executor = effectiveRowExecutor(row, executorKey); + if (!rowOnSuccessRefused(executor)) return; + const key = row[executorKey] === '' ? `an empty \`${executorKey}\`` : `no \`${executorKey}\``; + const spelled = row[executorKey] + ? `\`${executorKey}: '${executor}'\`` + : executor !== undefined + ? `\`name: '${executor}'\` and ${key}, so the runner runs \`${executor}\`` + : `${key} and ${row.name === '' ? 'an empty' : 'no'} \`name\``; + ctx.addIssue({ + code: 'custom', + path: ['onSuccess'], + message: onSuccessBesideExecutorRefusal(`${owner} \`onSuccess\` (${spelled})`, executor, executorKey), + }); + }; +} + /** * 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. @@ -4271,7 +4335,7 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ guidance: ACTION_NODE_GUIDANCE, }, { name: z.string().optional() - .describe('Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone. With no `actionType` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target'), + .describe('Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone. With no `actionType`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names'), label: z.string().optional() .describe('Button text. A literal string, placed as-is — localize through the translation bundle entry for this component id, not an inline locale map'), icon: z.string().optional() @@ -4291,7 +4355,7 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ description: z.string().optional() .describe('Action description, forwarded to the runner — the parameter dialog shows it under its title'), target: z.string().optional() - .describe('Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: \'url\'`, or no `actionType` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused'), + .describe('Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: \'url\'`, or no or an empty `actionType` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused'), openIn: z.enum(['self', 'new-tab']).optional() .describe('For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab'), method: z.string().optional().describe('HTTP method for an `api` action, forwarded to the runner'), @@ -4310,11 +4374,12 @@ 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. `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`, 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`'), 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(refuseRowUrlExecutorAlias('actionType', "An `action:button`'s `actionType: 'navigation'`"))); + .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). */ export type ActionButtonProps = z.input; /** @@ -4373,7 +4438,7 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ }, }, { name: z.string().optional() - .describe('Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button`. With no `actionType` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target'), + .describe('Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button`. With no `actionType`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names'), label: z.string().optional() .describe('Accessible label and tooltip text (and its first letter stands in when no icon resolves). A literal string — localize through the translation bundle entry for this component id'), icon: z.string().optional() @@ -4391,7 +4456,7 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ params: z.unknown().optional() .describe('Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user; an object is forwarded as the static parameter values'), target: z.string().optional() - .describe('Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: \'url\'`, or no `actionType` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused'), + .describe('Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: \'url\'`, or no or an empty `actionType` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused'), openIn: z.enum(['self', 'new-tab']).optional() .describe('For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab'), method: z.string().optional().describe('HTTP method for an `api` action, forwarded to the runner'), @@ -4408,11 +4473,12 @@ 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. `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`, 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`'), 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(refuseRowUrlExecutorAlias('actionType', "An `action:icon`'s `actionType: 'navigation'`"))); + .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). */ export type ActionIconProps = z.input; /** @@ -4560,7 +4626,7 @@ export type ActionIconPropsParsed = z.infer; function actionContainerMemberShape() { return { name: z.string().optional() - .describe('Action name, forwarded to the action runner, and the item\'s text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target'), + .describe('Action name, forwarded to the action runner, and the item\'s text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names'), label: z.string().optional() .describe('The item\'s text. A literal string, placed as-is — localize through the translation bundle entry for this component id'), icon: z.string().optional() @@ -4580,7 +4646,7 @@ function actionContainerMemberShape() { description: z.string().optional() .describe('Action description, forwarded to the runner — the parameter dialog shows it under its title'), target: z.string().optional() - .describe('Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`. For the url navigator (`type: \'url\'`, or no `type` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused'), + .describe('Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`. For the url navigator (`type: \'url\'`, or no or an empty `type` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused'), openIn: z.enum(['self', 'new-tab']).optional() .describe('For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab'), method: z.string().optional().describe('HTTP method for an `api` action, forwarded to the runner'), @@ -4597,7 +4663,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. `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`, 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`'), 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'), }; @@ -4686,7 +4752,8 @@ function buildActionGroupMember() { .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(refuseUnopenableRowUrlTarget('type', "An `action:group` member's")) - .superRefine(refuseRowUrlExecutorAlias('type', "An `action:group` member's `type: 'navigation'`")); + .superRefine(refuseRowUrlExecutorAlias('type', "An `action:group` member's `type: 'navigation'`")) + .superRefine(refuseRowOnSuccessBesideExecutor('type', "An `action:group` member's")); } let actionGroupMemberOnce: ReturnType | undefined; /** The one {@link buildActionGroupMember} instance. */ @@ -4707,7 +4774,8 @@ function buildActionMenuMember() { }, }, actionContainerMemberShape()).superRefine(actionContainerMemberParamsFitType('action:menu')) .superRefine(refuseUnopenableRowUrlTarget('type', "An `action:menu` member's")) - .superRefine(refuseRowUrlExecutorAlias('type', "An `action:menu` member's `type: 'navigation'`")); + .superRefine(refuseRowUrlExecutorAlias('type', "An `action:menu` member's `type: 'navigation'`")) + .superRefine(refuseRowOnSuccessBesideExecutor('type', "An `action:menu` member's")); } let actionMenuMemberOnce: ReturnType | undefined; /** The one {@link buildActionMenuMember} instance. */ From 861f5670767c4fd93675997408b807d5b9564d76 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 18:16:32 +0000 Subject: [PATCH 2/3] test(spec, lint): pin the rows' onSuccess executor rule; add its D3 entry and changeset Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude --- ...e-action-row-on-success-beside-executor.md | 35 +++++ ...ate-component-props-row-on-success.test.ts | 32 +++++ ...-row-on-success-beside-executor-refused.ts | 36 +++++ .../spec/src/ui/action-on-success.test.ts | 14 +- ...ent-action-row-on-success-executor.test.ts | 136 ++++++++++++++++++ 5 files changed, 249 insertions(+), 4 deletions(-) create mode 100644 .changeset/23168-page-action-row-on-success-beside-executor.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-on-success-beside-executor-refused.ts diff --git a/.changeset/23168-page-action-row-on-success-beside-executor.md b/.changeset/23168-page-action-row-on-success-beside-executor.md new file mode 100644 index 00000000000..48ed2cf446c --- /dev/null +++ b/.changeset/23168-page-action-row-on-success-beside-executor.md @@ -0,0 +1,35 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: a page action row's `onSuccess` is refused beside a console runner built-in other than `api` / `script`, and beside a row with no executor; `ActionSchema`'s `onSuccess` refusal now states its true reason + +Clause-②: no (narrowing) + + + +**BREAKING**: an accept-set narrowing on one key 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 `onSuccess` to the console's action runner, which performs the post-success hop after every executor that succeeds. `ActionSchema` refuses `onSuccess` beside any `type` but `api` and `script`; the rows judged it beside no executor at all. They now carry the action's rule on the vocabulary the action's closed `ActionType` can see, read against the row's effective executor (the executor key, or the row's `name` when that key is omitted or empty, as the runner resolves it): + +- refused beside the runner built-ins `url`, `navigation`, `modal`, `flow` and `form`; +- refused on a row with no effective executor (no executor key, or an empty one, and no `name`); +- accepted beside `api` and `script`, and beside any other name: that is a handler a host registers, the runner hops after it too, and only the host knows whether its handler navigates. + +`ActionSchema`'s own reach is unchanged (`url`, `modal`, `flow` and `form` are refused, as before). Its message used to say that on those types the key "would parse clean and never run" and that "no renderer reads a post-success hop today". Both were false: the runner hops after every executor. The action and the rows now print one refusal text with the true reason for each executor. + +### FROM → TO + +| before | what to write instead | +| --- | --- | +| `{ actionType: 'url', target: '/tasks', onSuccess: { navigate: '/done' } }` (or `type: 'url'` on a member, or `name: 'url'` with no executor key) | the destination in `target`, the tab in `openIn`, and no `onSuccess`: the url action is itself the navigation, so the hop navigated a second time | +| the same with `navigation` | the same, with `actionType: 'url'` (the alias is refused at the executor key as well) | +| `{ actionType: 'form', target: 'task_form', onSuccess: … }` | no `onSuccess`; declare where a submitted form goes in the form view's `submitBehavior` | +| `{ actionType: 'modal', … , onSuccess: … }` or `{ actionType: 'flow', … , onSuccess: … }` | no `onSuccess`, or move the hop onto an `api` or `script` action, whose result it reads | +| `{ label: 'Go', onSuccess: { navigate: '/tasks' } }` (no executor key and no `name`) | declare the executor whose success the hop follows, `actionType: 'api'` or `actionType: 'script'` (`type` on a member), or drop `onSuccess` | + +**The one-line fix: keep `onSuccess` only on a page action row whose executor is `api`, `script` or a host-registered handler, and declare that executor.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-on-success-beside-executor-refused`; the rewrite is by hand, because where the hop belongs is the author's call. + +**What an author now sees.** `os validate`'s props gate reports the refusal at the row's `onSuccess` (`properties.onSuccess`, or `properties.actions.N.onSuccess` for a member). For example, `{ actionType: 'url', target: '/tasks', onSuccess: { navigate: '/done' } }` on an `action:button` gets: ``An `action:button`'s `onSuccess` (`actionType: 'url'`) would navigate a second time: a `url` action is itself the navigation, and the console's action runner performs the post-success hop after every executor that succeeds.``, followed by what the hop is declared beside and the prescription. The same row without `onSuccess` parses unchanged. + +**Measured producers.** None in this repository or in hotcrm (at `0f84c9c`). In objectui (at its pin `1fb1ced36243` and its main `72594f29`), one test row with no executor and no `name` carries `onSuccess` and is asserted accepted; its remedy is to declare `actionType: 'api'` on that row. A host-handler row (`actionType: 'run'`) with `onSuccess` stays accepted. 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 f60c3640ebe..589b19405a8 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 @@ -76,3 +76,35 @@ describe("the props gate refuses `'navigation'` at a page action row's executor expect(validateComponentProps(stackWith(node({ [key]: 'url', target: '/apps/crm/tasks' })))).toEqual([]); }); }); + +// `onSuccess` beside the row's effective executor: `ActionSchema`'s "only beside +// `api` / `script`", carried to the rows on the runner's built-in vocabulary. +// Refused beside `url`, `navigation`, `modal`, `flow` and `form` and beside a +// row with no executor; a host-registered handler name stays open. + +/** How a refusal on a row's own key opens, read off the row's `navigation` subject. */ +const ownerOf = (subject: string) => subject.slice(0, subject.lastIndexOf(' `')); + +describe("the props gate refuses a page action row's `onSuccess` beside an executor it cannot follow", () => { + it.each(ROWS)('REFUSAL: %s with `form` and `onSuccess` is one finding at `onSuccess`', (_row, node, at, key, subject) => { + const findings = validateComponentProps(stackWith(node({ + [key]: 'form', target: 'task_form', 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\` (\`${key}: 'form'\`)`); + }); + + it.each(ROWS)('REFUSAL: %s with no executor key and no `name` is one finding at `onSuccess`', (_row, node, at, key, subject) => { + const findings = validateComponentProps(stackWith(node({ 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\` (no \`${key}\` and no \`name\`)`); + }); + + it.each(ROWS)('ACCEPTED CONTROL: %s with a host-registered handler and `onSuccess` reports nothing', (_row, node, _at, key) => { + expect(validateComponentProps(stackWith(node({ [key]: 'run', onSuccess: { navigate: '/tasks' } })))).toEqual([]); + }); + + it.each(ROWS)('ACCEPTED CONTROL: %s with `form` and no `onSuccess` reports nothing', (_row, node, _at, key) => { + expect(validateComponentProps(stackWith(node({ [key]: 'form', target: 'task_form' })))).toEqual([]); + }); +}); diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-on-success-beside-executor-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-on-success-beside-executor-refused.ts new file mode 100644 index 00000000000..6e7d0081f90 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-on-success-beside-executor-refused.ts @@ -0,0 +1,36 @@ +// 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-on-success-beside-executor-refused', + surface: 'the `onSuccess` of a page action row (`properties.onSuccess` on an `action:button` or `action:icon`, ' + + 'and `onSuccess` on each `action:group` / `action:menu` member of `properties.actions`) whose effective ' + + 'executor is a console runner built-in other than `api` / `script`: `url`, `navigation`, `modal`, `flow` or ' + + '`form`, spelled by the executor key (`actionType` on a node, `type` on a member) or, with that key omitted ' + + 'or empty, by the row\'s `name`. Also the `onSuccess` of a row with no effective executor: no (or an empty) ' + + 'executor key and no `name`', + replacement: 'the hop on an `api` or `script` row, whose success carries the result `${result.*}` reads; or no ' + + '`onSuccess`. On a `url` row, the destination is the row\'s `target` (with the tab in its `openIn`); on a ' + + '`form` row, where a submitted form goes is the form view\'s `submitBehavior`. A row with no executor ' + + 'declares the one whose success the hop follows (`actionType: \'api\'` or `\'script\'`, `type` on a ' + + 'member). A handler a host registers under another name keeps its `onSuccess`. ⛔ NOT mechanically ' + + 'convertible: whether the hop belongs on another action, in the target, or nowhere is the author\'s call.', + reason: + 'The console\'s action runner performs the post-success hop after every executor that succeeds, so the rows\' ' + + '`onSuccess` was read beside every executor and judged beside none: `url`, `navigation` and `form` are ' + + 'themselves navigations, so the hop navigated a second time; a `modal` hop followed whatever the dialog\'s ' + + 'host answered and a `flow` hop the run\'s start, a run paused at its first screen included; and a row with ' + + 'no executor dispatches on nothing. `ActionSchema` already refuses `onSuccess` beside any `type` but `api` / ' + + '`script`; the rows now carry that rule on the vocabulary the action\'s closed `ActionType` can see, with ' + + 'the same refusal text. A name outside the runner\'s built-in table is a host-registered handler and stays ' + + 'open: the runner hops after it too, and only the host knows whether its handler navigates. 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 `onSuccess`. Measured producers: none in this ' + + 'repository or in hotcrm; objectui\'s one is a test row with no executor asserted accepted.', + acceptanceCriteria: + 'Every `action:button` / `action:icon` `properties.onSuccess` and every `action:group` / `action:menu` member ' + + '`onSuccess` sits on a row whose effective executor is `api`, `script` or a name outside the runner\'s ' + + 'built-in table; `os validate` reports no `component-props-invalid` finding at `properties.onSuccess` or ' + + '`properties.actions.N.onSuccess` for the executor, and every such row parses as before.', +}; diff --git a/packages/spec/src/ui/action-on-success.test.ts b/packages/spec/src/ui/action-on-success.test.ts index 4d59e8045d6..c9cd072bf95 100644 --- a/packages/spec/src/ui/action-on-success.test.ts +++ b/packages/spec/src/ui/action-on-success.test.ts @@ -147,10 +147,16 @@ describe('ActionSchema.onSuccess — one closed post-success navigation key for onSuccess: { navigate: '/x' }, }); expect(r.success).toBe(false); - const msg = r.error!.issues.map((i) => i.message).join('\n'); - expect(msg).toContain('onSuccess'); - expect(msg).toContain("'api'"); - expect(msg).toContain("'script'"); + const issues = r.error!.issues; + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', ['onSuccess']]]); + // The subject names the action's type; the text is the one the page + // action rows print (`action-on-success-executor.ts`), pinned there. + expect(issues[0].message.startsWith(`A \`type: '${type}'\` action's \`onSuccess\``)).toBe(true); + }); + + it.each(['api', 'script'] as const)('NEGATIVE CONTROL: accepts onSuccess on a type:%s action', (type) => { + const r = ActionSchema.safeParse({ ...base, type, target: type === 'api' ? '/api/v1/x' : 'fn', onSuccess: { navigate: '/x' } }); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); }); }); 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 dd572cd52fa..061630ca3de 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 @@ -17,6 +17,12 @@ * `'navigation'`, the runner's local alias of `'url'`, with the lossless * rename onto `'url'` — whose `target` the url scheme set judges. Every other * executor name, a host-registered handler included, stays open. + * - **`onSuccess` beside the effective executor** (section 3): `ActionSchema`'s + * "`onSuccess` only beside `api` / `script`", carried to the rows on the + * vocabulary the action's closed `ActionType` can see. Refused beside every + * runner built-in other than those two (`url`, `navigation`, `modal`, + * `flow`, `form`) and beside a row with no effective executor; a host + * handler name stays open; the refusal text is the action's own. * * Each row gets one refusal (its Zod issue path and its first sentence) and one * accepted control (a full parse). A page's `properties` bag is outside the @@ -65,6 +71,8 @@ interface Row { readonly at: readonly PropertyKey[]; readonly executorKey: 'actionType' | 'type'; readonly navigationSubject: string; + /** How a refusal on one of the row's own keys opens ("An `action:button`'s"). */ + readonly owner: string; /** The parsed row's action object. */ readonly parsed: (data: unknown) => Record; } @@ -76,6 +84,7 @@ const ROWS: readonly Row[] = [ at: [], executorKey: 'actionType', navigationSubject: "An `action:button`'s `actionType: 'navigation'`", + owner: "An `action:button`'s", parsed: (data) => data as Record, }, { @@ -84,6 +93,7 @@ const ROWS: readonly Row[] = [ at: [], executorKey: 'actionType', navigationSubject: "An `action:icon`'s `actionType: 'navigation'`", + owner: "An `action:icon`'s", parsed: (data) => data as Record, }, { @@ -92,6 +102,7 @@ const ROWS: readonly Row[] = [ at: ['actions', 0], executorKey: 'type', navigationSubject: "An `action:group` member's `type: 'navigation'`", + owner: "An `action:group` member's", parsed: (data) => (data as { actions: Record[] }).actions[0], }, { @@ -100,6 +111,7 @@ const ROWS: readonly Row[] = [ at: ['actions', 0], executorKey: 'type', navigationSubject: "An `action:menu` member's `type: 'navigation'`", + owner: "An `action:menu` member's", parsed: (data) => (data as { actions: Record[] }).actions[0], }, ]; @@ -201,3 +213,127 @@ describe("a page action row's executor key refuses `'navigation'`, the runner's expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); }); }); + +// =========================================================================== +// 3. onSuccess beside the effective executor — ActionSchema's own rule +// =========================================================================== + +/** The row's subject for an issue on `onSuccess`, given the spelling that resolved its executor. */ +const onSuccessSubject = (row: Row, spelled: string) => `${row.owner} \`onSuccess\` (${spelled})`; + +/** A target each executor takes without a refusal of its own. */ +const TARGETS: Readonly> = { + url: '/apps/crm/tasks', + navigation: '/apps/crm/tasks', + modal: 'task_modal', + flow: 'close_task', + form: 'task_form', + api: '/api/v1/tasks/clone', + script: 'clone_task_fn', + run: 'clone_task', +}; + +const HOP = { navigate: '/apps/crm/tasks/${result.id}' }; + +describe("a page action row's `onSuccess` is refused beside a runner built-in other than `api` / `script`", () => { + const REFUSED = ['url', 'modal', 'flow', 'form'] as const; + + it.each(ROWS.flatMap((row) => REFUSED.map((executor) => [row.name, executor, row] as const)))( + 'REFUSAL: %s with `%s` and `onSuccess` is refused at `onSuccess`, naming the row and its executor', + (_name, executor, row) => { + const issues = issuesOf(row.parse({ [row.executorKey]: executor, target: TARGETS[executor], onSuccess: HOP })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, 'onSuccess']]]); + expect(issues[0].message.startsWith(onSuccessSubject(row, `\`${row.executorKey}: '${executor}'\``))).toBe(true); + }, + ); + + it.each(ROWS.flatMap((row) => REFUSED.map((executor) => [row.name, executor, row] as const)))( + 'NEGATIVE CONTROL: %s with `%s` and no `onSuccess` parses', + (_name, executor, row) => { + const r = row.parse({ [row.executorKey]: executor, target: TARGETS[executor] }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + }, + ); + + it.each(ROWS)("REFUSAL: $name with `'navigation'` and `onSuccess` gets the alias refusal AND the `onSuccess` one", (row) => { + const issues = issuesOf(row.parse({ [row.executorKey]: 'navigation', target: TARGETS.navigation, onSuccess: HOP })); + expect(issues.map((i) => [i.code, i.path])).toEqual([ + ['custom', [...row.at, row.executorKey]], + ['custom', [...row.at, 'onSuccess']], + ]); + expect(issues[1].message.startsWith(onSuccessSubject(row, `\`${row.executorKey}: 'navigation'\``))).toBe(true); + }); + + it.each(ROWS.flatMap((row) => REFUSED.map((executor) => [row.name, executor, row] as const)))( + "ONE TEXT: %s with `%s` gives `ActionSchema`'s own refusal, after the subject", + (_name, executor, row) => { + const rowMessage = issuesOf(row.parse({ [row.executorKey]: executor, target: TARGETS[executor], onSuccess: HOP }))[0].message; + const actionIssues = issuesOf(ActionSchema.safeParse({ + name: 'clone_task', label: 'Clone', type: executor, target: TARGETS[executor], onSuccess: HOP, + })); + expect(actionIssues.map((i) => [i.code, i.path])).toEqual([['custom', ['onSuccess']]]); + const actionSubject = `A \`type: '${executor}'\` action's \`onSuccess\``; + expect(actionIssues[0].message.startsWith(actionSubject)).toBe(true); + const rowSubject = onSuccessSubject(row, `\`${row.executorKey}: '${executor}'\``); + expect(rowMessage.slice(rowSubject.length)).toBe(actionIssues[0].message.slice(actionSubject.length)); + }, + ); + + it.each(ROWS)('REFUSAL: $name on the name leg — no executor key and `name: \'form\'` — is refused as `form`', (row) => { + const issues = issuesOf(row.parse({ name: 'form', target: TARGETS.form, onSuccess: HOP })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, 'onSuccess']]]); + expect(issues[0].message.startsWith( + onSuccessSubject(row, `\`name: 'form'\` and no \`${row.executorKey}\`, so the runner runs \`form\``), + )).toBe(true); + }); +}); + +describe("a page action row's `onSuccess` is refused beside no effective executor", () => { + it.each(ROWS)('REFUSAL: $name with no executor key and no `name` is refused at `onSuccess`', (row) => { + const issues = issuesOf(row.parse({ onSuccess: HOP })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, 'onSuccess']]]); + expect(issues[0].message.startsWith(onSuccessSubject(row, `no \`${row.executorKey}\` and no \`name\``))).toBe(true); + expect(issues[0].message).toContain(`\`${row.executorKey}: 'api'\``); + }); + + it.each(ROWS)('REFUSAL: $name with an EMPTY executor key and no `name` is refused too — the runner reads `||`', (row) => { + const issues = issuesOf(row.parse({ [row.executorKey]: '', onSuccess: HOP })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, 'onSuccess']]]); + expect(issues[0].message.startsWith(onSuccessSubject(row, `an empty \`${row.executorKey}\` and no \`name\``))).toBe(true); + }); + + it.each(ROWS)('NEGATIVE CONTROL: $name with no executor and no `onSuccess` parses', (row) => { + const r = row.parse({}); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + }); +}); + +describe("a page action row's `onSuccess` stays open beside `api`, `script` and a host-registered handler", () => { + it.each(ROWS.flatMap((row) => (['api', 'script', 'run'] as const).map((executor) => [row.name, executor, row] as const)))( + 'ACCEPTED: %s with `%s` and `onSuccess` parses, the block resolved as on the action', + (_name, executor, row) => { + const r = row.parse({ [row.executorKey]: executor, target: TARGETS[executor], onSuccess: HOP }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + expect(row.parsed(r.data).onSuccess).toEqual({ ...HOP, openIn: 'self' }); + }, + ); + + it.each(ROWS)('ACCEPTED: $name on the name leg — no executor key and `name: \'api\'` or a host handler name — parses', (row) => { + for (const name of ['api', 'script', 'clone_task']) { + const r = row.parse({ name, onSuccess: HOP }); + expect(r.success, `${name}: ${JSON.stringify(issuesOf(r))}`).toBe(true); + } + const empty = row.parse({ [row.executorKey]: '', name: 'api', onSuccess: HOP }); + expect(empty.success, JSON.stringify(issuesOf(empty))).toBe(true); + }); + + it.each(ROWS)('ACCEPTED: $name with a declared executor key outranks its `name` — `api` named `url` parses', (row) => { + const r = row.parse({ [row.executorKey]: 'api', name: 'url', target: TARGETS.api, onSuccess: HOP }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + }); + + it.each(ROWS)('NEGATIVE CONTROL: $name with a declared `form` key still refuses its `name: \'api\'` row\'s `onSuccess`', (row) => { + const issues = issuesOf(row.parse({ [row.executorKey]: 'form', name: 'api', target: TARGETS.form, onSuccess: HOP })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, 'onSuccess']]]); + }); +}); From a2bfa8b7a78e9b22ff4ca37deac7dcd99971c13c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 18:24:45 +0000 Subject: [PATCH 3/3] docs(spec): regenerate the ui component reference for the rows' onSuccess executor rule Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index b48dc6ccdec..33e7212fc8f 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -28,7 +28,7 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | optional | Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone. With no `actionType` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target | +| **name** | `string` | optional | Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone. With no `actionType`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names | | **label** | `string` | optional | Button text. A literal string, placed as-is — localize through the translation bundle entry for this component id, not an inline locale map | | **icon** | `string` | optional | Lucide icon name drawn left of the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | | **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. Write it here, not as `type`: on a page component `type` is the component itself | @@ -38,7 +38,7 @@ const result = ActionButtonPropsSchema.parse(data); | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the button is shown but cannot be pressed while it is TRUE. Omit for never-disabled | | **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs; an object is forwarded as the static parameter values | | **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title | -| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: 'url'`, or no `actionType` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused | +| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: 'url'`, or no or an empty `actionType` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab | | **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 | @@ -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. `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`, 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` | | **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` @@ -86,7 +86,7 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target | +| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names | | **label** | `string` | optional | The item's text. A literal string, placed as-is — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | | **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) | @@ -96,7 +96,7 @@ const result = ActionButtonPropsSchema.parse(data); | **tags** | `Enum<'separator-before'>[]` | optional | Item tags — `separator-before` draws a divider above the item in a dropdown or menu (not above the first item); no other tag is drawn | | **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs. Only a `type: 'api'` member takes any other value, forwarded as its request payload (use `bodyExtra` for that); on every other member a non-array `params` is refused — author an action with static parameter values as its own `action:button` node | | **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title | -| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`. For the url navigator (`type: 'url'`, or no `type` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused | +| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`. For the url navigator (`type: 'url'`, or no or an empty `type` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab | | **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 | @@ -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. `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`, 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` | | **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 | @@ -123,7 +123,7 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | optional | Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button`. With no `actionType` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target | +| **name** | `string` | optional | Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button`. With no `actionType`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names | | **label** | `string` | optional | Accessible label and tooltip text (and its first letter stands in when no icon resolves). A literal string — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name, resolved through the shared action-icon resolver (renderer registration default `play`) | | **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. Write it here, not as `type` | @@ -132,7 +132,7 @@ const result = ActionButtonPropsSchema.parse(data); | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the icon is shown but cannot be pressed while it is TRUE. Omit for never-disabled | | **description** | `string` | optional | Action description — the tooltip text when there is no `label`, and forwarded to the runner for the parameter dialog | | **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user; an object is forwarded as the static parameter values | -| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: 'url'`, or no `actionType` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused | +| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: 'url'`, or no or an empty `actionType` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab | | **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 | @@ -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. `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`, 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` | | **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` @@ -176,7 +176,7 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target | +| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names | | **label** | `string` | optional | The item's text. A literal string, placed as-is — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | | **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) | @@ -186,7 +186,7 @@ const result = ActionButtonPropsSchema.parse(data); | **tags** | `Enum<'separator-before'>[]` | optional | Item tags — `separator-before` draws a divider above the item in a dropdown or menu (not above the first item); no other tag is drawn | | **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs. Only a `type: 'api'` member takes any other value, forwarded as its request payload (use `bodyExtra` for that); on every other member a non-array `params` is refused — author an action with static parameter values as its own `action:button` node | | **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title | -| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`. For the url navigator (`type: 'url'`, or no `type` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused | +| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`. For the url navigator (`type: 'url'`, or no or an empty `type` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab | | **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 | @@ -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. `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`, 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` | | **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 |