Skip to content

Commit 6676991

Browse files
fix(plugin-grid): fill a bulk confirmText's {{ count }} hole with the run's record count (#26030)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi91vuMhRJwN3WrWzqrtvosbw)_ Part of #25942 Clause-②: no Metadata-protocol: none ## What Step 1 of ruling B on #24798 (the renderer half): a bulk action's `confirmText` fills its `{{ count }}` hole with the number of records the run covers. - `ui/packages/plugin-grid/src/components/bulkConfirmText.ts` gains `bulkConfirmText(confirmText, { count })`. It reads the slot through the existing `bulkConfirmTextSource` (string or `{ dialect: 'template', source }`), and when the sentence carries a `{{ }}` hole it fills it through `@object-ui/core`'s `fillTemplateHoles`, the template engine the flow notifications and the single-record `confirmText` already render with. The root is the protocol's own constant, `BULK_CONFIRM_COUNT_ROOT` from `@objectstack/spec/ui`, bound to `rows.length` (the selection after the def's `visible` narrowed it, the same number the default sentence states). - `BulkActionDialog.tsx` renders the confirm description through it. The default sentence, the place of the confirm step and everything else in the dialog are untouched. - `bulkConfirmTextEnvelope-13183.test.tsx`: the dialog test that pinned an envelope's `{{ count }}` printed as written now expects it filled (`Archive 2 account(s)?`); the reader tests there are unchanged, since the reader still returns the source as written. - `scripts/sdui-manifest.record.json` re-recorded (`node scripts/gen-sdui-manifest-node.mjs`), because the registry source hash moved. - Changeset: `.changeset/25942-bulk-confirm-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." A hole holding logic, an unbalanced delimiter or an unknown formatter is refused by the engine, and the dialog then shows the sentence as written (the server-side doors refuse such a sentence by name already). ## What stays for step 2 (the `Fixes #25942` PR) The param roots. At this step only `count` is bound, so a hole naming one of the def's params prints empty, as an unbound path does in every template. Step 2 binds each param to its display value under the spec's rule (option label, lookup/user display name, the raw value otherwise so formatters apply), and #25942 remains open for it. ## Verification (head `b8e5134c`) - Targeted: `cd ui && pnpm exec vitest run --maxWorkers=2 packages/plugin-grid/src/__tests__/bulkConfirmCount-25942.test.tsx packages/plugin-grid/src/__tests__/bulkConfirmTextEnvelope-13183.test.tsx` → Test Files 2 passed, Tests 20 passed. - New file `bulkConfirmCount-25942.test.tsx`, 9 tests: the protocol root name; a bare string and an envelope; `{{ count | number }}` → `1,234`; a hole-less sentence and a dollar-brace token shown as written; blank, source-less and absent slots return `undefined`; an unbalanced and a logic-bearing source returned as written; the dialog shows `Archive 3 account(s)?` / `Archive 1 account(s)?` for the rows it was handed and keeps the default sentence when no `confirmText` is authored. - Ablation (after commit `812c71ec`): the render site set back to `bulkConfirmTextSource(def.confirmText)` through `scripts/ablation-replace.mjs` (anchor 1 → 0, blob `c1b43e1f66ea` → `2e7497c942ea`) → `Tests 3 failed | 17 passed` (the three dialog fill tests); restored to blob `c1b43e1f66ea`, `git status --porcelain` empty. No build leg: the tests import the dialog by relative path. - Whole package: `cd ui && pnpm exec vitest run --maxWorkers=2 packages/plugin-grid/` under the lock → Test Files 248 passed (248), Tests 2213 passed (2213), VERDICT command-exit 0. - `pnpm --filter @object-ui/plugin-grid run type-check` (both `tsc --noEmit` and `tsconfig.test.json`) → exit 0, after `pnpm --filter '@object-ui/plugin-grid^...' run build`. - `node scripts/check-sdui-manifest.mjs` → exit 0 after the re-record. - Derived gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `b8e5134c` → 46 commands, all exit 0; `--ran` with exit codes → 46 derived, 46 run, 0 NOT-MEASURED, 0 UNRUN. CI owns the type-check lanes, `Test Core` and the repo-wide scans. ## Acceptance notes - The `number` / `currency` / `date` formatters render in the engine's default locale (`en-US`), as the single-record `confirmText` fill does today; `fillTemplateHoles` takes no locale. Step 2 looks at this together with the param roots, since the engine reads its formatter locale from the same scope the roots are passed in. - `ObjectGrid.tsx`, `ui/.github/**` and the root `ci.yml` are not touched. --- _Generated by [Claude Code](https://claude.ai/code/session_016zsTRTJ3SP3dpjEkyLrzWS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dbfb52a commit 6676991

6 files changed

Lines changed: 167 additions & 6 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/plugin-grid': patch
3+
---
4+
5+
fix(plugin-grid): a bulk action's `confirmText` fills its `{{ count }}` hole with the number of records the run covers
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
A hole naming one of the action's params prints empty for now; filling the params with their display values is the next change.
13+
14+
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":"035da9ddea388a34473ed81a27daa7b5d0036d5a7bafc277c5c55df6552dfdc9","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/plugin-grid","sources":38,"sha256":"c7afb4b073f949588abff953f2f9c14c6c9275b5892d98a6da6c695e4f415136","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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 1 — a bulk action's `confirmText` is a `template`
11+
* slot, and its `{{ count }}` hole is filled with the number of records the run
12+
* covers, through the shared template engine (whitelisted formatters, no
13+
* logic). A sentence with no hole is shown as written, and the default
14+
* sentence is untouched.
15+
*
16+
* Two halves:
17+
* - the helper `bulkConfirmText`: the count root, a formatter, the two slot
18+
* shapes, a sentence without holes, blank and absent values, a source the
19+
* engine refuses;
20+
* - the real `BulkActionDialog`: the confirm sentence shows the count of the
21+
* rows it was handed, for a string and an envelope alike.
22+
*/
23+
import { describe, it, expect, vi, beforeAll } from 'vitest';
24+
import React from 'react';
25+
import { render, screen, cleanup } from '@testing-library/react';
26+
import '@testing-library/jest-dom';
27+
import type { BulkActionDef } from '@object-ui/types';
28+
import { BULK_CONFIRM_COUNT_ROOT } from '@objectstack/spec/ui';
29+
30+
import { BulkActionDialog } from '../components/BulkActionDialog';
31+
import { bulkConfirmText } from '../components/bulkConfirmText';
32+
33+
beforeAll(() => {
34+
if (!Element.prototype.scrollIntoView) {
35+
Element.prototype.scrollIntoView = vi.fn() as any;
36+
}
37+
});
38+
39+
describe('bulkConfirmText fills {{ count }} (objectstack#25942)', () => {
40+
it('binds the root the protocol names for the count', () => {
41+
expect(BULK_CONFIRM_COUNT_ROOT).toBe('count');
42+
expect(bulkConfirmText('Archive {{count}} account(s)?', { count: 3 })).toBe('Archive 3 account(s)?');
43+
});
44+
45+
it('fills the hole in a bare string and in an envelope alike', () => {
46+
expect(bulkConfirmText('Close {{ count }} deal(s)?', { count: 7 })).toBe('Close 7 deal(s)?');
47+
expect(
48+
bulkConfirmText({ dialect: 'template', source: 'Close {{ count }} deal(s)?' }, { count: 7 }),
49+
).toBe('Close 7 deal(s)?');
50+
});
51+
52+
it("applies the engine's whitelisted formatters", () => {
53+
expect(bulkConfirmText('Update {{ count | number }} records?', { count: 1234 })).toBe('Update 1,234 records?');
54+
});
55+
56+
it('shows a sentence without holes as written, a `${}` token included', () => {
57+
expect(bulkConfirmText('Archive these accounts?', { count: 2 })).toBe('Archive these accounts?');
58+
expect(bulkConfirmText('Pay ${count} now?', { count: 2 })).toBe('Pay ${count} now?');
59+
});
60+
61+
it('returns undefined for a blank or absent slot, so the default sentence shows', () => {
62+
expect(bulkConfirmText(' ', { count: 2 })).toBeUndefined();
63+
expect(bulkConfirmText({ dialect: 'template' }, { count: 2 })).toBeUndefined();
64+
expect(bulkConfirmText(undefined, { count: 2 })).toBeUndefined();
65+
expect(bulkConfirmText(null, { count: 2 })).toBeUndefined();
66+
});
67+
68+
it('returns a source the engine refuses as written', () => {
69+
expect(bulkConfirmText('Archive {{ count }} of {{ count', { count: 2 })).toBe('Archive {{ count }} of {{ count');
70+
expect(bulkConfirmText('Archive {{ count + 1 }}?', { count: 2 })).toBe('Archive {{ count + 1 }}?');
71+
});
72+
});
73+
74+
const ROWS = [
75+
{ id: 'r1', name: 'Acme' },
76+
{ id: 'r2', name: 'Globex' },
77+
{ id: 'r3', name: 'Initech' },
78+
];
79+
80+
function openConfirm(confirmText: BulkActionDef['confirmText'], rows = ROWS) {
81+
cleanup();
82+
const def: BulkActionDef = {
83+
name: 'archive',
84+
label: 'Archive',
85+
operation: 'update',
86+
patch: { archived: true },
87+
...(confirmText !== undefined && { confirmText }),
88+
};
89+
render(
90+
<BulkActionDialog
91+
def={def}
92+
rows={rows}
93+
resource="account"
94+
dataSource={{ update: vi.fn(async () => ({})), delete: vi.fn(async () => ({})) } as any}
95+
open
96+
onClose={() => {}}
97+
/>,
98+
);
99+
}
100+
101+
describe('BulkActionDialog fills {{ count }} in its confirm sentence (objectstack#25942)', () => {
102+
it('shows the count of the rows the run covers', async () => {
103+
openConfirm('Archive {{ count }} account(s)?');
104+
expect(await screen.findByText('Archive 3 account(s)?')).toBeInTheDocument();
105+
expect(screen.queryByText(/\{\{/)).toBeNull();
106+
});
107+
108+
it('counts the rows it was handed, not a fixed number', async () => {
109+
openConfirm({ dialect: 'template', source: 'Archive {{ count }} account(s)?' }, ROWS.slice(0, 1));
110+
expect(await screen.findByText('Archive 1 account(s)?')).toBeInTheDocument();
111+
});
112+
113+
it('keeps the default sentence when no confirmText is authored', async () => {
114+
openConfirm(undefined);
115+
expect(await screen.findByText(/This will apply to 3 record/)).toBeInTheDocument();
116+
});
117+
});

‎ui/packages/plugin-grid/src/__tests__/bulkConfirmTextEnvelope-13183.test.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
* - the real `BulkActionDialog`: an envelope's `source` is the confirm
1818
* sentence (never `[object Object]`), a plain string renders unchanged, and
1919
* a blank or absent value keeps the default sentence.
20-
* Holes are not filled at this step: `{{ count }}` stays as written.
20+
* The reader leaves holes as written; the dialog fills `{{ count }}` since
21+
* objectstack#25942 (its own pins: `bulkConfirmCount-25942.test.tsx`).
2122
*/
2223
import { describe, it, expect, vi, beforeAll } from 'vitest';
2324
import React from 'react';
@@ -99,7 +100,7 @@ function openConfirm(confirmText: BulkActionDef['confirmText']) {
99100
describe('BulkActionDialog renders confirmText through the reader (objectui#13183)', () => {
100101
it("renders an envelope's source, never [object Object]", async () => {
101102
openConfirm({ dialect: 'template', source: 'Archive {{ count }} account(s)?' });
102-
expect(await screen.findByText('Archive {{ count }} account(s)?')).toBeInTheDocument();
103+
expect(await screen.findByText('Archive 2 account(s)?')).toBeInTheDocument();
103104
expect(screen.queryByText(/\[object Object\]/)).toBeNull();
104105
});
105106

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import {
3939
lookupTargetObject,
4040
} from './bulkParamToField';
4141
import { unresolvedFieldOf } from '../resolveBulkActions';
42-
import { bulkConfirmTextSource } from './bulkConfirmText';
42+
import { bulkConfirmText } from './bulkConfirmText';
4343

4444
/**
4545
* The name the confirm step lists an affected record by (objectui#13107).
@@ -410,7 +410,7 @@ export const BulkActionDialog: React.FC<BulkActionDialogProps> = ({
410410
</DialogTitle>
411411
{step === 'confirm' && (
412412
<DialogDescription>
413-
{bulkConfirmTextSource(def.confirmText) ?? t('grid.bulk.confirmDefault', { count: rows.length, defaultValue: `This will apply to ${rows.length} record(s).` })}
413+
{bulkConfirmText(def.confirmText, { count: rows.length }) ?? 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: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
* LICENSE file in the root directory of this source tree.
77
*/
88

9+
import { fillTemplateHoles, hasTemplateHoles } from '@object-ui/core';
910
import type { BulkActionDef } from '@object-ui/types';
11+
import { BULK_CONFIRM_COUNT_ROOT } from '@objectstack/spec/ui';
1012

1113
/**
1214
* The text a bulk action's `confirmText` carries: the string itself, or the
@@ -23,7 +25,7 @@ import type { BulkActionDef } from '@object-ui/types';
2325
* the spec does.
2426
*
2527
* ⚠️ The `{{ }}` holes are NOT filled here: `source` is returned as written.
26-
* Filling `{{ count }}` and the param roots is the card's next step.
28+
* {@link bulkConfirmText} is the reader that fills them.
2729
*/
2830
export function bulkConfirmTextSource(
2931
confirmText: BulkActionDef['confirmText'] | null | undefined,
@@ -36,3 +38,30 @@ export function bulkConfirmTextSource(
3638
: undefined;
3739
return text !== undefined && text.trim() !== '' ? text : undefined;
3840
}
41+
42+
/**
43+
* The confirm sentence a bulk action's `confirmText` shows, its `{{ }}` holes
44+
* filled over the bulk-confirm scope the protocol declares
45+
* (`bulkConfirmTemplateRoots`, ruling B on objectstack#24798): `{{ count }}` is
46+
* the number of records the run covers. Returns `undefined` exactly when
47+
* {@link bulkConfirmTextSource} does, so the caller keeps its default
48+
* sentence.
49+
*
50+
* Filled through core's `fillTemplateHoles`, the template engine the flow
51+
* notifications and the single-record `confirmText` render with, so the
52+
* engine's whitelisted formatters apply (`{{ count | number }}`) and no hole
53+
* 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.
59+
*/
60+
export function bulkConfirmText(
61+
confirmText: BulkActionDef['confirmText'] | null | undefined,
62+
scope: { count: number },
63+
): string | undefined {
64+
const source = bulkConfirmTextSource(confirmText);
65+
if (source === undefined || !hasTemplateHoles(source)) return source;
66+
return fillTemplateHoles(source, { [BULK_CONFIRM_COUNT_ROOT]: scope.count });
67+
}

0 commit comments

Comments
 (0)