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
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/spec': minor
---

fix(spec)!: a page `action:icon` (`ActionIconPropsSchema`) and each `action:group` / `action:menu` member refuse `confirmText` beside a non-empty array `params`, as `action:button`, 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:icon` block forwards its row's `confirmText` and an array `params` (as `actionParams`) to the console's action runner, and both containers do the same for each member. The runner awaits the confirm dialog and only then opens the param dialog, so the user saw a confirm that already read as "the action ran" while nothing had been sent, then a second dialog. The other three action doors refused that pair; these two accepted it. They now refuse it with the same predicate and the same message, at the `confirmText` path: `confirmText` on the icon's properties, `actions.N.confirmText` on a container member.

Clause-②: no (narrowing) — `ActionIconPropsSchema` and the `action:group` / `action:menu` member rows refuse `confirmText` beside a non-empty array `params`, which they accepted before. Nothing is widened.

Metadata-protocol: narrowed (a page `action:icon`, and an `action:group` or `action:menu` member, may no longer declare `confirmText` beside a non-empty array `params`)

<!-- adr-0087: registered ui-page-action-row-confirm-text-beside-params-refused -->

**BREAKING** accept-set narrowing at `ActionIconPropsSchema` and the `action:group` / `action:menu` member rows (`@objectstack/spec`), served where a page's component `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:menu', properties: { actions: [{ name: 'reject', label: 'Reject', type: '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. The same held for an `action:icon` with both keys.
- **Now.** The same `properties` are refused at `actions.0.confirmText` (at `confirmText` on an `action:icon`) with the action doors' message: the pair shows TWO dialogs for one decision; carry the question in `description` and drop `confirmText`.
- **Unchanged.** `confirmText` with no `params`, beside `params: []`, or beside an object `params` (an icon's static parameter values; a `type: 'api'` member's request payload — neither opens a dialog) parses as before; so does an array `params` with `description`.
- **Who sees it.** An author whose page declares an `action:icon`, or an `action:group` / `action:menu` member, with both keys. A stored page keeps loading and serving; its next save is refused, with the remedy in the finding.

**The one-line fix: move the confirm sentence to the action's `description`, which the param dialog draws under its title, and drop `confirmText`.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-confirm-text-beside-params-refused`, which names all four page action rows (`action:button`, `action:icon`, and each `action:group` / `action:menu` member) — the `action:button` refusal shipped earlier in 18.x is covered by the same entry. The rewrite is by hand, because whether the sentence reads as the dialog's description line is the author's call. On an `action:icon` with no `label`, `description` is also the tooltip text; give the icon a `label` to keep them apart.
6 changes: 3 additions & 3 deletions content/docs/references/ui/component.mdx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

export const entry: SemanticMigration = {
id: 'ui-page-action-row-confirm-text-beside-params-refused',
surface: 'the `confirmText` of a page action row beside a non-empty array `params`: `properties.confirmText` '
+ 'beside a non-empty array `properties.params` on an `action:button` or `action:icon`, and `confirmText` '
+ 'beside a non-empty array `params` on each `action:group` / `action:menu` member of `properties.actions`. '
+ 'Not `params: []`, not an object `params` (an `action:button` / `action:icon` row\'s static parameter '
+ 'values, a `type: \'api\'` member\'s request payload), and not a row with no `params`',
replacement: 'the confirm question on the row\'s `description`, which the param dialog draws under its title, '
+ 'and no `confirmText`: one dialog that asks the question and collects the inputs. A row that collects '
+ 'nothing keeps its `confirmText` and drops the `params`. ⛔ NOT mechanically convertible: whether the '
+ 'confirm sentence reads as the param dialog\'s description line, or should be reworded, is the '
+ 'author\'s call.',
reason:
'The console\'s action runner awaits the confirm dialog and only then opens the param dialog for an array '
+ '`params`, and all four rows forward both keys to it (an array `params` as `actionParams`), so a row '
+ 'carrying both showed the user TWO dialogs for one decision: a confirm that already read as "the action '
+ 'ran" while nothing had been sent, then the param prompt. `ActionSchema` and `InlineActionSchema` already '
+ 'refuse the pair; the four page rows now carry that rule with the same predicate and the same refusal '
+ 'text. On an `action:icon` with no `label`, `description` is also the tooltip text, so moving the '
+ 'question there changes the hover text too; give the icon a `label` to keep them apart. A page\'s '
+ '`properties` is not parsed on the metadata load path, so a stored page keeps loading; `os validate`\'s '
+ 'props gate, which the metadata save door also runs on the next save of the page, parses it against '
+ 'these same rows and reports the refusal at the row\'s `confirmText`. Measured producers: none shipped '
+ 'in this repository; the one carrier was a spec test fixture, respelled.',
acceptanceCriteria:
'No `action:button` / `action:icon` `properties` and no `action:group` / `action:menu` member declares '
+ '`confirmText` beside a non-empty array `params`; `os validate` reports no `component-props-invalid` '
+ 'finding at `properties.confirmText` or `properties.actions.N.confirmText` for the pair, and every row '
+ 'with no `params`, `params: []` or an object `params` parses as before.',
};
21 changes: 13 additions & 8 deletions packages/spec/src/ui/action-confirm-beside-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,28 @@

/**
* `confirmText` beside a non-empty `params`: TWO sequential dialogs for ONE
* decision. ONE predicate and ONE refusal, read by the three doors that refuse
* decision. ONE predicate and ONE refusal, read by the five 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
* page action rows in `component.zod.ts` — `action:button`
* (`ActionButtonPropsSchema`), `action:icon` (`ActionIconPropsSchema`) and each
* `action:group` / `action:menu` member. Pinned in
* `action-confirm-params-guard.test.ts`, which asserts the five doors'
* 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.
* and `action:icon` blocks forward their row's `confirmText` and an array
* `params` (as `actionParams`) to it too, as the `action:group` /
* `action:menu` containers do for each member, so the pair opens the same two
* dialogs on all five 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
* the pair. Nor is an object-form `params`: on an `action:button` /
* `action:icon` row an object is the static parameter values, and on a
* `type: 'api'` container member the request payload; neither opens a
* 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
Expand Down
126 changes: 123 additions & 3 deletions packages/spec/src/ui/action-confirm-params-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@
* 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 static values and opens no dialog. So do `action:icon` and the
* `action:group` / `action:menu` members (#30017, ruling record 6624208841
* letter A), on the same terms. The 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 @@ -35,7 +37,12 @@
import { describe, expect, it } from 'vitest';
import { ActionSchema, InlineActionSchema, defineAction } from './action.zod';
import { BulkActionDefSchema } from './bulk-action.zod';
import { ActionButtonPropsSchema } from './component.zod';
import {
ActionButtonPropsSchema,
ActionGroupPropsSchema,
ActionIconPropsSchema,
ActionMenuPropsSchema,
} from './component.zod';

/**
* Minimum legal registered action. `type` defaults to `script`, whose own
Expand Down Expand Up @@ -378,3 +385,116 @@ describe('`confirmText` + non-empty ARRAY `params` is refused on the `action:but
expect(result.success, parseErrorOf(result)).toBe(true);
});
});

describe('`confirmText` + non-empty ARRAY `params` is refused on `action:icon` and the `action:group` / `action:menu` members too', () => {
// #30017 (ruling record 6624208841, letter A). `action:icon` forwards its
// row's `confirmText` and an array `params` (as `actionParams`) to the same
// `ActionRunner`, and so do both containers for each member: the same two
// dialogs. Same predicate, same refusal: asserted against `action:button`'s
// own issue, so a wording drifting onto one door goes red. An object `params`
// (the icon's static values; a `type: 'api'` member's request payload) opens
// no dialog, so it keeps `confirmText`.
const pairParams = [{ name: 'reason', label: 'Reason', type: 'textarea' }];
const iconReject = {
label: 'Reject',
icon: 'x',
actionType: 'api',
target: '/api/v1/approvals/reject',
params: pairParams,
} as const;
const memberReject = {
name: 'reject',
label: 'Reject',
type: 'api',
target: '/api/v1/approvals/reject',
params: pairParams,
} as const;
const asContainer = (member: Record<string, unknown>) => ({
actions: [{ name: 'approve', label: 'Approve', type: 'script', target: 'approve' }, member],
});
const CONTAINERS = [
['action:group', ActionGroupPropsSchema],
['action:menu', ActionMenuPropsSchema],
] as const;

const parseErrorOf = (result: { success: boolean; error?: unknown }) => JSON.stringify(result.error);
/** `action:button`'s issue for the pair: the message every other door must equal. */
const buttonMessage = (): string | undefined => {
const r = ActionButtonPropsSchema.safeParse({ ...iconReject, confirmText: 'Reject this request?' });
return r.success ? undefined : r.error.issues.find((i) => i.path.join('.') === 'confirmText')?.message;
};

it('`action:icon` refuses the pair at the `confirmText` path, with `action:button`\'s message', () => {
const result = ActionIconPropsSchema.safeParse({ ...iconReject, confirmText: 'Reject this request?' });

expect(result.success).toBe(false);
const issues = result.success ? [] : result.error.issues;
expect(issues).toHaveLength(1);
expect(issues[0]?.code).toBe('custom');
expect(issues[0]?.path).toEqual(['confirmText']);
expect(buttonMessage()).toBeDefined();
expect(issues[0]?.message).toBe(buttonMessage());
// The prescription: the remedy key, and the param-less case that keeps `confirmText` live.
expect(issues[0]?.message).toContain('TWO dialogs');
expect(issues[0]?.message).toContain('`description`');
expect(issues[0]?.message).toContain('param-LESS');
});

for (const [row, schema] of CONTAINERS) {
it(`an \`${row}\` member refuses the pair at \`actions.N.confirmText\`, with \`action:button\`'s message`, () => {
const result = schema.safeParse(asContainer({ ...memberReject, confirmText: 'Reject this request?' }));

expect(result.success).toBe(false);
const issues = result.success ? [] : result.error.issues;
expect(issues).toHaveLength(1);
expect(issues[0]?.code).toBe('custom');
// The member's own index, so the editor underlines the offending member, not the list.
expect(issues[0]?.path).toEqual(['actions', 1, 'confirmText']);
expect(issues[0]?.message).toBe(buttonMessage());
expect(issues[0]?.message).toContain('TWO dialogs');
expect(issues[0]?.message).toContain('`description`');
});

it(`an \`${row}\` member refuses the template-envelope form of \`confirmText\` too`, () => {
// A row's `confirmText` is a template slot: the bare string and the
// `{ dialect: 'template', source }` envelope are its two spellings (a
// localized map is refused by the slot itself, for its own reason).
const result = schema.safeParse(asContainer({
...memberReject,
confirmText: { dialect: 'template', source: 'Reject this request?' },
}));

expect(result.success).toBe(false);
expect(result.success ? [] : result.error.issues.map((i) => [i.code, i.path.join('.')]))
.toEqual([['custom', 'actions.1.confirmText']]);
});
}

it('`action:icon` accepts `confirmText` with no `params`, beside `params: []`, and beside an OBJECT `params`', () => {
const { params: _params, ...paramless } = iconReject;
for (const props of [
{ ...paramless, confirmText: 'Reject this request?' },
{ ...iconReject, params: [], confirmText: 'Reject this request?' },
{ ...iconReject, params: { reason: 'duplicate' }, confirmText: 'Reject this request?' },
{ ...iconReject, description: 'Reject this request? Say why — the requester sees it.' },
]) {
const result = ActionIconPropsSchema.safeParse(props);
expect(result.success, parseErrorOf(result)).toBe(true);
}
});

for (const [row, schema] of CONTAINERS) {
it(`an \`${row}\` member accepts \`confirmText\` with no \`params\`, beside \`params: []\`, and beside a \`type: 'api'\` member's OBJECT \`params\``, () => {
const { params: _params, ...paramless } = memberReject;
for (const member of [
{ ...paramless, confirmText: 'Reject this request?' },
{ ...memberReject, params: [], confirmText: 'Reject this request?' },
{ ...memberReject, params: { reason: 'duplicate' }, confirmText: 'Reject this request?' },
{ ...memberReject, description: 'Reject this request? Say why — the requester sees it.' },
]) {
const result = schema.safeParse(asContainer(member));
expect(result.success, parseErrorOf(result)).toBe(true);
}
});
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,15 @@ describe('§1 each member accepts every shape a measured writer authors', () =>
successMessage: { dialect: 'template', source: 'Closed' },
errorMessage: { dialect: 'template', source: 'Could not close' },
refreshAfter: true, locations: ['record_header'], objectName: 'case',
}],
}],
// #30017: an array `params` beside `confirmText` is refused (two dialogs for
// one decision), so the param-collecting member carries its question on
// `description` instead.
['a member collecting params', 'action:menu', {
actions: [{
name: 'close_case', label: 'Close', type: 'api', target: '/api/close',
description: 'Close this case? Say why.',
params: [{ name: 'reason', label: 'Reason', type: 'text' }],
}],
}],
Expand Down
Loading
Loading