Skip to content

Commit ce9bc10

Browse files
fix(app-showcase): record-action success toasts name the record (#26604)
Fixes #26459 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:showcase 的五个记录级动作(Mark Done、Recalculate Estimate、Recalculate Selection、Archive、Submit for Sign-off)的成功提示现在点名被改的记录,例如 "PII access review marked done.",发票用发票号 "INV-1002 submitted for finance + legal sign-off."。只改了 `examples/app-showcase/src/ui/actions/index.ts` 里的 successMessage 文案,外加一个钉住提示原文的测试和一个 changeset。 - **为什么改**:用户连续标记多条任务完成时,每次都只看到 "Task marked done.",分不清改的是哪一条;裁决 C(#25849)要求记录级写操作点名记录(标题)或数量。 - **风险与回滚**:风险低,只动示例应用的文案。若某个列表行没带出 title 列,文案自动退回原来的对象措辞(如 "Task marked done."),不会出现空引号或原样的占位符。回滚即还原这一个提交。 - **先看**:`examples/app-showcase/src/ui/actions/index.ts` 的五处 successMessage,以及新测试 `examples/app-showcase/test/record-action-success-names-record.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/example-showcase exec vitest run test/record-action-success-names-record.test.ts`,10 条全绿;或启动 showcase,打开一条 done 为 false 的任务,点 Mark Done,提示应为该任务标题加 "marked done."。 - **需要你做的**:无须操作即可合入。可选:中文语言包里这几条 successMessage 仍是不带记录名的旧译文(本单按认领禁改语言包),是否另开卡由你定。 ## What The showcase's record-level actions now name the record they changed in their success toast: | Action | Before | After (record carries its title) | After (no title bound) | |---|---|---|---| | Mark Done | Task marked done. | PII access review marked done. | Task marked done. | | Recalculate Estimate | Estimate recalculated. | Estimate recalculated for PII access review. | Estimate recalculated for the task. | | Recalculate Selection | Estimates recalculated for the whole selection. | Estimates recalculated for PII access review. | Estimates recalculated for the whole selection. | | Archive | Task archived (demo — no data changed). | PII access review archived (demo — no data changed). | Task archived (demo — no data changed). | | Submit for Sign-off | Invoice submitted for finance + legal sign-off. | INV-1002 submitted for finance + legal sign-off. | Invoice submitted for finance + legal sign-off. | ## Why Ruling C on #25849: a record-level write names the record (its title) or the count. The console renders an authored `successMessage` verbatim, so the fix is the showcase's own copy, not the runner. ## How the hole binds (read from the runner, not changed) `ActionRunner.composeSuccessMessage` (objectui core) shows a `successMessage` as written unless it carries a template hole; then `fillTemplateHoles` fills it through `@objectstack/formula`'s `templateEngine` over exactly two roots: `result` (the handler's payload) and `record` (the dispatch's record: the clicked row's `_rowRecord`, else the page's record). So the record.title hole names a task and the record.name hole names an invoice (its "Invoice Number" field). An unbound path prints EMPTY. A list row whose view does not project `title` would therefore have read as a sentence with a hole-shaped gap, so every message pipes the hole through the engine's `default` formatter, and falls back to the object wording the toast used before. No sentence can render a raw hole or an empty quoted name. The count is not reachable from this copy: the copy's holes bind `record` and `result`, never the selection (`ctx.selection` is a target-interpolation scope only). It is also not needed. On the selection bar the aggregate dispatch runs with the success toast off (`ObjectGrid.runBulkActionAggregate` passes `showOnSuccess: false`), and the bulk dialog reports its own count ("Succeeded N / M"). The only placement where Recalculate Selection's toast shows is its declared `record_more` entry, a single-record dispatch, so it names that record and keeps "the whole selection" as the fallback. ## Tests All at head `6399091d` (after merging `origin/main`, closure rebuilt under the verify lock): - Red first, at `f9e79460` (pin only, copy unchanged): `vitest run test/record-action-success-names-record.test.ts`, 6 failed / 4 passed (10). The 4 that passed are the fallback cases, which equal today's wording by design. Example: `Expected: "PII access review marked done." Received: "Task marked done."`. - Green: the same file, 10/10 passed. - `pnpm --filter @objectstack/example-showcase test`: Test Files 40 passed (40), Tests 494 passed (494). - `pnpm --filter @objectstack/example-showcase typecheck`: exit 0. - `pnpm --filter @objectstack/example-showcase validate` (`objectstack validate`, which judges action copy templates): Validation passed. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `6399091d`: 50 derived; 49 ran with exit 0. `pnpm check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET (it needs every package's `dist/`, and this card builds only the showcase closure), so it is NOT MEASURED locally and is left to CI's build. `--ran` reconciliation: 50 accounted, 49 run, 1 NOT-MEASURED. `node scripts/check-sdui-manifest.mjs`: exit 0. ## Acceptance notes - The zh-CN pack in `examples/app-showcase/src/system/translations/index.ts` still carries the old record-less wording for these actions (e.g. 任务已标记为完成。). Locale packs are outside this card's claimed file surface, so a zh-CN viewer still sees no record name. - `docs/qa/platform-checklist/areas/ux-conventions.json` quotes the old Mark Done string in its variant text. It is the QA ledger's evidence prose, left to the QA seat. - `GenerateInvoicePdfAction` ("The PDF is attached to the invoice.") is also record-level copy, but the card does not name it, so it is unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8c0306f commit ce9bc10

4 files changed

Lines changed: 163 additions & 9 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/example-showcase': patch
3+
---
4+
5+
Clause-②: no
6+
Metadata-protocol: none
7+
8+
The showcase's record actions now say which record they changed. Mark Done, Recalculate Estimate,
9+
Recalculate Selection, Archive and Submit for Sign-off name the task's title (or the invoice number)
10+
in their success toast, through a template hole over the dispatched record, so "Task marked done."
11+
becomes "PII access review marked done.". When the dispatch carries no title (a list row whose view
12+
does not show the column), each message falls back to the object wording it used before. The zh-CN
13+
bundle entries for the four task actions carry the same hole, so a zh-CN viewer is told the record too.

‎examples/app-showcase/src/system/translations/index.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -514,13 +514,20 @@ export const ShowcaseTranslationBundle = {
514514
_actions: {
515515
// The two recalc surfaces of the same endpoint: one dispatch per
516516
// record vs ONE dispatch for the whole selection (objectui#3139).
517+
//
518+
// Each success message names the task it changed, as its English
519+
// source does (`src/ui/actions/index.ts`): this entry REPLACES the
520+
// source copy for a zh-CN viewer, and the runner fills its
521+
// `{{ record.title }}` hole the same way. An unbound path prints
522+
// empty, so `default` restores the object wording for a list row
523+
// whose view does not project `title`.
517524
showcase_recalc_estimate: {
518525
label: '重算工时',
519-
successMessage: '工时已重算。',
526+
successMessage: "已重算{{ record.title | default:'该任务' }}的工时。",
520527
},
521528
showcase_recalc_selection: {
522529
label: '重算所选',
523-
successMessage: '已为整个选中集重算工时。',
530+
successMessage: "已为{{ record.title | default:'整个选中集' }}重算工时。",
524531
},
525532
// The hook-refusal specimen: every click is refused by
526533
// showcase_guard_task_reopen, so it carries no successMessage.
@@ -530,13 +537,13 @@ export const ShowcaseTranslationBundle = {
530537
// word the recalc entries above use; 重新分配 is the reassign
531538
// wizard's own flow label (`flows.showcase_reassign_wizard`); 新建…
532539
// is the shape a create entry takes in this bundle.
533-
showcase_mark_done: { label: '标记为完成', successMessage: '任务已标记为完成。' },
540+
showcase_mark_done: { label: '标记为完成', successMessage: "{{ record.title | default:'任务' }}已标记为完成。" },
534541
showcase_open_docs: { label: '打开文档' },
535542
showcase_bulk_reassign: { label: '重新分配…' },
536543
showcase_quick_view: { label: '快速查看' },
537544
showcase_log_time: { label: '记录工时' },
538545
showcase_new_task: { label: '新建任务' },
539-
showcase_archive_task: { label: '归档', successMessage: '任务已归档(演示——未修改任何数据)。' },
546+
showcase_archive_task: { label: '归档', successMessage: "{{ record.title | default:'任务' }}已归档(演示——未修改任何数据)。" },
540547
},
541548
// Section headings of the six form-view projections in
542549
// `ui/views/task.view.ts` (edit / tabbed / wizard / split / quick).

‎examples/app-showcase/src/ui/actions/index.ts‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,12 @@ export const MarkDoneAction = defineAction({
6666
// that wiring a build-time refusal (`action-dispatch-contract-mismatch`)
6767
// instead of a failure on every click.
6868
execution: 'perRecord',
69-
successMessage: 'Task marked done.',
69+
// Names the task it wrote (ux-conventions.write-feedback: a record-level write
70+
// names the record). The runner fills a `{{ }}` hole over `record`, the
71+
// record the dispatch ran against; an unbound path prints empty, so a list
72+
// row whose view does not project `title` falls back to the object wording
73+
// through `default`.
74+
successMessage: "{{ record.title | default:'Task' }} marked done.",
7075
// Hide once the task is complete. Gate on `record.done` (the boolean this
7176
// action sets) so the button vanishes after a successful click and stays
7277
// hidden on finished records (the "why is it still here?" report). NOTE the
@@ -183,7 +188,8 @@ export const RecalcEstimateAction = defineAction({
183188
// endpoint, which is what the platform used to require: one body, one
184189
// contract, and until this key no way to say which.
185190
execution: 'perRecord',
186-
successMessage: 'Estimate recalculated.',
191+
// Names the task (see MarkDoneAction's note on the hole and its fallback).
192+
successMessage: "Estimate recalculated for {{ record.title | default:'the task' }}.",
187193
locations: ['record_more', 'record_section'],
188194
// The endpoint is record-scoped and rejects a body without an id. On a
189195
// record surface the page supplies it; invoked from a LIST ROW (see the
@@ -239,7 +245,12 @@ export const RecalcSelectionAction = defineAction({
239245
// bare-string `bulkActions` wiring of it is refused rather than silently
240246
// recalculating one row out of ten.
241247
execution: 'aggregate',
242-
successMessage: 'Estimates recalculated for the whole selection.',
248+
// This toast shows only at the single-record placement below: the selection
249+
// bar's aggregate dispatch runs with the success toast off and reports its
250+
// own count (`Succeeded N / M`), and the copy's holes bind `record` and
251+
// `result`, never the selection. So it names the record it ran against, and
252+
// says "the whole selection" only when no record is bound.
253+
successMessage: "Estimates recalculated for {{ record.title | default:'the whole selection' }}.",
243254
locations: ['record_more'],
244255
recordIdParam: 'recordId',
245256
refreshAfter: true,
@@ -329,7 +340,8 @@ export const SubmitForSignoffAction = defineAction({
329340
objectName: invoice,
330341
operation: 'update',
331342
patch: { status: 'sent' },
332-
successMessage: 'Invoice submitted for finance + legal sign-off.',
343+
// Names the invoice by its number (see MarkDoneAction's note on the hole).
344+
successMessage: "{{ record.name | default:'Invoice' }} submitted for finance + legal sign-off.",
333345
// Only on invoices not yet sent. `record.`-prefixed comparison, per the
334346
// ActionEngine's fail-closed CEL evaluation (see MarkDoneAction's note).
335347
// #8990 — `has()` guards the sparse face this reaches via `list_item`; the
@@ -479,7 +491,8 @@ export const ArchiveTaskAction = defineAction({
479491
"return { ok: true, archived: id };",
480492
capabilities: [],
481493
},
482-
successMessage: 'Task archived (demo — no data changed).',
494+
// Names the task (see MarkDoneAction's note on the hole and its fallback).
495+
successMessage: "{{ record.title | default:'Task' }} archived (demo — no data changed).",
483496
// Disabled while the task is not done — visible either way. #8990: `has()`
484497
// for the sparse face; `!=` against a literal cannot fault, so no `!= null`.
485498
disabled: 'has(record.done) && record.done != true',
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A record-level action's success toast names the record it wrote
5+
* (`ux-conventions.write-feedback` acceptance[1]: a record-level write names
6+
* the record — its title — or the count).
7+
*
8+
* The console's action runner shows an authored `successMessage` as written
9+
* unless it carries a template hole; then it fills the holes through
10+
* `@objectstack/formula`'s `templateEngine` over exactly two roots, `result`
11+
* (the handler's payload) and `record` (the record the dispatch ran against:
12+
* the clicked row, else the page's record). A hole naming anything the roots
13+
* do not bind prints EMPTY (objectui `ActionRunner.composeSuccessMessage` →
14+
* `actionCopyTemplate.fillTemplateHoles`). So a sentence that names the
15+
* record must say what it prints for a record whose title the dispatch does
16+
* not carry (a list row whose view does not project the column): each one
17+
* here falls back, through the `default` formatter, to the object wording the
18+
* toast used before.
19+
*
20+
* `fillAsTheRunnerDoes` restates that one call (same engine, same roots, same
21+
* "no hole ⇒ as written" rule) so these assertions read the toast the user
22+
* would see. The engine is aliased to source in `vitest.config.ts`.
23+
*/
24+
25+
import { describe, expect, it } from 'vitest';
26+
import { templateEngine } from '@objectstack/formula';
27+
import {
28+
ArchiveTaskAction,
29+
MarkDoneAction,
30+
RecalcEstimateAction,
31+
RecalcSelectionAction,
32+
SubmitForSignoffAction,
33+
} from '../src/ui/actions/index.js';
34+
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';
35+
36+
/** The copy's text: a bare string, or the `template` envelope's `source`. */
37+
function sourceOf(copy: unknown): string {
38+
if (typeof copy === 'string') return copy;
39+
const source = (copy as { source?: unknown } | undefined)?.source;
40+
if (typeof source !== 'string') throw new Error(`successMessage carries no text: ${JSON.stringify(copy)}`);
41+
return source;
42+
}
43+
44+
/** The success toast the runner composes for `action` run against `record`. */
45+
function fillAsTheRunnerDoes(action: { successMessage?: unknown }, record: Record<string, unknown>): string {
46+
const source = sourceOf(action.successMessage);
47+
if (!/\{\{[^}]*\}\}/.test(source)) return source;
48+
const filled = templateEngine.evaluate<string>(
49+
{ dialect: 'template', source },
50+
{ extra: { result: { success: true }, record } } as never,
51+
);
52+
if (!filled.ok) throw new Error(`template refused: ${filled.error.message}`);
53+
return filled.value;
54+
}
55+
56+
const task = { id: 't1', title: 'PII access review', done: false };
57+
/** A list row whose view does not project `title`. */
58+
const sparseTask = { id: 't1', done: false };
59+
const invoice = { id: 'i1', name: 'INV-1002', status: 'draft' };
60+
const sparseInvoice = { id: 'i1', status: 'draft' };
61+
62+
describe('showcase record actions — the success toast names the record', () => {
63+
it.each([
64+
['Mark Done', MarkDoneAction, task, 'PII access review marked done.'],
65+
['Recalculate Estimate', RecalcEstimateAction, task, 'Estimate recalculated for PII access review.'],
66+
['Recalculate Selection (record overflow menu)', RecalcSelectionAction, task, 'Estimates recalculated for PII access review.'],
67+
['Archive', ArchiveTaskAction, task, 'PII access review archived (demo — no data changed).'],
68+
['Submit for Sign-off', SubmitForSignoffAction, invoice, 'INV-1002 submitted for finance + legal sign-off.'],
69+
] as const)('%s names the record by its title', (_label, action, record, expected) => {
70+
expect(fillAsTheRunnerDoes(action, record)).toBe(expected);
71+
});
72+
73+
it.each([
74+
['Mark Done', MarkDoneAction, sparseTask, 'Task marked done.'],
75+
['Recalculate Estimate', RecalcEstimateAction, sparseTask, 'Estimate recalculated for the task.'],
76+
['Recalculate Selection', RecalcSelectionAction, sparseTask, 'Estimates recalculated for the whole selection.'],
77+
['Archive', ArchiveTaskAction, sparseTask, 'Task archived (demo — no data changed).'],
78+
['Submit for Sign-off', SubmitForSignoffAction, sparseInvoice, 'Invoice submitted for finance + legal sign-off.'],
79+
] as const)('%s falls back to the object wording when the dispatch carries no title', (_label, action, record, expected) => {
80+
expect(fillAsTheRunnerDoes(action, record)).toBe(expected);
81+
});
82+
});
83+
84+
/**
85+
* The zh-CN bundle's entry REPLACES the authored copy before dispatch (the
86+
* console's `useActionTextLocalizer` resolves
87+
* `objects.<object>._actions.<name>.successMessage`, and i18next leaves a
88+
* template hole it cannot bind untouched), and the runner then fills that
89+
* entry's holes exactly as it fills the authored ones. So a zh-CN viewer is
90+
* only told which record changed when the bundle entry carries the hole too.
91+
* Every one of the five actions that HAS a zh-CN entry is held here.
92+
*/
93+
describe('showcase record actions — the zh-CN success toast names the record', () => {
94+
const zhActions = (ShowcaseTranslationBundle['zh-CN'] as {
95+
objects: Record<string, { _actions?: Record<string, { successMessage?: unknown }> }>;
96+
}).objects.showcase_task._actions ?? {};
97+
const zh = (name: string) => {
98+
const entry = zhActions[name];
99+
if (!entry?.successMessage) throw new Error(`no zh-CN successMessage for ${name}`);
100+
return entry;
101+
};
102+
const zhTask = { id: 't1', title: '个人信息访问审查', done: false };
103+
104+
it.each([
105+
['showcase_mark_done', zhTask, '个人信息访问审查已标记为完成。'],
106+
['showcase_recalc_estimate', zhTask, '已重算个人信息访问审查的工时。'],
107+
['showcase_recalc_selection', zhTask, '已为个人信息访问审查重算工时。'],
108+
['showcase_archive_task', zhTask, '个人信息访问审查已归档(演示——未修改任何数据)。'],
109+
] as const)('%s names the record by its title', (name, record, expected) => {
110+
expect(fillAsTheRunnerDoes(zh(name), record)).toBe(expected);
111+
});
112+
113+
it.each([
114+
['showcase_mark_done', sparseTask, '任务已标记为完成。'],
115+
['showcase_recalc_estimate', sparseTask, '已重算该任务的工时。'],
116+
['showcase_recalc_selection', sparseTask, '已为整个选中集重算工时。'],
117+
['showcase_archive_task', sparseTask, '任务已归档(演示——未修改任何数据)。'],
118+
] as const)('%s falls back to the object wording when the dispatch carries no title', (name, record, expected) => {
119+
expect(fillAsTheRunnerDoes(zh(name), record)).toBe(expected);
120+
});
121+
});

0 commit comments

Comments
 (0)