Skip to content

Commit 0c6f397

Browse files
committed
fix(spec)!: action:icon and action:group/menu members refuse confirmText beside a non-empty array params
The fourth and fifth doors of the confirmText + params pair refusal, with action:button's predicate and message (declaresConfirmBesideParams, CONFIRM_BESIDE_PARAMS_REFUSAL). Both blocks forward confirmText and an array params (as actionParams) to the same runner, which opens the confirm and then the param dialog. Still accepted: no params, params: [], and an object params (an icon's static values; an api member's payload). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6bhgVugtCCwBs98FeR27P
1 parent 2ffdb64 commit 0c6f397

4 files changed

Lines changed: 180 additions & 8 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
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
6+
7+
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.
8+
9+
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.
10+
11+
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`)
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed: the icon row and the container member already declared both keys and keep them, and this adds the pair refusal the other three action doors 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:icon or container member carrying the pair; the one carrier was this package's own test fixture, repaired here. -->
14+
15+
**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.
16+
17+
- **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.
18+
- **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`.
19+
- **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`.
20+
- **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.
21+
22+
**The remedy.** Move the confirm sentence to the action's `description`, which the param dialog draws under its title, and drop `confirmText`. On an `action:icon` with no `label`, `description` is also the tooltip text.

‎packages/spec/src/ui/action-confirm-params-guard.test.ts‎

Lines changed: 119 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@
2323
* two dialogs open, and its pick carries the `description` remedy. So does the
2424
* page `action:button` row, `ActionButtonPropsSchema` (#29844, ruling record
2525
* 6597621758 letter A), for an ARRAY `params` only: an object `params` there is
26-
* the static values and opens no dialog. The three doors share one predicate
27-
* and one message (`./action-confirm-beside-params.ts`), pinned equal below.
26+
* the static values and opens no dialog. So do `action:icon` and the
27+
* `action:group` / `action:menu` members (#30017, ruling record 6624208841
28+
* letter A), on the same terms. The doors share one predicate and one message
29+
* (`./action-confirm-beside-params.ts`), pinned equal below.
2830
*
2931
* The rejection tests assert the issue PATH and the MESSAGE SUBSTANCE rather
3032
* than a bare `success === false`. One condition, one wording: a refusal whose
@@ -35,7 +37,12 @@
3537
import { describe, expect, it } from 'vitest';
3638
import { ActionSchema, InlineActionSchema, defineAction } from './action.zod';
3739
import { BulkActionDefSchema } from './bulk-action.zod';
38-
import { ActionButtonPropsSchema } from './component.zod';
40+
import {
41+
ActionButtonPropsSchema,
42+
ActionGroupPropsSchema,
43+
ActionIconPropsSchema,
44+
ActionMenuPropsSchema,
45+
} from './component.zod';
3946

4047
/**
4148
* Minimum legal registered action. `type` defaults to `script`, whose own
@@ -378,3 +385,112 @@ describe('`confirmText` + non-empty ARRAY `params` is refused on the `action:but
378385
expect(result.success, parseErrorOf(result)).toBe(true);
379386
});
380387
});
388+
389+
describe('`confirmText` + non-empty ARRAY `params` is refused on `action:icon` and the `action:group` / `action:menu` members too', () => {
390+
// #30017 (ruling record 6624208841, letter A). `action:icon` forwards its
391+
// row's `confirmText` and an array `params` (as `actionParams`) to the same
392+
// `ActionRunner`, and so do both containers for each member: the same two
393+
// dialogs. Same predicate, same refusal: asserted against `action:button`'s
394+
// own issue, so a wording drifting onto one door goes red. An object `params`
395+
// (the icon's static values; a `type: 'api'` member's request payload) opens
396+
// no dialog, so it keeps `confirmText`.
397+
const pairParams = [{ name: 'reason', label: 'Reason', type: 'textarea' }];
398+
const iconReject = {
399+
label: 'Reject',
400+
icon: 'x',
401+
actionType: 'api',
402+
target: '/api/v1/approvals/reject',
403+
params: pairParams,
404+
} as const;
405+
const memberReject = {
406+
name: 'reject',
407+
label: 'Reject',
408+
type: 'api',
409+
target: '/api/v1/approvals/reject',
410+
params: pairParams,
411+
} as const;
412+
const asContainer = (member: Record<string, unknown>) => ({
413+
actions: [{ name: 'approve', label: 'Approve', type: 'script', target: 'approve' }, member],
414+
});
415+
const CONTAINERS = [
416+
['action:group', ActionGroupPropsSchema],
417+
['action:menu', ActionMenuPropsSchema],
418+
] as const;
419+
420+
const parseErrorOf = (result: { success: boolean; error?: unknown }) => JSON.stringify(result.error);
421+
/** `action:button`'s issue for the pair: the message every other door must equal. */
422+
const buttonMessage = (): string | undefined => {
423+
const r = ActionButtonPropsSchema.safeParse({ ...iconReject, confirmText: 'Reject this request?' });
424+
return r.success ? undefined : r.error.issues.find((i) => i.path.join('.') === 'confirmText')?.message;
425+
};
426+
427+
it('`action:icon` refuses the pair at the `confirmText` path, with `action:button`\'s message', () => {
428+
const result = ActionIconPropsSchema.safeParse({ ...iconReject, confirmText: 'Reject this request?' });
429+
430+
expect(result.success).toBe(false);
431+
const issues = result.success ? [] : result.error.issues;
432+
expect(issues).toHaveLength(1);
433+
expect(issues[0]?.code).toBe('custom');
434+
expect(issues[0]?.path).toEqual(['confirmText']);
435+
expect(buttonMessage()).toBeDefined();
436+
expect(issues[0]?.message).toBe(buttonMessage());
437+
// The prescription: the remedy key, and the param-less case that keeps `confirmText` live.
438+
expect(issues[0]?.message).toContain('TWO dialogs');
439+
expect(issues[0]?.message).toContain('`description`');
440+
expect(issues[0]?.message).toContain('param-LESS');
441+
});
442+
443+
for (const [row, schema] of CONTAINERS) {
444+
it(`an \`${row}\` member refuses the pair at \`actions.N.confirmText\`, with \`action:button\`'s message`, () => {
445+
const result = schema.safeParse(asContainer({ ...memberReject, confirmText: 'Reject this request?' }));
446+
447+
expect(result.success).toBe(false);
448+
const issues = result.success ? [] : result.error.issues;
449+
expect(issues).toHaveLength(1);
450+
expect(issues[0]?.code).toBe('custom');
451+
// The member's own index, so the editor underlines the offending member, not the list.
452+
expect(issues[0]?.path).toEqual(['actions', 1, 'confirmText']);
453+
expect(issues[0]?.message).toBe(buttonMessage());
454+
expect(issues[0]?.message).toContain('TWO dialogs');
455+
expect(issues[0]?.message).toContain('`description`');
456+
});
457+
458+
it(`an \`${row}\` member refuses the localized-map form of \`confirmText\` too`, () => {
459+
const result = schema.safeParse(asContainer({
460+
...memberReject,
461+
confirmText: { en: 'Reject this request?', 'zh-CN': '拒绝该请求?' },
462+
}));
463+
464+
expect(result.success).toBe(false);
465+
expect(result.success ? [] : result.error.issues.map((i) => i.path.join('.'))).toEqual(['actions.1.confirmText']);
466+
});
467+
}
468+
469+
it('`action:icon` accepts `confirmText` with no `params`, beside `params: []`, and beside an OBJECT `params`', () => {
470+
const { params: _params, ...paramless } = iconReject;
471+
for (const props of [
472+
{ ...paramless, confirmText: 'Reject this request?' },
473+
{ ...iconReject, params: [], confirmText: 'Reject this request?' },
474+
{ ...iconReject, params: { reason: 'duplicate' }, confirmText: 'Reject this request?' },
475+
{ ...iconReject, description: 'Reject this request? Say why — the requester sees it.' },
476+
]) {
477+
const result = ActionIconPropsSchema.safeParse(props);
478+
expect(result.success, parseErrorOf(result)).toBe(true);
479+
}
480+
});
481+
482+
for (const [row, schema] of CONTAINERS) {
483+
it(`an \`${row}\` member accepts \`confirmText\` with no \`params\`, beside \`params: []\`, and beside a \`type: 'api'\` member's OBJECT \`params\``, () => {
484+
const { params: _params, ...paramless } = memberReject;
485+
for (const member of [
486+
{ ...paramless, confirmText: 'Reject this request?' },
487+
{ ...memberReject, params: [], confirmText: 'Reject this request?' },
488+
{ ...memberReject, params: { reason: 'duplicate' }, confirmText: 'Reject this request?' },
489+
{ ...memberReject, description: 'Reject this request? Say why — the requester sees it.' },
490+
]) {
491+
const result = schema.safeParse(asContainer(member));
492+
expect(result.success, parseErrorOf(result)).toBe(true);
493+
}
494+
});
495+
}
496+
});

‎packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,15 @@ describe('§1 each member accepts every shape a measured writer authors', () =>
221221
successMessage: { dialect: 'template', source: 'Closed' },
222222
errorMessage: { dialect: 'template', source: 'Could not close' },
223223
refreshAfter: true, locations: ['record_header'], objectName: 'case',
224+
}],
225+
}],
226+
// #30017: an array `params` beside `confirmText` is refused (two dialogs for
227+
// one decision), so the param-collecting member carries its question on
228+
// `description` instead.
229+
['a member collecting params', 'action:menu', {
230+
actions: [{
231+
name: 'close_case', label: 'Close', type: 'api', target: '/api/close',
232+
description: 'Close this case? Say why.',
224233
params: [{ name: 'reason', label: 'Reason', type: 'text' }],
225234
}],
226235
}],

‎packages/spec/src/ui/component.zod.ts‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4163,7 +4163,8 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({
41634163
bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'),
41644164
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` and `onSuccess` are refused, and `list_toolbar` is not a location'),
41654165
patch: rowPatchRetired("An `action:icon`'s"),
4166-
confirmText: actionRowCopy('confirmText').optional().describe(ACTION_ROW_COPY_DESCRIBE.confirmText),
4166+
confirmText: actionRowCopy('confirmText').optional()
4167+
.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)`),
41674168
successMessage: actionRowCopy('successMessage').optional().describe(ACTION_ROW_COPY_DESCRIBE.successMessage),
41684169
errorMessage: actionRowCopy('errorMessage').optional().describe(ACTION_ROW_COPY_DESCRIBE.errorMessage),
41694170
refreshAfter: z.boolean().optional().describe('Refresh the surrounding data after the action runs'),
@@ -4178,7 +4179,14 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({
41784179
}).superRefine(rowDeclarativeUpdate('actionType', "An `action:icon`'s"))
41794180
.superRefine(refuseUnopenableRowUrlTarget('actionType', "An `action:icon`'s"))
41804181
.superRefine(refuseRowUrlExecutorAlias('actionType', "An `action:icon`'s `actionType: 'navigation'`"))
4181-
.superRefine(refuseRowOnSuccessBesideExecutor('actionType', "An `action:icon`'s")));
4182+
.superRefine(refuseRowOnSuccessBesideExecutor('actionType', "An `action:icon`'s"))
4183+
// #30017 (ruling record 6624208841, letter A): the fourth door of the
4184+
// `confirmText` + non-empty `params` refusal, `action:button`'s predicate and
4185+
// message. This block forwards `confirmText` and an ARRAY `params` (as
4186+
// `actionParams`) to the same runner, which awaits the confirm and then opens
4187+
// the param dialog. An object `params` is the static values, which open no
4188+
// dialog, and the predicate's `Array.isArray` read leaves it alone.
4189+
.refine((data) => !declaresConfirmBesideParams(data), CONFIRM_BESIDE_PARAMS_REFUSAL));
41824190
/** Author state (ADR-0122: the bare name is the author state). */
41834191
export type ActionIconProps = z.input<typeof ActionIconPropsSchema>;
41844192
/**
@@ -4313,7 +4321,8 @@ function actionContainerMemberShape() {
43134321
bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'),
43144322
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` and `onSuccess` are refused, and `list_toolbar` is not a location'),
43154323
patch: rowPatchRetired('An `action:group` or `action:menu` member\'s'),
4316-
confirmText: actionRowCopy('confirmText').optional().describe(ACTION_ROW_COPY_DESCRIBE.confirmText),
4324+
confirmText: actionRowCopy('confirmText').optional()
4325+
.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 a \`type: 'api'\` member's object \`params\` (its request payload, no param dialog)`),
43174326
successMessage: actionRowCopy('successMessage').optional().describe(ACTION_ROW_COPY_DESCRIBE.successMessage),
43184327
errorMessage: actionRowCopy('errorMessage').optional().describe(ACTION_ROW_COPY_DESCRIBE.errorMessage),
43194328
refreshAfter: z.boolean().optional().describe('Refresh the surrounding data after the action runs'),
@@ -4413,7 +4422,15 @@ function buildActionGroupMember() {
44134422
.superRefine(actionContainerMemberParamsFitType('action:group'))
44144423
.superRefine(refuseUnopenableRowUrlTarget('type', "An `action:group` member's"))
44154424
.superRefine(refuseRowUrlExecutorAlias('type', "An `action:group` member's `type: 'navigation'`"))
4416-
.superRefine(refuseRowOnSuccessBesideExecutor('type', "An `action:group` member's"));
4425+
.superRefine(refuseRowOnSuccessBesideExecutor('type', "An `action:group` member's"))
4426+
// #30017 (ruling record 6624208841, letter A): `action:button`'s
4427+
// `confirmText` + non-empty ARRAY `params` refusal, its predicate and its
4428+
// message. The container forwards a member's `confirmText` and an array
4429+
// `params` (as `actionParams`) to the same runner, which awaits the confirm
4430+
// and then opens the param dialog. A `type: 'api'` member's object `params`
4431+
// is its request payload, which opens no dialog. On the member, so the
4432+
// issue lands at `actions.N.confirmText`.
4433+
.refine((member) => !declaresConfirmBesideParams(member), CONFIRM_BESIDE_PARAMS_REFUSAL);
44174434
}
44184435
let actionGroupMemberOnce: ReturnType<typeof buildActionGroupMember> | undefined;
44194436
/** The one {@link buildActionGroupMember} instance. */
@@ -4436,7 +4453,15 @@ function buildActionMenuMember() {
44364453
.superRefine(actionContainerMemberParamsFitType('action:menu'))
44374454
.superRefine(refuseUnopenableRowUrlTarget('type', "An `action:menu` member's"))
44384455
.superRefine(refuseRowUrlExecutorAlias('type', "An `action:menu` member's `type: 'navigation'`"))
4439-
.superRefine(refuseRowOnSuccessBesideExecutor('type', "An `action:menu` member's"));
4456+
.superRefine(refuseRowOnSuccessBesideExecutor('type', "An `action:menu` member's"))
4457+
// #30017 (ruling record 6624208841, letter A): `action:button`'s
4458+
// `confirmText` + non-empty ARRAY `params` refusal, its predicate and its
4459+
// message. The container forwards a member's `confirmText` and an array
4460+
// `params` (as `actionParams`) to the same runner, which awaits the confirm
4461+
// and then opens the param dialog. A `type: 'api'` member's object `params`
4462+
// is its request payload, which opens no dialog. On the member, so the
4463+
// issue lands at `actions.N.confirmText`.
4464+
.refine((member) => !declaresConfirmBesideParams(member), CONFIRM_BESIDE_PARAMS_REFUSAL);
44404465
}
44414466
let actionMenuMemberOnce: ReturnType<typeof buildActionMenuMember> | undefined;
44424467
/** The one {@link buildActionMenuMember} instance. */

0 commit comments

Comments
 (0)