diff --git a/.changeset/29844-action-button-confirm-beside-params-refused.md b/.changeset/29844-action-button-confirm-beside-params-refused.md new file mode 100644 index 00000000000..2bcd6f960e1 --- /dev/null +++ b/.changeset/29844-action-button-confirm-beside-params-refused.md @@ -0,0 +1,22 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec)!: a page `action:button` (`ActionButtonPropsSchema`) refuses `confirmText` beside a non-empty array `params`, as a registered action (`ActionSchema`) and an inline action (`InlineActionSchema`) already do, instead of accepting a pair that opens two dialogs for one decision + +The `action:button` block forwards its row's `confirmText` and an array `params` (as `actionParams`) to the console's action runner, which awaits the confirm dialog and only then opens the param dialog. So a button carrying both keys showed the user a confirm that already read as "the action ran" while nothing had been sent, then a second dialog. The two action doors refused that pair; the button row accepted it. It now refuses the pair with the same predicate and the same message as the other two doors, at the `confirmText` path. The predicate and the refusal move from `action.zod.ts` to a sibling module (`ui/action-confirm-beside-params.ts`) that all three doors read; neither is exported from the package. + +Clause-②: no (narrowing) — `ActionButtonPropsSchema` refuses `confirmText` beside a non-empty array `params`, which it accepted before. Nothing is widened. + +Metadata-protocol: narrowed (a page `action:button` may no longer declare `confirmText` beside a non-empty array `params`) + + + +**BREAKING** accept-set narrowing at `ActionButtonPropsSchema` (`@objectstack/spec`), served where a page's `action:button` `properties` are judged: the component-props gate (`component-props-invalid`, `@objectstack/lint`) at every CLI command (`os validate`, `os compile`, the publish gate) and at the runtime `page` save door. `defineStack` does not parse a component's `properties` bag, so it does not refuse the pair. + +- **Before.** `{ type: 'action:button', properties: { label: 'Reject', actionType: 'api', target: '/api/v1/approvals/reject', confirmText: 'Reject this request?', params: [{ name: 'reason', type: 'textarea' }] } }` parsed clean, and the console showed a confirm dialog and then the param dialog. +- **Now.** The same `properties` are refused at `confirmText` with the action doors' message: the pair shows TWO dialogs for one decision; carry the question in `description` and drop `confirmText`. +- **Unchanged.** `confirmText` on an `action:button` with no `params`, beside `params: []`, or beside an object `params` (the static parameter values, which open no dialog) parses as before; so does an array `params` with `description`. `action:icon` and the `action:group` / `action:menu` members are not changed here. +- **Who sees it.** An author whose page declares an `action:button` with both keys. A stored page keeps loading and serving; its next save is refused, with the remedy in the finding. + +**The remedy.** Move the confirm sentence to the button's `description`, which the param dialog draws under its title, and drop `confirmText`. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index ead55ae1843..768f81f982c 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -45,7 +45,7 @@ const result = ActionButtonPropsSchema.parse(data); | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | | **operation** | `Enum<'update'>` | optional | Declarative single-record write: `ActionSchema`'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row's values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row's `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape`, `recordIdField` and `onSuccess` are refused, and `list_toolbar` is not a location | | **patch** | `never` | optional | [REMOVED] An `action:button`'s `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. 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. | -| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. Beside a non-empty array `params` it is refused — the runner would show the confirm and then the param dialog, two dialogs for one decision; put the question on `description` instead. Correct with no `params`, beside `params: []`, and beside an object `params` (static values, no param dialog) | | **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **errorMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action fails, in place of the raw error — a `template` slot (ADR-0032) that binds no root: a bare string is shorthand for `{ dialect: 'template', source }`, and the text is shown as written, so a `{{ }}` hole, a `${…}` or a `{x}` token in it is literal text. Each of those, and a blank sentence, is named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **refreshAfter** | `boolean` | optional | Refresh the surrounding data after the action runs | diff --git a/packages/spec/src/ui/action-confirm-beside-params.ts b/packages/spec/src/ui/action-confirm-beside-params.ts new file mode 100644 index 00000000000..44732edf784 --- /dev/null +++ b/packages/spec/src/ui/action-confirm-beside-params.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `confirmText` beside a non-empty `params`: TWO sequential dialogs for ONE + * decision. ONE predicate and ONE refusal, read by the three doors that refuse + * the pair: `ActionSchema` and `InlineActionSchema` (`action.zod.ts`), and the + * page `action:button` row, `ActionButtonPropsSchema` (`component.zod.ts`). + * Pinned in `action-confirm-params-guard.test.ts`, which asserts the three + * messages equal. + * + * The console action runner awaits the confirm, THEN the param prompt (objectui + * `packages/core/src/actions/ActionRunner.ts`), so the first dialog already + * reads as "the action ran" while nothing has been sent. The `element:button` + * renderer hands an inline action to that same runner, and the `action:button` + * block forwards its row's `confirmText` and an array `params` (as + * `actionParams`) to it too, so the pair opens the same two dialogs on all + * three doors. + * + * An empty `params` collects nothing and opens no second dialog, so it is not + * the pair. Nor is an object-form `params`: on an `action:button` row an object + * is the static parameter values, which open no dialog, and the + * `Array.isArray` read below leaves it alone. + * + * Lives in its own module, not in `action.zod.ts`, so `component.zod.ts` can + * read it without the `export *` in `ui/index.ts` publishing it. + */ +export function declaresConfirmBesideParams(data: { confirmText?: unknown; params?: unknown }): boolean { + return data.confirmText != null && Array.isArray(data.params) && data.params.length > 0; +} + +export const CONFIRM_BESIDE_PARAMS_REFUSAL = { + message: + 'An action that declares `confirmText` beside a non-empty `params` shows the user TWO dialogs ' + + 'for one decision — the confirm, then the param prompt, with nothing sent until the second. ' + + "Carry the confirm question in the action's top-level `description` instead (it renders under " + + 'the param dialog\'s title) and drop `confirmText`: one condition, one wording, one dialog. ' + + 'Not `ai.description` — that is the LLM-facing tool contract. `confirmText` stays ' + + 'correct for a param-LESS action, where the confirm IS the only dialog, and for a view\'s ' + + '`bulkActionDefs`, where the pair renders one dialog by that schema\'s own contract.', + path: ['confirmText'], +}; diff --git a/packages/spec/src/ui/action-confirm-params-guard.test.ts b/packages/spec/src/ui/action-confirm-params-guard.test.ts index 9204194d50c..a82699adc1e 100644 --- a/packages/spec/src/ui/action-confirm-params-guard.test.ts +++ b/packages/spec/src/ui/action-confirm-params-guard.test.ts @@ -20,8 +20,11 @@ * * `InlineActionSchema` refuses the pair too (#29383, ruling record 6595808462 * letter A): an inline page action runs through the same runner, so the same - * two dialogs open, and its pick carries the `description` remedy. Both doors - * share one predicate and one message, pinned equal below. + * two dialogs open, and its pick carries the `description` remedy. So does the + * page `action:button` row, `ActionButtonPropsSchema` (#29844, ruling record + * 6597621758 letter A), for an ARRAY `params` only: an object `params` there is + * the static values and opens no dialog. The three doors share one predicate + * and one message (`./action-confirm-beside-params.ts`), pinned equal below. * * The rejection tests assert the issue PATH and the MESSAGE SUBSTANCE rather * than a bare `success === false`. One condition, one wording: a refusal whose @@ -32,6 +35,7 @@ import { describe, expect, it } from 'vitest'; import { ActionSchema, InlineActionSchema, defineAction } from './action.zod'; import { BulkActionDefSchema } from './bulk-action.zod'; +import { ActionButtonPropsSchema } from './component.zod'; /** * Minimum legal registered action. `type` defaults to `script`, whose own @@ -297,3 +301,80 @@ describe('`confirmText` + non-empty `params` is refused on InlineActionSchema to expect(result.success, JSON.stringify((result as { error?: unknown }).error)).toBe(true); }); }); + +describe('`confirmText` + non-empty ARRAY `params` is refused on the `action:button` row too', () => { + // #29844 (ruling record 6597621758, letter A). The `action:button` block + // forwards its row's `confirmText` and an array `params` (as `actionParams`) + // to the same `ActionRunner`, which awaits the confirm and THEN the param + // prompt — the same two dialogs. Same predicate, same refusal: asserted + // against both other doors' issues, so a wording drifting onto one door goes + // red. An object `params` on this row is the static values, which open no + // dialog, so it keeps `confirmText`. + const buttonReject = { + label: 'Reject', + actionType: 'api', + target: '/api/v1/approvals/reject', + params: [{ name: 'reason', label: 'Reason', type: 'textarea' }], + } as const; + + const parseErrorOf = (result: { success: boolean; error?: unknown }) => JSON.stringify(result.error); + + it('refuses the pair at the `confirmText` path, with the other two doors\' message', () => { + const result = ActionButtonPropsSchema.safeParse({ ...buttonReject, confirmText: 'Reject this request?' }); + + expect(result.success).toBe(false); + const issues = result.success ? [] : result.error.issues.filter((i) => i.path.join('.') === 'confirmText'); + expect(issues).toHaveLength(1); + expect(issues[0]?.code).toBe('custom'); + expect(issues[0]?.path).toEqual(['confirmText']); + + const registered = guardIssue(ActionSchema.safeParse({ + ...base, + confirmText: 'Reject this request?', + params: oneParam, + })); + const inline = InlineActionSchema.safeParse({ + type: 'url', + target: '/approvals?reject=1', + label: 'Reject', + params: oneParam, + confirmText: 'Reject this request?', + }); + const inlineIssue = inline.success ? undefined : inline.error.issues.find((i) => i.path.join('.') === 'confirmText'); + expect(registered?.message).toBeDefined(); + expect(issues[0]?.message).toBe(registered?.message); + expect(issues[0]?.message).toBe(inlineIssue?.message); + }); + + it('accepts `description` beside an array `params` and no `confirmText` — the remedy', () => { + const result = ActionButtonPropsSchema.safeParse({ + ...buttonReject, + description: 'Reject this request? Say why — the requester sees it.', + }); + + expect(result.success, parseErrorOf(result)).toBe(true); + }); + + it('accepts `confirmText` on a row with no `params` — the confirm is the only dialog', () => { + const { params: _params, ...paramless } = buttonReject; + const result = ActionButtonPropsSchema.safeParse({ ...paramless, confirmText: 'Reject this request?' }); + + expect(result.success, parseErrorOf(result)).toBe(true); + }); + + it('accepts `confirmText` beside an EMPTY `params` array — nothing is collected, no second dialog', () => { + const result = ActionButtonPropsSchema.safeParse({ ...buttonReject, params: [], confirmText: 'Reject this request?' }); + + expect(result.success, parseErrorOf(result)).toBe(true); + }); + + it('accepts `confirmText` beside an OBJECT `params` — static values, no param dialog opens', () => { + const result = ActionButtonPropsSchema.safeParse({ + ...buttonReject, + params: { reason: 'duplicate' }, + confirmText: 'Reject this request?', + }); + + expect(result.success, parseErrorOf(result)).toBe(true); + }); +}); diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index c1a9cf11d77..197e8a8788e 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -55,6 +55,7 @@ import { aiJsonSchemaSlot } from '../shared/ai-json-schema-slot'; import { ACTION_TARGET_ALIASES } from './action-target-aliases'; import { ON_SUCCESS_EXECUTORS, onSuccessBesideExecutorRefusal } from './action-on-success-executor'; import { refuseDeclarativeUpdateContradictions, type DeclarativeUpdateSurface } from './action-declarative-update'; +import { CONFIRM_BESIDE_PARAMS_REFUSAL, declaresConfirmBesideParams } from './action-confirm-beside-params'; import { navigationTargetRefusal } from './navigation-target'; /** @@ -1512,9 +1513,12 @@ const actionObject = () => strictObject({ /** UX Behavior */ // NB this describe() renders into the InlineAction reference table too (both // tables derive from this one field factory). The #7428 refusal lives on - // both doors' refine chains — `ActionSchema`'s and, since #29383, + // both these tables' refine chains — `ActionSchema`'s and, since #29383, // `InlineActionSchema`'s — so the pair sentence below is unqualified; a - // view's `bulkActionDefs` declares its own `confirmText`, not this one. + // view's `bulkActionDefs` declares its own `confirmText`, not this one. The + // page `action:button` row declares its own `confirmText` too and, since + // #29844, refuses the pair through the same predicate + // (`./action-confirm-beside-params.ts`). // // [#24814, ADR-0032 Decisions 2–3] `confirmText` and `successMessage` are // `template` slots, one family with the page action rows' copy keys @@ -2200,33 +2204,6 @@ function refuseIdentityAssignmentBodyWrite( } } -/** - * `confirmText` beside a non-empty `params`: TWO sequential dialogs for ONE - * decision. The console action runner awaits the confirm, THEN the param - * prompt (objectui `packages/core/src/actions/ActionRunner.ts`), so the first - * dialog already reads as "the action ran" while nothing has been sent. The - * `element:button` renderer hands an inline action to that same runner, so the - * pair opens the same two dialogs on both doors that refuse it here — - * {@link ActionSchema} and {@link InlineActionSchema} — and each spells the - * rule through this one predicate and this one refusal. An empty `params` - * collects nothing and opens no second dialog, so it is not the pair. - */ -function declaresConfirmBesideParams(data: { confirmText?: unknown; params?: unknown }): boolean { - return data.confirmText != null && Array.isArray(data.params) && data.params.length > 0; -} - -const CONFIRM_BESIDE_PARAMS_REFUSAL = { - message: - 'An action that declares `confirmText` beside a non-empty `params` shows the user TWO dialogs ' - + 'for one decision — the confirm, then the param prompt, with nothing sent until the second. ' - + "Carry the confirm question in the action's top-level `description` instead (it renders under " - + 'the param dialog\'s title) and drop `confirmText`: one condition, one wording, one dialog. ' - + 'Not `ai.description` — that is the LLM-facing tool contract. `confirmText` stays ' - + 'correct for a param-LESS action, where the confirm IS the only dialog, and for a view\'s ' - + '`bulkActionDefs`, where the pair renders one dialog by that schema\'s own contract.', - path: ['confirmText'], -}; - export const ActionSchema = lazySchema(() => actionObject().refine((data) => { // Require `target` for types that reference an external resource if (TARGET_REQUIRED_TYPES.has(data.type) && !data.target) { @@ -2328,6 +2305,10 @@ export const ActionSchema = lazySchema(() => actionObject().refine((data) => { // (#29383, ruling record 6595808462 letter A): an inline action is drawn by // the same runner, so the pair opens the same two dialogs there, and its // pick carries `description`, so the remedy has a slot on that door too. + // The page `action:button` row (`ActionButtonPropsSchema`, `component.zod.ts`) + // is the third door (#29844, ruling record 6597621758 letter A): it forwards + // its `confirmText` and an array `params` to the same runner. All three read + // the predicate and the refusal from `./action-confirm-beside-params.ts`. return !declaresConfirmBesideParams(data); }, CONFIRM_BESIDE_PARAMS_REFUSAL).refine((data) => { // [#27944] The mirror of the rule above, for the param dialog's button copy. diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 3c77db51315..c9c80b8eecd 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -68,6 +68,7 @@ import { AggregationFunction } from '../data/query.zod'; // outside the `ui` barrel so the two carriers share it without publishing it. import { ListViewExportOptionsSchema } from './list-view-export-options'; import { ActionSchema, ActionType, InlineActionSchema, ActionLocationSchema } from './action.zod'; +import { CONFIRM_BESIDE_PARAMS_REFUSAL, declaresConfirmBesideParams } from './action-confirm-beside-params'; import { ACTION_TARGET_ALIASES } from './action-target-aliases'; import { ON_SUCCESS_EXECUTORS, onSuccessBesideExecutorRefusal } from './action-on-success-executor'; import { refuseDeclarativeUpdateContradictions } from './action-declarative-update'; @@ -3980,7 +3981,8 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'), operation: actionOperation().optional().describe('Declarative single-record write: `ActionSchema`\'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row\'s values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row\'s `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape`, `recordIdField` and `onSuccess` are refused, and `list_toolbar` is not a location'), patch: rowPatchRetired("An `action:button`'s"), - confirmText: actionRowCopy('confirmText').optional().describe(ACTION_ROW_COPY_DESCRIBE.confirmText), + confirmText: actionRowCopy('confirmText').optional() + .describe(`${ACTION_ROW_COPY_DESCRIBE.confirmText} Beside a non-empty array \`params\` it is refused — the runner would show the confirm and then the param dialog, two dialogs for one decision; put the question on \`description\` instead. Correct with no \`params\`, beside \`params: []\`, and beside an object \`params\` (static values, no param dialog)`), successMessage: actionRowCopy('successMessage').optional().describe(ACTION_ROW_COPY_DESCRIBE.successMessage), errorMessage: actionRowCopy('errorMessage').optional().describe(ACTION_ROW_COPY_DESCRIBE.errorMessage), refreshAfter: z.boolean().optional().describe('Refresh the surrounding data after the action runs'), @@ -3997,7 +3999,15 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ }).superRefine(rowDeclarativeUpdate('actionType', "An `action:button`'s")) .superRefine(refuseUnopenableRowUrlTarget('actionType', "An `action:button`'s")) .superRefine(refuseRowUrlExecutorAlias('actionType', "An `action:button`'s `actionType: 'navigation'`")) - .superRefine(refuseRowOnSuccessBesideExecutor('actionType', "An `action:button`'s"))); + .superRefine(refuseRowOnSuccessBesideExecutor('actionType', "An `action:button`'s")) + // #29844 (ruling record 6597621758, letter A): the third door of the + // `confirmText` + non-empty `params` refusal, beside `ActionSchema` and + // `InlineActionSchema`, with their predicate and their message. This block + // forwards `confirmText` and an ARRAY `params` (as `actionParams`) to the + // same runner, which awaits the confirm and then opens the param dialog. An + // object `params` is the static values, which open no dialog, and the + // predicate's `Array.isArray` read leaves it alone. + .refine((data) => !declaresConfirmBesideParams(data), CONFIRM_BESIDE_PARAMS_REFUSAL)); /** Author state (ADR-0122: the bare name is the author state). */ export type ActionButtonProps = z.input; /**