diff --git a/.changeset/25800-flow-action-on-success.md b/.changeset/25800-flow-action-on-success.md new file mode 100644 index 00000000000..47275a9123a --- /dev/null +++ b/.changeset/25800-flow-action-on-success.md @@ -0,0 +1,31 @@ +--- +'@objectstack/spec': minor +'@object-ui/types': minor +'@object-ui/core': minor +'@object-ui/app-shell': minor +--- + +feat(spec,console): a `type: 'flow'` action may declare `onSuccess`, and the console navigates after a run that completes + +Clause-②: yes +Metadata-protocol: added + +A flow action that creates a record could not take the user to it. `ActionSchema` refused `onSuccess` beside `type: 'flow'` (and the page action rows refused it beside a `flow` executor), so after the run the user stayed on the page they started from. + +- **`@objectstack/spec`:** `onSuccess` is now accepted on a `type: 'flow'` action and beside a page action row's `flow` executor, next to `api` and `script`. On a flow action, `${result.*}` reads the completed run's answer (`AutomationResult`). Its `output` holds the flow's `isOutput` variables, so a `create_record` node that writes an `isOutput` variable `renewal` is reached as `${result.output.renewal.id}`. A variable the flow does not declare `isOutput: true` is not in `output`. The refusal text for `url`, `navigation`, `modal` and `form` now names the three executors. The Studio action form shows the `onSuccess` row for a flow action. +- **`@object-ui/types`:** `ActionResult` gains `handedOff`: the success is a hand-off to a follow-up surface, not a completion. +- **`@object-ui/app-shell`:** the flow launch judge marks a run that pauses at a screen, or ends refused, `handedOff`. A run that completes is not marked, including one whose toast is the flow's own `successMessage`. +- **`@object-ui/core`:** the action runner performs no `onSuccess` hop for a `handedOff` success. A flow run that pauses at a screen or ends refused does not navigate, neither when the screen or the refusal notice opens nor when the flow runner later finishes the run. + +Example: + +```ts +{ + name: 'start_renewal', + type: 'flow', + target: 'renewal_start', + onSuccess: { navigate: '/apps/clm/clm_contract/record/${result.output.renewal.id}' }, +} +``` + +with the flow declaring `variables: [{ name: 'renewal', type: 'object', isOutput: true }]` and its `create_record` node writing `outputVariable: 'renewal'`. diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index e26e6057748..cfa78d28f5c 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -522,7 +522,7 @@ const result = ApiMethod.parse(data); | **mode** | `Enum<'create' \| 'edit' \| 'delete' \| 'custom'>` | optional | Semantic mode of the action. | | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | | **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | -| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | +| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api', type:'script' and type:'flow' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response; for a flow, the completed run's answer); `openIn` defaults 'self'. A flow run that pauses at a screen or ends refused performs no navigation. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | | **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index b2ec876114d..c85a98dbbd8 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -355,7 +355,7 @@ const result = MetadataBulkResultSchema.parse(data); | **mode** | `Enum<'create' \| 'edit' \| 'delete' \| 'custom'>` | optional | Semantic mode of the action. | | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | | **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | -| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | +| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api', type:'script' and type:'flow' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response; for a flow, the completed run's answer); `openIn` defaults 'self'. A flow run that pauses at a screen or ends refused performs no navigation. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | | **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index f14a2f2abf9..7dd8e91b70d 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -69,7 +69,7 @@ const result = ActionSchema.parse(data); | **mode** | `Enum<'create' \| 'edit' \| 'delete' \| 'custom'>` | optional | Semantic mode of the action. | | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | | **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | -| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | +| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api', type:'script' and type:'flow' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response; for a flow, the completed run's answer); `openIn` defaults 'self'. A flow run that pauses at a screen or ends refused performs no navigation. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | | **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -178,7 +178,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions. | +| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`; on a type:'flow' action the completed run's answer, whose `output` carries the flow's isOutput variables, e.g. $`{result.output.renewal.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions. | | **openIn** | `Enum<'self' \| 'newTab'>` | optional (default: `"self"`) | Where to perform the post-success navigation: 'self' (default — in-place SPA navigation, immune to popup blocking) or 'newTab'. Closed enum — no general navigation DSL. | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 24e217b862e..2da1d109adc 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -54,14 +54,14 @@ const result = ActionButtonPropsSchema.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Action locations, forwarded to the runner — the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | | **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | -| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation, forwarded to the runner: `ActionSchema`'s own `{ navigate, openIn }` block. Taken beside an `api` or `script` executor, or a handler a host registers under another name; refused beside the built-ins `url`, `navigation`, `modal`, `flow` and `form`, on a row that names no executor, and beside `operation: 'update'`, whose write the runner performs before any executor. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` | +| **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`, `script` or `flow` executor (for `flow`, only a run that completes: a run that pauses at a screen or ends refused performs no navigation), or a handler a host registers under another name; refused beside the built-ins `url`, `navigation`, `modal` and `form`, on a row that names no executor, and beside `operation: 'update'`, whose write the runner performs before any executor. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` | | **objectName** | `string` | optional | Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page's object. Omit to act on the page's object | ### Nested Shape: `ActionButtonProps.onSuccess` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions. | +| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`; on a type:'flow' action the completed run's answer, whose `output` carries the flow's isOutput variables, e.g. $`{result.output.renewal.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions. | | **openIn** | `Enum<'self' \| 'newTab'>` | optional (default: `"self"`) | Where to perform the post-success navigation: 'self' (default — in-place SPA navigation, immune to popup blocking) or 'newTab'. Closed enum — no general navigation DSL. | @@ -110,7 +110,7 @@ const result = ActionButtonPropsSchema.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Action locations, forwarded to the runner — an `action:group` with a `location` draws only the members that list it, and the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | | **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | -| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation, forwarded to the runner: `ActionSchema`'s own `{ navigate, openIn }` block. Taken beside an `api` or `script` executor, or a handler a host registers under another name; refused beside the built-ins `url`, `navigation`, `modal`, `flow` and `form`, on a row that names no executor, and beside `operation: 'update'`, whose write the runner performs before any executor. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` | +| **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`, `script` or `flow` executor (for `flow`, only a run that completes: a run that pauses at a screen or ends refused performs no navigation), or a handler a host registers under another name; refused beside the built-ins `url`, `navigation`, `modal` and `form`, on a row that names no executor, and beside `operation: 'update'`, whose write the runner performs before any executor. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` | | **objectName** | `string` | optional | Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page's object. Omit to act on the page's object | | **size** | `Enum<'default' \| 'sm' \| 'lg' \| 'icon' \| 'md'>` | optional | Inline button size — the Button primitive's vocabulary, plus `md` (drawn as `default`), falling back to the group's `size`. A dropdown item reads no size | @@ -146,14 +146,14 @@ const result = ActionButtonPropsSchema.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Action locations, forwarded to the runner — the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | | **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | -| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation, forwarded to the runner: `ActionSchema`'s own `{ navigate, openIn }` block. Taken beside an `api` or `script` executor, or a handler a host registers under another name; refused beside the built-ins `url`, `navigation`, `modal`, `flow` and `form`, on a row that names no executor, and beside `operation: 'update'`, whose write the runner performs before any executor. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` | +| **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`, `script` or `flow` executor (for `flow`, only a run that completes: a run that pauses at a screen or ends refused performs no navigation), or a handler a host registers under another name; refused beside the built-ins `url`, `navigation`, `modal` and `form`, on a row that names no executor, and beside `operation: 'update'`, whose write the runner performs before any executor. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` | | **objectName** | `string` | optional | Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page's object. Omit to act on the page's object | ### Nested Shape: `ActionIconProps.onSuccess` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions. | +| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`; on a type:'flow' action the completed run's answer, whose `output` carries the flow's isOutput variables, e.g. $`{result.output.renewal.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions. | | **openIn** | `Enum<'self' \| 'newTab'>` | optional (default: `"self"`) | Where to perform the post-success navigation: 'self' (default — in-place SPA navigation, immune to popup blocking) or 'newTab'. Closed enum — no general navigation DSL. | @@ -200,7 +200,7 @@ const result = ActionButtonPropsSchema.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Action locations, forwarded to the runner — an `action:group` with a `location` draws only the members that list it, and the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | | **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | -| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation, forwarded to the runner: `ActionSchema`'s own `{ navigate, openIn }` block. Taken beside an `api` or `script` executor, or a handler a host registers under another name; refused beside the built-ins `url`, `navigation`, `modal`, `flow` and `form`, on a row that names no executor, and beside `operation: 'update'`, whose write the runner performs before any executor. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` | +| **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`, `script` or `flow` executor (for `flow`, only a run that completes: a run that pauses at a screen or ends refused performs no navigation), or a handler a host registers under another name; refused beside the built-ins `url`, `navigation`, `modal` and `form`, on a row that names no executor, and beside `operation: 'update'`, whose write the runner performs before any executor. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` | | **objectName** | `string` | optional | Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page's object. Omit to act on the page's object | diff --git a/packages/lint/src/validate-component-props-row-on-success.test.ts b/packages/lint/src/validate-component-props-row-on-success.test.ts index 8b245ae9e32..386c9ab305a 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 @@ -78,9 +78,9 @@ describe("the props gate refuses `'navigation'` at a page action row's executor }); // `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. +// `api` / `script` / `flow`", carried to the rows on the runner's built-in +// vocabulary. Refused beside `url`, `navigation`, `modal` 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(' `')); diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index 628cd97b4e4..df2394ff776 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -800,7 +800,11 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // `data.type == 'text'`, a single-quoted literal comparison. // It is 65 today: #24735 removed the object form's `expression` gate, // `data.type == 'formula'`, one single-quoted literal comparison. - expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(65); + // It is 63 today: #25800 opened the action form's `onSuccess` row to + // `flow`, the third type its parse accepts, and respelled the gate + // `data.type in ['api','script','flow']` — an `in`-list, outside this rule, + // so its two `==` literal comparisons left the census. + expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(63); const rhsFindings = validatePredicatePathRefs(corrupted) .filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED); diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 9d94d65a26d..3eead2d1341 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -217,7 +217,7 @@ "evidenceScope": "in-repo", "evidence": "ui/packages/core/src/actions/ActionRunner.ts#handlePostExecution (on a successful action, `readOnSuccessNavigation(action.onSuccess)` shape-guards the block and hands it to `navigateOnSuccess`); ui/packages/core/src/actions/ActionRunner.ts#navigateOnSuccess (interpolates `block.navigate` through `interpolateTarget` with the `${param.*}`, `${ctx.*}` and `${result.*}` scopes, the last one being the handler's own return value, refuses a URL that is neither http(s) nor relative, and hands the result to the host's `navigationHandler`); ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx#useConsoleActionRuntime (the console's `navigateHandler`, wired as the runner's `onNavigate`: a relative URL is a react-router `navigate`, an external or new-tab one opens a window)", "producer": "ui/packages/components/src/renderers/action/action-button.tsx#ActionButtonRenderer (forwards `onSuccess: schema.onSuccess` into the def the runner executes); ui/packages/components/src/renderers/action/action-icon.tsx#ActionIconRenderer (the same forward); ui/packages/components/src/renderers/action/action-menu.tsx#ActionMenuRenderer (the same forward); ui/packages/components/src/renderers/action/action-group.tsx#ActionGroupRenderer (the same forward); ui/packages/app-shell/src/views/DeclaredActionsBar.tsx#DeclaredActionButton (spreads the declared action, `onSuccess` included, into its dispatch)", - "note": "FLIPPED planned → live 2026-09-27 (#20296). It was PLANNED under the #9340 map / #9463 viewMode convention for a spec-first contract-split key: the #9566/#9474 maintainer ruling (2026-08-18) declared one post-success route/URL template for type:'api'/'script' actions whose interpolation scope gains `${result.*}` (the server response) beside `${param.*}`/`${ctx.*}`, and at spec/console 17.0.0 no console consumer read it (consoleServerAction drove only the handler-return `{ redirectUrl }` convention, and interpolateTarget had no `result` member). The objectui half has since landed (objectui#5221 for the runner hop, objectui#5493 for the renderer forward, and objectui#5934, which retired the runner's older chained-callback meaning of this key), and the `.objectui-sha` pin f8a9d0fb carries it, so every pointer above was read at the pin this repo builds against. Each read is unchanged at objectui main 256b4c9e. The hop sits in `handlePostExecution`, after the executor for every action type, so it is not an executeAPI branch: `api` actions reach it through the console's `api` handler and `script` actions through its server-action handler. That handler (objectui app-shell consoleServerAction.ts) defers to a declared block rather than also following a handler-returned `redirectUrl`, so one success makes one navigation; the schema refuses `onSuccess` beside `opensInNewTab: true`. `${result.*}` reads `readActionPayload(result.data)`, one level below the action envelope, and `navigateOnSuccess` is the only caller that passes a result scope. MOUNT CHAIN, closed by hand at the pin: the console root (apps/console App.tsx) mounts `ConsoleShell`, whose `useConsoleActionRuntime` feeds ``; @object-ui/react's ActionContext builds the `ActionRunner` and calls `setNavigationHandler(onNavigate)`. A declared action rendered by `action:button` / `action:icon` / `action:menu` / `action:group` (ObjectView's list toolbar renders `action:bar`) or by DeclaredActionsBar reaches `ActionRunner.execute` with `onSuccess` on the def (`producer`). Executor pairing (packages/spec/src/ui/action-on-success-executor.ts ON_SUCCESS_EXECUTORS; read points moved out of spec source, #23434), measured at objectui @1fb1ced36243 and byte-identical at objectui main @72594f29: every branch of objectui @1fb1ced36243 packages/core/src/actions/ActionRunner.ts#execute — a host-registered handler, a built-in, and the declarative `operation: 'update'`, which is read before any executor — ends in objectui @1fb1ced36243 packages/core/src/actions/ActionRunner.ts#handlePostExecution, which hands `onSuccess` to objectui @1fb1ced36243 packages/core/src/actions/ActionRunner.ts#readOnSuccessNavigation and then `navigateOnSuccess`." + "note": "FLIPPED planned → live 2026-09-27 (#20296). It was PLANNED under the #9340 map / #9463 viewMode convention for a spec-first contract-split key: the #9566/#9474 maintainer ruling (2026-08-18) declared one post-success route/URL template for type:'api'/'script' actions whose interpolation scope gains `${result.*}` (the server response) beside `${param.*}`/`${ctx.*}`, and at spec/console 17.0.0 no console consumer read it (consoleServerAction drove only the handler-return `{ redirectUrl }` convention, and interpolateTarget had no `result` member). The objectui half has since landed (objectui#5221 for the runner hop, objectui#5493 for the renderer forward, and objectui#5934, which retired the runner's older chained-callback meaning of this key), and the `.objectui-sha` pin f8a9d0fb carries it, so every pointer above was read at the pin this repo builds against. Each read is unchanged at objectui main 256b4c9e. The hop sits in `handlePostExecution`, after the executor for every action type, so it is not an executeAPI branch: `api` actions reach it through the console's `api` handler and `script` actions through its server-action handler. That handler (objectui app-shell consoleServerAction.ts) defers to a declared block rather than also following a handler-returned `redirectUrl`, so one success makes one navigation; the schema refuses `onSuccess` beside `opensInNewTab: true`. `${result.*}` reads `readActionPayload(result.data)`, one level below the action envelope, and `navigateOnSuccess` is the only caller that passes a result scope. MOUNT CHAIN, closed by hand at the pin: the console root (apps/console App.tsx) mounts `ConsoleShell`, whose `useConsoleActionRuntime` feeds ``; @object-ui/react's ActionContext builds the `ActionRunner` and calls `setNavigationHandler(onNavigate)`. A declared action rendered by `action:button` / `action:icon` / `action:menu` / `action:group` (ObjectView's list toolbar renders `action:bar`) or by DeclaredActionsBar reaches `ActionRunner.execute` with `onSuccess` on the def (`producer`). Executor pairing (packages/spec/src/ui/action-on-success-executor.ts ON_SUCCESS_EXECUTORS; read points moved out of spec source, #23434), measured at objectui @1fb1ced36243 and byte-identical at objectui main @72594f29: every branch of objectui @1fb1ced36243 packages/core/src/actions/ActionRunner.ts#execute — a host-registered handler, a built-in, and the declarative `operation: 'update'`, which is read before any executor — ends in objectui @1fb1ced36243 packages/core/src/actions/ActionRunner.ts#handlePostExecution, which hands `onSuccess` to objectui @1fb1ced36243 packages/core/src/actions/ActionRunner.ts#readOnSuccessNavigation and then `navigateOnSuccess`. 2026-10-26 (#25800): `flow` joined the pairing, so ON_SUCCESS_EXECUTORS is api / script / flow. A flow action's hop reads the completed run's answer, the `AutomationResult` both flow doors serve (the trigger route and the action route), so `${result.output.VAR}` reads one of the flow's `isOutput` variables. It follows a completed run only, read in-tree: ui/packages/app-shell/src/utils/flowResponse.ts#judgeFlowLaunch marks the paused and refused arms `handedOff: true`, and ui/packages/core/src/actions/ActionRunner.ts#handlePostExecution performs no hop for a handed-off success, so a run paused at a screen or ended refused gets no navigation." }, "openIn": { "status": "live", diff --git a/packages/spec/src/ui/action-on-success-executor.ts b/packages/spec/src/ui/action-on-success-executor.ts index a96432e1c8a..1846bf00590 100644 --- a/packages/spec/src/ui/action-on-success-executor.ts +++ b/packages/spec/src/ui/action-on-success-executor.ts @@ -2,13 +2,14 @@ /** * 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 + * ONE set 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 + * succeeds, unless the success is marked as a hand-off (`flow`, below). 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` (the read points are recorded by symbol in * `packages/spec/liveness/action.json`, row `onSuccess.navigate`). So the rule is not that the @@ -18,6 +19,17 @@ * - `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}). + * - `flow` succeeds with a result too when its run completes: the run's answer, + * the `AutomationResult` both doors serve (`POST /automation/FLOW/trigger` + * and the action route), whose `output` carries the flow's `isOutput` + * variables, so `${result.output.VAR}` reads one. The hop is declared for + * `flow` as well, and it follows the COMPLETED run only. A run that pauses + * at its first screen, or one that ends refused, is a hand-off to a + * follow-up surface (the flow runner, the refusal notice), not a completion: + * the console's launch judge (`judgeFlowLaunch`, app-shell + * `utils/flowResponse.ts`) marks that success `handedOff` and the runner + * performs no hop for it, neither when the surface opens nor when the flow + * runner later finishes the run. * - `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` @@ -27,11 +39,6 @@ * 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. * * `operation: 'update'` is read BEFORE any executor (`ActionRunner.execute`): * the runner performs the declarative write through @@ -54,19 +61,19 @@ * 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 + * must read the SAME set 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']); +/** The three executors the post-success hop is declared beside. */ +export const ON_SUCCESS_EXECUTORS: ReadonlySet = new Set(['api', 'script', 'flow']); /** 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."; + '`onSuccess` is declared beside `api`, `script` and `flow` only, whose success carries the result ' + + "`${result.*}` reads: the call's response, the handler's return, the completed run's answer."; /** The runtime fact every refusal below rests on. */ const HOPS_AFTER_EVERY_EXECUTOR = @@ -74,11 +81,11 @@ const HOPS_AFTER_EVERY_EXECUTOR = /** 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.'; + 'Drop `onSuccess`, or move the hop onto an `api`, `script` or `flow` 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. + * / `script` / `flow`, 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). @@ -107,16 +114,11 @@ export function onSuccessBesideExecutorRefusal( 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\`.`; + + `${DECLARED_BESIDE} Declare the executor whose success the hop follows, \`${executorKey}: 'api'\`, ` + + `\`${executorKey}: 'script'\` or \`${executorKey}: 'flow'\`, 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-on-success.test.ts b/packages/spec/src/ui/action-on-success.test.ts index c9cd072bf95..fc457049aea 100644 --- a/packages/spec/src/ui/action-on-success.test.ts +++ b/packages/spec/src/ui/action-on-success.test.ts @@ -3,14 +3,16 @@ // action types, `navigate` (route/URL template whose scope gains `${result.*}`, // the server response) + `openIn: 'self' | 'newTab'` defaulting `'self'`. // These pins hold the ruled shape: the accept set, the materialized default, -// the closed enum, the strict inner object, and the api/script type scope. +// the closed enum, the strict inner object, and the type scope — api and +// script, and flow, whose completed run answers with the flow's `isOutput` +// variables under `output` (`action-on-success-executor.ts`). import { describe, it, expect } from 'vitest'; import { ActionSchema, InlineActionSchema } from './action.zod'; import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; const base = { name: 'copy_as_new_version', label: 'Copy as new version' }; -describe('ActionSchema.onSuccess — one closed post-success navigation key for api and script actions', () => { +describe('ActionSchema.onSuccess — one closed post-success navigation key for api, script and flow actions', () => { describe('accept pins', () => { it('accepts the full shape on a type:api action', () => { const r = ActionSchema.safeParse({ @@ -34,6 +36,23 @@ describe('ActionSchema.onSuccess — one closed post-success navigation key for expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); }); + it('accepts a hop to the record a flow run produced, read off the run\'s `output`', () => { + // The run's answer is the `AutomationResult`; its `output` carries the + // flow's `isOutput` variables, so a `create_record` node writing an + // `isOutput` variable `renewal` is reached as `${result.output.renewal.id}`. + const r = ActionSchema.safeParse({ + ...base, + name: 'start_renewal', + label: 'Start renewal', + type: 'flow', + target: 'renewal_start', + onSuccess: { navigate: '/apps/clm/clm_contract/record/${result.output.renewal.id}' }, + }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + expect((r.data as { onSuccess: unknown }).onSuccess) + .toEqual({ navigate: '/apps/clm/clm_contract/record/${result.output.renewal.id}', openIn: 'self' }); + }); + it('reaches the same shape through the registered `action` metadata schema (the parsing door)', () => { const schema = getMetadataTypeSchema('action'); expect(schema).toBeDefined(); @@ -138,8 +157,8 @@ describe('ActionSchema.onSuccess — one closed post-success navigation key for }); }); - describe('type scope — api and script only, refused on any other type, never silently ignored', () => { - it.each(['url', 'modal', 'flow', 'form'] as const)('refuses onSuccess on a type:%s action', (type) => { + describe('type scope — api, script and flow only, refused on any other type, never silently ignored', () => { + it.each(['url', 'modal', 'form'] as const)('refuses onSuccess on a type:%s action', (type) => { const r = ActionSchema.safeParse({ ...base, type, @@ -154,8 +173,9 @@ describe('ActionSchema.onSuccess — one closed post-success navigation key for 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' } }); + it.each(['api', 'script', 'flow'] as const)('NEGATIVE CONTROL: accepts onSuccess on a type:%s action', (type) => { + const target = { api: '/api/v1/x', script: 'fn', flow: 'renewal_start' }[type]; + const r = ActionSchema.safeParse({ ...base, type, target, onSuccess: { navigate: '/x' } }); expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); }); }); diff --git a/packages/spec/src/ui/action.form.ts b/packages/spec/src/ui/action.form.ts index da0842c7161..97125cc2039 100644 --- a/packages/spec/src/ui/action.form.ts +++ b/packages/spec/src/ui/action.form.ts @@ -121,9 +121,11 @@ export const actionForm = defineForm({ { field: 'errorMessage', helpText: 'Error message shown when the action fails, in place of the raw error.' }, // #21863 (ruling 5995552118, 1A) — the two post-success keys, each with // the control ruling 5861442317 already gave its shape, so no designed - // control. Both are gated on the two types the parse admits them on + // control. Each is gated on the types the parse admits it on // (`refuseInertOutcomeMessages` and the `onSuccess` type refinement in - // `action.zod.ts`): only `api` and `script` have a server response. + // `action.zod.ts`): `outcomeMessages` on `api` and `script`, whose + // server response names an outcome; `onSuccess` on those two and on + // `flow`, whose completed run answers with its `isOutput` variables. // // `outcomeMessages` is a record keyed by outcome name, so G1's `json`, // the face `patch` and `bodyExtra` carry. @@ -133,7 +135,7 @@ export const actionForm = defineForm({ { field: 'onSuccess', type: 'composite', - visibleWhen: "data.type == 'api' || data.type == 'script'", + visibleWhen: "data.type in ['api','script','flow']", helpText: 'Navigation after the action succeeds: the route to open, and where to open it.', fields: [ { field: 'navigate', type: 'text', required: true, helpText: 'Route or URL template opened after the action succeeds. It may interpolate ${param.*} (the values the params dialog collected), ${ctx.*} (origin, apiBase, user, org, recordId, selection) and ${result.*} (the server response, e.g. ${result.id}). A relative value is an in-app route.' }, diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 16d72892f31..7545ef0c7f9 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -1692,11 +1692,13 @@ const actionObject = () => strictObject({ /** * Post-success navigation (#9566 / #9474, maintainer ruling 2026-08-18) — * where to take the user after a server-executing action succeeds. Read for - * `type: 'api'` and `type: 'script'` only (the refinement below refuses it on - * other types: a `type:'url'` action already navigates via `target` + - * `openIn`, and `modal`/`flow`/`form` dispatch on `target` with no success - * event this key could ride — a declared key those paths never read is the - * ADR-0078 silently-inert shape). + * `type: 'api'`, `type: 'script'` and `type: 'flow'` only (the refinement + * below refuses it on other types: a `type:'url'` action already navigates + * via `target` + `openIn`, and `modal`/`form` dispatch on `target` with no + * success result this key could ride — a declared key those paths never read + * is the ADR-0078 silently-inert shape). `flow` joined `api` and `script` + * later (`action-on-success-executor.ts` holds the set): its hop follows a + * run that COMPLETES, never one that pauses at a screen or ends refused. * * **`navigate`** is a route/URL template. Its interpolation scope is the * CONTRACT recorded here; the interpolation ENGINE is the renderer's @@ -1708,9 +1710,14 @@ const actionObject = () => strictObject({ * scope `target` interpolates today). * - `${result.X}` — **NEW with this key**: the action's server response * payload. For a `type:'api'` action, the response body of the `target` - * call; for a `type:'script'` action, the handler's return value. This is - * what makes "server clones a record → jump to the new record" declarable: - * `navigate: '/apps/crm/tasks/${result.id}'`. + * call; for a `type:'script'` action, the handler's return value; for a + * `type:'flow'` action, the completed run's answer (`AutomationResult`), + * whose `output` carries the flow's `isOutput` variables. This is what + * makes "server clones a record → jump to the new record" declarable: + * `navigate: '/apps/crm/tasks/${result.id}'`, or, from a flow whose + * `create_record` node writes an `isOutput` variable `renewal`, + * `navigate: '/apps/clm/clm_contract/record/${result.output.renewal.id}'`. + * A variable the flow does not declare `isOutput` is not in `output`. * * Renderers MUST `encodeURIComponent` interpolated values in URL query * positions (same rule as `target`). A relative template is an SPA route @@ -1750,9 +1757,11 @@ const actionObject = () => strictObject({ * * The console consumer is objectui's action runner (recorded by symbol in * `packages/spec/liveness/action.json`, rows `onSuccess.*`). After an `api` or `script` action - * succeeds, it interpolates `navigate` with the `${param.*}`, `${ctx.*}` and - * `${result.*}` scopes and hands the URL to the console's router. It opens - * a new tab only on `openIn: 'newTab'`. The liveness ledger's two + * succeeds, or a `flow` action's run completes, it interpolates `navigate` + * with the `${param.*}`, `${ctx.*}` and `${result.*}` scopes and hands the + * URL to the console's router. A `flow` run that pauses at a screen or ends + * refused hands off instead (the console's launch judge marks the success + * `handedOff`) and gets no hop. It opens a new tab only on `openIn: 'newTab'`. The liveness ledger's two * `onSuccess` rows are `live` and cite this reader. * * **The doubled channel is refused where the schema can see it** (#11519, @@ -1784,7 +1793,7 @@ const actionObject = () => strictObject({ "the tab choice is the closed enum `openIn: 'self' | 'newTab'` — write `openIn: 'newTab'`.", }, }, { - navigate: z.string().describe("Route/URL template navigated to after the action succeeds. Interpolates ${param.*} (params-dialog values), ${ctx.*} (origin/apiBase/user/org/recordId/selection) and ${result.*} (the action's server response payload — NEW with this key, e.g. ${result.id}). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions."), + navigate: z.string().describe("Route/URL template navigated to after the action succeeds. Interpolates ${param.*} (params-dialog values), ${ctx.*} (origin/apiBase/user/org/recordId/selection) and ${result.*} (the action's server response payload — NEW with this key, e.g. ${result.id}; on a type:'flow' action the completed run's answer, whose `output` carries the flow's isOutput variables, e.g. ${result.output.renewal.id}). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions."), openIn: z.enum(['self', 'newTab'], { error: (issue) => (issue.input === 'new-tab' ? "`onSuccess.openIn` spells the new-tab member `'newTab'` (matching the handler-return " @@ -1792,7 +1801,7 @@ const actionObject = () => strictObject({ + "to the top-level `openIn` key for `type:'url'` actions. Write `openIn: 'newTab'`." : undefined), }).default('self').describe("Where to perform the post-success navigation: 'self' (default — in-place SPA navigation, immune to popup blocking) or 'newTab'. Closed enum — no general navigation DSL."), - }).superRefine(refuseUnopenableOnSuccessNavigate).optional().describe("Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating ${param.*}, ${ctx.*} and ${result.*} (the server response); `openIn` defaults 'self'. The handler-return convention ({ redirectUrl } without openIn) keeps its 17.0.0 new-tab behavior."), + }).superRefine(refuseUnopenableOnSuccessNavigate).optional().describe("Post-success navigation for type:'api', type:'script' and type:'flow' actions. `navigate` is a route/URL template interpolating ${param.*}, ${ctx.*} and ${result.*} (the server response; for a flow, the completed run's answer); `openIn` defaults 'self'. A flow run that pauses at a screen or ends refused performs no navigation. The handler-return convention ({ redirectUrl } without openIn) keeps its 17.0.0 new-tab behavior."), // `aria` REMOVED (ADR-0049 enforce-or-remove; the triage record on the card, // comment 5860351140 on #20323, follows the `ChartConfig.aria` retirement @@ -2060,16 +2069,19 @@ export const ActionSchema = lazySchema(() => actionObject().refine((data) => { + '`bulkActionDefs`, where the pair renders one dialog by that schema\'s own contract.', path: ['confirmText'], }).superRefine((data, ctx) => { - // #9566/#9474 — `onSuccess` is scoped to the two action types whose success + // #9566/#9474 — `onSuccess` is scoped to the 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. + // response body), `script` (the handler's return value) and `flow` (the + // completed run's answer, its `output` the flow's `isOutput` variables; a + // run that pauses or ends refused is a hand-off and gets no hop). The + // console's action runner performs the hop after EVERY executor that + // succeeds, so on `url` / `modal` / `form` the key is read, not dead: it + // navigates a second time, or follows a result the contract does not + // declare. The set 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 three. if (data.onSuccess && !ON_SUCCESS_EXECUTORS.has(data.type)) { ctx.addIssue({ code: 'custom', 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 dda8d6e27ea..029cb8ac274 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 @@ -18,10 +18,10 @@ * 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 + * "`onSuccess` only beside `api` / `script` / `flow`", carried to the rows on + * the vocabulary the action's closed `ActionType` can see. Refused beside + * every runner built-in other than those three (`url`, `navigation`, + * `modal`, `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 @@ -235,8 +235,8 @@ const TARGETS: Readonly> = { 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; +describe("a page action row's `onSuccess` is refused beside a runner built-in other than `api` / `script` / `flow`", () => { + const REFUSED = ['url', 'modal', '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', @@ -308,8 +308,8 @@ describe("a page action row's `onSuccess` is refused beside no effective executo }); }); -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)))( +describe("a page action row's `onSuccess` stays open beside `api`, `script`, `flow` and a host-registered handler", () => { + it.each(ROWS.flatMap((row) => (['api', 'script', 'flow', '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 }); @@ -319,7 +319,7 @@ describe("a page action row's `onSuccess` stays open beside `api`, `script` and ); 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']) { + for (const name of ['api', 'script', 'flow', 'clone_task']) { const r = row.parse({ name, onSuccess: HOP }); expect(r.success, `${name}: ${JSON.stringify(issuesOf(r))}`).toBe(true); } diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index e64cd0cdcdc..9862b95bb4a 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -3849,9 +3849,9 @@ const ROW_BUILTIN_EXECUTORS: ReadonlySet = new Set([...ActionType.option /** * 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`), + * beside `api` / `script` / `flow`", 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 three (`url`, `navigation`, `modal`, `form`), * and beside a row with no effective executor, which the runner dispatches on * nothing. * @@ -4082,7 +4082,7 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ toast: z.unknown().optional().describe('Toast behaviour, forwarded to the runner'), resultDialog: z.unknown().optional().describe('One-shot result dialog for a value the response shows exactly once, forwarded to the runner'), onSuccess: actionOnSuccessBlock().optional() - .describe('Post-success navigation, forwarded to the runner: `ActionSchema`\'s own `{ navigate, openIn }` block. Taken beside an `api` or `script` executor, or a handler a host registers under another name; refused beside the built-ins `url`, `navigation`, `modal`, `flow` and `form`, on a row that names no executor, and beside `operation: \'update\'`, whose write the runner performs before any executor. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab`'), + .describe('Post-success navigation, forwarded to the runner: `ActionSchema`\'s own `{ navigate, openIn }` block. Taken beside an `api`, `script` or `flow` executor (for `flow`, only a run that completes: a run that pauses at a screen or ends refused performs no navigation), or a handler a host registers under another name; refused beside the built-ins `url`, `navigation`, `modal` and `form`, on a row that names no executor, and beside `operation: \'update\'`, whose write the runner performs before any executor. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab`'), objectName: z.string().optional() .describe('Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page\'s object. Omit to act on the page\'s object'), }).superRefine(rowDeclarativeUpdate('actionType', "An `action:button`'s")) @@ -4180,7 +4180,7 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ toast: z.unknown().optional().describe('Toast behaviour, forwarded to the runner'), resultDialog: z.unknown().optional().describe('One-shot result dialog for a value the response shows exactly once, forwarded to the runner'), onSuccess: actionOnSuccessBlock().optional() - .describe('Post-success navigation, forwarded to the runner: `ActionSchema`\'s own `{ navigate, openIn }` block. Taken beside an `api` or `script` executor, or a handler a host registers under another name; refused beside the built-ins `url`, `navigation`, `modal`, `flow` and `form`, on a row that names no executor, and beside `operation: \'update\'`, whose write the runner performs before any executor. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab`'), + .describe('Post-success navigation, forwarded to the runner: `ActionSchema`\'s own `{ navigate, openIn }` block. Taken beside an `api`, `script` or `flow` executor (for `flow`, only a run that completes: a run that pauses at a screen or ends refused performs no navigation), or a handler a host registers under another name; refused beside the built-ins `url`, `navigation`, `modal` and `form`, on a row that names no executor, and beside `operation: \'update\'`, whose write the runner performs before any executor. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab`'), objectName: z.string().optional() .describe('Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page\'s object. Omit to act on the page\'s object'), }).superRefine(rowDeclarativeUpdate('actionType', "An `action:icon`'s")) @@ -4330,7 +4330,7 @@ function actionContainerMemberShape() { toast: z.unknown().optional().describe('Toast behaviour, forwarded to the runner'), resultDialog: z.unknown().optional().describe('One-shot result dialog for a value the response shows exactly once, forwarded to the runner'), onSuccess: actionOnSuccessBlock().optional() - .describe('Post-success navigation, forwarded to the runner: `ActionSchema`\'s own `{ navigate, openIn }` block. Taken beside an `api` or `script` executor, or a handler a host registers under another name; refused beside the built-ins `url`, `navigation`, `modal`, `flow` and `form`, on a row that names no executor, and beside `operation: \'update\'`, whose write the runner performs before any executor. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab`'), + .describe('Post-success navigation, forwarded to the runner: `ActionSchema`\'s own `{ navigate, openIn }` block. Taken beside an `api`, `script` or `flow` executor (for `flow`, only a run that completes: a run that pauses at a screen or ends refused performs no navigation), or a handler a host registers under another name; refused beside the built-ins `url`, `navigation`, `modal` and `form`, on a row that names no executor, and beside `operation: \'update\'`, whose write the runner performs before any executor. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab`'), objectName: z.string().optional() .describe('Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page\'s object. Omit to act on the page\'s object'), }; diff --git a/scripts/sdui-manifest.sources/core.json b/scripts/sdui-manifest.sources/core.json index 18bbd8105a4..5bf85fb5e8a 100644 --- a/scripts/sdui-manifest.sources/core.json +++ b/scripts/sdui-manifest.sources/core.json @@ -1 +1 @@ -{"package":"ui/packages/core","sources":111,"sha256":"a5c4bc9e88dbbe531e2d19963f3f5e4367e23b2adabbb838eb05770ef086d16c","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/core","sources":111,"sha256":"05fe45802fffcc482f95bad59a66279b70572f8757decd9770dc126f119dab2e","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/types.json b/scripts/sdui-manifest.sources/types.json index b4a5258ce1e..c264a9f0078 100644 --- a/scripts/sdui-manifest.sources/types.json +++ b/scripts/sdui-manifest.sources/types.json @@ -1 +1 @@ -{"package":"ui/packages/types","sources":81,"sha256":"30832d40db85588aa490e7d3f7b2e2ff689071f4e84176bcab96bacf74060d4f","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/types","sources":81,"sha256":"d7928a68db8d76df5f05933d139f9d3d46e2fd87f74945f69c74e079f1a8eda2","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionDoor-12037.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionDoor-12037.test.tsx index 80f1667b195..9147c679d02 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionDoor-12037.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionDoor-12037.test.tsx @@ -138,7 +138,7 @@ describe('objectui#12037 — a declared flow action is started through the actio const { result, url } = await click(ROW_CLICK); expect(url).toBe(`/api/v1/actions/${OBJECT}/showcase_bulk_reassign`); - expect(result).toEqual({ success: true, silent: true }); + expect(result).toEqual({ success: true, silent: true, handedOff: true }); const dialog = await screen.findByRole('dialog'); expect(within(dialog).getByRole('heading', { name: 'New Assignee' })).toBeTruthy(); diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionSuccessMessage-12234.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionSuccessMessage-12234.test.tsx index 7eb31f10637..8cfe8f20daf 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionSuccessMessage-12234.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionSuccessMessage-12234.test.tsx @@ -127,7 +127,7 @@ describe('objectui#12234 — a paused action-door launch completes with the acti const result = await click(DECLARED); // The pause: silent, as ruled — no toast of either colour yet. - expect(result).toEqual({ success: true, silent: true }); + expect(result).toEqual({ success: true, silent: true, handedOff: true }); expect((toast as any).success).not.toHaveBeenCalled(); await submitWith(COMPLETED); diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowDoneSuccessMessage-12274.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowDoneSuccessMessage-12274.test.tsx index f26db3fdff1..b4e2bfe2b36 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowDoneSuccessMessage-12274.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowDoneSuccessMessage-12274.test.tsx @@ -151,7 +151,7 @@ describe('objectui#12274 — a straight-through action-door launch closes by the const result = await click(DECLARED_WITHOUT_MESSAGE); - expect(result).toEqual({ success: true, silent: true }); + expect(result).toEqual({ success: true, silent: true, handedOff: true }); expect(await screen.findByText('Refused: already escalated')).toBeTruthy(); expect((toast as any).success).not.toHaveBeenCalled(); }); diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.refusedLaunch-9973.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.refusedLaunch-9973.test.tsx index ade5e2b468a..c97446d4a89 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.refusedLaunch-9973.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.refusedLaunch-9973.test.tsx @@ -110,7 +110,7 @@ describe('objectui#9973 — list-action flow launch that ends refused', () => { it("does NOT toast the action's successMessage, does not refresh, and returns silent success", async () => { const { result, refreshSpy } = await launch({ success: true, status: 'refused', refusalMessage: SENTENCE }); - expect(result).toEqual({ success: true, silent: true }); + expect(result).toEqual({ success: true, silent: true, handedOff: true }); expect((toast as any).success).not.toHaveBeenCalled(); expect((toast as any).error).not.toHaveBeenCalled(); expect(refreshSpy).not.toHaveBeenCalled(); @@ -152,7 +152,7 @@ describe('objectui#11092 — a paused list-action launch names the flow by its s screen: { nodeId: 'confirm', title: 'Confirm', fields: [] }, }); - expect(result).toEqual({ success: true, silent: true }); + expect(result).toEqual({ success: true, silent: true, handedOff: true }); const dialog = await screen.findByRole('dialog'); expect(within(dialog).getByRole('heading', { name: 'Confirm' })).toBeTruthy(); expect(within(dialog).getByText('Duplicate Screening')).toBeTruthy(); diff --git a/ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts b/ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts index 658231f0704..258e2b9ec1d 100644 --- a/ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts +++ b/ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts @@ -7,7 +7,8 @@ * incomplete ones got wrong. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; +import { ActionRunner } from '@object-ui/core'; import { builtInLocales, registerBuiltInLocales } from '@object-ui/i18n/locales'; import { ApiErrorSchema, FlowFailureDetailsSchema } from '@objectstack/spec/api'; import type { AutomationResult } from '@objectstack/spec/contracts'; @@ -305,8 +306,9 @@ describe('judgeFlowLaunch — one launch decision for both hosts', () => { data: { success: true, status: 'refused', refusalMessage: 'Refused: Acme Corp is a confirmed duplicate' }, }); // Byte-identical to the paused arm: `silent` keeps the action's - // `successMessage` toast off, and nothing else rides along. - expect(out.result).toEqual({ success: true, silent: true }); + // `successMessage` toast off, `handedOff` keeps its `onSuccess` hop + // from firing (objectstack#25800), and nothing else rides along. + expect(out.result).toEqual({ success: true, silent: true, handedOff: true }); expect(out.followUp).toEqual({ kind: 'refusal', message: 'Refused: Acme Corp is a confirmed duplicate' }); expect(out.refresh).toBe(false); }); @@ -319,14 +321,14 @@ describe('judgeFlowLaunch — one launch decision for both hosts', () => { it('refused with no sentence: the disposition is the STATUS, the message is empty', () => { const out = judge({ success: true, data: { success: true, status: 'refused' } }); - expect(out.result).toEqual({ success: true, silent: true }); + expect(out.result).toEqual({ success: true, silent: true, handedOff: true }); expect(out.followUp).toEqual({ kind: 'refusal', message: '' }); }); it('paused: unchanged — silent success, the screen handed to FlowRunner, no refresh', () => { const screen = { nodeId: 'collect', title: 'New Assignee', fields: [] }; const out = judge({ success: true, data: { success: true, status: 'paused', runId: 'run-7', screen } }); - expect(out.result).toEqual({ success: true, silent: true }); + expect(out.result).toEqual({ success: true, silent: true, handedOff: true }); expect(out.followUp).toEqual({ kind: 'screen', runId: 'run-7', screen }); expect(out.refresh).toBe(false); }); @@ -353,6 +355,92 @@ describe('judgeFlowLaunch — one launch decision for both hosts', () => { }); }); +// objectstack#25800 — a `type: 'flow'` action may declare `onSuccess`, and its +// hop follows a COMPLETED run only. Each arm is judged from a real wire body and +// its result handed to a real `ActionRunner` (the one the console builds), with +// the router replaced by a spy: the hop is read off the navigation call itself, +// never off a flag the test computes. +describe('judgeFlowLaunch → ActionRunner — the onSuccess hop follows a completed run only', () => { + const RENEWAL_ID = 'ctr_0018'; + const ACTION = { + type: 'flow', + name: 'start_renewal', + target: 'renewal_start', + onSuccess: { navigate: '/apps/clm/clm_contract/record/${result.output.renewal.id}', openIn: 'self' }, + }; + + /** Judge `json` as the launch host does, and let the runner act on the result. */ + async function run(json: unknown, actionSuccessMessage?: string) { + const judged = judgeFlowLaunch( + interpretFlowResponse(ok, json, 'Flow "renewal_start"'), + undefined, + actionSuccessMessage, + ); + const nav = vi.fn(); + const runner = new ActionRunner({}); + runner.setNavigationHandler(nav as never); + runner.setToastHandler(vi.fn() as never); + runner.registerHandler('flow', async () => judged.result); + const result = await runner.execute({ ...ACTION, successMessage: actionSuccessMessage } as never); + return { judged, result, navigated: nav.mock.calls.map(([url]) => url as string) }; + } + + const completed = (extra: Record = {}) => ({ + success: true, + data: { success: true, status: 'completed', output: { renewal: { id: RENEWAL_ID, renewed_from: 'ctr_0001' } }, ...extra }, + }); + + it('done: the hop lands on the record the run produced, read off the run\'s `output`', async () => { + const { judged, result, navigated } = await run(completed(), 'Renewal draft created'); + expect(judged.result.handedOff).toBeUndefined(); + expect(result.success).toBe(true); + expect(navigated).toEqual([`/apps/clm/clm_contract/record/${RENEWAL_ID}`]); + }); + + it("done with the flow's own copy: `silent` is about the toast, so the run still hops", async () => { + const { judged, navigated } = await run(completed({ successMessage: 'Renewal draft created' })); + expect(judged.result.silent).toBe(true); + expect(judged.successToast).toBe('Renewal draft created'); + expect(navigated).toEqual([`/apps/clm/clm_contract/record/${RENEWAL_ID}`]); + }); + + it('paused at a screen: a hand-off, so no hop while the wizard is open', async () => { + const screen = { nodeId: 'confirm', title: 'Confirm renewal', fields: [] }; + const { judged, result, navigated } = await run({ + success: true, data: { success: true, status: 'paused', runId: 'run-9', screen }, + }); + expect(judged.followUp).toMatchObject({ kind: 'screen', runId: 'run-9' }); + expect(result.success).toBe(true); + expect(navigated).toEqual([]); + }); + + it('ended refused: a hand-off to the notice, so no hop past it', async () => { + const { judged, result, navigated } = await run({ + success: true, + data: { success: true, status: 'refused', refusalMessage: 'MSA-2026-0007 already has a renewal draft MSA-2026-0018' }, + }); + expect(judged.followUp).toMatchObject({ kind: 'refusal' }); + expect(result.success).toBe(true); + expect(navigated).toEqual([]); + }); + + it('failed: no success, so no hop', async () => { + const { result, navigated } = await run({ success: true, data: { success: false, error: "Node 'create' failed" } }); + expect(result.success).toBe(false); + expect(navigated).toEqual([]); + }); + + it("a flow that declares no `isOutput` variable answers `output: {}`, and the path resolves to ''", async () => { + // The authoring gap the card's hotclm report hit: a `create_record` + // node's `outputVariable` that is not declared `isOutput: true` never + // reaches `output`. The runner's one rule for a nullish path applies + // (`''`), so the hop lands on the bare collection path, not on a + // literal `${result.output.renewal.id}`. + const { navigated } = await run({ success: true, data: { success: true, status: 'completed', output: {} } }); + expect(navigated).toEqual(['/apps/clm/clm_contract/record/']); + }); +}); + // objectui#11092 — the flow's served label (`AutomationResult.flowLabel`) is // lifted here once, so both launch hosts and the runner's resume read it the // same way. The arms are asserted by kind, and the launch follow-up with @@ -391,7 +479,7 @@ describe('the served flowLabel is lifted once (objectui#11092)', () => { data: { success: true, status: 'paused', runId: 'run-7', screen, flowLabel: 'Reassign' }, }, 'Flow "reassign"'), undefined); expect(out.followUp).toEqual({ kind: 'screen', runId: 'run-7', screen, flowLabel: 'Reassign' }); - expect(out.result).toEqual({ success: true, silent: true }); + expect(out.result).toEqual({ success: true, silent: true, handedOff: true }); }); }); @@ -466,7 +554,7 @@ describe("judgeFlowLaunch — a straight-through run's success copy (objectui#12 it('refused and failed runs carry no success toast, whatever the flow declares', () => { const refused = done({ success: true, status: 'refused', refusalMessage: 'No.', successMessage: 'Escalation recorded' }); - expect(refused.result).toEqual({ success: true, silent: true }); + expect(refused.result).toEqual({ success: true, silent: true, handedOff: true }); expect(refused.successToast).toBeUndefined(); const failed = done({ success: false, error: 'boom', successMessage: 'Escalation recorded' }); expect(failed.result).toEqual({ success: false, error: 'boom' }); diff --git a/ui/packages/app-shell/src/utils/flowResponse.ts b/ui/packages/app-shell/src/utils/flowResponse.ts index 6b65e12b67a..b4ae44eb734 100644 --- a/ui/packages/app-shell/src/utils/flowResponse.ts +++ b/ui/packages/app-shell/src/utils/flowResponse.ts @@ -179,6 +179,18 @@ * shared. A `refused` or failed run never reaches a `done` arm, so neither * shows a success toast on either end. * + * ## A flow action's post-success hop follows a COMPLETED run (objectstack#25800) + * + * The spec admits `onSuccess` beside a `type: 'flow'` action, and the + * ActionRunner performs that hop after a success, reading `${result.*}` off the + * result's `data`. On a launch only the `done` arm completes the action: its + * `data` is the run's `AutomationResult`, whose `output` carries the flow's + * `isOutput` variables, so `${result.output.VAR}` resolves. The `paused` and + * `refused` arms answer success too, and the runner cannot tell them apart from + * a completion by `silent` — that flag is about the toast, and a `done` run + * whose copy is the flow's own is `silent` as well. So {@link judgeFlowLaunch} + * marks those two `handedOff`, the member the runner reads to skip the hop. + * * ## The flow's own toasts are translated here, once (objectui#12075) * * The engine serves the flow's `successMessage` and `errorMessage` verbatim, @@ -600,24 +612,36 @@ export interface FlowLaunchJudgement { * drift apart again (objectui#9973 — see the header). `refreshAfter` and * `actionSuccessMessage` are the invoking action's own flag and copy. * + * Each arm also decides whether the action's post-success hop + * (`ActionSchema.onSuccess`, objectstack#25800 — see the header) fires: only + * the `done` arm completes the action, so it is the only one that hops. + * * - `failed` — the ActionRunner's post-execution hook surfaces `error` as a - * toast; nothing refreshes, because nothing changed. + * toast; nothing refreshes, because nothing changed. No hop: the runner hops + * only after a success. * - `paused` — the action only OPENED the wizard, so the action-level success * toast is suppressed (`silent`) and `FlowRunner` owns completion messaging - * and the refresh. Byte-identical to the return this arm has always had - * (objectui#7707 pins it). + * and the refresh. The success is a hand-off (`handedOff`), so the runner + * performs no hop: navigating away would leave the screen behind, and the + * run has not answered with the output the hop reads. `FlowRunner` finishing + * the run later does not hop either. * - `refused` — the run ENDED, deliberately, and the answer was no * (objectstack#14945). The result is the paused arm's, byte for byte: - * `silent`, because the ruling forbids the completion toast; `success: true`, - * because a refusal is a successful evaluation (`AutomationResult.success` - * stays `true`), and `success: false` would put the runner hooks' error - * state on it; and no `data` and no `reload`. Nothing refreshes — the run - * wrote nothing, and a refresh would say it did. + * `silent`, because the ruling forbids the completion toast; `handedOff`, + * because the Close-only notice is the follow-up and a hop would navigate + * past it to a record the refusal never produced; `success: true`, because a + * refusal is a successful evaluation (`AutomationResult.success` stays + * `true`), and `success: false` would put the runner hooks' error state on + * it; and no `data` and no `reload`. Nothing refreshes — the run wrote + * nothing, and a refresh would say it did. * - `done` — terminal success, and the host refreshes unless the action opted * out with `refreshAfter: false`. The toast follows {@link flowSuccessCopy} * (objectui#12274): the runner speaks the action's copy or its own default * as before, and when the copy is the flow's the result goes `silent` and - * carries it as `successToast` instead. + * carries it as `successToast` instead. Not `handedOff`, `silent` or not: the + * run completed, so the runner hops, reading `${result.*}` off `data`, the + * run's `AutomationResult` (`${result.output.VAR}` is one of its `isOutput` + * variables). */ export function judgeFlowLaunch( outcome: FlowResponseOutcome, @@ -629,7 +653,7 @@ export function judgeFlowLaunch( return { result: { success: false, error: outcome.error }, refresh: false }; case 'paused': return { - result: { success: true, silent: true }, + result: { success: true, silent: true, handedOff: true }, followUp: { kind: 'screen', runId: outcome.runId ?? '', @@ -640,7 +664,7 @@ export function judgeFlowLaunch( }; case 'refused': return { - result: { success: true, silent: true }, + result: { success: true, silent: true, handedOff: true }, followUp: { kind: 'refusal', message: outcome.message }, refresh: false, }; diff --git a/ui/packages/app-shell/src/views/FlowRunner.tsx b/ui/packages/app-shell/src/views/FlowRunner.tsx index e98d7c63338..f40de657241 100644 --- a/ui/packages/app-shell/src/views/FlowRunner.tsx +++ b/ui/packages/app-shell/src/views/FlowRunner.tsx @@ -75,8 +75,9 @@ * route to: Submit withdrawn, a single Close. What it does NOT do is as ruled * as what it does — no toast of either colour, no `onComplete`, no * invalidation. The invoking action stays quiet on its own account, exactly as - * today: a paused run returns `{ success: true, silent: true }` and `silent` - * suppresses the action's `successMessage` at the ActionRunner's toast sink. + * today: a paused run returns `{ success: true, silent: true, handedOff: true }` + * and `silent` suppresses the action's `successMessage` at the ActionRunner's + * toast sink (`handedOff` keeps the action's `onSuccess` hop from firing). * ⛔ That last part is a thing that WORKS — it is pinned, not touched. * * ## A refused end shows the message alone (objectui#13342) diff --git a/ui/packages/app-shell/src/views/RecordDetailView.flowActionDoor-12037.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.flowActionDoor-12037.test.tsx index e7d362c5401..0afa7630ece 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.flowActionDoor-12037.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.flowActionDoor-12037.test.tsx @@ -243,7 +243,7 @@ describe('objectui#12037 — record-page flow clicks on a declared action use th expect(url).toBe(`/api/v1/actions/${PARENT}/qualify_lead`); expect(body).toEqual({ recordId: RECORD_ID, params: {} }); - expect(result).toEqual({ success: true, silent: true }); + expect(result).toEqual({ success: true, silent: true, handedOff: true }); const dialog = await screen.findByRole('dialog'); authFetchSpy.mockResolvedValueOnce(answered({ success: true, status: 'completed' })); fireEvent.click(within(dialog).getByRole('button', { name: 'Submit' })); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.flowActionSuccessMessage-12234.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.flowActionSuccessMessage-12234.test.tsx index 8c6d1d96c3e..5a3bfe110a8 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.flowActionSuccessMessage-12234.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.flowActionSuccessMessage-12234.test.tsx @@ -194,7 +194,7 @@ async function launchAndSubmit(declared: Record) { expect(doorCalls.map(([url]) => String(url))).toEqual([`/api/v1/actions/${OBJECT_NAME}/escalate_case`]); // The pause: silent, as ruled — no success toast yet. - expect(result).toEqual({ success: true, silent: true }); + expect(result).toEqual({ success: true, silent: true, handedOff: true }); expect((toast as any).success).not.toHaveBeenCalled(); const dialog = await screen.findByRole('dialog'); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.flowDoneSuccessMessage-12274.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.flowDoneSuccessMessage-12274.test.tsx index 3fbe1bdc4bd..da6ca7b7a8a 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.flowDoneSuccessMessage-12274.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.flowDoneSuccessMessage-12274.test.tsx @@ -223,7 +223,7 @@ describe('objectui#12274 — a straight-through record-page launch closes by the success: true, status: 'refused', refusalMessage: 'Refused: already escalated', successMessage: FLOW_MESSAGE, }); - expect(result).toEqual({ success: true, silent: true }); + expect(result).toEqual({ success: true, silent: true, handedOff: true }); expect(await screen.findByText('Refused: already escalated')).toBeTruthy(); expect((toast as any).success).not.toHaveBeenCalled(); }); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.refusedLaunch-9973.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.refusedLaunch-9973.test.tsx index 6db8a3d6f18..5989dc1bccb 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.refusedLaunch-9973.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.refusedLaunch-9973.test.tsx @@ -196,7 +196,7 @@ describe('objectui#9973 — record-page flow launch that ends refused', () => { it("does NOT toast the action's successMessage, does not invalidate the record, and returns silent success", async () => { const { result, changes } = await launch({ success: true, status: 'refused', refusalMessage: SENTENCE }); - expect(result).toEqual({ success: true, silent: true }); + expect(result).toEqual({ success: true, silent: true, handedOff: true }); expect((toast as any).success).not.toHaveBeenCalled(); expect((toast as any).error).not.toHaveBeenCalled(); expect(changes).toEqual([]); @@ -227,7 +227,7 @@ describe('objectui#11092 — a paused record-page launch names the flow by its s screen: { nodeId: 'confirm', title: 'Confirm', fields: [] }, }); - expect(result).toEqual({ success: true, silent: true }); + expect(result).toEqual({ success: true, silent: true, handedOff: true }); const dialog = await screen.findByRole('dialog'); expect(within(dialog).getByRole('heading', { name: 'Confirm' })).toBeTruthy(); expect(within(dialog).getByText('Duplicate Screening')).toBeTruthy(); diff --git a/ui/packages/app-shell/src/views/__tests__/FlowRunner.refusedOutcome-7707.test.tsx b/ui/packages/app-shell/src/views/__tests__/FlowRunner.refusedOutcome-7707.test.tsx index f0a7458f538..7f824f8f8d7 100644 --- a/ui/packages/app-shell/src/views/__tests__/FlowRunner.refusedOutcome-7707.test.tsx +++ b/ui/packages/app-shell/src/views/__tests__/FlowRunner.refusedOutcome-7707.test.tsx @@ -53,7 +53,7 @@ * `ActionRunner` post-execution sink, with a lit control: the identical * action whose handler omits `silent` DOES toast its `successMessage`. The * other half of that clause — that the console's flow handlers return - * `{ success: true, silent: true }` when a run pauses — is pinned by the + * `{ success: true, silent: true, handedOff: true }` when a run pauses — is pinned by the * test named 'flowHandler still OPENS the wizard on a screen pause (success * path intact)' in `useConsoleActionRuntime.test.tsx`, and is deliberately * not copied here. diff --git a/ui/packages/core/src/actions/ActionRunner.ts b/ui/packages/core/src/actions/ActionRunner.ts index 8b35266a435..3aebb3cab4e 100644 --- a/ui/packages/core/src/actions/ActionRunner.ts +++ b/ui/packages/core/src/actions/ActionRunner.ts @@ -1960,7 +1960,15 @@ export class ActionRunner { // declares `onSuccess` as a CLOSED STRICT object // `{ navigate: string, openIn: 'self' | 'newTab' }`, refine-scoped to // `type: 'api'` and `type: 'script'` — the two types whose success event - // carries a server response for `${result.*}` to read. + // carries a server response for `${result.*}` to read — and to + // `type: 'flow'`, whose COMPLETED run answers with its `AutomationResult` + // (`${result.output.VAR}` reads one of the flow's `isOutput` variables). + // + // A `handedOff` success performs no hop: a flow run paused at a screen or + // ended refused opened a follow-up surface instead of completing + // (`judgeFlowLaunch`, app-shell `utils/flowResponse.ts`), so there is no + // result for the hop to follow, and navigating away would leave that + // screen or notice behind. // // That declared meaning is the key's ONLY meaning. The runner's older // chained-callback channel (`onSuccess?: ActionDef | ActionDef[]`, @@ -1973,7 +1981,7 @@ export class ActionRunner { // discriminator: stored rows are rehydrated UNPARSED (objectstack-ai/objectstack#3903), so the value // is still read as data, and a shape the spec refuses gets no reading — // no navigation, no callback dispatch, no lenient fallback. - if (result.success && action.onSuccess) { + if (result.success && !result.handedOff && action.onSuccess) { const navigation = readOnSuccessNavigation(action.onSuccess); if (navigation) { this.navigateOnSuccess(navigation, action, result); diff --git a/ui/packages/core/src/actions/__tests__/ActionRunner.onSuccessNavigation.test.ts b/ui/packages/core/src/actions/__tests__/ActionRunner.onSuccessNavigation.test.ts index 1e14f8031bc..92eedf9975f 100644 --- a/ui/packages/core/src/actions/__tests__/ActionRunner.onSuccessNavigation.test.ts +++ b/ui/packages/core/src/actions/__tests__/ActionRunner.onSuccessNavigation.test.ts @@ -13,11 +13,12 @@ * The pinned `@objectstack/spec` declares the key as a CLOSED STRICT object * `{ navigate: string, openIn: 'self' | 'newTab' }`, refine-scoped to * `type: 'api'` and `type: 'script'` — the two types that have a success event - * carrying a server response for `${result.*}` to read. `openIn` is a - * materialised `.default('self')`, so parse output ALWAYS carries a resolved - * member and this runner writes no default of its own. + * carrying a server response for `${result.*}` to read — and to `type: 'flow'`, + * whose completed run answers with its `AutomationResult` (objectstack#25800). + * `openIn` is a materialised `.default('self')`, so parse output ALWAYS + * carries a resolved member and this runner writes no default of its own. * - * Three facts under test, and they are different facts: + * Four facts under test, and they are different facts: * * 1. **The hop happens, through the app's own router.** `navigationHandler` is * the SPA seam every other navigator in this file already uses (the console @@ -32,6 +33,10 @@ * `'self' | 'newTab'`; the top-level `type: 'url'` switch is * `'self' | 'new-tab'`. Spec refuses each crossover with a keyed error, so * this runner must not quietly accept one for the other. + * 4. **A hand-off is not a completion.** A `flow` run that paused at a screen + * or ended refused answers success marked `handedOff` (app-shell + * `judgeFlowLaunch`), and gets no hop; a completed run's `AutomationResult` + * is the payload, so `${result.output.VAR}` reads an `isOutput` variable. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -224,6 +229,68 @@ describe('ActionSchema.onSuccess — controls', () => { }); }); +describe('ActionSchema.onSuccess — a flow action hops after a COMPLETED run only', () => { + /** A runner whose `flow` handler answers `result`, as the console's launch judge would. */ + function flowRunner(result: Record) { + const nav = vi.fn(); + const runner = new ActionRunner({}); + runner.setNavigationHandler(nav as never); + runner.setToastHandler(vi.fn() as never); + runner.registerHandler('flow', async () => result as never); + const calls = () => nav.mock.calls as unknown as NavCall[]; + return { runner, nav, calls }; + } + + const ACTION = { + type: 'flow', + name: 'start_renewal', + target: 'renewal_start', + onSuccess: { navigate: '/apps/clm/clm_contract/record/${result.output.renewal.id}', openIn: 'self' }, + }; + + /** The completed run as the launch judge hands it on: `data` is the `AutomationResult`. */ + const COMPLETED = { + success: true, + data: { success: true, status: 'completed', output: { renewal: { id: 'ctr_18' } }, flowLabel: 'Start renewal' }, + reload: true, + }; + + it('reads ${result.output.VAR} off the run\'s AutomationResult and lands there', async () => { + const { runner, calls } = flowRunner(COMPLETED); + + const result = await runner.execute(ACTION as never); + + expect(result.success).toBe(true); + expect(calls()).toHaveLength(1); + expect(calls()[0][0]).toBe('/apps/clm/clm_contract/record/ctr_18'); + expect(calls()[0][1]?.newTab).toBe(false); + }); + + it('a completed run whose toast the host speaks (`silent`) still hops', async () => { + const { runner, calls } = flowRunner({ ...COMPLETED, silent: true }); + + await runner.execute(ACTION as never); + + expect(calls().map(([url]) => url)).toEqual(['/apps/clm/clm_contract/record/ctr_18']); + }); + + it('a handed-off success performs no hop (positive control: the same runner hops once it completes)', async () => { + const handedOff = flowRunner({ success: true, silent: true, handedOff: true }); + + const result = await handedOff.runner.execute(ACTION as never); + + expect(result.success).toBe(true); + expect(handedOff.nav).not.toHaveBeenCalled(); + + // POSITIVE CONTROL — the identical action and harness, the handler now + // answering the completed run: the hop happens, so the silence above is a + // reading of `handedOff`, not of a dead harness. + handedOff.runner.registerHandler('flow', async () => COMPLETED as never); + await handedOff.runner.execute(ACTION as never); + expect(handedOff.calls().map(([url]) => url)).toEqual(['/apps/clm/clm_contract/record/ctr_18']); + }); +}); + describe('ActionSchema.onSuccess — the two openIn spellings stay apart', () => { it("does not accept the type:'url' kebab spelling as a new-tab request", async () => { // Spec refuses `onSuccess.openIn: 'new-tab'` at parse with a keyed error. diff --git a/ui/packages/types/src/ui-action.ts b/ui/packages/types/src/ui-action.ts index 57a3f326574..6f5558d6c18 100644 --- a/ui/packages/types/src/ui-action.ts +++ b/ui/packages/types/src/ui-action.ts @@ -1109,9 +1109,21 @@ export interface ActionResult { * e.g. a `flow` action that paused at a screen and opened the flow-runner. The * action hasn't "completed yet", so a "success" toast on open would be * misleading; the follow-up surface owns its own completion messaging. + * That the success IS a hand-off is {@link ActionResult.handedOff}'s to say. */ silent?: boolean; + /** + * The success is a HAND-OFF, not a completion: the action opened a follow-up + * surface that owns the rest — a `flow` run paused at a screen (the flow + * runner finishes it) or one that ended refused (the refusal notice says + * why). The runner performs no post-success hop (`ActionSchema.onSuccess`) + * for it: the hop follows a result the action completed with, and a hand-off + * has none. `silent` is a different fact, about the toast alone — a run that + * completes with the flow's own success copy is `silent` and still hops. + */ + handedOff?: boolean; + /** * An undoable operation captured by the handler (e.g. an `undoable` update * action's prior field values). When present, the runner pushes it onto the