From f25bc2456ac1e42169ade17cde3a63dcc8136490 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:40:31 +0000 Subject: [PATCH 1/2] feat(core,react): action copy reads the template envelope and fills {{ }} holes ActionRunner reads confirmText, successMessage and errorMessage as a string or a { dialect: 'template', source } envelope, and fills {{ }} holes through @objectstack/formula's templateEngine: confirmText over record and params, successMessage over result and record, errorMessage verbatim. A sentence with no {{ }} hole keeps the ${...} reading. useActionTextLocalizer keeps the envelope instead of collapsing it to its first string value. Claude-Session: https://claude.ai/code/session_0159TkE3rFbrKwMs3mWhnniW Co-authored-by: Claude --- .changeset/13367-action-copy-template.md | 19 ++ packages/core/src/actions/ActionRunner.ts | 131 +++++++-- .../ActionRunner.copyTemplate-13367.test.ts | 265 ++++++++++++++++++ .../core/src/actions/actionCopyTemplate.ts | 83 ++++++ packages/core/src/actions/index.ts | 1 + ...tLocalizer.templateEnvelope-13367.test.tsx | 101 +++++++ .../react/src/hooks/useActionTextLocalizer.ts | 40 ++- 7 files changed, 613 insertions(+), 27 deletions(-) create mode 100644 .changeset/13367-action-copy-template.md create mode 100644 packages/core/src/actions/__tests__/ActionRunner.copyTemplate-13367.test.ts create mode 100644 packages/core/src/actions/actionCopyTemplate.ts create mode 100644 packages/react/src/hooks/__tests__/useActionTextLocalizer.templateEnvelope-13367.test.tsx diff --git a/.changeset/13367-action-copy-template.md b/.changeset/13367-action-copy-template.md new file mode 100644 index 0000000000..d40f9cde58 --- /dev/null +++ b/.changeset/13367-action-copy-template.md @@ -0,0 +1,19 @@ +--- +'@object-ui/core': minor +'@object-ui/react': patch +--- + +An action's `confirmText`, `successMessage` and `errorMessage` read the `template` envelope and fill `{{ }}` holes (objectui#13367). + +`@objectstack/spec` is making these three keys `template` slots (objectstack#24814), so a parsed action serves `{ dialect: 'template', source }` where it served a string. Before this change the success toast skipped the envelope and showed the default text, and the error toast showed the generic failure. `ActionRunner` now reads a string or the envelope for all three, and fills `{{ path }}` / `{{ path | formatter }}` holes with `@objectstack/formula`'s `templateEngine`, the engine the flow notifications render with: + +- `confirmText`: `record` (the record the action runs against, the clicked row on a list) and `params` (the action's static params); +- `successMessage`: `result` (the payload `${result.*}` reads) and `record`; +- `errorMessage`: no hole is filled; the text shows as written. + +A sentence with no `{{ }}` hole keeps today's `${…}` reading, so the platform's own `${result.email}`-style success sentences still work until the spec converts them. A sentence is read in one dialect only: a value filled into a `{{ }}` hole is never read again as `${…}`. An envelope with no text never reaches the dialog or a toast as an object; a `confirmText` envelope with no text still asks for confirmation. + +`useActionTextLocalizer` keeps the envelope: it used to read it as a per-locale map and hand the runner the word `template`. A bundle entry replaces the envelope's `source`. `@object-ui/core` exports the reader it uses: `ActionCopyTemplate`, `isActionCopyEnvelope`, `actionCopySource`, `hasTemplateHoles` and `fillTemplateHoles`. + +Clause-②: yes +Metadata-protocol: none diff --git a/packages/core/src/actions/ActionRunner.ts b/packages/core/src/actions/ActionRunner.ts index 074b624787..25569557ee 100644 --- a/packages/core/src/actions/ActionRunner.ts +++ b/packages/core/src/actions/ActionRunner.ts @@ -24,6 +24,7 @@ import type { ActionContext, ActionResult, RunnableActionType, UIActionSchema } from '@object-ui/types'; import type { Action as SpecActionInput } from '@objectstack/spec/ui'; import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator.js'; +import { actionCopySource, fillTemplateHoles, hasTemplateHoles, type ActionCopyTemplate } from './actionCopyTemplate.js'; import { hasDeclaredPredicate } from '../evaluator/declaredPredicate.js'; import { globalUndoManager } from './UndoManager.js'; import { warnOnDeprecatedObjectParams, warnOnUnknownActionKeys } from './actionKeys.js'; @@ -154,8 +155,13 @@ export interface ActionDef { * confirm spelling: `@objectstack/spec`'s action surface and the * TranslationBundle key (`{ns}.objects.{obj}._actions.{name}.confirmText`) * both stand on it. + * + * A string or the `template` envelope (objectui#13367, the renderer half of + * objectstack#24814): its `{{ }}` holes are filled over `record` and + * `params`, and a sentence with none keeps the transitional `${…}` reading. + * See `confirmMessage`. */ - confirmText?: string; + confirmText?: ActionCopyTemplate; /** * RETIRED (objectui#4314): the structured confirm object used to sit here * and its `message` OUTRANKED `confirmText` — untranslated, since no bundle @@ -269,10 +275,14 @@ export interface ActionDef { /** Toast configuration */ toast?: { showOnSuccess?: boolean; showOnError?: boolean; duration?: number }; /** Success message (from UIActionSchema). The success toast's second rung, - * after {@link outcomeMessages}; may interpolate `${result.*}`. */ - successMessage?: string; - /** Error message (from UIActionSchema) */ - errorMessage?: string; + * after {@link outcomeMessages}. A string or the `template` envelope + * (objectui#13367): `{{ }}` holes over `result` and `record`, or, in a + * sentence with none, the transitional `${result.*}` tokens. */ + successMessage?: ActionCopyTemplate; + /** Error message (from UIActionSchema). A string or the `template` + * envelope (objectui#13367); shown as written — no scope is declared for + * its holes yet. */ + errorMessage?: ActionCopyTemplate; /** Whether to refresh data after execution (from UIActionSchema) */ refreshAfter?: boolean; /** Single-record update actions: offer an Undo affordance on success. */ @@ -597,7 +607,8 @@ export type ToastHandler = (message: string, options?: { * * An author's `outcomeMessages` / `successMessage` / `errorMessage` and an * action's own error are never in this table: the host localizes authored copy - * before dispatch, and the runner only fills in its `${result.*}` tokens. + * before dispatch, and the runner only fills in their holes (`{{ }}` or the + * transitional `${result.*}`). */ const RUNNER_TEXT = { completedSuccessfully: { @@ -1068,13 +1079,28 @@ function isUpdateOperationAction(action: ActionDef): boolean { return action.operation === 'update'; } +/** + * The row a dispatch carries — the clicked row a row surface stashes under + * `params._rowRecord` — or `undefined` when it carries none. The record its + * copy templates bind as `record` (see `ActionRunner.templateEvaluatorFor`). + */ +function dispatchRow(action: ActionDef): Record | undefined { + const params = action.params; + const row = params && typeof params === 'object' && !Array.isArray(params) + ? (params as Record)._rowRecord + : undefined; + return row && typeof row === 'object' && !Array.isArray(row) ? row as Record : undefined; +} + /** * Whether the action declares its own success copy, read the way - * `composeSuccessMessage` reads its `successMessage` rung: a non-empty string. - * A navigation toasts only then (objectui#12755). + * `composeSuccessMessage` reads its `successMessage` rung: a non-empty string, + * or a `template` envelope whose `source` is one (objectui#13367). A + * navigation toasts only then (objectui#12755). */ function declaresSuccessMessage(action: ActionDef): boolean { - return typeof action.successMessage === 'string' && action.successMessage.trim() !== ''; + const text = actionCopySource(action.successMessage); + return text !== undefined && text.trim() !== ''; } /** @@ -1466,10 +1492,9 @@ export class ActionRunner { // `${record.*}` hole the record header fills was shown raw from a list // row (objectui#12967). const templates = this.templateEvaluatorFor(action); - if (action.confirmText) { - const confirmed = await this.confirmHandler( - templates.evaluate(action.confirmText) as string, - ); + const confirmMessage = this.confirmMessage(action, templates); + if (confirmMessage !== undefined) { + const confirmed = await this.confirmHandler(confirmMessage); if (!confirmed) { return { success: false, error: 'Action cancelled by user' }; } @@ -1693,13 +1718,22 @@ export class ActionRunner { * sink differs: a toast is text, so a value is inserted as-is, where a URL * position percent-encodes it. * + * Rung 2 is also a `template` slot (objectui#13367, the renderer half of + * objectstack#24814): a string or the `{ dialect: 'template', source }` + * envelope. A sentence with a `{{ }}` hole is filled by the template engine + * over `result` (the same payload) and `record` (the record the action ran + * against), and is then not read for `${…}` tokens; one without keeps the + * `${result.*}` reading above until the spec card converts the platform's + * own `${result.email}`-style sentences. + * * ⛔ `result.data.message` is not a rung. It used to outrank both rungs of * author copy, which put whatever language the server happened to write in * front of the user's locale; the contract names no server-message rung, and * an author who wants the server's sentence can still write * `${result.message}` as the copy. * - * A rung counts only when it is a non-empty string. An `I18nLabel` map that + * A rung counts only when it is a non-empty string (for rung 2, or an + * envelope whose `source` is one). An `I18nLabel` map that * reached the runner without passing through a localizer has no language to * resolve against here, so it is skipped rather than handed to the toast as * an object — the React #31 crash the error branch below guards against. @@ -1715,14 +1749,62 @@ export class ActionRunner { && Object.prototype.hasOwnProperty.call(outcomeMessages, outcome) ? (outcomeMessages as Record)[outcome] : undefined; - for (const copy of [outcomeCopy, action.successMessage]) { - if (typeof copy === 'string' && copy.trim() !== '') { - return substituteScopeTokens(copy, { result: payload }, (value) => value); - } + if (typeof outcomeCopy === 'string' && outcomeCopy.trim() !== '') { + return substituteScopeTokens(outcomeCopy, { result: payload }, (value) => value); + } + const successCopy = actionCopySource(action.successMessage); + if (successCopy !== undefined && successCopy.trim() !== '') { + return hasTemplateHoles(successCopy) + ? fillTemplateHoles(successCopy, { result: payload, record: this.dispatchRecord(action) }) + : substituteScopeTokens(successCopy, { result: payload }, (value) => value); } return this.runnerText(RUNNER_TEXT.completedSuccessfully); } + /** + * The confirm dialog's body, or `undefined` when the action asks for no + * confirmation (objectui#13367). + * + * `confirmText` is a string or the `template` envelope. A sentence with a + * `{{ }}` hole is filled by the template engine over two roots: `record` — + * the record the action runs against, the same one `templateEvaluatorFor` + * binds — and `params`, the action's static params object when it has one + * (the dialog's collected values do not exist yet: the confirmation comes + * first). A sentence with no `{{ }}` hole keeps the transitional `${…}` + * reading through `templates`, so the platform's own `${…}` copy works + * until the spec card converts it. + * + * A declared value that carries no text — an envelope with no string + * `source` — still gates the run: the author declared a confirmation, and + * dropping it would let a destructive action run unasked. The dialog then + * shows no body text, never the object, which as a React child crashes the + * page. Every answer is a string for the same reason. + */ + private confirmMessage(action: ActionDef, templates: ExpressionEvaluator): string | undefined { + const declared: unknown = action.confirmText; + if (!declared) return undefined; + const text = actionCopySource(declared); + if (text === undefined) return ''; + if (!hasTemplateHoles(text)) { + const evaluated: unknown = templates.evaluate(text); + return typeof evaluated === 'string' ? evaluated : evaluated == null ? '' : String(evaluated); + } + const params = action.params; + const staticParams = params && typeof params === 'object' && !Array.isArray(params) + ? Object.fromEntries(Object.entries(params).filter(([key]) => key !== '_rowRecord')) + : undefined; + return fillTemplateHoles(text, { record: this.dispatchRecord(action), params: staticParams }); + } + + /** + * The record a dispatch runs against, for its copy templates: the row the + * dispatch carries (`params._rowRecord`), else the context's record — the + * one rule `templateEvaluatorFor` applies (objectui#12967). + */ + private dispatchRecord(action: ActionDef): unknown { + return dispatchRow(action) ?? this.context.record; + } + /** * Post-execution: emit toast notifications, handle chaining, callbacks. */ @@ -1764,7 +1846,11 @@ export class ActionRunner { // through here — and a non-string toast payload is rendered as a React // child, crashing the page (React #31). Coerce defensively at this // single sink so no handler can take the whole page down. - const raw: unknown = action.errorMessage || result.error; + // An authored `errorMessage` is shown as written — a string, or the + // `template` envelope's `source` (objectui#13367); no scope is declared + // for its holes yet. An envelope with no text falls to the error. + const authored = actionCopySource(action.errorMessage); + const raw: unknown = authored || result.error; const message = typeof raw === 'string' ? raw : (raw && typeof (raw as { message?: unknown }).message === 'string') @@ -2750,11 +2836,8 @@ export class ActionRunner { * empty. */ private templateEvaluatorFor(action: ActionDef): ExpressionEvaluator { - const params = action.params; - const row = params && typeof params === 'object' && !Array.isArray(params) - ? (params as Record)._rowRecord - : undefined; - if (!row || typeof row !== 'object' || Array.isArray(row)) return this.evaluator; + const row = dispatchRow(action); + if (!row) return this.evaluator; return new ExpressionEvaluator({ ...this.context, record: row }); } diff --git a/packages/core/src/actions/__tests__/ActionRunner.copyTemplate-13367.test.ts b/packages/core/src/actions/__tests__/ActionRunner.copyTemplate-13367.test.ts new file mode 100644 index 0000000000..0052c882bb --- /dev/null +++ b/packages/core/src/actions/__tests__/ActionRunner.copyTemplate-13367.test.ts @@ -0,0 +1,265 @@ +/** + * 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#13367 — `confirmText`, `successMessage` and `errorMessage` read the + * `template` envelope and fill `{{ }}` holes (the renderer half of + * objectstack#24814, ruling A). + * + * The spec card types the three slots as `template` slots, so a served value + * becomes `{ dialect: 'template', source }` where it was a string. Measured + * before this card: the runner filled `${…}` in `confirmText` and + * `${result.*}` in `successMessage`, skipped a non-string success copy (the + * toast fell back to the default text) and interpolated nothing in + * `errorMessage`; no `{{ }}` hole was filled anywhere. So with the spec first, + * an authored success or error toast would silently become the default. + * + * What these pin, through the real runner and `@objectstack/formula`'s + * `templateEngine`: + * - a plain string, and an envelope, for each key; + * - each declared root: `confirmText` → `record`, `params`; + * `successMessage` → `result`, `record`; `errorMessage` → none (verbatim); + * - the transition: a `${…}` sentence is still filled as before; + * - one dialect per sentence: a value filled into a `{{ }}` hole is never + * read again as `${…}`; + * - a non-string never reaches the confirm handler or the toast. + */ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { ActionRunner, type ActionDef, type ToastHandler } from '../ActionRunner'; +import { actionCopySource, fillTemplateHoles, hasTemplateHoles, isActionCopyEnvelope } from '../actionCopyTemplate'; + +const ROW = { id: 'lead_1', name: 'Acme', amount: 1250 }; +const envelope = (source?: string) => ({ dialect: 'template' as const, ...(source === undefined ? {} : { source }) }); + +/** The confirm handler's argument, for one run that is then cancelled. */ +async function confirmTextFor(runner: ActionRunner, def: ActionDef): Promise { + const confirm = vi.fn().mockResolvedValue(false); + runner.setConfirmHandler(confirm); + const result = await runner.execute(def); + expect(result).toMatchObject({ success: false, error: 'Action cancelled by user' }); + expect(confirm).toHaveBeenCalledTimes(1); + return confirm.mock.calls[0][0]; +} + +describe('confirmText reads the envelope and fills {{ }} over record and params (objectui#13367)', () => { + const header = () => new ActionRunner({ objectName: 'crm_lead', record: { ...ROW } }); + const list = () => new ActionRunner({ objectName: 'crm_lead' }); + const BASE: ActionDef = { name: 'archive', onClick: () => {} }; + + it('a plain string with no hole is shown as written', async () => { + expect(await confirmTextFor(header(), { ...BASE, confirmText: 'Archive this lead?' })).toBe('Archive this lead?'); + }); + + it('an envelope: its source, with the `record` hole filled from the record header', async () => { + const def = { ...BASE, confirmText: envelope('Archive {{ record.name }}?') }; + expect(await confirmTextFor(header(), def)).toBe('Archive Acme?'); + }); + + it('a list row is `record` for the template too, as it is for `${…}` (objectui#12967)', async () => { + const def = { ...BASE, confirmText: envelope('Archive {{record.name}}?'), params: { _rowRecord: { ...ROW } } }; + expect(await confirmTextFor(list(), def)).toBe('Archive Acme?'); + }); + + it('a plain string carrying a `{{ }}` hole is filled the same way (a bundle entry is a string)', async () => { + expect(await confirmTextFor(header(), { ...BASE, confirmText: '归档 {{ record.name }}?' })).toBe('归档 Acme?'); + }); + + it('`params`: the action\'s static params object, without the stashed row', async () => { + const def = { + ...BASE, + confirmText: envelope('Move {{ record.name }} to {{ params.stage }}?{{ params._rowRecord.id }}'), + params: { stage: 'Qualified', _rowRecord: { ...ROW } }, + }; + expect(await confirmTextFor(list(), def)).toBe('Move Acme to Qualified?'); + }); + + it('a whitelisted formatter applies', async () => { + const def = { ...BASE, confirmText: envelope('Close at {{ record.amount | number:2 }}?') }; + expect(await confirmTextFor(header(), def)).toBe('Close at 1,250.00?'); + }); + + it('a root the slot does not declare prints empty — the runner context is not a root', async () => { + const runner = new ActionRunner({ record: { ...ROW }, user: { id: 'u1', name: 'Ann' } }); + expect(await confirmTextFor(runner, { ...BASE, confirmText: envelope('By {{ user.name }}{{ result.id }}.') })).toBe('By .'); + }); + + it('transition: a `${…}` sentence is still read through the expression evaluator', async () => { + expect(await confirmTextFor(header(), { ...BASE, confirmText: 'Archive ${record.name}?' })).toBe('Archive Acme?'); + }); + + it('one dialect per sentence: a record value is never evaluated as `${…}` after filling a hole', async () => { + const runner = new ActionRunner({ record: { name: '${record.secret}', secret: 'leaked' } }); + expect(await confirmTextFor(runner, { ...BASE, confirmText: envelope('Archive {{ record.name }}?') })) + .toBe('Archive ${record.secret}?'); + }); + + it('a source the engine refuses (logic in a hole) is shown as written, never thrown', async () => { + const def = { ...BASE, confirmText: envelope('Archive {{ record.amount * 2 }}?') }; + expect(await confirmTextFor(header(), def)).toBe('Archive {{ record.amount * 2 }}?'); + }); + + it('an envelope with no string source still gates the run, with a string body, never the object', async () => { + const shown = await confirmTextFor(header(), { ...BASE, confirmText: envelope() as ActionDef['confirmText'] }); + expect(shown).toBe(''); + expect(typeof shown).toBe('string'); + }); + + it('CONTROL: no confirmText, no confirmation', async () => { + const confirm = vi.fn().mockResolvedValue(false); + const runner = header(); + runner.setConfirmHandler(confirm); + const result = await runner.execute({ ...BASE }); + expect(confirm).not.toHaveBeenCalled(); + expect(result.success).toBe(true); + }); +}); + +describe('successMessage reads the envelope and fills {{ }} over result and record (objectui#13367)', () => { + let runner: ActionRunner; + let toast: Mock; + const ANSWER = async () => ({ success: true, data: { email: 'ann@example.com', id: 'inv_9' } }); + + beforeEach(() => { + runner = new ActionRunner({ objectName: 'sys_invitation', record: { name: 'Acme' } }); + toast = vi.fn(); + runner.setToastHandler(toast); + runner.registerHandler('script', ANSWER); + }); + + const toastText = () => { + expect(toast).toHaveBeenCalledTimes(1); + return toast.mock.calls[0][0]; + }; + + it('a plain string with no hole is shown as written', async () => { + await runner.execute({ type: 'script', name: 'invite', successMessage: 'Invitation sent' }); + expect(toastText()).toBe('Invitation sent'); + }); + + it('an envelope: its source, with `result` and `record` holes filled', async () => { + await runner.execute({ + type: 'script', + name: 'invite', + successMessage: envelope('Invited {{ result.email }} to {{ record.name }}'), + }); + expect(toastText()).toBe('Invited ann@example.com to Acme'); + }); + + it('the row a dispatch carries is `record`', async () => { + await runner.execute({ + type: 'script', + name: 'invite', + params: { _rowRecord: { name: 'Globex' } }, + successMessage: envelope('Invited {{ result.email }} to {{ record.name }}'), + }); + expect(toastText()).toBe('Invited ann@example.com to Globex'); + }); + + it('an envelope is no longer dropped to the default text (the pre-card failure)', async () => { + await runner.execute({ type: 'script', name: 'invite', successMessage: envelope('Invitation sent') }); + expect(toastText()).toBe('Invitation sent'); + }); + + it('transition: the platform objects\' `${result.email}` sentence is still filled', async () => { + await runner.execute({ type: 'script', name: 'invite', successMessage: 'Invitation sent to ${result.email}' }); + expect(toastText()).toBe('Invitation sent to ann@example.com'); + }); + + it('one dialect per sentence: a filled value is not read again as `${result.*}`', async () => { + runner.registerHandler('script', async () => ({ success: true, data: { email: '${result.id}', id: 'inv_9' } })); + await runner.execute({ type: 'script', name: 'invite', successMessage: envelope('Invited {{ result.email }}') }); + expect(toastText()).toBe('Invited ${result.id}'); + }); + + it('an envelope with no string source falls to the default text, as a string', async () => { + await runner.execute({ type: 'script', name: 'invite', successMessage: envelope() as ActionDef['successMessage'] }); + expect(toastText()).toBe('Action completed successfully'); + }); + + it('an outcomeMessages entry still outranks the envelope', async () => { + runner.registerHandler('script', async () => ({ success: true, data: { outcome: 'resent', email: 'ann@example.com' } })); + await runner.execute({ + type: 'script', + name: 'invite', + outcomeMessages: { resent: 'Resent to ${result.email}' }, + successMessage: envelope('Invited {{ result.email }}'), + }); + expect(toastText()).toBe('Resent to ann@example.com'); + }); + + it('an envelope counts as declared copy for a navigation (objectui#12755)', async () => { + const nav = vi.fn(); + runner.setNavigationHandler(nav); + await runner.execute({ type: 'url', name: 'open', target: '/x', successMessage: envelope('Opened') }); + expect(nav).toHaveBeenCalled(); + expect(toastText()).toBe('Opened'); + }); +}); + +describe('errorMessage reads the envelope and is shown as written (objectui#13367)', () => { + let runner: ActionRunner; + let toast: Mock; + + beforeEach(() => { + runner = new ActionRunner({ record: { name: 'Acme' } }); + toast = vi.fn(); + runner.setToastHandler(toast); + runner.registerHandler('script', async () => ({ success: false, error: 'HTTP 500' })); + }); + + const toastText = () => { + expect(toast).toHaveBeenCalledTimes(1); + expect(toast.mock.calls[0][1]).toMatchObject({ type: 'error' }); + return toast.mock.calls[0][0]; + }; + + it('a plain string is shown as written', async () => { + await runner.execute({ type: 'script', name: 'sync', errorMessage: 'Sync failed' }); + expect(toastText()).toBe('Sync failed'); + }); + + it('an envelope: its source, verbatim — no root is declared, so no hole is filled', async () => { + await runner.execute({ type: 'script', name: 'sync', errorMessage: envelope('Could not sync {{ record.name }}') }); + expect(toastText()).toBe('Could not sync {{ record.name }}'); + }); + + it('an envelope with no string source falls to the action\'s own error, never the object', async () => { + await runner.execute({ type: 'script', name: 'sync', errorMessage: envelope() as ActionDef['errorMessage'] }); + expect(toastText()).toBe('HTTP 500'); + }); +}); + +describe('the copy reader (objectui#13367)', () => { + it('reads a string, an envelope\'s source, and nothing else', () => { + expect(actionCopySource('Hi')).toBe('Hi'); + expect(actionCopySource({ dialect: 'template', source: 'Hi {{ record.name }}' })).toBe('Hi {{ record.name }}'); + expect(actionCopySource({ source: 'Hi' })).toBe('Hi'); + expect(actionCopySource({ dialect: 'template' })).toBeUndefined(); + expect(actionCopySource({ en: 'Hi', 'zh-CN': '你好' })).toBeUndefined(); + expect(actionCopySource(42)).toBeUndefined(); + expect(actionCopySource(undefined)).toBeUndefined(); + }); + + it('tells the envelope from an I18nLabel map', () => { + expect(isActionCopyEnvelope({ dialect: 'template', source: 'x' })).toBe(true); + expect(isActionCopyEnvelope({ dialect: 'template' })).toBe(true); + expect(isActionCopyEnvelope({ en: 'x', 'zh-CN': 'y' })).toBe(false); + expect(isActionCopyEnvelope('x')).toBe(false); + expect(isActionCopyEnvelope(['x'])).toBe(false); + }); + + it('a sentence is a template only when it carries a `{{ }}` hole', () => { + expect(hasTemplateHoles('Hi {{ record.name }}')).toBe(true); + expect(hasTemplateHoles('Hi ${record.name}')).toBe(false); + expect(hasTemplateHoles('Hi {name}')).toBe(false); + }); + + it('fills through the formula template engine', () => { + expect(fillTemplateHoles('{{ result.email | upper }}', { result: { email: 'a@b.c' } })).toBe('A@B.C'); + }); +}); diff --git a/packages/core/src/actions/actionCopyTemplate.ts b/packages/core/src/actions/actionCopyTemplate.ts new file mode 100644 index 0000000000..74c2c0c047 --- /dev/null +++ b/packages/core/src/actions/actionCopyTemplate.ts @@ -0,0 +1,83 @@ +/** + * 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 { templateEngine } from '@objectstack/formula'; + +/** + * An action's person-facing copy slot — `confirmText`, `successMessage`, + * `errorMessage` — as it can reach the runner: a string, or ADR-0032's + * `template` envelope. + * + * Two arms, because the protocol is moving these slots to the `template` + * dialect (objectstack#24814, ruling A): there a bare string is shorthand for + * `{ dialect: 'template', source }`, and a parsed definition carries the + * envelope. The renderer moves first (objectui#13367), so both arms are read + * here before the spec types the slot. `source` is optional, as on the + * protocol's `Expression`: a parsed slot may carry its `ast` instead, and then + * there is no text to show. The same shape the bulk `confirmText` reader + * (`bulkConfirmTextSource` in plugin-grid) and core's `formatTitleTemplate` + * read. + */ +export type ActionCopyTemplate = string | { dialect?: 'template'; source?: string }; + +/** + * Whether `value` is the `template` envelope rather than a plain string or an + * `I18nLabel` locale map. A locale map's keys are language tags, so neither + * `dialect` nor `source` is ever one of them. + */ +export function isActionCopyEnvelope( + value: unknown, +): value is { dialect?: 'template'; source?: unknown } { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + return (value as { dialect?: unknown }).dialect === 'template' + || Object.prototype.hasOwnProperty.call(value, 'source'); +} + +/** + * The text a copy slot carries: the string itself, or the envelope's + * `source`, as written. `undefined` for anything else — an envelope with no + * string `source`, a number, an object that reached the runner without + * passing through a localizer — so no caller hands a non-string to a toast or + * a dialog (the React #31 crash). + */ +export function actionCopySource(copy: unknown): string | undefined { + if (typeof copy === 'string') return copy; + if (isActionCopyEnvelope(copy) && typeof copy.source === 'string') return copy.source; + return undefined; +} + +/** A `{{ … }}` hole, as the template engine delimits one. */ +const TEMPLATE_HOLE = /\{\{[^}]*\}\}/; + +/** + * Whether `text` carries a `{{ }}` hole, which makes it a `template`-dialect + * sentence. The runner reads ONE dialect per sentence: a sentence with a + * `{{ }}` hole is filled by the template engine alone, and one without keeps + * the transitional `${…}` reading. So a value filled into a hole is never read + * a second time as a `${…}` expression. + */ +export function hasTemplateHoles(text: string): boolean { + return TEMPLATE_HOLE.test(text); +} + +/** + * Fill the `{{ path }}` / `{{ path | formatter }}` holes of `source` over + * `roots`, through `@objectstack/formula`'s `templateEngine` — the engine the + * flow notifications render with, and the one objectui#13183 names for the + * bulk `confirmText`. ⛔ No second hole grammar here. + * + * `roots` is the slot's whole scope: a hole naming anything else prints empty, + * as an unbound path does in every template. A source the engine refuses (an + * unbalanced delimiter, logic inside a hole) is returned as written: the + * publish gate refuses it on the server side, and the user still reads the + * author's sentence rather than nothing. + */ +export function fillTemplateHoles(source: string, roots: Record): string { + const filled = templateEngine.evaluate({ dialect: 'template', source }, { extra: roots }); + return filled.ok && typeof filled.value === 'string' ? filled.value : source; +} diff --git a/packages/core/src/actions/index.ts b/packages/core/src/actions/index.ts index e2fa9bb380..ab7e92720a 100644 --- a/packages/core/src/actions/index.ts +++ b/packages/core/src/actions/index.ts @@ -18,3 +18,4 @@ export * from './serverActionHandler.js'; export * from './recordIdParam.js'; export * from './recordDelete.js'; export * from './navigationTarget.js'; +export * from './actionCopyTemplate.js'; diff --git a/packages/react/src/hooks/__tests__/useActionTextLocalizer.templateEnvelope-13367.test.tsx b/packages/react/src/hooks/__tests__/useActionTextLocalizer.templateEnvelope-13367.test.tsx new file mode 100644 index 0000000000..db0594be17 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useActionTextLocalizer.templateEnvelope-13367.test.tsx @@ -0,0 +1,101 @@ +/** + * 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. + * + * `useActionTextLocalizer` (objectui#13367) — the `template` envelope reaches + * the runner as an envelope. + * + * objectstack#24814 types `confirmText`, `successMessage` and `errorMessage` + * as `template` slots, so they can arrive as `{ dialect: 'template', source }`. + * Read as an `I18nLabel` map, `pickLocalized` collapsed that envelope to its + * first string value — the word `template` — which the confirm dialog and the + * toasts then showed. The hook now keeps the envelope, and a bundle entry + * replaces its `source`. + */ + +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import React from 'react'; +import { I18nProvider, useObjectTranslation } from '@object-ui/i18n'; +import { useActionTextLocalizer } from '../useActionTextLocalizer'; + +const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(I18nProvider, { + config: { defaultLanguage: 'zh-CN', detectBrowserLanguage: false }, + children, + }); + +function setup() { + const { result } = renderHook( + () => ({ localize: useActionTextLocalizer(), i18n: useObjectTranslation().i18n }), + { wrapper }, + ); + return result; +} + +const COPY = { + confirmText: { dialect: 'template', source: 'Invite {{ record.name }}?' }, + successMessage: { dialect: 'template', source: 'Invited {{ result.email }}' }, + errorMessage: { dialect: 'template', source: 'Could not invite {{ record.name }}' }, +}; + +describe('useActionTextLocalizer keeps the template envelope (objectui#13367)', () => { + it('a nameless action: every envelope comes back as written, never as the word "template"', () => { + const result = setup(); + const action = { type: 'api', target: '/x', label: 'Invite', ...COPY }; + const out = result.current.localize(undefined, action); + expect(out.confirmText).toEqual(COPY.confirmText); + expect(out.successMessage).toEqual(COPY.successMessage); + expect(out.errorMessage).toEqual(COPY.errorMessage); + }); + + it('a named action with no bundle entry: the envelopes come back as written', () => { + const result = setup(); + const out = result.current.localize(undefined, { name: 'invite_member', type: 'api', ...COPY }); + expect(out.confirmText).toEqual(COPY.confirmText); + expect(out.successMessage).toEqual(COPY.successMessage); + expect(out.errorMessage).toEqual(COPY.errorMessage); + }); + + it('a bundle entry replaces the source and keeps the envelope, holes intact for the runner', () => { + const result = setup(); + result.current.i18n.addResourceBundle( + result.current.i18n.language, + 'translation', + { + cloud: { + globalActions: { + invite_member: { confirmText: '邀请 {{ record.name }}?', successMessage: '已邀请 {{ result.email }}' }, + }, + }, + }, + true, + true, + ); + const out = result.current.localize(undefined, { name: 'invite_member', type: 'api', ...COPY }); + expect(out.confirmText).toEqual({ dialect: 'template', source: '邀请 {{ record.name }}?' }); + expect(out.successMessage).toEqual({ dialect: 'template', source: '已邀请 {{ result.email }}' }); + }); + + it('an envelope with no source and no bundle entry passes through unchanged', () => { + const result = setup(); + const bare = { dialect: 'template' }; + const out = result.current.localize(undefined, { name: 'archive', type: 'api', confirmText: bare }); + expect(out.confirmText).toBe(bare); + }); + + it('CONTROL: a plain string and an I18nLabel map read as before', () => { + const result = setup(); + const out = result.current.localize(undefined, { + name: 'invite_member', + type: 'api', + confirmText: 'Invite {{ record.name }}?', + successMessage: { en: 'Invited', 'zh-CN': '已邀请' }, + }); + expect(out.confirmText).toBe('Invite {{ record.name }}?'); + expect(out.successMessage).toBe('已邀请'); + }); +}); diff --git a/packages/react/src/hooks/useActionTextLocalizer.ts b/packages/react/src/hooks/useActionTextLocalizer.ts index cfd0b84ddc..c9fe002df6 100644 --- a/packages/react/src/hooks/useActionTextLocalizer.ts +++ b/packages/react/src/hooks/useActionTextLocalizer.ts @@ -75,9 +75,17 @@ * action has not run yet, so there is no result to read. The runner fills * the token in after the action succeeds, through the same `${result.*}` * scope `onSuccess.navigate` reads — one interpolation, in one place. + * - **The `template` envelope keeps its shape** (objectui#13367, the renderer + * half of objectstack#24814). `confirmText`, `successMessage` and + * `errorMessage` may arrive as `{ dialect: 'template', source }` instead of + * a string. The envelope is not an `I18nLabel` map — read as one it would + * collapse to its first string value, the word `template` — so its `source` + * is what the bundle's fallback and answer replace, and the runner receives + * the envelope and fills its `{{ }}` holes (`ActionRunner`'s copy reads). */ import { useMemo } from 'react'; +import { isActionCopyEnvelope } from '@object-ui/core'; import { useObjectLabel, useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import { isConfigBag } from '../utils/configBag.js'; @@ -106,6 +114,32 @@ export type ActionTextLocalizer = >( /** The declared `I18nLabel` copy, besides `label`, that reaches the runner. */ const DECLARED_COPY_KEYS = ['confirmText', 'successMessage', 'errorMessage'] as const; +/** + * Collapse one declared copy value to the active language: an `I18nLabel` + * through `pickLocalized`, a `template` envelope as itself (its `source` is + * already one string; see "The `template` envelope keeps its shape"). + */ +function collapseCopy(copy: unknown, language: string): unknown { + return isActionCopyEnvelope(copy) ? copy : pickLocalized(copy, language); +} + +/** + * Hand a collapsed copy value to a bundle resolver as its fallback, and put + * the answer back in the value's own shape: a string stays the resolver's + * answer, an envelope keeps every member but `source`. An envelope with no + * string `source` offers no fallback, so it comes back unchanged unless the + * bundle holds an entry for the key. + */ +function resolveCopy( + copy: unknown, + resolve: (fallback: string | undefined) => string | undefined, +): unknown { + if (!isActionCopyEnvelope(copy)) return resolve(copy as string | undefined); + const source = typeof copy.source === 'string' ? copy.source : undefined; + const answer = resolve(source); + return answer === undefined || answer === source ? copy : { ...copy, source: answer }; +} + export function useActionTextLocalizer(): ActionTextLocalizer { const { actionLabel, actionConfirm, actionSuccess, actionOutcome } = useObjectLabel(); const { language } = useObjectTranslation(); @@ -133,7 +167,7 @@ export function useActionTextLocalizer(): ActionTextLocalizer { for (const key of DECLARED_COPY_KEYS) { const declaredCopy: unknown = action[key]; if (declaredCopy === undefined) continue; - collapsed[key] = pickLocalized(declaredCopy, language); + collapsed[key] = collapseCopy(declaredCopy, language); if (collapsed[key] !== declaredCopy) changed = true; } @@ -156,10 +190,10 @@ export function useActionTextLocalizer(): ActionTextLocalizer { label: actionLabel(keyObject, name, displayLabel), }; if ((action as any).confirmText !== undefined) { - out.confirmText = actionConfirm(keyObject, name, collapsed.confirmText); + out.confirmText = resolveCopy(collapsed.confirmText, (fallback) => actionConfirm(keyObject, name, fallback)); } if ((action as any).successMessage !== undefined) { - out.successMessage = actionSuccess(keyObject, name, collapsed.successMessage); + out.successMessage = resolveCopy(collapsed.successMessage, (fallback) => actionSuccess(keyObject, name, fallback)); } // The map is an authored bag (outcome → copy), so the one "is this a // real bag?" predicate in this package answers whether to read it. From 9d7dd71d161c473ade42da3f8a4c29f3975b2556 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:00:01 +0000 Subject: [PATCH 2/2] fix(app-shell): a flow action's success copy reads the template envelope flowSuccessCopy read the invoking action's successMessage only as a string, so an envelope fell to the flow's own copy. It now reads it through core's actionCopySource, and FlowRunner's actionSuccessMessage takes the same two shapes as ActionDef.successMessage. Claude-Session: https://claude.ai/code/session_0159TkE3rFbrKwMs3mWhnniW Co-authored-by: Claude --- .changeset/13367-action-copy-template.md | 3 ++- .../src/utils/__tests__/flowResponse.test.ts | 26 +++++++++++++++++++ packages/app-shell/src/utils/flowResponse.ts | 11 +++++--- packages/app-shell/src/views/FlowRunner.tsx | 5 +++- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/.changeset/13367-action-copy-template.md b/.changeset/13367-action-copy-template.md index d40f9cde58..a42aaf7091 100644 --- a/.changeset/13367-action-copy-template.md +++ b/.changeset/13367-action-copy-template.md @@ -1,6 +1,7 @@ --- '@object-ui/core': minor '@object-ui/react': patch +'@object-ui/app-shell': patch --- An action's `confirmText`, `successMessage` and `errorMessage` read the `template` envelope and fill `{{ }}` holes (objectui#13367). @@ -13,7 +14,7 @@ An action's `confirmText`, `successMessage` and `errorMessage` read the `templat A sentence with no `{{ }}` hole keeps today's `${…}` reading, so the platform's own `${result.email}`-style success sentences still work until the spec converts them. A sentence is read in one dialect only: a value filled into a `{{ }}` hole is never read again as `${…}`. An envelope with no text never reaches the dialog or a toast as an object; a `confirmText` envelope with no text still asks for confirmation. -`useActionTextLocalizer` keeps the envelope: it used to read it as a per-locale map and hand the runner the word `template`. A bundle entry replaces the envelope's `source`. `@object-ui/core` exports the reader it uses: `ActionCopyTemplate`, `isActionCopyEnvelope`, `actionCopySource`, `hasTemplateHoles` and `fillTemplateHoles`. +`useActionTextLocalizer` keeps the envelope: it used to read it as a per-locale map and hand the runner the word `template`. A bundle entry replaces the envelope's `source`. On a flow action the console reads the envelope too, so the action's own success copy still outranks the flow's (`flowSuccessCopy`). `@object-ui/core` exports the reader it uses: `ActionCopyTemplate`, `isActionCopyEnvelope`, `actionCopySource`, `hasTemplateHoles` and `fillTemplateHoles`. Clause-②: yes Metadata-protocol: none diff --git a/packages/app-shell/src/utils/__tests__/flowResponse.test.ts b/packages/app-shell/src/utils/__tests__/flowResponse.test.ts index 0e75b6fdb8..a0d8e6041f 100644 --- a/packages/app-shell/src/utils/__tests__/flowResponse.test.ts +++ b/packages/app-shell/src/utils/__tests__/flowResponse.test.ts @@ -525,3 +525,29 @@ describe("interpretFlowResponse — the flow's own toasts in the active language expect(interpretFlowResponse(ok, json, 'Flow "case_escalate"')).toMatchObject({ successMessage: 'Case escalated' }); }); }); + +// objectui#13367 — the invoking action's `successMessage` may arrive as the +// `template` envelope (objectstack#24814). Read as "not a string", it fell to +// the flow's own copy, so the author's sentence never showed on a flow action. +describe("flowSuccessCopy — the action's copy as a template envelope (objectui#13367)", () => { + it("an envelope's source wins over the flow's copy, as written", () => { + expect(flowSuccessCopy({ dialect: 'template', source: 'Escalated {{ record.name }}' }, 'Escalation recorded')) + .toEqual({ text: 'Escalated {{ record.name }}', from: 'action' }); + }); + + it('an envelope with no text counts as none', () => { + expect(flowSuccessCopy({ dialect: 'template' }, 'Escalation recorded')) + .toEqual({ text: 'Escalation recorded', from: 'flow' }); + }); + + it('on a straight-through run the result is unchanged, so the runner speaks the envelope', () => { + const data = { success: true, status: 'completed', successMessage: 'Escalation recorded' }; + const out = judgeFlowLaunch( + interpretFlowResponse(ok, { success: true, data }, 'Flow "case_escalate"'), + undefined, + { dialect: 'template', source: 'Escalated {{ record.name }}' }, + ); + expect(out.result).toEqual({ success: true, data, reload: true }); + expect(out.successToast).toBeUndefined(); + }); +}); diff --git a/packages/app-shell/src/utils/flowResponse.ts b/packages/app-shell/src/utils/flowResponse.ts index 985b2b1034..efc1d83d6d 100644 --- a/packages/app-shell/src/utils/flowResponse.ts +++ b/packages/app-shell/src/utils/flowResponse.ts @@ -186,7 +186,7 @@ * own failure prose (`error`) is passed through untouched — it has no key. */ -import { actionErrorDetail } from '@object-ui/core'; +import { actionCopySource, actionErrorDetail } from '@object-ui/core'; import type { ActionResult } from '@object-ui/core'; import { errorCodeIs } from '@object-ui/types'; import type { AutomationResult } from '@objectstack/spec/contracts'; @@ -457,14 +457,19 @@ function usableCopy(copy: unknown): copy is string { * neither is usable; the caller then speaks its own default sentence. * * `actionMessage` is the action's copy exactly as it reached the host's flow - * handler, so already localized by the surface that dispatched it. + * handler, so already localized by the surface that dispatched it. It is a + * string or the `template` envelope (objectui#13367), read through core's + * `actionCopySource` as the action runner reads it, so an envelope still + * outranks the flow's own copy. The text is answered as written: on the + * straight-through run the runner speaks it and fills its holes. * `flowMessage` is the flow's own `successMessage` as `interpretFlowResponse` * answered it — the served `AutomationResult.successMessage`, translated * against `flows.FLOW.successMessage` where the bundle carries one * (objectui#12075). */ export function flowSuccessCopy(actionMessage: unknown, flowMessage: unknown): FlowSuccessCopy | undefined { - if (usableCopy(actionMessage)) return { text: actionMessage, from: 'action' }; + const actionText = actionCopySource(actionMessage); + if (usableCopy(actionText)) return { text: actionText, from: 'action' }; if (usableCopy(flowMessage)) return { text: flowMessage, from: 'flow' }; return undefined; } diff --git a/packages/app-shell/src/views/FlowRunner.tsx b/packages/app-shell/src/views/FlowRunner.tsx index 64ade92ac3..d0b2f4b210 100644 --- a/packages/app-shell/src/views/FlowRunner.tsx +++ b/packages/app-shell/src/views/FlowRunner.tsx @@ -221,6 +221,7 @@ import { cn, } from '@object-ui/components'; import { notifyDataChanged, useObjectTranslation } from '@object-ui/react'; +import type { ActionCopyTemplate } from '@object-ui/core'; import { FLOW_SCREEN_FIELD_COPY_KEYS, resolveFlowScreenFieldOptions, @@ -353,8 +354,10 @@ export interface ScreenFlowState extends Pick { * toasts when the run completes without pausing (objectui#12234 — see the * header). Absent when no action launched the run or the action declares * none; the runner then keeps the flow-completed toast it always had. + * A string or the `template` envelope (objectui#13367), read by + * `flowSuccessCopy`. */ - actionSuccessMessage?: string; + actionSuccessMessage?: ActionCopyTemplate; } /**