Skip to content

Commit ea5807e

Browse files
fix(console): zh form titles space a Latin object name, the lookup create dialog has a title, the success toast names the action, money metrics keep currency decimals (#30495)
domain:ui#5 · os-sales · session_01L6bhgTuQiDduLj5Xcj3KMa <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuXevN4DpuBALL1b2DnF5gWK)_ Part of #30126 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 只做 #30126 的 items 1、2、3、7;其余条目不在本 PR。 - item 1:zh 的「新建/编辑/查看{{object}}」在对象名是拉丁文时补一个空格(「新建 Repair Ticket」),中文名不变(「新建联系人」)。做法是 i18n.ts 里按 key 列表生效的 zh postProcessor,语言包拼法不动。 - item 2:查找字段「新建关联记录」弹窗有了标题,复用 form.createTitle;作者写的 title 仍优先。 - item 3:默认成功提示说出动作名(「“解决”已完成」),新 key actions.completedNamed(10 个语言包);无 label 的动作仍是通用文案。 - item 7:无 format 的金额指标按币种自身小数位显示(CNY ¥1,293.00、JPY ¥1,293),与列表单元格一致;根因在 core 的 dataset-format.ts,MetricWidget 静态卡同规则修。 - 门禁 35/35 exit 0,type-check 与 check:spec-symbols exit 0;五处消融各自变红;1440 宽 zh 前后截图见下。 ## What changes - **item 1, zh titles space a Latin object name.** `ui/packages/i18n/src/i18n.ts` registers an i18next post-processor that runs only for the keys in `CJK_LATIN_SPACED_KEYS` (`form.createTitle`, `form.editTitle`, `form.viewTitle`, `console.ai.recordWriteCreateTitle`, `console.ai.recordWriteUpdateTitle`) and only in `zh`. It pads the interpolated `object` value with a space on each side where a Latin letter or digit meets Han text. `新建{{object}}` stays the pack spelling, so the placeholder-spelling ruling (objectui#3512) holds. - **item 2, the lookup's create dialog has a title.** `useActionModal`'s `{ objectName, mode: 'create' }` branch titles the form with `form.createTitle` and the object's label (the console's own New dialog title) when the descriptor has no `title`. An authored `title` wins; an edit form is not given one. - **item 3, the default success toast names the action.** `ActionRunner.composeSuccessMessage`'s last rung reads the new key `actions.completedNamed` (`{{action}} completed`, zh `“{{action}}”已完成`) for an action with a `label`; an action with no label keeps `actions.completedSuccessfully`. The host still localizes the label before dispatch, so the toast carries the label the user saw on the button. - **item 7, money metrics keep the currency's decimals.** `formatMeasure` (core `dataset-format.ts`) no longer passes `minimumFractionDigits: 0, maximumFractionDigits: 2` to a currency amount with no `format`, so Intl uses the currency's own minor-unit width. `MetricWidget`'s static card with a `currency` and no `format` gets the same rule instead of whole units. An authored `format` pattern still states its width. - Changeset: patch for `@object-ui/i18n`, `@object-ui/core`, `@object-ui/app-shell`, `@object-ui/plugin-dashboard`. ## Tests - `i18n.test.ts`: zh 新建/编辑/查看 + `Repair Ticket` is spaced; `新建维修工单` is not; a key outside the table (`form.createSuccess`) is not; ja and en are untouched; unit cases for `spaceLatinValueFromCjk`. - `useActionModal.objectCreateTitle-30126.test.tsx` (new): en `Create Repair Ticket`, zh `新建 Repair Ticket`, authored title wins, CONTROL edit form has no create title. - `ActionRunner.defaultSuccessToast-10900.test.ts`: `Resolve completed` with no translator; the translator is asked for `actions.completedNamed` with `action`; an empty answer falls back to the filled English; a blank label keeps the generic line; `successMessage` still wins. - `dataset-format.test.ts`: CNY 1293 → `¥1,293.00`, 1280.5 → `¥1,280.50`, JPY `¥1,293`, KWD three decimals, pattern `0,0` → `¥1,293`. - `MetricWidget.numberFormat.test.tsx`: CNY `¥1,293.00` / `¥1,280.50`, JPY `¥1,293`, pattern `0,0` → `¥1,281`. - Moved pins (each now reads the new behaviour): `RecordFormPage.i18n.test.tsx` (`新建 Contacts`, pack value still `新建{{object}}`), the two `flowDoneSuccessMessage-12274` tests (`Escalate case completed`), `ObjectView.toolbarScriptSelection-12331` (`Add to Campaign completed`), and `MetricWidget.numberFormat` (`$1,930,000.00`, `1.930.000,00 €`). ## Acceptance notes - **Out of scope:** #30126 items 4, 5, 6 and 8 to 16 (server, CLI, re-measure, or held by other cards). Nothing here touches `packages/**`, `AppHeader.tsx` or `RecordDetailView`. - **Mechanism deviation 1 (item 1):** an i18next formatter has to be spelled inside the pack value (`{{object, name}}`), which breaks the placeholder-spelling gate (`fallback-placeholder-spelling-3512.test.ts`, the objectui#3512 / #4135 ruling: packs use `{{name}}` only). A key-listed post-processor gives the same result with the pack unchanged; the seat agreed (claim amendment 2). - **Mechanism deviation 2 (item 7):** the reported readings do not come from `MetricWidget.metricPatternDecimals`. They come from the dataset metric path, `DatasetWidget` → core `formatMeasure`, which passed `minimumFractionDigits: 0, maximumFractionDigits: 2` (code identical at the `@object-ui/*@18.3.0` release commit 83d7e4a and on `main`). That pair is exactly what prints `¥1,280.5` for 1280.5 (the 18.3.0 report) and `¥1,293` for 1293.00 (the `main` report); `Intl` with no fraction options gives `¥1,280.50` / `¥1,293.00`. Re-measured live on `main` (989ae42): `¥1,293` before, `¥1,293.00` after. The static `MetricWidget` card had the same gap in a different form (whole units, `¥1,281`), fixed by the same rule. The seat added `dataset-format.ts` to the surface. - **Bounded in-place widening:** `form.editTitle`, `form.viewTitle` and `console.ai.recordWriteUpdateTitle` share the exact shape of `createTitle`, so they are in the key list too. - **item 3 interpolation:** `completedNamedText` fills the one `{{action}}` hole with a single `String.replace` for the no-translator fallback; it is not a new general interpolator (the 3512 gate's needle list is unchanged). - **Pre-existing:** `eslint` reports one error in `MetricWidget.tsx` (react-hooks "Cannot create components during render"); it is on base at line 419 too, not from this diff. - New i18n key `actions.completedNamed` in all 10 packs is the Clause-② widening; no key is removed or renamed. ## Verification - Head `70fc02c9` (`origin/main` merged; merge-tree against today's `main` is clean). - `node scripts/pm/dispatch-gates.mjs --commands` on the changed paths: 35 derived families, all run, all exit 0; `--ran` reconcile: 0 NOT-MEASURED, 0 UNRUN. - `type-check` exit 0 for i18n, core, react, plugin-dashboard, app-shell (i18n and app-shell re-run after the merge). `pnpm --dir ui run check:spec-symbols` exit 0. `check:new-line-citations`: 0 new. - vitest on the touched packages after the merge: 100/100 files, 1508 tests passed. `pnpm --dir ui exec vitest run scripts/`: 170 files passed (2 skipped), 4985 tests. - Ablations (each reverted, `git diff HEAD` empty after): post-processor off → 1 failed; modal title off → 2 failed; named toast off → 3 failed; `formatMeasure` width back → 3 failed; `MetricWidget` width back → 4 failed. <details><summary>Before / after, zh, 1440 wide (showcase stack, objects Repair Ticket / Repair Asset)</summary> | item | before (`main`) | after | |---|---|---| | 1 | [新建Repair Ticket](https://github.com/objectstack-ai/objectstack/blob/a21e05e8838cb35609a47e759ef901af618b342a/evidence/30126/item1-before-zh-1440.png) | [新建 Repair Ticket](https://github.com/objectstack-ai/objectstack/blob/a21e05e8838cb35609a47e759ef901af618b342a/evidence/30126/item1-after-zh-1440.png) | | 2 | [no heading](https://github.com/objectstack-ai/objectstack/blob/a21e05e8838cb35609a47e759ef901af618b342a/evidence/30126/item2-before-zh-1440.png) | [新建 Repair Asset](https://github.com/objectstack-ai/objectstack/blob/a21e05e8838cb35609a47e759ef901af618b342a/evidence/30126/item2-after-zh-1440.png) | | 3 | [操作已成功完成](https://github.com/objectstack-ai/objectstack/blob/a21e05e8838cb35609a47e759ef901af618b342a/evidence/30126/item3-before-zh-1440.png) | [“Resolve”已完成](https://github.com/objectstack-ai/objectstack/blob/a21e05e8838cb35609a47e759ef901af618b342a/evidence/30126/item3-after-zh-1440.png) | | 7 | [¥1,293](https://github.com/objectstack-ai/objectstack/blob/a21e05e8838cb35609a47e759ef901af618b342a/evidence/30126/item7-before-zh-1440.png) | [¥1,293.00](https://github.com/objectstack-ai/objectstack/blob/a21e05e8838cb35609a47e759ef901af618b342a/evidence/30126/item7-after-zh-1440.png) | The screenshots live on evidence commit a21e05e and are removed by the next commit, so the diff carries no binaries. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgTuQiDduLj5Xcj3KMa)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgTuQiDduLj5Xcj3KMa --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c322421 commit ea5807e

25 files changed

Lines changed: 402 additions & 26 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/i18n': patch
3+
'@object-ui/core': patch
4+
'@object-ui/app-shell': patch
5+
'@object-ui/plugin-dashboard': patch
6+
---
7+
8+
fix(console): four copy and display details from a real-user walkthrough: Latin object names spaced in zh form titles, a title on the lookup's create dialog, a success toast that names the action, and money metrics at the currency's decimals
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: none
12+
13+
- **zh form titles space a Latin object name.** `新建{{object}}`, `编辑{{object}}` and `查看{{object}}` (and the AI chat's record-write card titles) read `新建 Repair Ticket` when the object has no Chinese label, and still `新建联系人` when it has one. `createI18n` applies the spacing to those keys only, through an i18next post-processor; the packs keep the plain `{{object}}` spelling.
14+
- **The lookup's "create the referenced record" dialog has a title.** A `{ objectName, mode: 'create' }` modal descriptor with no `title` now reads `form.createTitle` with the object's label, the title of the console's own New dialog. An authored title still wins.
15+
- **The default success toast names the action.** An action with a `label` and no `successMessage` (and no matching `outcomeMessages` entry) toasts the new pack key `actions.completedNamed` (`Resolve completed`, zh `“解决”已完成`) instead of the generic `Action completed successfully`. An action with no label keeps the generic text.
16+
- **Money metrics keep the currency's decimals.** A dataset metric over a currency measure with no `format`, and a static metric card with a `currency` and no `format`, show the currency's own minor-unit width (CNY `¥1,293.00`, JPY `¥1,293`), the width the list cell shows. Before, the dataset metric printed the shortest form (`¥1,293`, `¥1,280.5`) and the static card rounded to whole units. An authored `format` pattern still states its width.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* An object create form opened from a `{ objectName, mode: 'create' }`
11+
* descriptor is titled by its object (#30126 item 2).
12+
*
13+
* The descriptor's only producer is the lookup field's inline "create the
14+
* referenced record", which passes no title, so the dialog had no heading and
15+
* opened straight onto the first section's name. It now reads
16+
* `form.createTitle`, the console's own New dialog title. An authored title
17+
* still wins, and an edit form is not given a create title.
18+
*/
19+
20+
import * as React from 'react';
21+
import { describe, it, expect, afterEach } from 'vitest';
22+
import { render, screen, cleanup, act } from '@testing-library/react';
23+
import '@testing-library/jest-dom';
24+
import { MetadataCtx } from '@object-ui/react';
25+
import { I18nProvider } from '@object-ui/i18n';
26+
import { useActionModal } from '../useActionModal';
27+
28+
afterEach(cleanup);
29+
30+
type Handler = (schema: unknown) => Promise<unknown>;
31+
32+
const REPAIR_TICKET = { name: 'repair_ticket', label: 'Repair Ticket', fields: { subject: { type: 'text', label: 'Subject' } } };
33+
34+
const dataSource = {
35+
getObjectSchema: async () => REPAIR_TICKET,
36+
find: async () => ({ data: [], total: 0 }),
37+
findOne: async () => null,
38+
};
39+
40+
const metadata: any = {
41+
apps: [], objects: [REPAIR_TICKET], dashboards: [], reports: [], pages: [],
42+
loading: false, error: null,
43+
refresh: async () => {}, invalidate: () => {}, ensureType: async () => [],
44+
getItem: async () => null,
45+
getItemsByType: () => [],
46+
getTypeStatus: () => 'ready',
47+
};
48+
49+
function Harness({ onReady }: { onReady: (h: Handler) => void }) {
50+
const { modalHandler, modalElement } = useActionModal(dataSource);
51+
React.useEffect(() => onReady(modalHandler), [onReady, modalHandler]);
52+
return <>{modalElement}</>;
53+
}
54+
55+
async function open(descriptor: Record<string, unknown>, language?: string) {
56+
let handler: Handler | null = null;
57+
const harness = (
58+
<MetadataCtx.Provider value={metadata}>
59+
<Harness onReady={(h) => { handler = h; }} />
60+
</MetadataCtx.Provider>
61+
);
62+
render(language ? <I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>{harness}</I18nProvider> : harness);
63+
await act(async () => {
64+
void handler!(descriptor);
65+
});
66+
return screen.findByRole('dialog');
67+
}
68+
69+
describe('useActionModal: an object create form is titled by its object (#30126 item 2)', () => {
70+
it('a create descriptor with no title reads form.createTitle', async () => {
71+
await open({ objectName: 'repair_ticket', mode: 'create' });
72+
expect(await screen.findByRole('heading', { name: 'Create Repair Ticket' })).toBeInTheDocument();
73+
});
74+
75+
it('in a zh session it is the zh title, spaced from a Latin label', async () => {
76+
await open({ objectName: 'repair_ticket', mode: 'create' }, 'zh');
77+
expect(await screen.findByRole('heading', { name: '新建 Repair Ticket' })).toBeInTheDocument();
78+
});
79+
80+
it('an authored title still wins', async () => {
81+
await open({ objectName: 'repair_ticket', mode: 'create', title: 'Log a repair' });
82+
expect(await screen.findByRole('heading', { name: 'Log a repair' })).toBeInTheDocument();
83+
expect(screen.queryByRole('heading', { name: 'Create Repair Ticket' })).toBeNull();
84+
});
85+
86+
it('CONTROL: an edit form is not given a create title', async () => {
87+
await open({ objectName: 'repair_ticket', mode: 'edit', recordId: 't1' });
88+
expect(screen.queryByRole('heading', { name: 'Create Repair Ticket' })).toBeNull();
89+
});
90+
});

‎ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowDoneSuccessMessage-12274.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ const OBJECT = 'service_case';
5555
const FLOW = 'case_escalate';
5656
const ACTION_MESSAGE = 'Case escalated';
5757
const FLOW_MESSAGE = 'Escalation recorded';
58-
/** The runner's own default text, in the en pack. */
59-
const RUNNER_DEFAULT = 'Action completed successfully';
58+
/** The runner's own default text, in the en pack: it names the labelled action (#30126). */
59+
const RUNNER_DEFAULT = 'Escalate case completed';
6060
/** The declared action, as the object metadata carries it. */
6161
const DECLARED_WITHOUT_MESSAGE = { name: 'escalate_case', label: 'Escalate case', type: 'flow', target: FLOW };
6262
const DECLARED = { ...DECLARED_WITHOUT_MESSAGE, successMessage: ACTION_MESSAGE };

‎ui/packages/app-shell/src/hooks/useActionModal.tsx‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
cn,
5151
} from '@object-ui/components';
5252
import { SchemaRenderer, useMetadata } from '@object-ui/react';
53+
import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
5354
import { ModalForm } from '@object-ui/plugin-form';
5455
import { resolveFormViewLayout } from '../utils/recordFormNavigation.js';
5556
import { modalTargetRefusalMessage } from '../utils/modalTargetDiagnostics.js';
@@ -165,6 +166,9 @@ export function useActionModal(dataSource?: any) {
165166
// layout needs, and an action is when it is needed.
166167
const metadata = useMetadata();
167168
const { getItem, ensureType } = metadata;
169+
// The object form's title when the descriptor names none (below).
170+
const { t } = useObjectTranslation();
171+
const { objectLabel } = useObjectLabel();
168172

169173
const close = useCallback((r: ActionResult) => {
170174
setState((s) => {
@@ -267,12 +271,21 @@ export function useActionModal(dataSource?: any) {
267271
// a create uses the object's `create` form view when it declares one
268272
// (objectui#13099), read from the same mode the form below opens in.
269273
const formMode = (d.mode as 'create' | 'view' | 'edit') || 'create';
274+
const objectDef = metadata.objects.find((o: any) => o?.name === d.objectName);
270275
const viewLayout = (d.fields || (d as any).sections)
271276
? {}
272-
: resolveFormViewLayout(
273-
metadata.objects.find((o: any) => o?.name === d.objectName),
274-
formMode === 'create' ? 'create' : 'edit',
275-
);
277+
: resolveFormViewLayout(objectDef, formMode === 'create' ? 'create' : 'edit');
278+
// A create form is titled by its object, the way the console's own
279+
// New dialog is (`form.createTitle`). The lookup field's inline "create
280+
// the referenced record" hands a descriptor with no title, and its
281+
// dialog opened straight onto the first section's heading.
282+
const title = d.title
283+
?? (formMode === 'create' && objectDef
284+
? t('form.createTitle', {
285+
defaultValue: 'Create {{object}}',
286+
object: objectLabel({ name: objectDef.name, label: objectDef.label ?? objectDef.name }),
287+
})
288+
: undefined);
276289
modalElement = (
277290
<ModalForm
278291
schema={{
@@ -281,7 +294,7 @@ export function useActionModal(dataSource?: any) {
281294
objectName: d.objectName,
282295
mode: formMode,
283296
recordId: d.recordId,
284-
title: d.title,
297+
title,
285298
description: d.description,
286299
fields: d.fields,
287300
...viewLayout,

‎ui/packages/app-shell/src/views/ObjectView.toolbarScriptSelection-12331.test.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,8 @@ describe('objectui#12331 — a record-scoped script action at list_toolbar, with
277277
await tickRows(1);
278278
await launchFromHeader();
279279

280-
expect(await screen.findByText('Action completed successfully', undefined, { timeout: 5000 })).toBeInTheDocument();
280+
// The runner's default line names the labelled action (#30126).
281+
expect(await screen.findByText('Add to Campaign completed', undefined, { timeout: 5000 })).toBeInTheDocument();
281282
expect(actionCalls()).toHaveLength(1);
282283
});
283284
});
@@ -296,7 +297,8 @@ describe('objectui#12331 — the refusal follows the `locations` declaration', (
296297
await tickRows(0);
297298
await launchFromHeader();
298299

299-
expect(await screen.findByText('Action completed successfully', undefined, { timeout: 5000 })).toBeInTheDocument();
300+
// The runner's default line names the labelled action (#30126).
301+
expect(await screen.findByText('Add to Campaign completed', undefined, { timeout: 5000 })).toBeInTheDocument();
300302
expect(actionCalls()).toHaveLength(1);
301303
expect(postedBody(0)).toEqual({ params: { campaign: 'cmp_webinar' } });
302304
});

‎ui/packages/app-shell/src/views/RecordDetailView.flowDoneSuccessMessage-12274.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@ const RECORD_ID = 'case-41';
8686
const FLOW = 'case_escalate';
8787
const ACTION_MESSAGE = 'Case escalated';
8888
const FLOW_MESSAGE = 'Escalation recorded';
89-
/** The runner's own default text, in the en pack. */
90-
const RUNNER_DEFAULT = 'Action completed successfully';
89+
/** The runner's own default text, in the en pack: it names the labelled action (#30126). */
90+
const RUNNER_DEFAULT = 'Escalate case completed';
9191
/** The declared action, as the object metadata carries it. */
9292
const DECLARED_WITHOUT_MESSAGE = { name: 'escalate_case', label: 'Escalate case', type: 'flow', target: FLOW };
9393
const DECLARED = { ...DECLARED_WITHOUT_MESSAGE, successMessage: ACTION_MESSAGE };

‎ui/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,10 @@ describe('RecordFormPage title resolves from the locale packs (#3469)', () => {
181181
// the title is Chinese. No inline English `defaultValue` can satisfy this
182182
// assertion, so it fails loudly if the packs ever stop being consulted.
183183
renderPage('create', { language: 'zh' });
184-
expect(title()).toBe(String(builtInLocales.zh.form.createTitle).replace('{{object}}', 'Contacts'));
185-
expect(title()).toBe('新建Contacts');
184+
// The pack's `新建{{object}}`, with the zh spacing between Han text and a
185+
// Latin label that `createI18n` applies to this key.
186+
expect(String(builtInLocales.zh.form.createTitle)).toBe('新建{{object}}');
187+
expect(title()).toBe('新建 Contacts');
186188
});
187189
});
188190

‎ui/packages/core/src/actions/ActionRunner.ts‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,9 @@ export type ToastHandler = (message: string, options?: {
643643
* - `completedSuccessfully` — the success toast when the author supplied no
644644
* copy for it: no `outcomeMessages` entry for the answer's `outcome` and no
645645
* `successMessage` (objectui#10900, objectui#11344).
646+
* - `completedNamed` — the same toast for an action that carries a label: it
647+
* names the action (`{{action}}`), so the user reads what was done rather
648+
* than that something was.
646649
* - `failed` — the error toast when the error that reached the toast carries
647650
* no readable message, and a parallel chain's result when its last action
648651
* rejected (objectui#10969).
@@ -666,6 +669,10 @@ const RUNNER_TEXT = {
666669
key: 'actions.completedSuccessfully',
667670
defaultValue: 'Action completed successfully',
668671
},
672+
completedNamed: {
673+
key: 'actions.completedNamed',
674+
defaultValue: '{{action}} completed',
675+
},
669676
failed: {
670677
key: 'actions.failed',
671678
defaultValue: 'Action failed',
@@ -682,6 +689,16 @@ const RUNNER_TEXT = {
682689

683690
type RunnerText = { readonly key: string; readonly defaultValue: string };
684691

692+
/** The one hole in `completedNamed`, the action's label. */
693+
const COMPLETED_NAMED_HOLE = '{{action}}';
694+
695+
/**
696+
* What {@link ActionRunner.setTranslator} takes: an i18next-shaped `t`. The
697+
* options carry `defaultValue` and, for a text with a hole, the hole's value
698+
* (`completedNamed`'s `action`).
699+
*/
700+
type RunnerTranslate = (key: string, options: { defaultValue: string } & Record<string, string>) => string;
701+
685702
/**
686703
* Modal handler — consumers provide to render modal dialogs.
687704
*/
@@ -1372,7 +1389,7 @@ export class ActionRunner {
13721389
private navigationHandler: NavigationHandler | null;
13731390
private paramCollectionHandler: ParamCollectionHandler | null;
13741391
private resultDialogHandler: ResultDialogHandler | null;
1375-
private translate: ((key: string, options: { defaultValue: string }) => string) | null;
1392+
private translate: RunnerTranslate | null;
13761393
private displayLocale: (() => string | undefined) | null;
13771394
private tenantCurrency: (() => string | undefined) | null;
13781395
/** Who {@link ActionRunner.subscribeContext} tells of a context change. */
@@ -1433,7 +1450,7 @@ export class ActionRunner {
14331450
* an early refusal that names its key (`ActionRunnerHandler`'s
14341451
* `refuseBeforeInput`). With no translator the text stays English.
14351452
*/
1436-
setTranslator(translate: (key: string, options: { defaultValue: string }) => string): void {
1453+
setTranslator(translate: RunnerTranslate): void {
14371454
this.translate = translate;
14381455
}
14391456

@@ -1916,6 +1933,17 @@ export class ActionRunner {
19161933
return this.translate?.(key, { defaultValue }) || defaultValue;
19171934
}
19181935

1936+
/**
1937+
* `completedNamed` for `label`: the translator's answer with the label as
1938+
* its `action` hole, else the English source with the label in that one
1939+
* hole. The runner fills no other hole and interpolates no other text.
1940+
*/
1941+
private completedNamedText(label: string): string {
1942+
const { key, defaultValue } = RUNNER_TEXT.completedNamed;
1943+
return this.translate?.(key, { action: label, defaultValue })
1944+
|| defaultValue.replace(COMPLETED_NAMED_HOLE, () => label);
1945+
}
1946+
19191947
/**
19201948
* The success toast's text, by the contract's three rungs
19211949
* (`ActionSchema.outcomeMessages`, objectui#11344):
@@ -1924,7 +1952,10 @@ export class ActionRunner {
19241952
* of the handler's return value — the payload `{{ result.* }}` reads, so
19251953
* it is the same fact an author writes as `{{ result.outcome }}`;
19261954
* 2. `successMessage`;
1927-
* 3. the runner's own default text, in its translator's language.
1955+
* 3. the runner's own default text, in its translator's language: it names
1956+
* the action by its `label` when it has one (`completedNamed`; the host
1957+
* localizes the label before dispatch, like the copy above), else the
1958+
* generic `completedSuccessfully`.
19281959
*
19291960
* Rungs 1 and 2 are the author's copy. Both arrive already localized — the
19301961
* host's localizer resolves the bundle entry or the inline `I18nLabel` map
@@ -1986,7 +2017,10 @@ export class ActionRunner {
19862017
? fillTemplateHoles(successCopy, resultRoots, this.displayLocale?.(), this.tenantCurrency?.())
19872018
: successCopy;
19882019
}
1989-
return this.runnerText(RUNNER_TEXT.completedSuccessfully);
2020+
const label = typeof action.label === 'string' ? action.label.trim() : '';
2021+
return label
2022+
? this.completedNamedText(label)
2023+
: this.runnerText(RUNNER_TEXT.completedSuccessfully);
19902024
}
19912025

19922026
/**

‎ui/packages/core/src/actions/__tests__/ActionRunner.defaultSuccessToast-10900.test.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,50 @@ describe('ActionRunner — the default success toast is translatable (objectui#1
8787
expect(toast).toHaveBeenCalledWith('Action completed successfully', expect.objectContaining({ type: 'success' }));
8888
});
8989
});
90+
91+
describe('ActionRunner — the default success toast names a labelled action (#30126 item 3)', () => {
92+
let runner: ActionRunner;
93+
let toast: Mock<ToastHandler>;
94+
95+
beforeEach(() => {
96+
runner = new ActionRunner({});
97+
toast = vi.fn();
98+
runner.setToastHandler(toast);
99+
runner.registerHandler('script', SUCCEEDS);
100+
});
101+
102+
it('with no translator installed, the toast is the English source with the label filled in', async () => {
103+
await runner.execute({ type: 'script', name: 'resolve', label: 'Resolve' });
104+
expect(toast).toHaveBeenCalledWith('Resolve completed', expect.objectContaining({ type: 'success' }));
105+
});
106+
107+
it('with a translator, it is asked for the named key with the label as its hole', async () => {
108+
const translate = vi.fn((_key: string, options: { defaultValue: string } & Record<string, string>) =>
109+
`“${options.action}”已完成`);
110+
runner.setTranslator(translate);
111+
112+
await runner.execute({ type: 'script', name: 'resolve', label: '解决' });
113+
114+
expect(translate).toHaveBeenCalledWith('actions.completedNamed', {
115+
action: '解决',
116+
defaultValue: '{{action}} completed',
117+
});
118+
expect(toast).toHaveBeenCalledWith('“解决”已完成', expect.objectContaining({ type: 'success' }));
119+
});
120+
121+
it('an empty answer from the translator falls back to the filled English source', async () => {
122+
runner.setTranslator(() => '');
123+
await runner.execute({ type: 'script', name: 'resolve', label: 'Resolve' });
124+
expect(toast).toHaveBeenCalledWith('Resolve completed', expect.objectContaining({ type: 'success' }));
125+
});
126+
127+
it('a blank label is no name: the generic text stays', async () => {
128+
await runner.execute({ type: 'script', name: 'resolve', label: ' ' });
129+
expect(toast).toHaveBeenCalledWith('Action completed successfully', expect.objectContaining({ type: 'success' }));
130+
});
131+
132+
it("an author's successMessage still wins over the label", async () => {
133+
await runner.execute({ type: 'script', name: 'resolve', label: 'Resolve', successMessage: 'Ticket resolved' });
134+
expect(toast).toHaveBeenCalledWith('Ticket resolved', expect.objectContaining({ type: 'success' }));
135+
});
136+
});

‎ui/packages/core/src/utils/__tests__/dataset-format.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,22 @@ describe('buildDatasetFieldHelpers', () => {
112112
expect(measureField('nope')).toBeUndefined();
113113
});
114114
});
115+
116+
describe('formatMeasure — an unformatted money measure keeps its currency width (#30126 item 7)', () => {
117+
it('a whole CNY amount keeps its cents, as the record shows it', () => {
118+
expect(formatMeasure(1293, undefined, 'CNY', undefined, 'zh-CN')).toBe('¥1,293.00');
119+
});
120+
121+
it('a half-cent amount is not shortened', () => {
122+
expect(formatMeasure(1280.5, undefined, 'CNY', undefined, 'zh-CN')).toBe('¥1,280.50');
123+
});
124+
125+
it('the width is the currency\'s own: JPY has none, KWD has three', () => {
126+
expect(formatMeasure(1293, undefined, 'JPY', undefined, 'en-US')).toBe('¥1,293');
127+
expect(formatMeasure(1.5, undefined, 'KWD', undefined, 'en-US')).toBe('KWD\u00a01.500');
128+
});
129+
130+
it('an authored pattern still states the width', () => {
131+
expect(formatMeasure(1293, '0,0', 'CNY', undefined, 'zh-CN')).toBe('¥1,293');
132+
});
133+
});

0 commit comments

Comments
 (0)