Skip to content

Commit 91dff12

Browse files
fix(app-shell): the translation preview names a global action by its label (objectui#11755) (#11763)
Fixes #11755 Clause-②: no The translation designer's preview drew every spec-valid `globalActions` entry as `NAME[object Object]`. It now shows the action node's `label`, quoted as a flat string is, and the node's key count when the node has no `label`. ## What changed - `TranslationPreview`: the `globalActions` category is nested now (`flat: false`) and carries a new `nodeLabel` flag on the file-local `CategoryDef`. `renderSampleValue` takes the category. For a `nodeLabel` category it returns the node's `label` when that is a string, and otherwise the existing key-count row. - The module docblock's "flat string maps" list no longer names `globalActions`, and says how the category is drawn. - The other seven categories render as before. The control pin reads `messages` and `objects`. - No export, prop, type member or language-pack key is added, and `CategoryDef` is file-local. So the claim's `Clause-②: no` holds. - Files: the component, the pin file beside it, and `.changeset/11755-translation-preview-global-actions.md` (`patch` on `@object-ui/app-shell`). All three are inside the claim's file surface. ## Measured before the change (objectui `77c12b9`, installed `@objectstack/spec` 17.7.0) A throwaway probe mounted the real component. It was deleted before the commit, and `git status` was clean afterwards. | bundle data | `TranslationDataSchema.safeParse` | rendered sample row | |---|---|---| | `globalActions: { portfolio_snapshot: { label: 'Snapshot' } }` | success | `portfolio_snapshot[object Object]` | | `objects: { sales_order: { label, _actions: { close_order: { label } } } }` | success | `sales_order{2 keys}` | | `globalActions: { close_order: 'Close' }` (legacy flat string) | refused: `invalid_type` at `globalActions.close_order` | `close_order"Close"` | After the change, the same probe read `portfolio_snapshot"Snapshot"`, then `close_orderClose`, then `sales_order{2 keys}` (unchanged). None of the three contains `[object Object]`. ### "The same per-node rendering the object `_actions` entries get": that rendering does not exist The preview never renders an `objects.X._actions.Y` entry by itself. The objects category lists only the top-level object names, each with its inner key count (`sales_order{2 keys}` above), and never descends into `_actions`. So the triage scope's phrase points at nothing, and the card's Done-when ("at least `label`") decides. **Named gap:** object nodes keep their key count, and only `globalActions` reads a node's `label`. Other nested categories (`apps`, `dashboards`, `metadataForms`) also declare `label` in the spec. They are not changed here. ### The legacy flat-string value `globalActions.NAME: 'Close'` is refused by the spec (`invalid_type`, expected object). The designer's own client validation refuses it too: the same probe ran `validateMetadataDraft('translation', …)` and got `globalActions.close_order: Invalid input: expected object, received string`. After this change the preview draws that value through the nested branch's existing scalar fallback, `String(v)`. It reads `Close`, unquoted. That is readable, never `[object Object]` and never blank, and it does not look like a valid label. No branch was added for the refused shape (AGENTS.md #0.1, contract-first). The spec refuses the value at validation, and the preview shows what is stored. The behaviour is not pinned, because a pin would fix in place how the renderer treats input the spec refuses. ## Pins: `TranslationPreview.globalActions-11755.test.tsx` (4 tests) Each test first parses its fixture with the installed spec's `TranslationDataSchema` and asserts success. The fixture is this file's own, because objectui#11439 moves the preview sample's only `globalActions` entry out. 1. A spec-valid node `{ label: 'Snapshot' }` renders `"Snapshot"`, and the preview text has no `[object Object]`. 2. A node with `confirmText` and `successMessage` beside `label` still renders the label. 3. A node without `label` renders its key count. The expected row is read back through `tFormat`. 4. Control: `messages` renders `"Bienvenue"` as before, and `objects` keeps its key count. ## Ablations Both ablations were run on the committed fix `29bff9e` through objectstack's `scripts/ablation-replace.mjs`. The tool asserts that the anchor count fell and the blob hash changed, then restores the file and proves its blob equals HEAD with an empty `git diff HEAD`. - **Restore `flat: true` on the `globalActions` def** (anchor hit x1 to x0, blob `ca2d00a4bc7b` to `682296fde42c`): `Tests 3 failed | 1 passed (4)`. The failures were `expected '[object Object]' to be '"Snapshot"'` twice and `expected '[object Object]' to be '{2 keys}'`. The control stayed green. Restored: blob equals HEAD (`ca2d00a4bc7b`), and `git diff HEAD` is empty. - **Delete `nodeLabel: true`, keeping `flat: false`** (blob `ca2d00a4bc7b` to `2b964160a448`): `Tests 2 failed | 2 passed (4)`. The failures were `expected '{1 keys}' to be '"Snapshot"'` and `expected '{3 keys}' to be '"Snapshot"'`, so the label branch carries the fix, not the `flat` flip alone. Restored the same way. ## Gates (all on HEAD `29bff9e`, run from the worktree root) | command | exit | verdict line | |---|---|---| | `pnpm exec vitest run packages/app-shell/` | 0 | `Test Files 1053 passed, 1 skipped (1054)`, `Tests 10306 passed, 9 skipped (10315)` | | `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build` | 0 | dependency closure built for the type-check | | `pnpm --filter @object-ui/app-shell type-check` | 0 | `tsc --noEmit && tsc -p tsconfig.test.json`; `--listFilesOnly` on the test project lists the new pin file | | `pnpm exec eslint` on the two touched `.tsx` files | 0 | no output | | `pnpm check:control-bytes` | 0 | `check-control-bytes: OK` | | `pnpm check:test-path-roots` | 0 | `check-test-path-roots: OK` | | `pnpm check:changeset-claims` | 0 | `No pending changeset names a file this change touches.` | | `pnpm check:pending-changeset-literals` | 0 | `No test source names a pending changeset.` | | `pnpm check:new-line-citations` | 0 | `0 new citation(s)` | | `node scripts/check-changeset-presence.mjs` | 0 | `2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)` | | `node scripts/check-changeset-no-major.mjs`, `check-changeset-overwrite.mjs`, `check-changeset-fixed.mjs` | 0 | each OK | | `pnpm check:spec-symbols`, `pnpm check:i18n-keys`, `node scripts/check-type-check-coverage.mjs`, `node scripts/check-lint-coverage.mjs` | 0 | each OK | Not run locally because the diff does not reach them: the i18n parity and drift gates (no locale pack changed) and `check:readme-exports` (no export changed). The full farm is CI's. ## Acceptance notes - **Spec drift in the same `CATEGORIES` table** is reported to the seat as a finding and is not fixed here. Fixing it needs new category heading rows, which is a `Clause-②` question. The table lists `validationMessages`, which `@objectstack/spec` 17.0.0 removed (`TranslationDataSchema` refuses it with `unrecognized_keys`), and `settings`, a platform-only group that the application bundle schema refuses. It omits `picklists`, `datasets`, `pages`, `flows` and `settingsCommon`. Measured on the real component at `77c12b9`: a bundle whose only entries are `pages` (or `picklists`) passes `TranslationDataSchema` and the designer's client validation, yet the preview reads "This bundle is empty — add at least one translated string to see the coverage report." - The en `keyCount` row reads `{1 keys}` for a single key (seen in the second ablation). This is polish and is not filed. - objectui#11439 (in flight) moves the preview sample's `globalActions.close_order` out. This PR does not touch `apps/console/src/preview-samples.ts`. Session: `https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8` --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3bf8894 commit 91dff12

3 files changed

Lines changed: 124 additions & 8 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
The translation designer's preview names each global action by its translated label (objectui#11755).
6+
7+
`TranslationDataSchema` declares every `globalActions` entry as an action translation node (`label`, `description`, `confirmText`, and the rest), not a string. The preview treated the category as a flat string map and drew each value with `String(v)`, so every entry the spec accepts read as `NAME[object Object]`. The category's sample now shows the node's `label`, quoted as a flat string is. A node without a `label` shows its key count, as the other nested categories do.
8+
9+
The other categories render as before. Nothing is added to the package entry: no export, prop, type member or language-pack key.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#11755 — the translation designer's preview draws a `globalActions`
5+
* entry by its node's `label`.
6+
*
7+
* `TranslationDataSchema` declares `globalActions.NAME` as an action
8+
* translation node (`label`, `description`, `confirmText`, …), not a string.
9+
* The preview used to mark the category flat and draw each value with
10+
* `String(v)`, so every entry the spec accepts rendered as
11+
* `NAME[object Object]`. The category is now nested, and its sample shows the
12+
* node's `label`, quoted as a flat string is; a node without a `label` (the key
13+
* is optional) shows its inner key count, as the other nested categories do.
14+
*
15+
* Every fixture here is parsed by the installed spec's `TranslationDataSchema`
16+
* first, so no case passes on a bundle the spec would refuse. The pins read
17+
* the rendered row of the real component; the count row is read back through
18+
* `tFormat` rather than restated.
19+
*/
20+
21+
import * as React from 'react';
22+
import { describe, it, expect, afterEach } from 'vitest';
23+
import { render, cleanup, screen } from '@testing-library/react';
24+
import { TranslationDataSchema } from '@objectstack/spec/system';
25+
26+
import { t, tFormat } from '../i18n';
27+
import { TranslationPreview } from './TranslationPreview';
28+
29+
afterEach(cleanup);
30+
31+
const LOCALE = 'en-US';
32+
33+
/** A bundle the installed spec accepts, mounted in the real preview. */
34+
function mountSpecValid(data: Record<string, unknown>) {
35+
const parsed = TranslationDataSchema.safeParse(data);
36+
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
37+
render(<TranslationPreview type="translation" name="fr" draft={{ locale: 'fr-FR', data }} locale={LOCALE} />);
38+
}
39+
40+
/** The sample row's value text for `key` inside the card headed by the category row `labelKey`. */
41+
function sampleValue(labelKey: string, key: string): string {
42+
const heading = screen.getByText(t(labelKey, LOCALE));
43+
const card = heading.closest('.rounded.border') as HTMLElement | null;
44+
expect(card, `${labelKey}: the category card`).toBeTruthy();
45+
const row = Array.from(card!.querySelectorAll('li')).find((li) => li.querySelector('code')?.textContent === key);
46+
expect(row, `${labelKey}: a sample row for ${key}`).toBeTruthy();
47+
return row!.querySelector('span')?.textContent ?? '';
48+
}
49+
50+
const GLOBAL_ACTIONS = 'engine.translationPreview.category.globalActions';
51+
52+
describe('TranslationPreview draws a globalActions node by its label (objectui#11755)', () => {
53+
it('a spec-valid globalActions node renders its label, and no [object Object]', () => {
54+
mountSpecValid({ globalActions: { portfolio_snapshot: { label: 'Snapshot' } } });
55+
expect(sampleValue(GLOBAL_ACTIONS, 'portfolio_snapshot')).toBe('"Snapshot"');
56+
expect(document.body.textContent).not.toContain('[object Object]');
57+
});
58+
59+
it('a node with its other translated fields still renders its label', () => {
60+
mountSpecValid({
61+
globalActions: { portfolio_snapshot: { label: 'Snapshot', confirmText: 'Take a snapshot?', successMessage: 'Done' } },
62+
});
63+
expect(sampleValue(GLOBAL_ACTIONS, 'portfolio_snapshot')).toBe('"Snapshot"');
64+
expect(document.body.textContent).not.toContain('[object Object]');
65+
});
66+
67+
it('a node without a label renders its inner key count', () => {
68+
mountSpecValid({ globalActions: { archive: { confirmText: 'Archive?', successMessage: 'Archived' } } });
69+
expect(sampleValue(GLOBAL_ACTIONS, 'archive')).toBe(
70+
tFormat('engine.translationPreview.keyCount', LOCALE, { count: 2 }),
71+
);
72+
expect(document.body.textContent).not.toContain('[object Object]');
73+
});
74+
75+
it('control: the flat and the other nested categories render as before', () => {
76+
mountSpecValid({
77+
messages: { welcome: 'Bienvenue' },
78+
objects: { account: { label: 'Compte', _actions: { close: { label: 'Fermer' } } } },
79+
});
80+
expect(sampleValue('engine.translationPreview.category.messages', 'welcome')).toBe('"Bienvenue"');
81+
// An object node keeps its key count: only the globalActions category reads a node's label.
82+
expect(sampleValue('engine.translationPreview.category.objects', 'account')).toBe(
83+
tFormat('engine.translationPreview.keyCount', LOCALE, { count: 2 }),
84+
);
85+
});
86+
});

‎packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx‎

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@
1717
* a sample of up to 5 keys so the user can confirm the right
1818
* bundle is loaded.
1919
*
20-
* For flat string maps (messages, validationMessages, globalActions,
21-
* settings) we render a small key→value sample table. For nested
22-
* objects (objects, apps, dashboards, metadataForms) we list the
23-
* top-level keys with their inner key count.
20+
* For flat string maps (messages, validationMessages, settings) we
21+
* render a small key→value sample table. For nested objects (objects,
22+
* apps, dashboards, metadataForms) we list the top-level keys with their
23+
* inner key count. globalActions is nested too: each entry is an action
24+
* translation node (`TranslationDataSchema.globalActions.NAME`), so its
25+
* sample shows the node's `label`, quoted as a flat string is, and the
26+
* inner key count when the node carries no `label` (objectui#11755).
2427
*/
2528

2629
import * as React from 'react';
@@ -51,6 +54,11 @@ interface CategoryDef {
5154
icon: React.ComponentType<{ className?: string }>;
5255
/** When true, the value is { key: string } (flat). Otherwise { key: nestedObject }. */
5356
flat: boolean;
57+
/**
58+
* Nested categories only: each value is a translation node whose `label` the
59+
* sample shows in place of its inner key count (objectui#11755).
60+
*/
61+
nodeLabel?: boolean;
5462
}
5563

5664
const CATEGORIES: CategoryDef[] = [
@@ -63,7 +71,13 @@ const CATEGORIES: CategoryDef[] = [
6371
icon: ShieldAlert,
6472
flat: true,
6573
},
66-
{ key: 'globalActions', label: 'engine.translationPreview.category.globalActions', icon: ClipboardList, flat: true },
74+
{
75+
key: 'globalActions',
76+
label: 'engine.translationPreview.category.globalActions',
77+
icon: ClipboardList,
78+
flat: false,
79+
nodeLabel: true,
80+
},
6781
{ key: 'dashboards', label: 'engine.translationPreview.category.dashboards', icon: LayoutDashboard, flat: false },
6882
{ key: 'settings', label: 'engine.translationPreview.category.settings', icon: Settings2, flat: true },
6983
{ key: 'metadataForms', label: 'engine.translationPreview.category.metadataForms', icon: FileText, flat: false },
@@ -173,7 +187,7 @@ function CategoryCard({
173187
{cat.sample.map(([k, v]) => (
174188
<li key={k} className="flex items-baseline gap-2 truncate">
175189
<code className="font-mono text-muted-foreground shrink-0">{k}</code>
176-
<span className="truncate text-foreground/80">{renderSampleValue(v, cat.flat, locale)}</span>
190+
<span className="truncate text-foreground/80">{renderSampleValue(v, cat, locale)}</span>
177191
</li>
178192
))}
179193
{cat.count > cat.sample.length && (
@@ -188,13 +202,20 @@ function CategoryCard({
188202
);
189203
}
190204

191-
function renderSampleValue(v: unknown, flat: boolean, locale: string | undefined): string {
192-
if (flat) {
205+
function renderSampleValue(
206+
v: unknown,
207+
cat: Pick<CategoryDef, 'flat' | 'nodeLabel'>,
208+
locale: string | undefined,
209+
): string {
210+
if (cat.flat) {
193211
if (typeof v === 'string') return `"${v}"`;
194212
if (v == null) return '∅';
195213
return String(v);
196214
}
197215
if (v && typeof v === 'object') {
216+
// `label` is optional on a translation node, so a node without one keeps the key count.
217+
const nodeLabel = (v as Dict).label;
218+
if (cat.nodeLabel && typeof nodeLabel === 'string') return `"${nodeLabel}"`;
198219
const n = Object.keys(v as Dict).length;
199220
return tFormat('engine.translationPreview.keyCount', locale, { count: n });
200221
}

0 commit comments

Comments
 (0)