Skip to content

Commit ef5d0a4

Browse files
fix(plugin-grid): fill a bulk confirmText's param holes with the params' display values (#26217)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi91vuMhRJwN3WrWzqrtvosbw)_ Fixes #25942 Clause-②: no Metadata-protocol: none ## What Step 2 of ruling B on #24798, the renderer half. Step 1 was #26030, merged as `66769910`. A bulk action's `confirmText` hole that names one of the def's params is now filled with that param's **display value**, following the rule the spec states on the slot (`packages/spec/src/ui/bulk-action.zod.ts`, the `confirmText` describe). - `ui/packages/plugin-grid/src/components/bulkConfirmText.ts`: - `bulkConfirmParamDisplayValue(param, value, lookupLabels)`: - an option-bearing param shows the chosen option's `label`, and a multi-value param shows its labels joined with ', '; - a `lookup` / user param shows the picked record's display name from the confirm step's resolved names, and the id until that name resolves; - any other param is returned as the entered value itself, not a string, so the engine's whitelisted formatters apply (`{{ amount | currency:EUR }}`, `{{ due | date:iso }}`); - an empty value is `undefined`, so it prints empty and `{{ x | default:'none' }}` can name it. - `bulkConfirmTemplateScope(scope)` builds the roots over the spec's closed scope, `bulkConfirmTemplateRoots`. It writes `count` last, so `count` wins over a param of that name, as the spec declares. - `bulkConfirmText(confirmText, { count, params, values, lookupLabels })` fills the slot through `fillTemplateHoles`, exactly as in step 1. - `BulkActionDialog.tsx` passes `params`, `values` and `lookupLabels` into the confirm sentence. The lookup names that resolve after the confirm step opens re-render the sentence, because it is computed in render. - `scripts/sdui-manifest.sources/plugin-grid.json` is re-recorded (`node scripts/gen-sdui-manifest-node.mjs`). Nothing else in the manifest set moved. - Changeset: `.changeset/25942-bulk-confirm-param-holes.md`, `@object-ui/plugin-grid` patch. The ruling, verbatim: "Nothing else moves: no CEL inside holes, no change to the default text, no change to where the confirm step sits." The default sentence, the confirm step and the value summary under it (`describeValue`) are untouched, and the display value is computed apart from `describeValue`, whose output is a string and would not take a formatter. ## Verification (head `4a874ae1`) - New file `bulkConfirmParams-25942.test.tsx`, with 10 tests: - helper: option label; multi-value labels joined; a value no option declares; a lookup's resolved name, and its id before that; currency, number and date formatters on the raw value; an empty param printing empty and the `default` formatter naming it; `count` winning over a param named `count`; a hole outside the closed scope printing empty. - dialog: the option label the params step chose (`Move 2 account(s) to Silver?`), and a lookup name arriving after the step opened (`… to u1?`, then `… to Ada Lovelace?`, with `findOne('sys_user', 'u1')`). - Confirm-sentence files together (`bulkConfirmParams-25942`, `bulkConfirmCount-25942`, `bulkConfirmTextEnvelope-13183`): Test Files 3 passed, Tests 30 passed. - Ablations after commit `aaf4a4ac`, run with `scripts/ablation-replace.mjs`; each leg was restored to its HEAD blob and `git status --porcelain` came back empty: - dialog scope cut back to `{ count: rows.length }` (anchor 1 → 0, blob `76a683a69c71` → `c1b43e1f66ea`): `Tests 2 failed | 8 passed` (both dialog tests). - display value returned raw, without labels (anchor 1 → 0, blob `393b2f5ce8bb` → `557d7171d747`): `Tests 5 failed | 5 passed`. - No build leg was needed, because the tests import the dialog and helper by relative path. - Whole package under the lock: `cd ui && pnpm exec vitest run --maxWorkers=2 packages/plugin-grid/` gave Test Files 251 passed (251), Tests 2246 passed (2246), VERDICT command-exit 0. - `pnpm --filter @object-ui/plugin-grid run type-check` exited 0, after `pnpm --filter '@object-ui/plugin-grid^...' run build`, under the same lock run as the re-record. `node scripts/check-sdui-manifest.mjs` exited 0. - Derived gates at `4a874ae1`: 47 commands, all exit 0. `--ran` with exit codes reports 47 derived, 47 run, 0 NOT-MEASURED, 0 UNRUN. CI owns the type-check lanes, `Test Core` and the repo-wide scans. ## Acceptance notes - **Formatter locale.** The engine reads its formatter locale from the same scope the roots are passed in, so the formatters render `en-US`, and a param named `locale` would set the locale for the whole sentence. The seat ruled that this is fixed in the engine on its own card, so it is not folded in here. - **Params with no options.** A boolean param shows `true` / `false`, and an array value on a param with no options is joined with ', '. Both follow the spec's "the entered value otherwise". - **Not touched:** `ObjectGrid.tsx`, `ui/.github/**` and the root `ci.yml`. --- _Generated by [Claude Code](https://claude.ai/code/session_016zsTRTJ3SP3dpjEkyLrzWS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b4d0ed3 commit ef5d0a4

5 files changed

Lines changed: 252 additions & 12 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/plugin-grid': patch
3+
---
4+
5+
fix(plugin-grid): a bulk action's `confirmText` fills each param hole with that param's display value
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A bulk action's `confirmText` holes may name the action's params by `name` (`{{ tier }}`), and the protocol states what such a hole shows: the param's display value. The bulk dialog filled `{{ count }}` only, so a param hole printed empty. It now fills each param root by that rule. An option-bearing param shows the chosen option's label, and a multi-value param shows its labels joined with ', '. A `lookup` or user param shows the picked record's display name (its id until the name has resolved; the sentence updates when the name arrives). Any other param shows the entered value itself, so the template engine's formatters apply (`{{ amount | currency:EUR }}`, `{{ due | date:long }}`). An empty param prints empty, and `{{ count }}` still wins over a param named `count`. The default sentence and the place of the confirm step are unchanged.
11+
12+
What to do: nothing.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"package":"ui/packages/plugin-grid","sources":38,"sha256":"d34e7acc9010e7fc875131b416c4a83a2bac115c893c2067d3ccb85fcaf8bcd0","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/plugin-grid","sources":38,"sha256":"f6f7b1cbd89a1536a6e33febf4db131b8d526bd7281be460c401dccb8e97876a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#25942 step 2 — a bulk `confirmText` hole naming one of the def's
11+
* params is filled with that param's DISPLAY value, by the rule the protocol
12+
* states on the slot: the chosen option's label (a multi-value param's labels
13+
* joined with ', '), the picked record's display name for a lookup / user
14+
* param, and the entered value otherwise, so the engine's formatters apply.
15+
* `count` wins over a param of that name.
16+
*
17+
* Two halves:
18+
* - the helper `bulkConfirmText` over a full scope;
19+
* - the real `BulkActionDialog`: an option label, and a lookup name that
20+
* arrives after the confirm step has opened.
21+
*/
22+
import { describe, it, expect, vi, beforeAll } from 'vitest';
23+
import React from 'react';
24+
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
25+
import '@testing-library/jest-dom';
26+
import type { BulkActionDef, BulkActionParam } from '@object-ui/types';
27+
28+
import { BulkActionDialog } from '../components/BulkActionDialog';
29+
import { bulkConfirmText } from '../components/bulkConfirmText';
30+
31+
beforeAll(() => {
32+
if (!Element.prototype.scrollIntoView) {
33+
Element.prototype.scrollIntoView = vi.fn() as any;
34+
}
35+
});
36+
37+
const TIER: BulkActionParam = {
38+
name: 'tier',
39+
label: 'Tier',
40+
type: 'select',
41+
options: [
42+
{ label: 'Gold', value: 'gold' },
43+
{ label: 'Silver', value: 'silver' },
44+
],
45+
};
46+
const TAGS: BulkActionParam = {
47+
name: 'tags',
48+
label: 'Tags',
49+
type: 'multiselect',
50+
options: [
51+
{ label: 'Alpha', value: 'a' },
52+
{ label: 'Beta', value: 'b' },
53+
],
54+
};
55+
const OWNER: BulkActionParam = { name: 'owner', label: 'Owner', type: 'lookup', object: 'sys_user' };
56+
const AMOUNT: BulkActionParam = { name: 'amount', label: 'Amount', type: 'number' };
57+
const DUE: BulkActionParam = { name: 'due', label: 'Due', type: 'date' };
58+
const NOTE: BulkActionParam = { name: 'note', label: 'Note', type: 'text' };
59+
60+
function fill(source: string, values: Record<string, unknown>, extra: { count?: number; params?: BulkActionParam[]; lookupLabels?: Record<string, Record<string, string>> } = {}) {
61+
return bulkConfirmText(source, {
62+
count: extra.count ?? 2,
63+
params: extra.params ?? [TIER, TAGS, OWNER, AMOUNT, DUE, NOTE],
64+
values,
65+
lookupLabels: extra.lookupLabels,
66+
});
67+
}
68+
69+
describe('bulkConfirmText fills param holes with display values (objectstack#25942)', () => {
70+
it("shows the chosen option's label, not its value", () => {
71+
expect(fill('Move {{ count }} account(s) to {{ tier }}?', { tier: 'gold' })).toBe('Move 2 account(s) to Gold?');
72+
});
73+
74+
it("joins a multi-value param's labels with ', '", () => {
75+
expect(fill('Tag them {{ tags }}?', { tags: ['a', 'b'] })).toBe('Tag them Alpha, Beta?');
76+
});
77+
78+
it('shows a value no option declares as entered', () => {
79+
expect(fill('Move to {{ tier }}?', { tier: 'bronze' })).toBe('Move to bronze?');
80+
});
81+
82+
it("shows a lookup's resolved record name, and the id until it is resolved", () => {
83+
expect(fill('Assign to {{ owner }}?', { owner: 'u1' }, { lookupLabels: { owner: { u1: 'Ada Lovelace' } } }))
84+
.toBe('Assign to Ada Lovelace?');
85+
expect(fill('Assign to {{ owner }}?', { owner: 'u1' })).toBe('Assign to u1?');
86+
});
87+
88+
it('hands the entered value to the formatters', () => {
89+
expect(fill('Charge {{ amount | currency:EUR }} each?', { amount: 1234.5 })).toBe('Charge €1,234.50 each?');
90+
expect(fill('Charge {{ amount | number:2 }} each?', { amount: 3 })).toBe('Charge 3.00 each?');
91+
expect(fill('Due {{ due | date:iso }}?', { due: '2026-10-26' })).toBe('Due 2026-10-26?');
92+
});
93+
94+
it('prints an empty param empty, so the default formatter can name it', () => {
95+
expect(fill('Note: {{ note }}.', {})).toBe('Note: .');
96+
expect(fill("Note: {{ note | default:'none' }}.", { note: '' })).toBe('Note: none.');
97+
expect(fill("Tags: {{ tags | default:'none' }}.", { tags: [] })).toBe('Tags: none.');
98+
});
99+
100+
it('lets count win over a param named count', () => {
101+
const countParam: BulkActionParam = { name: 'count', label: 'Count', type: 'number' };
102+
expect(fill('Update {{ count }} record(s)?', { count: 99 }, { count: 3, params: [countParam] }))
103+
.toBe('Update 3 record(s)?');
104+
});
105+
106+
it('prints a hole outside the closed scope empty', () => {
107+
expect(fill('Update {{ record.name }} to {{ tier }}?', { tier: 'silver' })).toBe('Update to Silver?');
108+
});
109+
});
110+
111+
const ROWS = [{ id: 'r1', name: 'Acme' }, { id: 'r2', name: 'Globex' }];
112+
113+
function openDialog(def: BulkActionDef, dataSource: Record<string, unknown> = {}) {
114+
cleanup();
115+
render(
116+
<BulkActionDialog
117+
def={def}
118+
rows={ROWS}
119+
resource="account"
120+
dataSource={{ update: vi.fn(async () => ({})), delete: vi.fn(async () => ({})), ...dataSource } as any}
121+
open
122+
onClose={() => {}}
123+
/>,
124+
);
125+
}
126+
127+
describe('BulkActionDialog fills param holes in its confirm sentence (objectstack#25942)', () => {
128+
it('shows the option label the params step chose', async () => {
129+
openDialog({
130+
name: 'set_tier',
131+
label: 'Set tier',
132+
operation: 'update',
133+
params: [{ ...TIER, default: 'silver' }],
134+
confirmText: { dialect: 'template', source: 'Move {{ count }} account(s) to {{ tier }}?' },
135+
});
136+
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
137+
expect(await screen.findByText('Move 2 account(s) to Silver?')).toBeInTheDocument();
138+
});
139+
140+
it('shows the lookup record name once it resolves after the step opens', async () => {
141+
let resolveName: (rec: Record<string, unknown>) => void = () => {};
142+
const findOne = vi.fn(() => new Promise<Record<string, unknown>>((r) => { resolveName = r; }));
143+
openDialog(
144+
{
145+
name: 'assign',
146+
label: 'Assign',
147+
operation: 'update',
148+
params: [{ ...OWNER, default: 'u1' }],
149+
confirmText: 'Assign {{ count }} account(s) to {{ owner }}?',
150+
},
151+
{ findOne },
152+
);
153+
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
154+
expect(await screen.findByText('Assign 2 account(s) to u1?')).toBeInTheDocument();
155+
expect(findOne).toHaveBeenCalledWith('sys_user', 'u1');
156+
resolveName({ id: 'u1', name: 'Ada Lovelace' });
157+
expect(await screen.findByText('Assign 2 account(s) to Ada Lovelace?')).toBeInTheDocument();
158+
});
159+
});

‎ui/packages/plugin-grid/src/components/BulkActionDialog.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,7 @@ export const BulkActionDialog: React.FC<BulkActionDialogProps> = ({
410410
</DialogTitle>
411411
{step === 'confirm' && (
412412
<DialogDescription>
413-
{bulkConfirmText(def.confirmText, { count: rows.length }) ?? t('grid.bulk.confirmDefault', { count: rows.length, defaultValue: `This will apply to ${rows.length} record(s).` })}
413+
{bulkConfirmText(def.confirmText, { count: rows.length, params, values, lookupLabels }) ?? t('grid.bulk.confirmDefault', { count: rows.length, defaultValue: `This will apply to ${rows.length} record(s).` })}
414414
</DialogDescription>
415415
)}
416416
</DialogHeader>

‎ui/packages/plugin-grid/src/components/bulkConfirmText.ts‎

Lines changed: 79 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
*/
88

99
import { fillTemplateHoles, hasTemplateHoles } from '@object-ui/core';
10-
import type { BulkActionDef } from '@object-ui/types';
11-
import { BULK_CONFIRM_COUNT_ROOT } from '@objectstack/spec/ui';
10+
import type { BulkActionDef, BulkActionParam } from '@object-ui/types';
11+
import { BULK_CONFIRM_COUNT_ROOT, bulkConfirmTemplateRoots } from '@objectstack/spec/ui';
12+
import { isLookupishParam } from './bulkParamToField';
1213

1314
/**
1415
* The text a bulk action's `confirmText` carries: the string itself, or the
@@ -39,29 +40,97 @@ export function bulkConfirmTextSource(
3940
return text !== undefined && text.trim() !== '' ? text : undefined;
4041
}
4142

43+
/**
44+
* What a bulk `confirmText` hole is filled from: the run's record count, and
45+
* the dialog's param values with what it needs to show them.
46+
*/
47+
export interface BulkConfirmScope {
48+
/** The number of records the run covers (the dialog's `rows.length`). */
49+
count: number;
50+
/** The def's params, in declaration order. */
51+
params?: readonly BulkActionParam[];
52+
/** The dialog's in-progress param values, by param `name`. */
53+
values?: Record<string, unknown>;
54+
/**
55+
* The display names the confirm step resolved for picker params: param name
56+
* → record id → name. Filled lazily once the step opens.
57+
*/
58+
lookupLabels?: Record<string, Record<string, string>>;
59+
}
60+
61+
/**
62+
* A param's DISPLAY value for a confirm hole, by the rule the protocol states
63+
* on `confirmText` (`packages/spec/src/ui/bulk-action.zod.ts`): the chosen
64+
* option's `label` for an option-bearing param, the picked record's display
65+
* name for a `lookup` / user param, and the entered value otherwise — returned
66+
* as the value itself, not a string, so the engine's formatters
67+
* (`{{ amount | currency:EUR }}`, `{{ due | date:long }}`) apply to it. A
68+
* multi-value param's entries are joined with ', '. An empty value is
69+
* `undefined`, so it prints empty and `{{ x | default:'—' }}` can name it.
70+
*
71+
* A picker id whose name has not been resolved (yet, or ever) shows the id,
72+
* as the confirm step's own value summary does; the sentence re-renders when
73+
* the name arrives.
74+
*/
75+
export function bulkConfirmParamDisplayValue(
76+
param: BulkActionParam,
77+
value: unknown,
78+
lookupLabels?: Record<string, Record<string, string>>,
79+
): unknown {
80+
if (value === undefined || value === null || value === '') return undefined;
81+
if (Array.isArray(value) && value.length === 0) return undefined;
82+
let labelOf: ((entry: unknown) => string) | undefined;
83+
if (isLookupishParam(param)) {
84+
const names = lookupLabels?.[param.name] ?? {};
85+
labelOf = (entry) => names[String(entry)] ?? String(entry);
86+
} else if (Array.isArray(param.options) && param.options.length > 0) {
87+
const options = param.options;
88+
labelOf = (entry) => {
89+
const option = options.find((o) => String(o.value) === String(entry));
90+
return option ? String(option.label) : String(entry);
91+
};
92+
}
93+
if (Array.isArray(value)) return value.map((entry) => (labelOf ? labelOf(entry) : String(entry))).join(', ');
94+
return labelOf ? labelOf(value) : value;
95+
}
96+
97+
/**
98+
* The roots a bulk `confirmText` is filled over: the protocol's closed scope
99+
* (`bulkConfirmTemplateRoots`), `count` bound to the run's record count and
100+
* each param bound to its display value. `count` is written last, so it wins
101+
* over a param of that name, as the protocol declares.
102+
*/
103+
export function bulkConfirmTemplateScope(scope: BulkConfirmScope): Record<string, unknown> {
104+
const params = scope.params ?? [];
105+
const roots: Record<string, unknown> = {};
106+
for (const root of bulkConfirmTemplateRoots({ params })) {
107+
if (root === BULK_CONFIRM_COUNT_ROOT) continue;
108+
const param = params.find((p) => p.name === root);
109+
if (param) roots[root] = bulkConfirmParamDisplayValue(param, scope.values?.[root], scope.lookupLabels);
110+
}
111+
roots[BULK_CONFIRM_COUNT_ROOT] = scope.count;
112+
return roots;
113+
}
114+
42115
/**
43116
* The confirm sentence a bulk action's `confirmText` shows, its `{{ }}` holes
44117
* filled over the bulk-confirm scope the protocol declares
45118
* (`bulkConfirmTemplateRoots`, ruling B on objectstack#24798): `{{ count }}` is
46-
* the number of records the run covers. Returns `undefined` exactly when
119+
* the number of records the run covers, and `{{ <param name> }}` that param's
120+
* display value ({@link bulkConfirmParamDisplayValue}). Returns `undefined` exactly when
47121
* {@link bulkConfirmTextSource} does, so the caller keeps its default
48122
* sentence.
49123
*
50124
* Filled through core's `fillTemplateHoles`, the template engine the flow
51125
* notifications and the single-record `confirmText` render with, so the
52126
* engine's whitelisted formatters apply (`{{ count | number }}`) and no hole
53127
* holds logic. A sentence without a `{{ }}` hole is shown as written.
54-
*
55-
* ⚠️ Step 1 of objectstack#25942: only `count` is bound. A hole naming one of
56-
* the def's params resolves to nothing and prints empty, as an unbound path
57-
* does in every template, until the param roots are filled with their display
58-
* values.
59128
*/
60129
export function bulkConfirmText(
61130
confirmText: BulkActionDef['confirmText'] | null | undefined,
62-
scope: { count: number },
131+
scope: BulkConfirmScope,
63132
): string | undefined {
64133
const source = bulkConfirmTextSource(confirmText);
65134
if (source === undefined || !hasTemplateHoles(source)) return source;
66-
return fillTemplateHoles(source, { [BULK_CONFIRM_COUNT_ROOT]: scope.count });
135+
return fillTemplateHoles(source, bulkConfirmTemplateScope(scope));
67136
}

0 commit comments

Comments
 (0)