Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.
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
14 changes: 14 additions & 0 deletions .changeset/13476-holeless-action-copy.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 10 additions & 5 deletions content/docs/core/enhanced-actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();

Expand Down
83 changes: 43 additions & 40 deletions packages/core/src/actions/ActionRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
Expand All @@ -1821,25 +1826,22 @@ 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
* 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 {
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'))
Expand Down Expand Up @@ -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<string, unknown> = { param: params, ctx: this.buildInterpolationContext() };
if (resultScope !== undefined) scopes.result = resultScope;
return substituteScopeTokens(target, scopes, encodeURIComponent);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading