From bbb06a1f1c0e4fb2fb91a0a7605302777748ed39 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 07:39:40 +0000 Subject: [PATCH 1/2] fix(plugin-grid,types): read bulk confirmText as a string or a template envelope The bulk confirm step now reads confirmText through bulkConfirmTextSource: the string itself, or a { dialect: 'template', source } envelope's source verbatim; a blank or absent value keeps the default sentence. Holes are not filled yet. Step 1 of the landing order for objectstack#24947. Refs #13183 --- .../13183-bulk-confirm-text-envelope.md | 8 ++ .../bulkConfirmTextEnvelope-13183.test.tsx | 112 ++++++++++++++++++ .../src/components/BulkActionDialog.tsx | 3 +- .../src/components/bulkConfirmText.ts | 38 ++++++ packages/types/src/objectql.ts | 15 ++- 5 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 .changeset/13183-bulk-confirm-text-envelope.md create mode 100644 packages/plugin-grid/src/__tests__/bulkConfirmTextEnvelope-13183.test.tsx create mode 100644 packages/plugin-grid/src/components/bulkConfirmText.ts diff --git a/.changeset/13183-bulk-confirm-text-envelope.md b/.changeset/13183-bulk-confirm-text-envelope.md new file mode 100644 index 0000000000..15e46fc1fe --- /dev/null +++ b/.changeset/13183-bulk-confirm-text-envelope.md @@ -0,0 +1,8 @@ +--- +'@object-ui/types': patch +'@object-ui/plugin-grid': patch +--- + +The bulk action confirm step reads `confirmText` as a string or as a `{ dialect: 'template', source }` envelope (objectui#13183). + +`@objectstack/spec` is making a bulk action's `confirmText` a `template` slot (objectstack#24947), so a parsed bulk definition serves the envelope where it served a string. The confirm step rendered `confirmText` as it came, so an envelope would have broken it. It now shows the string, or the envelope's `source` as written; a blank or absent value still shows the default sentence ("This will apply to N record(s)."). `{{ }}` holes are not filled yet: `{{ count }}` still shows as written. `BulkActionDef.confirmText` in `@object-ui/types` is typed `string | { dialect?: 'template'; source: string }`, and `@object-ui/plugin-grid` reads it through one helper, `bulkConfirmTextSource`. diff --git a/packages/plugin-grid/src/__tests__/bulkConfirmTextEnvelope-13183.test.tsx b/packages/plugin-grid/src/__tests__/bulkConfirmTextEnvelope-13183.test.tsx new file mode 100644 index 0000000000..91dcb6b4d0 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/bulkConfirmTextEnvelope-13183.test.tsx @@ -0,0 +1,112 @@ +/** + * 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. + */ + +/** + * objectui#13183 step 1 — a bulk action's `confirmText` is read as a string or + * as the `{ dialect: 'template', source }` envelope the protocol serves once + * objectstack#24947 makes the slot a `template` slot. + * + * Two halves: + * - the reader `bulkConfirmTextSource`: a string, an envelope (with and + * without `dialect`), blank and absent values; + * - 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. + */ +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 { BulkActionDialog } from '../components/BulkActionDialog'; +import { bulkConfirmTextSource } from '../components/bulkConfirmText'; + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +describe('bulkConfirmTextSource (objectui#13183)', () => { + it('returns a plain string as written', () => { + expect(bulkConfirmTextSource('Archive these accounts?')).toBe('Archive these accounts?'); + }); + + it("returns an envelope's source verbatim, holes unfilled", () => { + expect( + bulkConfirmTextSource({ dialect: 'template', source: 'Update {{ count }} record(s)?' }), + ).toBe('Update {{ count }} record(s)?'); + }); + + it('reads an envelope that omits `dialect`', () => { + expect(bulkConfirmTextSource({ source: 'Close these deals?' })).toBe('Close these deals?'); + }); + + it('returns undefined for a blank string or a blank source', () => { + expect(bulkConfirmTextSource('')).toBeUndefined(); + expect(bulkConfirmTextSource(' ')).toBeUndefined(); + expect(bulkConfirmTextSource({ dialect: 'template', source: ' ' })).toBeUndefined(); + }); + + it('returns undefined when absent', () => { + expect(bulkConfirmTextSource(undefined)).toBeUndefined(); + expect(bulkConfirmTextSource(null)).toBeUndefined(); + }); + + it('returns undefined for an envelope with no string source', () => { + expect(bulkConfirmTextSource({ dialect: 'template' } as never)).toBeUndefined(); + }); +}); + +const ROWS = [{ id: 'r1', name: 'Acme' }, { id: 'r2', name: 'Globex' }]; + +function openConfirm(confirmText: BulkActionDef['confirmText']) { + 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 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(screen.queryByText(/\[object Object\]/)).toBeNull(); + }); + + it('renders a plain string unchanged', async () => { + openConfirm('Archive these accounts?'); + expect(await screen.findByText('Archive these accounts?')).toBeInTheDocument(); + }); + + it('keeps the default sentence when the slot is absent', async () => { + openConfirm(undefined); + expect(await screen.findByText(/This will apply to 2 record/)).toBeInTheDocument(); + }); + + it('keeps the default sentence when the slot is blank', async () => { + openConfirm({ dialect: 'template', source: ' ' }); + expect(await screen.findByText(/This will apply to 2 record/)).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-grid/src/components/BulkActionDialog.tsx b/packages/plugin-grid/src/components/BulkActionDialog.tsx index 15c5e833e1..6fc13abc33 100644 --- a/packages/plugin-grid/src/components/BulkActionDialog.tsx +++ b/packages/plugin-grid/src/components/BulkActionDialog.tsx @@ -39,6 +39,7 @@ import { lookupTargetObject, } from './bulkParamToField'; import { unresolvedFieldOf } from '../resolveBulkActions'; +import { bulkConfirmTextSource } from './bulkConfirmText'; /** * The name the confirm step lists an affected record by (objectui#13107). @@ -409,7 +410,7 @@ export const BulkActionDialog: React.FC = ({ {step === 'confirm' && ( - {def.confirmText ?? t('grid.bulk.confirmDefault', { count: rows.length, defaultValue: `This will apply to ${rows.length} record(s).` })} + {bulkConfirmTextSource(def.confirmText) ?? t('grid.bulk.confirmDefault', { count: rows.length, defaultValue: `This will apply to ${rows.length} record(s).` })} )} diff --git a/packages/plugin-grid/src/components/bulkConfirmText.ts b/packages/plugin-grid/src/components/bulkConfirmText.ts new file mode 100644 index 0000000000..1b974cb645 --- /dev/null +++ b/packages/plugin-grid/src/components/bulkConfirmText.ts @@ -0,0 +1,38 @@ +/** + * 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. + */ + +import type { BulkActionDef } from '@object-ui/types'; + +/** + * The text a bulk action's `confirmText` carries: the string itself, or the + * `source` of a `{ dialect: 'template', source }` envelope, verbatim. Returns + * `undefined` when the slot is absent, blank (empty or whitespace-only), or + * carries no string `source` — the caller shows its default sentence then. + * + * Why two arms (objectui#13183, step 1 of the landing order for + * objectstack#24947): the protocol is making this slot a `template`-dialect + * slot, so a parsed bulk def serves the envelope where it served a string. + * Rendered as a React child, the envelope is an object and the confirm step + * breaks. This reader accepts both shapes, the way core's + * `formatTitleTemplate` reads `titleFormat`, so the renderer can move before + * 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. + */ +export function bulkConfirmTextSource( + confirmText: BulkActionDef['confirmText'] | null | undefined, +): string | undefined { + const text = + typeof confirmText === 'string' + ? confirmText + : confirmText !== null && typeof confirmText === 'object' && typeof confirmText.source === 'string' + ? confirmText.source + : undefined; + return text !== undefined && text.trim() !== '' ? text : undefined; +} diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 22fbb6e953..977eb78e51 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -676,8 +676,19 @@ export interface BulkActionDef { * dialog skips the params step and jumps straight to confirm. */ params?: BulkActionParam[]; - /** Confirmation text shown above the affected-record summary. */ - confirmText?: string; + /** + * Confirmation text shown above the affected-record summary. + * + * Two arms, because the protocol is moving this slot to ADR-0032's + * `template` dialect (objectstack#24798, landed by objectstack#24947): there + * a bare string is shorthand for `{ dialect: 'template', source }`, and a + * parsed definition (what `os compile` serializes) carries the envelope. The + * installed spec still types the slot as a plain string, so both arms reach + * the renderer during the move. Read it through plugin-grid's + * `bulkConfirmTextSource`, never as a React child: the envelope arm is an + * object. + */ + confirmText?: string | { dialect?: 'template'; source: string }; /** Custom Confirm button label (default: "Run"). */ confirmLabel?: string; /** From fef2d040e87deec7d013dde2bfa3c9f497cebbfd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 07:44:53 +0000 Subject: [PATCH 2/2] fix(types): mirror the two-arm bulk confirmText in the ObjectGrid zod schema The zod-mirror parity ledger compares ObjectGridBulkActionDefSchema with BulkActionDef member for member, so the mirror takes the same string or template-envelope union. --- packages/types/src/zod/objectql.zod.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 1f03721294..12c61ef310 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -584,7 +584,9 @@ const ObjectGridBulkActionDefSchema = z.strictObject({ operation: stripImportedDefaults(SpecBulkActionOperationSchema), patch: z.record(z.string(), z.unknown()).optional(), params: z.array(ObjectGridBulkActionParamSchema).optional(), - confirmText: z.string().optional(), + // The twin's two arms (objectui#13183): a string, or the `template` envelope a + // parsed def carries once the spec makes the slot a template slot (objectstack#24947). + confirmText: z.union([z.string(), z.object({ dialect: z.literal('template').optional(), source: z.string() })]).optional(), confirmLabel: z.string().optional(), visible: z.union([ExpressionWireSchema, stripImportedDefaults(SpecBulkActionDefSchema).shape.visible]).optional(), requiredPermissions: z.array(z.string()).optional(),