From 812c71ecdd88580a94c41083f2393a7694d0943f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:45:12 +0000 Subject: [PATCH 01/10] fix(plugin-grid): fill a bulk confirmText's {{ count }} hole with the run's record count The bulk dialog printed an authored confirmText as written, so a template slot's {{ count }} reached the person as braces. bulkConfirmText now fills the slot over the protocol's bulk-confirm count root through core's fillTemplateHoles (the shared template engine, whitelisted formatters, no logic), keeping a hole-less sentence, the default sentence and a refused source as they were. Param roots stay unbound at this step. Claude-Session: https://claude.ai/code/session_016zsTRTJ3SP3dpjEkyLrzWS Co-authored-by: Claude --- .changeset/25942-bulk-confirm-holes.md | 14 +++ .../__tests__/bulkConfirmCount-25942.test.tsx | 117 ++++++++++++++++++ .../bulkConfirmTextEnvelope-13183.test.tsx | 5 +- .../src/components/BulkActionDialog.tsx | 4 +- .../src/components/bulkConfirmText.ts | 31 ++++- 5 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 .changeset/25942-bulk-confirm-holes.md create mode 100644 ui/packages/plugin-grid/src/__tests__/bulkConfirmCount-25942.test.tsx diff --git a/.changeset/25942-bulk-confirm-holes.md b/.changeset/25942-bulk-confirm-holes.md new file mode 100644 index 00000000000..def8e9b1d53 --- /dev/null +++ b/.changeset/25942-bulk-confirm-holes.md @@ -0,0 +1,14 @@ +--- +'@object-ui/plugin-grid': patch +--- + +fix(plugin-grid): a bulk action's `confirmText` fills its `{{ count }}` hole with the number of records the run covers + +Clause-②: no +Metadata-protocol: none + +A bulk action's `confirmText` is a `template` slot, and its declared scope names `count`, the number of records the run will cover. The bulk dialog printed the sentence as written, so a person confirming `Archive {{ count }} account(s)?` read the braces instead of the number. The confirm step now fills `{{ count }}` through the same template engine the flow notifications and the single-record `confirmText` render with, so the whitelisted formatters apply (`{{ count | number }}`) and a hole holds no logic. A sentence without a `{{ }}` hole is shown as written, the default sentence is unchanged, and a source the engine refuses is shown as written. + +A hole naming one of the action's params prints empty for now; filling the params with their display values is the next change. + +What to do: nothing. diff --git a/ui/packages/plugin-grid/src/__tests__/bulkConfirmCount-25942.test.tsx b/ui/packages/plugin-grid/src/__tests__/bulkConfirmCount-25942.test.tsx new file mode 100644 index 00000000000..c96408e5664 --- /dev/null +++ b/ui/packages/plugin-grid/src/__tests__/bulkConfirmCount-25942.test.tsx @@ -0,0 +1,117 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#25942 step 1 — a bulk action's `confirmText` is a `template` + * slot, and its `{{ count }}` hole is filled with the number of records the run + * covers, through the shared template engine (whitelisted formatters, no + * logic). A sentence with no hole is shown as written, and the default + * sentence is untouched. + * + * Two halves: + * - the helper `bulkConfirmText`: the count root, a formatter, the two slot + * shapes, a sentence without holes, blank and absent values, a source the + * engine refuses; + * - the real `BulkActionDialog`: the confirm sentence shows the count of the + * rows it was handed, for a string and an envelope alike. + */ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import React from 'react'; +import { render, screen, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import type { BulkActionDef } from '@object-ui/types'; +import { BULK_CONFIRM_COUNT_ROOT } from '@objectstack/spec/ui'; + +import { BulkActionDialog } from '../components/BulkActionDialog'; +import { bulkConfirmText } from '../components/bulkConfirmText'; + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +describe('bulkConfirmText fills {{ count }} (objectstack#25942)', () => { + it('binds the root the protocol names for the count', () => { + expect(BULK_CONFIRM_COUNT_ROOT).toBe('count'); + expect(bulkConfirmText('Archive {{count}} account(s)?', { count: 3 })).toBe('Archive 3 account(s)?'); + }); + + it('fills the hole in a bare string and in an envelope alike', () => { + expect(bulkConfirmText('Close {{ count }} deal(s)?', { count: 7 })).toBe('Close 7 deal(s)?'); + expect( + bulkConfirmText({ dialect: 'template', source: 'Close {{ count }} deal(s)?' }, { count: 7 }), + ).toBe('Close 7 deal(s)?'); + }); + + it("applies the engine's whitelisted formatters", () => { + expect(bulkConfirmText('Update {{ count | number }} records?', { count: 1234 })).toBe('Update 1,234 records?'); + }); + + it('shows a sentence without holes as written, a `${}` token included', () => { + expect(bulkConfirmText('Archive these accounts?', { count: 2 })).toBe('Archive these accounts?'); + expect(bulkConfirmText('Pay ${count} now?', { count: 2 })).toBe('Pay ${count} now?'); + }); + + it('returns undefined for a blank or absent slot, so the default sentence shows', () => { + expect(bulkConfirmText(' ', { count: 2 })).toBeUndefined(); + expect(bulkConfirmText({ dialect: 'template' }, { count: 2 })).toBeUndefined(); + expect(bulkConfirmText(undefined, { count: 2 })).toBeUndefined(); + expect(bulkConfirmText(null, { count: 2 })).toBeUndefined(); + }); + + it('returns a source the engine refuses as written', () => { + expect(bulkConfirmText('Archive {{ count }} of {{ count', { count: 2 })).toBe('Archive {{ count }} of {{ count'); + expect(bulkConfirmText('Archive {{ count + 1 }}?', { count: 2 })).toBe('Archive {{ count + 1 }}?'); + }); +}); + +const ROWS = [ + { id: 'r1', name: 'Acme' }, + { id: 'r2', name: 'Globex' }, + { id: 'r3', name: 'Initech' }, +]; + +function openConfirm(confirmText: BulkActionDef['confirmText'], rows = ROWS) { + cleanup(); + const def: BulkActionDef = { + name: 'archive', + label: 'Archive', + operation: 'update', + patch: { archived: true }, + ...(confirmText !== undefined && { confirmText }), + }; + render( + ({})), delete: vi.fn(async () => ({})) } as any} + open + onClose={() => {}} + />, + ); +} + +describe('BulkActionDialog fills {{ count }} in its confirm sentence (objectstack#25942)', () => { + it('shows the count of the rows the run covers', async () => { + openConfirm('Archive {{ count }} account(s)?'); + expect(await screen.findByText('Archive 3 account(s)?')).toBeInTheDocument(); + expect(screen.queryByText(/\{\{/)).toBeNull(); + }); + + it('counts the rows it was handed, not a fixed number', async () => { + openConfirm({ dialect: 'template', source: 'Archive {{ count }} account(s)?' }, ROWS.slice(0, 1)); + expect(await screen.findByText('Archive 1 account(s)?')).toBeInTheDocument(); + }); + + it('keeps the default sentence when no confirmText is authored', async () => { + openConfirm(undefined); + expect(await screen.findByText(/This will apply to 3 record/)).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/plugin-grid/src/__tests__/bulkConfirmTextEnvelope-13183.test.tsx b/ui/packages/plugin-grid/src/__tests__/bulkConfirmTextEnvelope-13183.test.tsx index 8cb5702e2ac..db6897f3116 100644 --- a/ui/packages/plugin-grid/src/__tests__/bulkConfirmTextEnvelope-13183.test.tsx +++ b/ui/packages/plugin-grid/src/__tests__/bulkConfirmTextEnvelope-13183.test.tsx @@ -17,7 +17,8 @@ * - the real `BulkActionDialog`: an envelope's `source` is the confirm * sentence (never `[object Object]`), a plain string renders unchanged, and * a blank or absent value keeps the default sentence. - * Holes are not filled at this step: `{{ count }}` stays as written. + * The reader leaves holes as written; the dialog fills `{{ count }}` since + * objectstack#25942 (its own pins: `bulkConfirmCount-25942.test.tsx`). */ import { describe, it, expect, vi, beforeAll } from 'vitest'; import React from 'react'; @@ -99,7 +100,7 @@ function openConfirm(confirmText: BulkActionDef['confirmText']) { describe('BulkActionDialog renders confirmText through the reader (objectui#13183)', () => { it("renders an envelope's source, never [object Object]", async () => { openConfirm({ dialect: 'template', source: 'Archive {{ count }} account(s)?' }); - expect(await screen.findByText('Archive {{ count }} account(s)?')).toBeInTheDocument(); + expect(await screen.findByText('Archive 2 account(s)?')).toBeInTheDocument(); expect(screen.queryByText(/\[object Object\]/)).toBeNull(); }); diff --git a/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx b/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx index 6fc13abc330..c1b43e1f66e 100644 --- a/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx +++ b/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx @@ -39,7 +39,7 @@ import { lookupTargetObject, } from './bulkParamToField'; import { unresolvedFieldOf } from '../resolveBulkActions'; -import { bulkConfirmTextSource } from './bulkConfirmText'; +import { bulkConfirmText } from './bulkConfirmText'; /** * The name the confirm step lists an affected record by (objectui#13107). @@ -410,7 +410,7 @@ export const BulkActionDialog: React.FC = ({ {step === 'confirm' && ( - {bulkConfirmTextSource(def.confirmText) ?? t('grid.bulk.confirmDefault', { count: rows.length, defaultValue: `This will apply to ${rows.length} record(s).` })} + {bulkConfirmText(def.confirmText, { count: rows.length }) ?? t('grid.bulk.confirmDefault', { count: rows.length, defaultValue: `This will apply to ${rows.length} record(s).` })} )} diff --git a/ui/packages/plugin-grid/src/components/bulkConfirmText.ts b/ui/packages/plugin-grid/src/components/bulkConfirmText.ts index 1b974cb6451..8413290768d 100644 --- a/ui/packages/plugin-grid/src/components/bulkConfirmText.ts +++ b/ui/packages/plugin-grid/src/components/bulkConfirmText.ts @@ -6,7 +6,9 @@ * LICENSE file in the root directory of this source tree. */ +import { fillTemplateHoles, hasTemplateHoles } from '@object-ui/core'; import type { BulkActionDef } from '@object-ui/types'; +import { BULK_CONFIRM_COUNT_ROOT } from '@objectstack/spec/ui'; /** * The text a bulk action's `confirmText` carries: the string itself, or the @@ -23,7 +25,7 @@ import type { BulkActionDef } from '@object-ui/types'; * the spec does. * * ⚠️ The `{{ }}` holes are NOT filled here: `source` is returned as written. - * Filling `{{ count }}` and the param roots is the card's next step. + * {@link bulkConfirmText} is the reader that fills them. */ export function bulkConfirmTextSource( confirmText: BulkActionDef['confirmText'] | null | undefined, @@ -36,3 +38,30 @@ export function bulkConfirmTextSource( : undefined; return text !== undefined && text.trim() !== '' ? text : undefined; } + +/** + * The confirm sentence a bulk action's `confirmText` shows, its `{{ }}` holes + * filled over the bulk-confirm scope the protocol declares + * (`bulkConfirmTemplateRoots`, ruling B on objectstack#24798): `{{ count }}` is + * the number of records the run covers. Returns `undefined` exactly when + * {@link bulkConfirmTextSource} does, so the caller keeps its default + * sentence. + * + * Filled through core's `fillTemplateHoles`, the template engine the flow + * notifications and the single-record `confirmText` render with, so the + * engine's whitelisted formatters apply (`{{ count | number }}`) and no hole + * holds logic. A sentence without a `{{ }}` hole is shown as written. + * + * ⚠️ Step 1 of objectstack#25942: only `count` is bound. A hole naming one of + * the def's params resolves to nothing and prints empty, as an unbound path + * does in every template, until the param roots are filled with their display + * values. + */ +export function bulkConfirmText( + confirmText: BulkActionDef['confirmText'] | null | undefined, + scope: { count: number }, +): string | undefined { + const source = bulkConfirmTextSource(confirmText); + if (source === undefined || !hasTemplateHoles(source)) return source; + return fillTemplateHoles(source, { [BULK_CONFIRM_COUNT_ROOT]: scope.count }); +} From b8e5134cbaefb4402eb4f9704182761bf0c06cd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:26:36 +0000 Subject: [PATCH 02/10] chore(sdui): re-record the manifest record for the plugin-grid source change Claude-Session: https://claude.ai/code/session_016zsTRTJ3SP3dpjEkyLrzWS Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 1555759c7e3..ee2e8adaaa6 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "ad9e0875b83f339e03c528fc3be774592615dfdc5ee5fe9becc4376ddb5f0d52" + "registrySourcesSha256": "b450a090e2654b87d0c3e3de06bb7ebe43c9a915ea5669594a7949d24b7aace6" } } From 7c0e357eed49e95b22ddd83e9b76033cd27b824a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:03:30 +0000 Subject: [PATCH 03/10] chore(sdui): re-record the manifest record after merging main Claude-Session: https://claude.ai/code/session_016zsTRTJ3SP3dpjEkyLrzWS Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index f65f470a705..1c48f4656a6 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "3fe916234f1b5346bff70f6f99322077630b851ebef81c9ae6ba8c4ac08e2063" + "registrySourcesSha256": "9c7909cc4135bd04fd8013a8e2ee44374afe0ae8b6568ba95d509567c8d2db38" } } From 7afd06d069642794a993b91eaad166ecc10c5a44 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 11:27:01 +0000 Subject: [PATCH 04/10] chore(sdui): re-record the manifest record after merging main Claude-Session: https://claude.ai/code/session_016zsTRTJ3SP3dpjEkyLrzWS Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 684a23dffc1..d98c61c0e7a 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "0e127be18a1104d857822dc5c71a6380e4f00b0db729cd8b8a230a11c120fe78" + "registrySourcesSha256": "33649e942cde16d7173d4127dd9599ecb928f43b5f3db0305df2b9366302f46e" } } From 2beca8d9f1c778e56c0356e47c48bfbdb6851ccc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:34:03 +0000 Subject: [PATCH 05/10] chore(sdui): re-record the manifest record after merging main Claude-Session: https://claude.ai/code/session_016zsTRTJ3SP3dpjEkyLrzWS Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index fdbe7220632..74ec22da5aa 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "7a4b546825cb499c56c72e11df824357786d9619b78c8835591fbe91a6ae6110" + "registrySourcesSha256": "b70099ed28411dfced357e0de89dc85283aef8c520d32d4bfc60eb850d44308c" } } From 5e5ba63f873ce1bbfdedea5d6a9c443ec7d79260 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 13:32:03 +0000 Subject: [PATCH 06/10] chore(sdui): re-record the manifest record after merging main Claude-Session: https://claude.ai/code/session_016zsTRTJ3SP3dpjEkyLrzWS Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 42d75f435e8..732bcaf587e 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "5fd9e0ebfb7dcc37cd51425ed367a00c968012c908c6351b3d736c99cb8b09b8" + "registrySourcesSha256": "5537650c3633fbfd53b7bb33eb7cc59a3097dd2468d9a57ab4569a58a31cc051" } } From 7a579f9fa407b87e467fe1a61528804c011a8467 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 13:57:12 +0000 Subject: [PATCH 07/10] chore(sdui): re-record plugin-grid's manifest source record after merging main Claude-Session: https://claude.ai/code/session_016zsTRTJ3SP3dpjEkyLrzWS Co-authored-by: Claude --- scripts/sdui-manifest.sources/plugin-grid.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.sources/plugin-grid.json b/scripts/sdui-manifest.sources/plugin-grid.json index 712681a1aa5..2e9f3579ad7 100644 --- a/scripts/sdui-manifest.sources/plugin-grid.json +++ b/scripts/sdui-manifest.sources/plugin-grid.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-grid","sources":38,"sha256":"035da9ddea388a34473ed81a27daa7b5d0036d5a7bafc277c5c55df6552dfdc9","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-grid","sources":38,"sha256":"c7afb4b073f949588abff953f2f9c14c6c9275b5892d98a6da6c695e4f415136","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} From aaf4a4ac9bec72b758c7c7d61a0da6db43f75082 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 15:22:12 +0000 Subject: [PATCH 08/10] fix(plugin-grid): fill a bulk confirmText's param holes with the params' display values bulkConfirmText now fills the protocol's whole bulk-confirm scope (bulkConfirmTemplateRoots): count, then each param bound to its display value: option label (multi joined with ', '), a picker's resolved record name (the id until it resolves), the entered value otherwise so the engine's formatters apply. count wins over a param of that name. Claude-Session: https://claude.ai/code/session_016zsTRTJ3SP3dpjEkyLrzWS Co-authored-by: Claude --- .changeset/25942-bulk-confirm-param-holes.md | 12 ++ .../bulkConfirmParams-25942.test.tsx | 159 ++++++++++++++++++ .../src/components/BulkActionDialog.tsx | 2 +- .../src/components/bulkConfirmText.ts | 89 ++++++++-- 4 files changed, 251 insertions(+), 11 deletions(-) create mode 100644 .changeset/25942-bulk-confirm-param-holes.md create mode 100644 ui/packages/plugin-grid/src/__tests__/bulkConfirmParams-25942.test.tsx diff --git a/.changeset/25942-bulk-confirm-param-holes.md b/.changeset/25942-bulk-confirm-param-holes.md new file mode 100644 index 00000000000..3b777835aa8 --- /dev/null +++ b/.changeset/25942-bulk-confirm-param-holes.md @@ -0,0 +1,12 @@ +--- +'@object-ui/plugin-grid': patch +--- + +fix(plugin-grid): a bulk action's `confirmText` fills each param hole with that param's display value + +Clause-②: no +Metadata-protocol: none + +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. + +What to do: nothing. diff --git a/ui/packages/plugin-grid/src/__tests__/bulkConfirmParams-25942.test.tsx b/ui/packages/plugin-grid/src/__tests__/bulkConfirmParams-25942.test.tsx new file mode 100644 index 00000000000..577199714de --- /dev/null +++ b/ui/packages/plugin-grid/src/__tests__/bulkConfirmParams-25942.test.tsx @@ -0,0 +1,159 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#25942 step 2 — a bulk `confirmText` hole naming one of the def's + * params is filled with that param's DISPLAY value, by the rule the protocol + * states on the slot: the chosen option's label (a multi-value param's labels + * joined with ', '), the picked record's display name for a lookup / user + * param, and the entered value otherwise, so the engine's formatters apply. + * `count` wins over a param of that name. + * + * Two halves: + * - the helper `bulkConfirmText` over a full scope; + * - the real `BulkActionDialog`: an option label, and a lookup name that + * arrives after the confirm step has opened. + */ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import React from 'react'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import type { BulkActionDef, BulkActionParam } from '@object-ui/types'; + +import { BulkActionDialog } from '../components/BulkActionDialog'; +import { bulkConfirmText } from '../components/bulkConfirmText'; + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +const TIER: BulkActionParam = { + name: 'tier', + label: 'Tier', + type: 'select', + options: [ + { label: 'Gold', value: 'gold' }, + { label: 'Silver', value: 'silver' }, + ], +}; +const TAGS: BulkActionParam = { + name: 'tags', + label: 'Tags', + type: 'multiselect', + options: [ + { label: 'Alpha', value: 'a' }, + { label: 'Beta', value: 'b' }, + ], +}; +const OWNER: BulkActionParam = { name: 'owner', label: 'Owner', type: 'lookup', object: 'sys_user' }; +const AMOUNT: BulkActionParam = { name: 'amount', label: 'Amount', type: 'number' }; +const DUE: BulkActionParam = { name: 'due', label: 'Due', type: 'date' }; +const NOTE: BulkActionParam = { name: 'note', label: 'Note', type: 'text' }; + +function fill(source: string, values: Record, extra: { count?: number; params?: BulkActionParam[]; lookupLabels?: Record> } = {}) { + return bulkConfirmText(source, { + count: extra.count ?? 2, + params: extra.params ?? [TIER, TAGS, OWNER, AMOUNT, DUE, NOTE], + values, + lookupLabels: extra.lookupLabels, + }); +} + +describe('bulkConfirmText fills param holes with display values (objectstack#25942)', () => { + it("shows the chosen option's label, not its value", () => { + expect(fill('Move {{ count }} account(s) to {{ tier }}?', { tier: 'gold' })).toBe('Move 2 account(s) to Gold?'); + }); + + it("joins a multi-value param's labels with ', '", () => { + expect(fill('Tag them {{ tags }}?', { tags: ['a', 'b'] })).toBe('Tag them Alpha, Beta?'); + }); + + it('shows a value no option declares as entered', () => { + expect(fill('Move to {{ tier }}?', { tier: 'bronze' })).toBe('Move to bronze?'); + }); + + it("shows a lookup's resolved record name, and the id until it is resolved", () => { + expect(fill('Assign to {{ owner }}?', { owner: 'u1' }, { lookupLabels: { owner: { u1: 'Ada Lovelace' } } })) + .toBe('Assign to Ada Lovelace?'); + expect(fill('Assign to {{ owner }}?', { owner: 'u1' })).toBe('Assign to u1?'); + }); + + it('hands the entered value to the formatters', () => { + expect(fill('Charge {{ amount | currency:EUR }} each?', { amount: 1234.5 })).toBe('Charge €1,234.50 each?'); + expect(fill('Charge {{ amount | number:2 }} each?', { amount: 3 })).toBe('Charge 3.00 each?'); + expect(fill('Due {{ due | date:iso }}?', { due: '2026-10-26' })).toBe('Due 2026-10-26?'); + }); + + it('prints an empty param empty, so the default formatter can name it', () => { + expect(fill('Note: {{ note }}.', {})).toBe('Note: .'); + expect(fill("Note: {{ note | default:'none' }}.", { note: '' })).toBe('Note: none.'); + expect(fill("Tags: {{ tags | default:'none' }}.", { tags: [] })).toBe('Tags: none.'); + }); + + it('lets count win over a param named count', () => { + const countParam: BulkActionParam = { name: 'count', label: 'Count', type: 'number' }; + expect(fill('Update {{ count }} record(s)?', { count: 99 }, { count: 3, params: [countParam] })) + .toBe('Update 3 record(s)?'); + }); + + it('prints a hole outside the closed scope empty', () => { + expect(fill('Update {{ record.name }} to {{ tier }}?', { tier: 'silver' })).toBe('Update to Silver?'); + }); +}); + +const ROWS = [{ id: 'r1', name: 'Acme' }, { id: 'r2', name: 'Globex' }]; + +function openDialog(def: BulkActionDef, dataSource: Record = {}) { + cleanup(); + render( + ({})), delete: vi.fn(async () => ({})), ...dataSource } as any} + open + onClose={() => {}} + />, + ); +} + +describe('BulkActionDialog fills param holes in its confirm sentence (objectstack#25942)', () => { + it('shows the option label the params step chose', async () => { + openDialog({ + name: 'set_tier', + label: 'Set tier', + operation: 'update', + params: [{ ...TIER, default: 'silver' }], + confirmText: { dialect: 'template', source: 'Move {{ count }} account(s) to {{ tier }}?' }, + }); + fireEvent.click(await screen.findByRole('button', { name: 'Next' })); + expect(await screen.findByText('Move 2 account(s) to Silver?')).toBeInTheDocument(); + }); + + it('shows the lookup record name once it resolves after the step opens', async () => { + let resolveName: (rec: Record) => void = () => {}; + const findOne = vi.fn(() => new Promise>((r) => { resolveName = r; })); + openDialog( + { + name: 'assign', + label: 'Assign', + operation: 'update', + params: [{ ...OWNER, default: 'u1' }], + confirmText: 'Assign {{ count }} account(s) to {{ owner }}?', + }, + { findOne }, + ); + fireEvent.click(await screen.findByRole('button', { name: 'Next' })); + expect(await screen.findByText('Assign 2 account(s) to u1?')).toBeInTheDocument(); + expect(findOne).toHaveBeenCalledWith('sys_user', 'u1'); + resolveName({ id: 'u1', name: 'Ada Lovelace' }); + expect(await screen.findByText('Assign 2 account(s) to Ada Lovelace?')).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx b/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx index c1b43e1f66e..76a683a69c7 100644 --- a/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx +++ b/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx @@ -410,7 +410,7 @@ export const BulkActionDialog: React.FC = ({ {step === 'confirm' && ( - {bulkConfirmText(def.confirmText, { count: rows.length }) ?? t('grid.bulk.confirmDefault', { count: rows.length, defaultValue: `This will apply to ${rows.length} record(s).` })} + {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).` })} )} diff --git a/ui/packages/plugin-grid/src/components/bulkConfirmText.ts b/ui/packages/plugin-grid/src/components/bulkConfirmText.ts index 8413290768d..393b2f5ce8b 100644 --- a/ui/packages/plugin-grid/src/components/bulkConfirmText.ts +++ b/ui/packages/plugin-grid/src/components/bulkConfirmText.ts @@ -7,8 +7,9 @@ */ import { fillTemplateHoles, hasTemplateHoles } from '@object-ui/core'; -import type { BulkActionDef } from '@object-ui/types'; -import { BULK_CONFIRM_COUNT_ROOT } from '@objectstack/spec/ui'; +import type { BulkActionDef, BulkActionParam } from '@object-ui/types'; +import { BULK_CONFIRM_COUNT_ROOT, bulkConfirmTemplateRoots } from '@objectstack/spec/ui'; +import { isLookupishParam } from './bulkParamToField'; /** * The text a bulk action's `confirmText` carries: the string itself, or the @@ -39,11 +40,84 @@ export function bulkConfirmTextSource( return text !== undefined && text.trim() !== '' ? text : undefined; } +/** + * What a bulk `confirmText` hole is filled from: the run's record count, and + * the dialog's param values with what it needs to show them. + */ +export interface BulkConfirmScope { + /** The number of records the run covers (the dialog's `rows.length`). */ + count: number; + /** The def's params, in declaration order. */ + params?: readonly BulkActionParam[]; + /** The dialog's in-progress param values, by param `name`. */ + values?: Record; + /** + * The display names the confirm step resolved for picker params: param name + * → record id → name. Filled lazily once the step opens. + */ + lookupLabels?: Record>; +} + +/** + * A param's DISPLAY value for a confirm hole, by the rule the protocol states + * on `confirmText` (`packages/spec/src/ui/bulk-action.zod.ts`): the chosen + * option's `label` for an option-bearing param, the picked record's display + * name for a `lookup` / user param, and the entered value otherwise — returned + * as the value itself, not a string, so the engine's formatters + * (`{{ amount | currency:EUR }}`, `{{ due | date:long }}`) apply to it. A + * multi-value param's entries are joined with ', '. An empty value is + * `undefined`, so it prints empty and `{{ x | default:'—' }}` can name it. + * + * A picker id whose name has not been resolved (yet, or ever) shows the id, + * as the confirm step's own value summary does; the sentence re-renders when + * the name arrives. + */ +export function bulkConfirmParamDisplayValue( + param: BulkActionParam, + value: unknown, + lookupLabels?: Record>, +): unknown { + if (value === undefined || value === null || value === '') return undefined; + if (Array.isArray(value) && value.length === 0) return undefined; + let labelOf: ((entry: unknown) => string) | undefined; + if (isLookupishParam(param)) { + const names = lookupLabels?.[param.name] ?? {}; + labelOf = (entry) => names[String(entry)] ?? String(entry); + } else if (Array.isArray(param.options) && param.options.length > 0) { + const options = param.options; + labelOf = (entry) => { + const option = options.find((o) => String(o.value) === String(entry)); + return option ? String(option.label) : String(entry); + }; + } + if (Array.isArray(value)) return value.map((entry) => (labelOf ? labelOf(entry) : String(entry))).join(', '); + return labelOf ? labelOf(value) : value; +} + +/** + * The roots a bulk `confirmText` is filled over: the protocol's closed scope + * (`bulkConfirmTemplateRoots`), `count` bound to the run's record count and + * each param bound to its display value. `count` is written last, so it wins + * over a param of that name, as the protocol declares. + */ +export function bulkConfirmTemplateScope(scope: BulkConfirmScope): Record { + const params = scope.params ?? []; + const roots: Record = {}; + for (const root of bulkConfirmTemplateRoots({ params })) { + if (root === BULK_CONFIRM_COUNT_ROOT) continue; + const param = params.find((p) => p.name === root); + if (param) roots[root] = bulkConfirmParamDisplayValue(param, scope.values?.[root], scope.lookupLabels); + } + roots[BULK_CONFIRM_COUNT_ROOT] = scope.count; + return roots; +} + /** * The confirm sentence a bulk action's `confirmText` shows, its `{{ }}` holes * filled over the bulk-confirm scope the protocol declares * (`bulkConfirmTemplateRoots`, ruling B on objectstack#24798): `{{ count }}` is - * the number of records the run covers. Returns `undefined` exactly when + * the number of records the run covers, and `{{ }}` that param's + * display value ({@link bulkConfirmParamDisplayValue}). Returns `undefined` exactly when * {@link bulkConfirmTextSource} does, so the caller keeps its default * sentence. * @@ -51,17 +125,12 @@ export function bulkConfirmTextSource( * notifications and the single-record `confirmText` render with, so the * engine's whitelisted formatters apply (`{{ count | number }}`) and no hole * holds logic. A sentence without a `{{ }}` hole is shown as written. - * - * ⚠️ Step 1 of objectstack#25942: only `count` is bound. A hole naming one of - * the def's params resolves to nothing and prints empty, as an unbound path - * does in every template, until the param roots are filled with their display - * values. */ export function bulkConfirmText( confirmText: BulkActionDef['confirmText'] | null | undefined, - scope: { count: number }, + scope: BulkConfirmScope, ): string | undefined { const source = bulkConfirmTextSource(confirmText); if (source === undefined || !hasTemplateHoles(source)) return source; - return fillTemplateHoles(source, { [BULK_CONFIRM_COUNT_ROOT]: scope.count }); + return fillTemplateHoles(source, bulkConfirmTemplateScope(scope)); } From 4a874ae141cca343188037df2d475eb6c9b64c26 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 15:37:26 +0000 Subject: [PATCH 09/10] chore(sdui): re-record plugin-grid's manifest source record Claude-Session: https://claude.ai/code/session_016zsTRTJ3SP3dpjEkyLrzWS Co-authored-by: Claude --- scripts/sdui-manifest.sources/plugin-grid.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.sources/plugin-grid.json b/scripts/sdui-manifest.sources/plugin-grid.json index 2e9f3579ad7..2fc0a70a8a4 100644 --- a/scripts/sdui-manifest.sources/plugin-grid.json +++ b/scripts/sdui-manifest.sources/plugin-grid.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-grid","sources":38,"sha256":"c7afb4b073f949588abff953f2f9c14c6c9275b5892d98a6da6c695e4f415136","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-grid","sources":38,"sha256":"de67aac8ff7f1944a02219eac87106df666e00764162f6916a4e1a8ca264d9d9","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} From 019fe1a8840e3f2f576f9a626ad66dc5c9830dca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 18:24:49 +0000 Subject: [PATCH 10/10] chore(sdui): re-record plugin-grid's manifest source record after merging main Claude-Session: https://claude.ai/code/session_016zsTRTJ3SP3dpjEkyLrzWS Co-authored-by: Claude --- scripts/sdui-manifest.sources/plugin-grid.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.sources/plugin-grid.json b/scripts/sdui-manifest.sources/plugin-grid.json index 086cb5905d6..8e53b237242 100644 --- a/scripts/sdui-manifest.sources/plugin-grid.json +++ b/scripts/sdui-manifest.sources/plugin-grid.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-grid","sources":38,"sha256":"d34e7acc9010e7fc875131b416c4a83a2bac115c893c2067d3ccb85fcaf8bcd0","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-grid","sources":38,"sha256":"f6f7b1cbd89a1536a6e33febf4db131b8d526bd7281be460c401dccb8e97876a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}