From f9e7946024e34e38937bfc8f2957abfb4853523d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 07:36:31 +0000 Subject: [PATCH 1/5] test(app-showcase): pin record-action success toasts naming the record Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- ...record-action-success-names-record.test.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 examples/app-showcase/test/record-action-success-names-record.test.ts diff --git a/examples/app-showcase/test/record-action-success-names-record.test.ts b/examples/app-showcase/test/record-action-success-names-record.test.ts new file mode 100644 index 00000000000..972e44dba5e --- /dev/null +++ b/examples/app-showcase/test/record-action-success-names-record.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A record-level action's success toast names the record it wrote + * (`ux-conventions.write-feedback` acceptance[1]: a record-level write names + * the record — its title — or the count). + * + * The console's action runner shows an authored `successMessage` as written + * unless it carries a template hole; then it fills the holes through + * `@objectstack/formula`'s `templateEngine` over exactly two roots, `result` + * (the handler's payload) and `record` (the record the dispatch ran against: + * the clicked row, else the page's record). A hole naming anything the roots + * do not bind prints EMPTY (objectui `ActionRunner.composeSuccessMessage` → + * `actionCopyTemplate.fillTemplateHoles`). So a sentence that names the + * record must say what it prints for a record whose title the dispatch does + * not carry (a list row whose view does not project the column): each one + * here falls back, through the `default` formatter, to the object wording the + * toast used before. + * + * `fillAsTheRunnerDoes` restates that one call (same engine, same roots, same + * "no hole ⇒ as written" rule) so these assertions read the toast the user + * would see. The engine is aliased to source in `vitest.config.ts`. + */ + +import { describe, expect, it } from 'vitest'; +import { templateEngine } from '@objectstack/formula'; +import { + ArchiveTaskAction, + MarkDoneAction, + RecalcEstimateAction, + RecalcSelectionAction, + SubmitForSignoffAction, +} from '../src/ui/actions/index.js'; + +/** The copy's text: a bare string, or the `template` envelope's `source`. */ +function sourceOf(copy: unknown): string { + if (typeof copy === 'string') return copy; + const source = (copy as { source?: unknown } | undefined)?.source; + if (typeof source !== 'string') throw new Error(`successMessage carries no text: ${JSON.stringify(copy)}`); + return source; +} + +/** The success toast the runner composes for `action` run against `record`. */ +function fillAsTheRunnerDoes(action: { successMessage?: unknown }, record: Record): string { + const source = sourceOf(action.successMessage); + if (!/\{\{[^}]*\}\}/.test(source)) return source; + const filled = templateEngine.evaluate( + { dialect: 'template', source }, + { extra: { result: { success: true }, record } } as never, + ); + if (!filled.ok) throw new Error(`template refused: ${filled.error.message}`); + return filled.value; +} + +const task = { id: 't1', title: 'PII access review', done: false }; +/** A list row whose view does not project `title`. */ +const sparseTask = { id: 't1', done: false }; +const invoice = { id: 'i1', name: 'INV-1002', status: 'draft' }; +const sparseInvoice = { id: 'i1', status: 'draft' }; + +describe('showcase record actions — the success toast names the record', () => { + it.each([ + ['Mark Done', MarkDoneAction, task, 'PII access review marked done.'], + ['Recalculate Estimate', RecalcEstimateAction, task, 'Estimate recalculated for PII access review.'], + ['Recalculate Selection (record overflow menu)', RecalcSelectionAction, task, 'Estimates recalculated for PII access review.'], + ['Archive', ArchiveTaskAction, task, 'PII access review archived (demo — no data changed).'], + ['Submit for Sign-off', SubmitForSignoffAction, invoice, 'INV-1002 submitted for finance + legal sign-off.'], + ] as const)('%s names the record by its title', (_label, action, record, expected) => { + expect(fillAsTheRunnerDoes(action, record)).toBe(expected); + }); + + it.each([ + ['Mark Done', MarkDoneAction, sparseTask, 'Task marked done.'], + ['Recalculate Estimate', RecalcEstimateAction, sparseTask, 'Estimate recalculated for the task.'], + ['Recalculate Selection', RecalcSelectionAction, sparseTask, 'Estimates recalculated for the whole selection.'], + ['Archive', ArchiveTaskAction, sparseTask, 'Task archived (demo — no data changed).'], + ['Submit for Sign-off', SubmitForSignoffAction, sparseInvoice, 'Invoice submitted for finance + legal sign-off.'], + ] as const)('%s falls back to the object wording when the dispatch carries no title', (_label, action, record, expected) => { + expect(fillAsTheRunnerDoes(action, record)).toBe(expected); + }); +}); From 7dca325031b9bfb0f00987237e862bc7df50e867 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 09:08:17 +0000 Subject: [PATCH 2/5] fix(app-showcase): record-action success toasts name the record Mark Done, Recalculate Estimate, Recalculate Selection, Archive and Submit for Sign-off now name the task title (or invoice number) through a template hole over the dispatched record, falling back to the object wording through the default formatter when the dispatch carries no title. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- ...6459-showcase-action-toasts-name-record.md | 12 ++++++++++ examples/app-showcase/src/ui/actions/index.ts | 23 +++++++++++++++---- 2 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 .changeset/26459-showcase-action-toasts-name-record.md diff --git a/.changeset/26459-showcase-action-toasts-name-record.md b/.changeset/26459-showcase-action-toasts-name-record.md new file mode 100644 index 00000000000..3e389d1f0aa --- /dev/null +++ b/.changeset/26459-showcase-action-toasts-name-record.md @@ -0,0 +1,12 @@ +--- +'@objectstack/example-showcase': patch +--- + +Clause-②: no +Metadata-protocol: none + +The showcase's record actions now say which record they changed. Mark Done, Recalculate Estimate, +Recalculate Selection, Archive and Submit for Sign-off name the task's title (or the invoice number) +in their success toast, through a template hole over the dispatched record, so "Task marked done." +becomes "PII access review marked done.". When the dispatch carries no title (a list row whose view +does not show the column), each message falls back to the object wording it used before. diff --git a/examples/app-showcase/src/ui/actions/index.ts b/examples/app-showcase/src/ui/actions/index.ts index e17b0b244c0..fcb8a237d4f 100644 --- a/examples/app-showcase/src/ui/actions/index.ts +++ b/examples/app-showcase/src/ui/actions/index.ts @@ -66,7 +66,12 @@ export const MarkDoneAction = defineAction({ // that wiring a build-time refusal (`action-dispatch-contract-mismatch`) // instead of a failure on every click. execution: 'perRecord', - successMessage: 'Task marked done.', + // Names the task it wrote (ux-conventions.write-feedback: a record-level write + // names the record). The runner fills a `{{ }}` hole over `record`, the + // record the dispatch ran against; an unbound path prints empty, so a list + // row whose view does not project `title` falls back to the object wording + // through `default`. + successMessage: "{{ record.title | default:'Task' }} marked done.", // Hide once the task is complete. Gate on `record.done` (the boolean this // action sets) so the button vanishes after a successful click and stays // hidden on finished records (the "why is it still here?" report). NOTE the @@ -183,7 +188,8 @@ export const RecalcEstimateAction = defineAction({ // endpoint, which is what the platform used to require: one body, one // contract, and until this key no way to say which. execution: 'perRecord', - successMessage: 'Estimate recalculated.', + // Names the task (see MarkDoneAction's note on the hole and its fallback). + successMessage: "Estimate recalculated for {{ record.title | default:'the task' }}.", locations: ['record_more', 'record_section'], // The endpoint is record-scoped and rejects a body without an id. On a // record surface the page supplies it; invoked from a LIST ROW (see the @@ -239,7 +245,12 @@ export const RecalcSelectionAction = defineAction({ // bare-string `bulkActions` wiring of it is refused rather than silently // recalculating one row out of ten. execution: 'aggregate', - successMessage: 'Estimates recalculated for the whole selection.', + // This toast shows only at the single-record placement below: the selection + // bar's aggregate dispatch runs with the success toast off and reports its + // own count (`Succeeded N / M`), and the copy's holes bind `record` and + // `result`, never the selection. So it names the record it ran against, and + // says "the whole selection" only when no record is bound. + successMessage: "Estimates recalculated for {{ record.title | default:'the whole selection' }}.", locations: ['record_more'], recordIdParam: 'recordId', refreshAfter: true, @@ -329,7 +340,8 @@ export const SubmitForSignoffAction = defineAction({ objectName: invoice, operation: 'update', patch: { status: 'sent' }, - successMessage: 'Invoice submitted for finance + legal sign-off.', + // Names the invoice by its number (see MarkDoneAction's note on the hole). + successMessage: "{{ record.name | default:'Invoice' }} submitted for finance + legal sign-off.", // Only on invoices not yet sent. `record.`-prefixed comparison, per the // ActionEngine's fail-closed CEL evaluation (see MarkDoneAction's note). // #8990 — `has()` guards the sparse face this reaches via `list_item`; the @@ -479,7 +491,8 @@ export const ArchiveTaskAction = defineAction({ "return { ok: true, archived: id };", capabilities: [], }, - successMessage: 'Task archived (demo — no data changed).', + // Names the task (see MarkDoneAction's note on the hole and its fallback). + successMessage: "{{ record.title | default:'Task' }} archived (demo — no data changed).", // Disabled while the task is not done — visible either way. #8990: `has()` // for the sparse face; `!=` against a literal cannot fault, so no `!= null`. disabled: 'has(record.done) && record.done != true', From 3268d2840e8714e0303feefe6dc3cf4f48547876 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 09:57:35 +0000 Subject: [PATCH 3/5] test(app-showcase): pin the zh-CN record-action success toasts naming the record Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- ...record-action-success-names-record.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/examples/app-showcase/test/record-action-success-names-record.test.ts b/examples/app-showcase/test/record-action-success-names-record.test.ts index 972e44dba5e..dcfd47ca91f 100644 --- a/examples/app-showcase/test/record-action-success-names-record.test.ts +++ b/examples/app-showcase/test/record-action-success-names-record.test.ts @@ -31,6 +31,7 @@ import { RecalcSelectionAction, SubmitForSignoffAction, } from '../src/ui/actions/index.js'; +import { ShowcaseTranslationBundle } from '../src/system/translations/index.js'; /** The copy's text: a bare string, or the `template` envelope's `source`. */ function sourceOf(copy: unknown): string { @@ -79,3 +80,42 @@ describe('showcase record actions — the success toast names the record', () => expect(fillAsTheRunnerDoes(action, record)).toBe(expected); }); }); + +/** + * The zh-CN bundle's entry REPLACES the authored copy before dispatch (the + * console's `useActionTextLocalizer` resolves + * `objects.._actions..successMessage`, and i18next leaves a + * template hole it cannot bind untouched), and the runner then fills that + * entry's holes exactly as it fills the authored ones. So a zh-CN viewer is + * only told which record changed when the bundle entry carries the hole too. + * Every one of the five actions that HAS a zh-CN entry is held here. + */ +describe('showcase record actions — the zh-CN success toast names the record', () => { + const zhActions = (ShowcaseTranslationBundle['zh-CN'] as { + objects: Record }>; + }).objects.showcase_task._actions ?? {}; + const zh = (name: string) => { + const entry = zhActions[name]; + if (!entry?.successMessage) throw new Error(`no zh-CN successMessage for ${name}`); + return entry; + }; + const zhTask = { id: 't1', title: '个人信息访问审查', done: false }; + + it.each([ + ['showcase_mark_done', zhTask, '个人信息访问审查已标记为完成。'], + ['showcase_recalc_estimate', zhTask, '已重算个人信息访问审查的工时。'], + ['showcase_recalc_selection', zhTask, '已为个人信息访问审查重算工时。'], + ['showcase_archive_task', zhTask, '个人信息访问审查已归档(演示——未修改任何数据)。'], + ] as const)('%s names the record by its title', (name, record, expected) => { + expect(fillAsTheRunnerDoes(zh(name), record)).toBe(expected); + }); + + it.each([ + ['showcase_mark_done', sparseTask, '任务已标记为完成。'], + ['showcase_recalc_estimate', sparseTask, '已重算该任务的工时。'], + ['showcase_recalc_selection', sparseTask, '已为整个选中集重算工时。'], + ['showcase_archive_task', sparseTask, '任务已归档(演示——未修改任何数据)。'], + ] as const)('%s falls back to the object wording when the dispatch carries no title', (name, record, expected) => { + expect(fillAsTheRunnerDoes(zh(name), record)).toBe(expected); + }); +}); From c79f6232a98d0d5dbe2f211f59361f20ea18e909 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 10:05:19 +0000 Subject: [PATCH 4/5] fix(app-showcase): zh-CN record-action success toasts name the record The zh-CN bundle entries for Mark Done, Recalculate Estimate, Recalculate Selection and Archive replace the source copy for a zh-CN viewer, so they carry the same record.title hole with a default fallback to the old wording. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .../app-showcase/src/system/translations/index.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 413d60c5d65..09cce8f102e 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -514,13 +514,20 @@ export const ShowcaseTranslationBundle = { _actions: { // The two recalc surfaces of the same endpoint: one dispatch per // record vs ONE dispatch for the whole selection (objectui#3139). + // + // Each success message names the task it changed, as its English + // source does (`src/ui/actions/index.ts`): this entry REPLACES the + // source copy for a zh-CN viewer, and the runner fills its + // `{{ record.title }}` hole the same way. An unbound path prints + // empty, so `default` restores the object wording for a list row + // whose view does not project `title`. showcase_recalc_estimate: { label: '重算工时', - successMessage: '工时已重算。', + successMessage: "已重算{{ record.title | default:'该任务' }}的工时。", }, showcase_recalc_selection: { label: '重算所选', - successMessage: '已为整个选中集重算工时。', + successMessage: "已为{{ record.title | default:'整个选中集' }}重算工时。", }, // The hook-refusal specimen: every click is refused by // showcase_guard_task_reopen, so it carries no successMessage. @@ -530,13 +537,13 @@ export const ShowcaseTranslationBundle = { // word the recalc entries above use; 重新分配 is the reassign // wizard's own flow label (`flows.showcase_reassign_wizard`); 新建… // is the shape a create entry takes in this bundle. - showcase_mark_done: { label: '标记为完成', successMessage: '任务已标记为完成。' }, + showcase_mark_done: { label: '标记为完成', successMessage: "{{ record.title | default:'任务' }}已标记为完成。" }, showcase_open_docs: { label: '打开文档' }, showcase_bulk_reassign: { label: '重新分配…' }, showcase_quick_view: { label: '快速查看' }, showcase_log_time: { label: '记录工时' }, showcase_new_task: { label: '新建任务' }, - showcase_archive_task: { label: '归档', successMessage: '任务已归档(演示——未修改任何数据)。' }, + showcase_archive_task: { label: '归档', successMessage: "{{ record.title | default:'任务' }}已归档(演示——未修改任何数据)。" }, }, // Section headings of the six form-view projections in // `ui/views/task.view.ts` (edit / tabbed / wizard / split / quick). From bb657a003deab8fd0905503e01178326a678263e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 10:05:32 +0000 Subject: [PATCH 5/5] chore(changeset): note the zh-CN success toasts Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .changeset/26459-showcase-action-toasts-name-record.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/26459-showcase-action-toasts-name-record.md b/.changeset/26459-showcase-action-toasts-name-record.md index 3e389d1f0aa..df38a278317 100644 --- a/.changeset/26459-showcase-action-toasts-name-record.md +++ b/.changeset/26459-showcase-action-toasts-name-record.md @@ -9,4 +9,5 @@ The showcase's record actions now say which record they changed. Mark Done, Reca Recalculate Selection, Archive and Submit for Sign-off name the task's title (or the invoice number) in their success toast, through a template hole over the dispatched record, so "Task marked done." becomes "PII access review marked done.". When the dispatch carries no title (a list row whose view -does not show the column), each message falls back to the object wording it used before. +does not show the column), each message falls back to the object wording it used before. The zh-CN +bundle entries for the four task actions carry the same hole, so a zh-CN viewer is told the record too.