Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/13183-bulk-confirm-text-envelope.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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(
<BulkActionDialog
def={def}
rows={ROWS}
resource="account"
dataSource={{ update: vi.fn(async () => ({})), 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();
});
});
3 changes: 2 additions & 1 deletion packages/plugin-grid/src/components/BulkActionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -409,7 +410,7 @@ export const BulkActionDialog: React.FC<BulkActionDialogProps> = ({
</DialogTitle>
{step === 'confirm' && (
<DialogDescription>
{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).` })}
</DialogDescription>
)}
</DialogHeader>
Expand Down
38 changes: 38 additions & 0 deletions packages/plugin-grid/src/components/bulkConfirmText.ts
Original file line number Diff line number Diff line change
@@ -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;
}
15 changes: 13 additions & 2 deletions packages/types/src/objectql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
4 changes: 3 additions & 1 deletion packages/types/src/zod/objectql.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading