Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/29844-action-button-confirm-beside-params-refused.md
Original file line number Diff line number Diff line change
@@ -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`)

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed: the button row already declared both keys and keeps them, and this adds the pair refusal ActionSchema and InlineActionSchema carry. There is no rewrite that keeps what the author meant without reading it (the confirm sentence may or may not suit the param dialog's description line), so the author decides, and the refusal names the remedy. The in-repo census at the base found no shipped action:button carrying the pair. -->

**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`.
2 changes: 1 addition & 1 deletion content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
41 changes: 41 additions & 0 deletions packages/spec/src/ui/action-confirm-beside-params.ts
Original file line number Diff line number Diff line change
@@ -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'],
};
85 changes: 83 additions & 2 deletions packages/spec/src/ui/action-confirm-params-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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);
});
});
Loading
Loading