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..df38a278317 --- /dev/null +++ b/.changeset/26459-showcase-action-toasts-name-record.md @@ -0,0 +1,13 @@ +--- +'@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. The zh-CN +bundle entries for the four task actions carry the same hole, so a zh-CN viewer is told the record too. 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). 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', 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..dcfd47ca91f --- /dev/null +++ b/examples/app-showcase/test/record-action-success-names-record.test.ts @@ -0,0 +1,121 @@ +// 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'; +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 { + 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); + }); +}); + +/** + * 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); + }); +});