diff --git a/.changeset/13476-holeless-action-copy.md b/.changeset/13476-holeless-action-copy.md new file mode 100644 index 0000000000..c735d1aa13 --- /dev/null +++ b/.changeset/13476-holeless-action-copy.md @@ -0,0 +1,14 @@ +--- +"@object-ui/core": minor +--- + +Clause-②: no +Metadata-protocol: narrowed + +An action's `confirmText` or `successMessage` with no `{{ }}` hole is now shown exactly as written (objectui#13476). + +objectui#13367 kept one transitional reading: a sentence with no `{{ }}` hole still had its `${…}` tokens filled, `${record.*}` in the confirm dialog and `${result.*}` in the success toast. That reading is gone. A `${…}` token in either text is now plain text, the same as in any other copy with no hole. To put a value into the sentence, write a `{{ }}` hole: `Archive {{ record.name }}?`, `Invitation sent to {{ result.email }}`. + +No in-tree producer writes the holeless `${…}` shape. objectstack's measurement of its examples, `platform-objects`, `plugin-approvals` and HotCRM found none. objectstack's lint already warns on the shape and refuses every other `${…}` token in this copy (objectstack#25702). The paired objectstack card turns the warning into an error once its objectui pin carries this change. + +`outcomeMessages` entries are unchanged: they still fill `${result.*}`. `errorMessage` is unchanged: it is shown as written. diff --git a/content/docs/core/enhanced-actions.mdx b/content/docs/core/enhanced-actions.mdx index f621ef32e4..e41e2786bc 100644 --- a/content/docs/core/enhanced-actions.mdx +++ b/content/docs/core/enhanced-actions.mdx @@ -323,10 +323,15 @@ What to write instead: one message per snake_case `outcome` the handler returns in its answer. The success toast shows the `outcomeMessages` entry the answer's `outcome` names, else `successMessage` (objectui#11344). Both may read the handler's answer as - `${result.*}` — `'Environment ${result.name} archived'` — the same scope - `onSuccess.navigate` reads. A `message` in the server's answer is not shown - unless the copy asks for it as `${result.message}`. Without either, the - runner falls back to a generic "Action completed successfully". + `result`. An `outcomeMessages` entry writes it as `${result.*}` — + `'Environment ${result.name} archived'` — the same scope `onSuccess.navigate` + reads. `successMessage` writes it as a `{{ }}` hole — + `'Environment {{ result.name }} updated'` — and can also name the record the + action ran against as `{{ record.* }}`; a `${…}` token in it is shown as + written (objectui#13476). A `message` in the server's answer is not shown + unless the copy asks for it (`{{ result.message }}` in `successMessage`). + Without either, the runner falls back to a generic "Action completed + successfully". That fallback is text the runner writes itself, and so are the "Action failed" error toast for an error with no readable message, a parallel chain's "One or more parallel actions failed", and the label it hands your @@ -408,7 +413,7 @@ const complexAction: ActionSchema = { // Confirm before processing actionType: 'confirm', - confirmText: 'Process this order for ${data.customerName}?', + confirmText: 'Process this order for {{ record.customer_name }}?', // Execution gate - this action is the premium path; the standard path is a // sibling action carrying the opposite predicate diff --git a/packages/app-shell/src/views/ObjectView.inviteFeedback-11817.test.tsx b/packages/app-shell/src/views/ObjectView.inviteFeedback-11817.test.tsx index e4f8799b41..6b166411e7 100644 --- a/packages/app-shell/src/views/ObjectView.inviteFeedback-11817.test.tsx +++ b/packages/app-shell/src/views/ObjectView.inviteFeedback-11817.test.tsx @@ -24,8 +24,9 @@ * the dialog's Confirm — no objectui code drops it. Pinned with its * control (a refused invite raises the server's message and no success * toast), and with the copy that NAMES the invitee: the runner fills - * `${result.*}` from the answer (objectui#11344), so an action whose - * `successMessage` reads `${result.email}` gets the invitee's address. + * `{{ result.* }}` from the answer (objectui#11344, objectui#13367), so + * an action whose `successMessage` reads `{{ result.email }}` gets the + * invitee's address. * Whether `invite_user` declares such copy is the action's metadata. * - **The subtitle.** An action that declares `description` gets it as the * dialog's subtitle; one that declares none gets the generic line. Both @@ -286,7 +287,7 @@ describe('objectui#11817 — Invite User says it worked', () => { }); it('copy that reads the answer\'s `result.email` names the invitee', async () => { - mountList('sys_user', 'all_users', [sysUser(`Invitation sent to \${result.email}`), ...MEMBERS]); + mountList('sys_user', 'all_users', [sysUser('Invitation sent to {{ result.email }}'), ...MEMBERS]); await inviteAda(); expect(await screen.findByText('Invitation sent to ada@example.com', undefined, { timeout: 5000 })).toBeInTheDocument(); diff --git a/packages/app-shell/src/views/ObjectView.toolbarScriptSelection-12331.test.tsx b/packages/app-shell/src/views/ObjectView.toolbarScriptSelection-12331.test.tsx index 1a8c92fef4..7df5dc0777 100644 --- a/packages/app-shell/src/views/ObjectView.toolbarScriptSelection-12331.test.tsx +++ b/packages/app-shell/src/views/ObjectView.toolbarScriptSelection-12331.test.tsx @@ -27,7 +27,7 @@ * record-scoped action refuses 0 and 2 rows with a toast and sends nothing; one * row sends one request, and its result reaches the user through the runner's * success toast — the action's own copy, filled from the answer's - * `${result.*}`, or the runner's generic line when it declares none. + * `{{ result.* }}`, or the runner's generic line when it declares none. * * The last block replays HotCRM's own declaration: a `list_item` placement and * a FIELD-BACKED param, so the dialog draws a record picker and the toast @@ -249,7 +249,7 @@ describe('objectui#12331 — a record-scoped script action at list_toolbar, with }); it('1 row: exactly one request carries that row, and the result reaches the user', async () => { - mountLeads(RECORD_SCOPED, `Added \${result.count}, skipped \${result.skipped}`); + mountLeads(RECORD_SCOPED, 'Added {{ result.count }}, skipped {{ result.skipped }}'); await tickRows(1); await launchFromHeader(); diff --git a/packages/core/src/actions/ActionRunner.ts b/packages/core/src/actions/ActionRunner.ts index ecb8a00f42..8b35266a43 100644 --- a/packages/core/src/actions/ActionRunner.ts +++ b/packages/core/src/actions/ActionRunner.ts @@ -158,8 +158,8 @@ export interface ActionDef { * * 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`. + * `params`, and a sentence with none is shown as written — a `${…}` token is + * text (objectui#13476). See `confirmMessage`. */ confirmText?: ActionCopyTemplate; /** @@ -276,8 +276,8 @@ export interface ActionDef { toast?: { showOnSuccess?: boolean; showOnError?: boolean; duration?: number }; /** Success message (from UIActionSchema). The success toast's second rung, * 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. */ + * (objectui#13367): `{{ }}` holes over `result` and `record`; a sentence + * with none is shown as written, `${…}` tokens included (objectui#13476). */ successMessage?: ActionCopyTemplate; /** Error message (from UIActionSchema). A string or the `template` * envelope (objectui#13367); shown as written — no scope is declared for @@ -626,8 +626,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 their holes (`{{ }}` or the - * transitional `${result.*}`). + * before dispatch, and the runner only fills in their holes (`{{ }}` in + * `successMessage`, `${result.*}` in `outcomeMessages`). */ const RUNNER_TEXT = { completedSuccessfully: { @@ -1521,12 +1521,11 @@ export class ActionRunner { // (objectui#4314): it had zero producers and no bundle key, so a dialog // authored that way silently lost localization. // - // Read through `templateEvaluatorFor`, not the runner's own evaluator: - // a row dispatch binds no `record` in the runner's context, so a - // `${record.*}` hole the record header fills was shown raw from a list - // row (objectui#12967). - const templates = this.templateEvaluatorFor(action); - const confirmMessage = this.confirmMessage(action, templates); + // Its `{{ }}` holes read the dispatch's record (`dispatchRecord`), not + // the runner's context alone: a row dispatch binds no `record` there, so + // a hole the record header fills was shown raw from a list row + // (objectui#12967). + const confirmMessage = this.confirmMessage(action); if (confirmMessage !== undefined) { const confirmed = await this.confirmHandler(confirmMessage); if (!confirmed) { @@ -1589,6 +1588,10 @@ export class ActionRunner { return lastRun; }; + // The dialog's `description` / `helpText` read the dispatch's record + // as `record`: a row dispatch binds none in the runner's context + // (objectui#12967). + const templates = this.templateEvaluatorFor(action); const collected = await this.paramCollectionHandler( paramDefs, action, @@ -1763,25 +1766,27 @@ export class ActionRunner { * * Rungs 1 and 2 are the author's copy. Both arrive already localized — the * host's localizer resolves the bundle entry or the inline `I18nLabel` map - * before dispatch — and both have their `${result.*}` tokens filled in here, - * through the one token grammar and scope `onSuccess.navigate` reads - * (`substituteScopeTokens` over `readActionPayload(result.data)`). Only the - * sink differs: a toast is text, so a value is inserted as-is, where a URL - * position percent-encodes it. + * before dispatch — and both read the same payload, + * `readActionPayload(result.data)`, as `result`. * - * Rung 2 is also a `template` slot (objectui#13367, the renderer half of + * Rung 1 has its `${result.*}` tokens filled in here, through the one token + * grammar and scope `onSuccess.navigate` reads (`substituteScopeTokens`). + * Only the sink differs: a toast is text, so a value is inserted as-is, where + * a URL position percent-encodes it. + * + * Rung 2 is 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. + * over `result` and `record` (the record the action ran against). A + * sentence with none is shown as written: the transitional `${result.*}` + * reading it kept is gone (objectui#13476), and objectstack's publish gate + * refuses a `${…}` token in this copy. * * ⛔ `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. + * `{{ result.message }}` as the copy. * * 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 @@ -1807,7 +1812,7 @@ export class ActionRunner { if (successCopy !== undefined && successCopy.trim() !== '') { return hasTemplateHoles(successCopy) ? fillTemplateHoles(successCopy, { result: payload, record: this.dispatchRecord(action) }) - : substituteScopeTokens(successCopy, { result: payload }, (value) => value); + : successCopy; } return this.runnerText(RUNNER_TEXT.completedSuccessfully); } @@ -1821,9 +1826,9 @@ export class ActionRunner { * 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. + * first). A sentence with no `{{ }}` hole is shown as written: a `${…}` + * token in it is text, not an expression (objectui#13476 retired the + * transitional `${…}` reading; objectstack's publish gate refuses the token). * * A declared value that carries no text — an envelope with no string * `source` — still gates the run: the author declared a confirmation, and @@ -1831,15 +1836,12 @@ export class ActionRunner { * 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 { + private confirmMessage(action: ActionDef): 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); - } + if (!hasTemplateHoles(text)) return text; const params = action.params; const staticParams = params && typeof params === 'object' && !Array.isArray(params) ? Object.fromEntries(Object.entries(params).filter(([key]) => key !== '_rowRecord')) @@ -2815,10 +2817,10 @@ export class ActionRunner { // there would widen the authorable surface with nothing behind it and // silently blank the token instead of leaving the author's mistake visible. // - // The success toast's copy is the one other caller of the grammar, through - // `substituteScopeTokens` directly (`composeSuccessMessage`): `result` only, - // because that is the one scope the contract declares for the copy, and no - // encoding, because a toast is text. + // The success toast's `outcomeMessages` copy is the one other caller of the + // grammar, through `substituteScopeTokens` directly (`composeSuccessMessage`): + // `result` only, because that is the one scope the contract declares for the + // copy, and no encoding, because a toast is text. const scopes: Record = { param: params, ctx: this.buildInterpolationContext() }; if (resultScope !== undefined) scopes.result = resultScope; return substituteScopeTokens(target, scopes, encodeURIComponent); @@ -2866,9 +2868,10 @@ export class ActionRunner { } /** - * The evaluator a dispatch's authored copy is read through: `confirmText`, - * and the param dialog's `description` / `helpText` via the handler's - * `interpolate` argument. + * The evaluator a dispatch's authored copy is read through: the param + * dialog's `description` / `helpText`, via the handler's `interpolate` + * argument. (`confirmText` reads `{{ }}` holes only, over `dispatchRecord`, + * the same record this binds — see `confirmMessage`.) * * One rule for every door (objectui#12967): the record the action runs * against is `record`. The record header binds it in the runner's context @@ -2999,7 +3002,7 @@ export function readOnSuccessNavigation(value: unknown): OnSuccessNavigation | n /** * The runner's ONE `${scope.path}` token grammar — the substitution behind * `interpolateTarget` (url / api targets and the `onSuccess.navigate` hop) and - * behind the success toast's `${result.*}` copy (`composeSuccessMessage`). + * behind the success toast's `outcomeMessages` copy (`composeSuccessMessage`). * * The scope MAP defines the vocabulary: the pattern is built from its keys, so * a token naming a scope the caller did not pass is left in the text exactly as diff --git a/packages/core/src/actions/__tests__/ActionRunner.callContext-13063.test.ts b/packages/core/src/actions/__tests__/ActionRunner.callContext-13063.test.ts index 228e7da0a9..fc11506d4b 100644 --- a/packages/core/src/actions/__tests__/ActionRunner.callContext-13063.test.ts +++ b/packages/core/src/actions/__tests__/ActionRunner.callContext-13063.test.ts @@ -87,7 +87,7 @@ describe('execute with a call context (objectui#13063)', () => { runner.setConfirmHandler(confirm); const result = await runner.execute( - { type: 'form', target: 'task.edit', confirmText: 'Log time on ${record.name}?' }, + { type: 'form', target: 'task.edit', confirmText: 'Log time on {{ record.name }}?' }, { record: ROW, objectName: 'task' }, ); expect(result.success).toBe(false); diff --git a/packages/core/src/actions/__tests__/ActionRunner.copyTemplate-13367.test.ts b/packages/core/src/actions/__tests__/ActionRunner.copyTemplate-13367.test.ts index 0052c882bb..ba5252d15e 100644 --- a/packages/core/src/actions/__tests__/ActionRunner.copyTemplate-13367.test.ts +++ b/packages/core/src/actions/__tests__/ActionRunner.copyTemplate-13367.test.ts @@ -24,7 +24,9 @@ * - 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; + * - no `${…}` reading: a sentence with no `{{ }}` hole is shown as written, + * `${…}` tokens included (objectui#13476 retired the transitional + * reading this card kept); * - 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. @@ -88,8 +90,10 @@ describe('confirmText reads the envelope and fills {{ }} over record and params 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('a `${…}` token in a sentence with no hole is text, not an expression (objectui#13476)', async () => { + expect(await confirmTextFor(header(), { ...BASE, confirmText: 'Archive ${record.name}?' })).toBe('Archive ${record.name}?'); + const row = { ...BASE, confirmText: envelope('Archive ${record.name}?'), params: { _rowRecord: { ...ROW } } }; + expect(await confirmTextFor(list(), row)).toBe('Archive ${record.name}?'); }); it('one dialect per sentence: a record value is never evaluated as `${…}` after filling a hole', async () => { @@ -165,9 +169,9 @@ describe('successMessage reads the envelope and fills {{ }} over result and reco expect(toastText()).toBe('Invitation sent'); }); - it('transition: the platform objects\' `${result.email}` sentence is still filled', async () => { + it('a `${result.*}` token in a sentence with no hole is text, not filled (objectui#13476)', async () => { await runner.execute({ type: 'script', name: 'invite', successMessage: 'Invitation sent to ${result.email}' }); - expect(toastText()).toBe('Invitation sent to ann@example.com'); + expect(toastText()).toBe('Invitation sent to ${result.email}'); }); it('one dialect per sentence: a filled value is not read again as `${result.*}`', async () => { diff --git a/packages/core/src/actions/__tests__/ActionRunner.outcomeMessages-11344.test.ts b/packages/core/src/actions/__tests__/ActionRunner.outcomeMessages-11344.test.ts index 080a73fca0..9e1cbb5889 100644 --- a/packages/core/src/actions/__tests__/ActionRunner.outcomeMessages-11344.test.ts +++ b/packages/core/src/actions/__tests__/ActionRunner.outcomeMessages-11344.test.ts @@ -15,9 +15,10 @@ * the user's locale. `ActionSchema.outcomeMessages` (`@objectstack/spec` * 17.6.0) carries the copy per outcome, and the spec's own docblock gives the * selection: the success payload's top-level `outcome` names the entry; failing - * a match, `successMessage`; failing that, the runner's default text. Each - * message may interpolate `${result.*}`, the scope `onSuccess.navigate` - * declares. + * a match, `successMessage`; failing that, the runner's default text. An + * `outcomeMessages` entry may interpolate `${result.*}`, the scope + * `onSuccess.navigate` declares; `successMessage` is a `template` slot and + * reads the same payload as `{{ result.* }}` (objectui#13367, objectui#13476). * * Every assertion reads what the host's toast handler RECEIVES — the only thing * the user sees. The handler's answer in most rows also carries a `message`, so @@ -42,7 +43,7 @@ const DELETE_ENVIRONMENT: ActionDef = { archived: 'Environment ${result.name} archived', already_archived: 'Environment ${result.name} was already archived', }, - successMessage: 'Environment ${result.name} updated', + successMessage: 'Environment {{ result.name }} updated', }; describe('ActionRunner success toast — outcomeMessages, then successMessage, then the default (objectui#11344)', () => { @@ -85,24 +86,30 @@ describe('ActionRunner success toast — outcomeMessages, then successMessage, t expect(shown()).toBe('Environment prod archived'); }); - it('rung 2 — an outcome with no entry falls back to successMessage, interpolated the same way', async () => { + it('rung 2 — an outcome with no entry falls back to successMessage, filled from the same payload', async () => { answer = { success: true, data: { ...ARCHIVED, outcome: 'purge_deferred' } }; await runner.execute(DELETE_ENVIRONMENT); expect(shown()).toBe('Environment prod updated'); }); - it('rung 2 — an action with only successMessage shows it, interpolated', async () => { + it('rung 2 — an action with only successMessage shows it, filled', async () => { answer = { success: true, data: { update_count: 2, summary: 'CRM 1.0.0→1.0.1' } }; await runner.execute({ type: 'script', name: 'check_app_updates', - successMessage: '${result.update_count} update(s): ${result.summary}', + successMessage: '{{ result.update_count }} update(s): {{ result.summary }}', }); - // Inserted as TEXT. The same grammar percent-encodes in a URL position - // (`onSuccess.navigate`), which here would read `CRM%201.0.0%E2%86%921.0.1`. + // Inserted as TEXT, never percent-encoded the way a URL position + // (`onSuccess.navigate`) would read it: `CRM%201.0.0%E2%86%921.0.1`. expect(shown()).toBe('2 update(s): CRM 1.0.0→1.0.1'); }); + it('rung 2 — a `${result.*}` token in a successMessage with no hole is text (objectui#13476)', async () => { + answer = { success: true, data: { ...ARCHIVED, outcome: 'purge_deferred' } }; + await runner.execute({ ...DELETE_ENVIRONMENT, successMessage: 'Environment ${result.name} updated' }); + expect(shown()).toBe('Environment ${result.name} updated'); + }); + it('rung 3 — neither rung applies: the runner default, in its translator\'s language', async () => { runner.setTranslator(() => '操作已成功完成'); await runner.execute({ type: 'script', name: 'delete_environment' }); @@ -125,19 +132,18 @@ describe('ActionRunner success toast — outcomeMessages, then successMessage, t expect(shown()).toBe('Action completed successfully'); }); - it("an author can still show the server's sentence by writing ${result.message} as the copy", async () => { - await runner.execute({ type: 'script', name: 'publish', successMessage: '${result.message}' }); + it("an author can still show the server's sentence by writing {{ result.message }} as the copy", async () => { + await runner.execute({ type: 'script', name: 'publish', successMessage: '{{ result.message }}' }); expect(shown()).toBe(ARCHIVED.message); }); - it('the copy reads the `result` scope only — a `${ctx.*}` or `${param.*}` token stays as written', async () => { + it('an outcome entry reads the `result` scope only — a `${ctx.*}` or `${param.*}` token stays as written', async () => { // `result` is the one scope the contract declares for this copy. The // grammar is shared with url / api targets, whose `ctx` and `param` scopes // stay theirs: a token naming them here is left visible, not blanked. await runner.execute({ - type: 'script', - name: 'delete_environment', - successMessage: '${result.name} by ${ctx.user.name} with ${param.reason}', + ...DELETE_ENVIRONMENT, + outcomeMessages: { archived: '${result.name} by ${ctx.user.name} with ${param.reason}' }, }); expect(shown()).toBe('prod by ${ctx.user.name} with ${param.reason}'); }); diff --git a/packages/core/src/actions/__tests__/ActionRunner.rowRecordTemplates-12967.test.ts b/packages/core/src/actions/__tests__/ActionRunner.rowRecordTemplates-12967.test.ts index 05eb83f6d4..53db7a5ef1 100644 --- a/packages/core/src/actions/__tests__/ActionRunner.rowRecordTemplates-12967.test.ts +++ b/packages/core/src/actions/__tests__/ActionRunner.rowRecordTemplates-12967.test.ts @@ -16,7 +16,8 @@ * runner. The runner read `confirmText` in its own context only, so one * action's `${record.display_name}` read filled from the header and raw from a * row. The param dialog's `description` / `helpText` were never filled on - * either door. + * either door. (`confirmText` has read `{{ }}` holes only since objectui#13476; + * the row is still its `record`.) * * Each pin drives the SAME action through both doors: * - header — a runner whose context carries `record`, dispatch without a row; @@ -31,7 +32,7 @@ const ROW = { id: 'env_1', display_name: 'Acme' }; const PAUSE: ActionDef = { name: 'pause_environment', label: 'Pause', - confirmText: 'Pause ${record.display_name}?', + confirmText: 'Pause {{ record.display_name }}?', onClick: () => {}, }; @@ -85,7 +86,7 @@ describe('ActionRunner — a row action reads the row as `record` (objectui#1296 }); it('(c) CONTROL: a hole naming a field the record lacks degrades the same way on both doors', async () => { - const missing: ActionDef = { ...PAUSE, confirmText: 'Pause ${record.no_such_field}?' }; + const missing: ActionDef = { ...PAUSE, confirmText: 'Pause {{ record.no_such_field }}?' }; const header = await confirmTextFor(headerDoor(), missing); const row = await confirmTextFor(listDoor(), asRow(missing)); // The header's degradation, measured: the member reads `undefined` and the @@ -96,11 +97,11 @@ describe('ActionRunner — a row action reads the row as `record` (objectui#1296 it('CONTROL: a dispatch without a row reads the runner context unchanged', async () => { // A list page firing a row-less action: `record` is unbound, so the hole - // stays as authored — the evaluator's existing answer for an unbound root. - expect(await confirmTextFor(listDoor(), PAUSE)).toBe('Pause ${record.display_name}?'); + // prints empty — the template engine's answer for an unbound root. + expect(await confirmTextFor(listDoor(), PAUSE)).toBe('Pause ?'); }); - it('(d) CONTROL: a toast\'s `${result.*}` is read from the result, unchanged by the row binding', async () => { + it('(d) CONTROL: a toast\'s `{{ result.* }}` is read from the result, unchanged by the row binding', async () => { const runner = listDoor(); const toast = vi.fn(); runner.setToastHandler(toast); @@ -108,7 +109,7 @@ describe('ActionRunner — a row action reads the row as `record` (objectui#1296 const result = await runner.execute(asRow({ name: 'custom_run', type: 'custom' as never, - successMessage: 'Done: ${result.display_name}', + successMessage: 'Done: {{ result.display_name }}', } as ActionDef)); expect(result.success).toBe(true); expect(toast).toHaveBeenCalledWith('Done: From result', expect.objectContaining({ type: 'success' })); diff --git a/packages/core/src/actions/__tests__/ActionRunner.test.ts b/packages/core/src/actions/__tests__/ActionRunner.test.ts index dfdb2095e6..a6fac418d1 100644 --- a/packages/core/src/actions/__tests__/ActionRunner.test.ts +++ b/packages/core/src/actions/__tests__/ActionRunner.test.ts @@ -868,13 +868,13 @@ describe('ActionRunner', () => { // This row pinned the opposite until objectui#11344: the server's // sentence outranked the author's copy. Ruling A on // objectstack-ai/cloud#2315 retired that rung \u2014 the server returns facts - // and the author's copy (here, `${result.*}`-interpolated) is the toast. + // and the author's copy (here, `{{ result.* }}`-filled) is the toast. runner.registerHandler('check-updates', vi.fn().mockResolvedValue({ success: true, data: { message: '2 app update(s) available: CRM 1.0.0\u21921.0.1', update_count: 2 }, })); - await runner.execute({ type: 'check-updates', successMessage: 'Checked: ${result.update_count} update(s).' }); + await runner.execute({ type: 'check-updates', successMessage: 'Checked: {{ result.update_count }} update(s).' }); expect(toastHandler).toHaveBeenCalledWith( 'Checked: 2 update(s).', diff --git a/packages/core/src/actions/actionCopyTemplate.ts b/packages/core/src/actions/actionCopyTemplate.ts index 74c2c0c047..9649da8a9d 100644 --- a/packages/core/src/actions/actionCopyTemplate.ts +++ b/packages/core/src/actions/actionCopyTemplate.ts @@ -56,10 +56,11 @@ 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. + * sentence. A sentence with a `{{ }}` hole is filled by the template engine + * alone; one without is shown as written — the transitional `${…}` reading it + * used to get is retired (objectui#13476). So no `${…}` token in this copy is + * ever read as an expression, and a value filled into a hole is never read a + * second time. */ export function hasTemplateHoles(text: string): boolean { return TEMPLATE_HOLE.test(text); diff --git a/packages/plugin-grid/src/__tests__/rowActionRecordHole-12967.test.tsx b/packages/plugin-grid/src/__tests__/rowActionRecordHole-12967.test.tsx index 412c2aaa5e..b63c39a8ae 100644 --- a/packages/plugin-grid/src/__tests__/rowActionRecordHole-12967.test.tsx +++ b/packages/plugin-grid/src/__tests__/rowActionRecordHole-12967.test.tsx @@ -7,7 +7,7 @@ */ /** - * objectui#12967 — one action, two doors: a `${record.*}` hole in its + * objectui#12967 — one action, two doors: a `record` hole in its * `confirmText` reads filled from the record header and from a list row. * * The header door is an `ActionProvider` whose context carries `record` (the @@ -15,7 +15,8 @@ * `ObjectGrid` firing the same action from a row's menu, which dispatches it as * `{ ...def, params: { _rowRecord: row } }` under a provider whose context * names no record (a list page). Before the fix the row door's confirm read - * `Pause ${record.display_name}?`, the template source. + * `Pause ${record.display_name}?`, the template source. (The hole is written + * `{{ record.* }}` since objectui#13476 retired the `${…}` reading.) */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, waitFor, cleanup } from '@testing-library/react'; @@ -100,13 +101,13 @@ afterEach(() => cleanup()); describe('one action, two doors: a record hole in `confirmText` (objectui#12967)', () => { it('(a) reads filled from the record header and from a list row', async () => { - expect(await headerConfirm('Pause ${record.display_name}?')).toBe('Pause Acme?'); - expect(await rowConfirm('Pause ${record.display_name}?')).toBe('Pause Acme?'); + expect(await headerConfirm('Pause {{ record.display_name }}?')).toBe('Pause Acme?'); + expect(await rowConfirm('Pause {{ record.display_name }}?')).toBe('Pause Acme?'); }); it('(c) CONTROL: a hole naming a field the record lacks degrades the same way on both doors', async () => { - const header = await headerConfirm('Pause ${record.no_such_field}?'); - const row = await rowConfirm('Pause ${record.no_such_field}?'); + const header = await headerConfirm('Pause {{ record.no_such_field }}?'); + const row = await rowConfirm('Pause {{ record.no_such_field }}?'); expect(header).toBe('Pause ?'); expect(row).toBe(header); }); diff --git a/packages/react/src/hooks/__tests__/useActionTextLocalizer.outcomeMessages-11344.test.tsx b/packages/react/src/hooks/__tests__/useActionTextLocalizer.outcomeMessages-11344.test.tsx index 180d0fd3d6..28ccbcc4e8 100644 --- a/packages/react/src/hooks/__tests__/useActionTextLocalizer.outcomeMessages-11344.test.tsx +++ b/packages/react/src/hooks/__tests__/useActionTextLocalizer.outcomeMessages-11344.test.tsx @@ -53,7 +53,7 @@ const DELETE_ENVIRONMENT = { }, already_archived: 'Environment ${result.name} was already archived', }, - successMessage: 'Environment ${result.name} updated', + successMessage: 'Environment {{ result.name }} updated', }; function harness(language: 'zh-CN' | 'ja-JP' | 'en', onToast: ToastHandler) { @@ -183,9 +183,9 @@ describe('outcome copy reaches the toast in the user\'s locale (objectui#11344)' const localized = result.current.localize('environment', { ...DELETE_ENVIRONMENT, outcomeMessages: { already_archived: 'Environment ${result.name} was already archived' }, - successMessage: { en: 'Environment ${result.name} updated', 'zh-CN': '环境 ${result.name} 已更新' }, + successMessage: { en: 'Environment {{ result.name }} updated', 'zh-CN': '环境 {{ result.name }} 已更新' }, }); - expect(localized.successMessage).toBe('环境 ${result.name} 已更新'); + expect(localized.successMessage).toBe('环境 {{ result.name }} 已更新'); await act(async () => { await result.current.action.execute(dispatched(localized));