Skip to content
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
13 changes: 13 additions & 0 deletions .changeset/26459-showcase-action-toasts-name-record.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 11 additions & 4 deletions examples/app-showcase/src/system/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).
Expand Down
23 changes: 18 additions & 5 deletions examples/app-showcase/src/ui/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
121 changes: 121 additions & 0 deletions examples/app-showcase/test/record-action-success-names-record.test.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): string {
const source = sourceOf(action.successMessage);
if (!/\{\{[^}]*\}\}/.test(source)) return source;
const filled = templateEngine.evaluate<string>(
{ 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.<object>._actions.<name>.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<string, { _actions?: Record<string, { successMessage?: unknown }> }>;
}).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);
});
});
Loading