Skip to content

Commit 8c9f48f

Browse files
fix(app-shell): draw a served report, page or dashboard label as served in the Console header, report index and recent list (#30308)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBKmABkq9Ha7c3RZSdaCFLLj)_ Fixes #30244 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Console 页眉的报表面包屑和页面标题、应用的报表索引、最近访问列表(报表、页面、仪表盘三类,以及侧边栏里未写标签、指向仪表盘的导航项)改为直接显示服务端返回的标签;只有服务端没给标签时才去查客户端词典。 - 为什么改:客户在 `.custom` 覆盖包里给已安装包的报表或仪表盘改了名,服务端按 #30193 的顺序返回新名字,Studio 也显示新名字;但 Console 拿着服务端的标签去查词典,词典里基础包的旧译名又把它盖了回去,用户在页眉和最近列表看到的是旧名字。 - 风险与回滚:未改动的条目显示不变(服务端本来就用同一份词典翻译过,实测 zh-CN 一致);切换语言时元数据整体重读,不会残留旧语言。回滚即还原本 PR。页面一项要等 #30062 服务端补上页面的 packagedBase 后才看得出效果;应用标签本 PR 未改,见 Acceptance notes。 - 先看:① `ui/packages/app-shell/src/layout/AppHeader.tsx` 报表与页面两个分支;② `ui/packages/app-shell/src/hooks/useNavTargetLabel.ts` 仪表盘分支(草稿标签仍走词典);③ `ui/packages/app-shell/src/hooks/useRecentItemLabel.ts` 页面与报表分支。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/layout/__tests__/AppHeader.servedLabels-11295.test.tsx packages/app-shell/src/hooks/__tests__/useRecentItemLabel.test.tsx packages/app-shell/src/views/__tests__/AppReportIndex-13020.test.tsx`,预期 3 个文件 49 个测试全部通过。 - 需要你做的:无,席位落地。 ## What changes - **The rule, at the callers that hold a served document.** A label the `/meta` read (or the app bundle) served is already translated by the server, by its label order for an overridden item (#30193), so it is drawn as given. The catalog key (`useObjectLabel`'s `reportLabel` / `pageLabel` / `dashboardLabel`) is asked only when no served label is at hand: the humanized slug, an item served with no label, a pending draft's label. This is the rule the header's dashboard branch already follows (objectui#11295). - `AppHeader.tsx`: the report crumb and the page's phone title. - `appReportIndex.ts` (`useAppReportEntries`): a report row's label. - `useRecentItemLabel.ts`: a page row and a report row. - `useNavTargetLabel.ts`: the dashboard target, which labels a recent dashboard row and an unlabelled sidebar entry that opens a dashboard. A pending draft's label still goes through the catalog. - **The helpers are unchanged.** `pageLabel`, `reportLabel` and `dashboardLabel` stay the catalog lookup for text no server translated, because some of their callers pass exactly that: `plugin-dashboard`'s `DashboardRenderer` passes a schema's `header.title`, and every caller of `appLabel` passes a keyed label the client translates. Flipping a helper would have changed those callers too. Their docblocks now say that a caller holding a served label draws it as given. Adding a served variant to `useObjectLabel` was weighed and not taken: 148 test files mock `useObjectLabel` with a fixed member list, and the caller-side form needs no new member. - **Test fixtures that served English in a zh-CN session.** Three suites (`RecentApps.labelAtRender-11678`, `useRecentItemLabel.readsByName-29235`, `useNavigationSync.standardEntryLocalizes-11201`) served an English label in a zh-CN session and let a client catalog key translate over it. The Console's catalog is the server's own `/api/v1/i18n/translations/:locale` answer (`ui/apps/console/src/loadLanguage.ts`), and the server translates a served label with that same catalog, so that world does not occur: measured on the showcase, `pages.showcase_task_schedule` is absent from the server's zh-CN catalog and the page is served in English; `pages.showcase_capability_map` is present and the page is served as 能力地图. The fixtures now serve what the server serves in that language; each suite's assertion is unchanged. ## Per-caller reading (what each caller holds) | Caller | What it holds | Where it comes from | Verdict | |:--|:--|:--|:--| | `AppHeader` report crumb | the report document | `metadata.reports` (`/meta/report`), translated by the server | served: drawn as given | | `AppHeader` page title | the page document | the app bundle, else `/meta/page` by name | served: drawn as given | | `useAppReportEntries` | the report document | `metadata.reports` | served: drawn as given | | `useRecentItemLabel` page / report | the page / report document | the app bundle or `/meta` by name | served: drawn as given | | `useNavTargetLabel` dashboard | the dashboard document, else a pending draft row | the app bundle or `/meta/dashboard`; `_drafts` | served: as given; draft: catalog, as before | A language switch does not leave a served label stale: `ConsoleShell` clears the adapter cache and remounts `MetadataProvider` with `key={language}`, and the app bundle reads live inside it, so every served label is read again in the new language. ## Tests - New: one top-level describe in `AppHeader.servedLabels-11295.test.tsx` (report crumb and page title, en and zh-CN, renamed / unedited / not served / no catalogue entry); one in `useRecentItemLabel.test.tsx` (report, page and dashboard each on its own cell, plus controls); one in `AppReportIndex-13020.test.tsx` (renamed, unedited, served with no label). Controls: an item the read did not serve, or served with no label, is still named by the catalog key; en with no catalogue entry keeps the humanized slug or machine name. - Ablation (the four source files restored to `origin/main` `31260ca8` with `git restore --source`, markers counted at 0 on disk, restored with `git checkout HEAD --`, `git status` clean after): 8 failed | 41 passed. The 8 are exactly the renamed cells (report crumb en/zh, page title en/zh, report index, recent report, recent page, recent dashboard); every control stayed green. <details><summary>Test runs</summary> - New pins, fix in place: `Test Files 3 passed (3)`, `Tests 49 passed (49)`, `VERDICT command-exit 0`. - Ablation leg: `Test Files 3 failed (3)`, `Tests 8 failed | 41 passed (49)`. - Consumer sweep (124 app-shell test files that import the four changed modules, render `AppHeader`, `UnifiedSidebar`, Home, the report index or the recent surfaces): `Test Files 3 failed | 121 passed (124)`, `Tests 4 failed | 936 passed (940)`. The 4 reds were the three fixture suites above; after the fixture change: `Test Files 3 passed (3)`, `Tests 11 passed (11)`. - 30 more test files carrying a zh-CN dashboard, page or report fixture: `Test Files 30 passed (30)`, `Tests 290 passed (290)`. - After merging `origin/main` (head 0aa33ca), the touched suites plus the whole `@object-ui/i18n` package: `Test Files 101 passed (101)`, `Tests 1503 passed | 13 skipped (1516)`. - `pnpm --filter @object-ui/app-shell run type-check` and `pnpm --filter @object-ui/i18n run type-check`: `VERDICT command-exit 0`. `eslint` on the changed files: 0 errors. </details> ## Live (showcase, zh-CN) The CRM example ships no report, so the live leg ran on the showcase (`pnpm dev -- --fresh`, the Console from `ui/` src through Vite). Overrides were written the way Studio's Customize writes them: `POST /api/v1/packages/com.example.showcase/ensure-customization` (pair `com.example.showcase.custom`), then `PUT /meta/TYPE/NAME?package=com.example.showcase.custom&mode=draft` with `overrides: { package: 'com.example.showcase', basedOn: '0.1.0' }` and a new `label`, then publish. | Served under `Accept-Language: zh-CN` | Before the override | After the override | |:--|:--|:--| | report `showcase_hours_by_status` (item and list) | 按状态统计工时(汇总) | Effort by Status (renamed-30244) | | dashboard `showcase_ops_dashboard` | 交付运营 | Delivery Cockpit (renamed-30244) | | page `showcase_capability_map` | 能力地图 | 能力地图 (en serves the override) | The report and the dashboard follow the #30193 order on `main`. The page does not yet: the server passes no `packagedBase` for a page (`PACKAGED_BASE_ACCESSORS` lists object, dashboard, view and report), which is #30062's server half. The page caller takes the same rule anyway; it is unmeasurable until #30062 lands, with the reading above as its before. | Console, zh-CN, same overrides | `origin/main` ui | this branch | |:--|:--|:--| | header crumb on the report | 按状态统计工时(汇总) | Effort by Status (renamed-30244) | | report index row | 按状态统计工时(汇总) | Effort by Status (renamed-30244) | | sidebar recent: report / dashboard / page | 按状态统计工时(汇总) / 交付运营 / 能力地图 | Effort by Status (renamed-30244) / Delivery Cockpit (renamed-30244) / 能力地图 | | Studio item properties | | Effort by Status (renamed-30244) | ## Acceptance notes - **App labels are left as they are.** The card's Wanted box names the app document too. On `main` the server passes no `packagedBase` for an app either, so an overridden app is served the base's translation today and the Console agrees with it. The app callers (about sixteen: the header, the app switcher, the sidebar, Home, AI chat, the no-access state and the report index's other-apps list) pass `resolveKeyedI18nLabel(app.label, t)`, which may be a keyed label the client translates, so a served-first rule there has to tell a served string from a keyed label first. Raised to the seat as an open question; it can ride with #30062. - **Same class, not touched here:** an object's and a list view's label in `useNavTargetLabel` and the recent `object` row (`objectPluralLabel`) still ask the catalog over the served label. The server keeps an overridden object's own text (it passes `packagedBase` for object and view), so a renamed object would show its base translation in the sidebar and recent list. Reported to the seat for a family card. - The claim's file surface did not list `useNavTargetLabel.ts`; the recent list's dashboard row is labelled through it, so it is touched here. ## Verification - Derived gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at 0aa33ca: 35 commands, each run with its exit code captured before any pipe, all 35 exit 0; `--ran` reconciliation: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. `pnpm --dir ui exec vitest run scripts/` ran under `os-verify-lock`. - Changeset: `.changeset/30244-served-labels-console-header.md`, `@object-ui/app-shell` patch, both declaration lines. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzjFKVx21wuZt6HYZ3J --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 665db00 commit 8c9f48f

12 files changed

Lines changed: 323 additions & 58 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): the Console header, the app report index and the recent list draw a served report, page or dashboard label as served
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Now.** A report's crumb and a page's phone title in the header, a report's row in the app's report index, and a report, page or dashboard row in the recent-items list (and an unlabelled sidebar entry that opens a dashboard) draw the label the `/meta` read or the app bundle served as given. The server has already translated it, by its label order for an overridden item: a `.custom` override's or a published edit's own text is kept over the base package's translation. Before, the served label reached the client's catalog lookup only as its fallback, so the base package's translation of the old name won.
11+
- **Unchanged.** An item the read did not serve, or served with no label, is still named by the catalog key under its name (`{ns}.reports.<name>.label`, `pages`, `dashboards`), then by its humanized name or machine name as before. An unedited item shows the same text as before, because the server serves the catalog's translation for it. A language switch reads the metadata again, so a served label is never left in the old language.

‎ui/packages/app-shell/src/console/home/__tests__/RecentApps.labelAtRender-11678.test.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@
2727
* label, so the three entries take three different roads to their zh-CN text:
2828
*
2929
* - the dashboard: its served label, translated by the server;
30-
* - Team Schedule: its served label is English, the client bundle's
31-
* `pages.<name>.label` key translates it;
30+
* - Team Schedule: its `pages.<name>.label` key is in the bundle the console
31+
* loads from the server, so the server translated its served label with it,
32+
* and the row draws that as served (objectstack#30244: the bundle is asked
33+
* only for an item served with no label, never over a served one);
3234
* - Capability Map: translated nowhere — it shows its own label, which is
3335
* what its own page shows, and never the minted name.
3436
*/
@@ -72,7 +74,7 @@ const SERVED = {
7274
'zh-CN': {
7375
dashboards: [{ name: 'showcase_ops_dashboard', label: '交付运营' }],
7476
pages: [
75-
{ name: 'showcase_task_schedule', label: 'Team Schedule (Gantt)' },
77+
{ name: 'showcase_task_schedule', label: '团队排期' },
7678
{ name: 'showcase_capability_map', label: 'Capability Map' },
7779
],
7880
objects: [{ name: 'showcase_task', label: '任务' }],

‎ui/packages/app-shell/src/hooks/__tests__/useNavigationSync.standardEntryLocalizes-11201.test.tsx‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
* `UnifiedSidebar` wires it (`useNavTargetLabel` plus the active
2525
* `language` as `locale`, objectui#11299), under a real `I18nProvider`
2626
* carrying the app's zh-CN translation bundle the way the console loads
27-
* it, shows the dashboard's label in each language.
27+
* it, shows the dashboard's label in each language: the label the `/meta`
28+
* read serves under that language, which the server translated with the
29+
* same bundle and the sidebar draws as served (objectstack#30244).
2830
*
2931
* The entry rendered is the one the writer stored, read back from the save
3032
* call, not a hand-written copy of what it should be.
@@ -147,11 +149,16 @@ function Sidebar({ items }: { items: NavigationItem[] }) {
147149
);
148150
}
149151

152+
/** The dashboard as the `/meta` read serves it under `language`: translated by the server's bundle. */
153+
function servedDashboard(language: string) {
154+
return language === 'zh-CN' ? { ...DASHBOARD, label: '管道健康' } : DASHBOARD;
155+
}
156+
150157
function renderSidebar(entry: Record<string, unknown>, language: string) {
151158
render(
152159
<I18nProvider instance={i18nIn(language)} persistLanguage={false}>
153160
<MemoryRouter initialEntries={['/apps/crm']}>
154-
<MetadataCtx.Provider value={metaValue([DASHBOARD])}>
161+
<MetadataCtx.Provider value={metaValue([servedDashboard(language)])}>
155162
<Sidebar items={[entry as unknown as NavigationItem]} />
156163
</MetadataCtx.Provider>
157164
</MemoryRouter>
@@ -180,7 +187,7 @@ describe('objectui#11201 — a synced standard entry is stored label-less and re
180187
cleanup();
181188
});
182189

183-
it('under zh-CN, the stored entry renders the dashboard’s zh label from the app bundle', () => {
190+
it('under zh-CN, the stored entry renders the dashboard’s zh label as served', () => {
184191
const sidebar = renderSidebar(stored, 'zh-CN');
185192
expect(sidebar.getByRole('link', { name: '管道健康' })).toBeInTheDocument();
186193
expect(sidebar.queryByText('Pipeline Health')).toBeNull();

‎ui/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.readsByName-29235.test.tsx‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
* Real subjects: `useRecentItemLabel`, `MetadataProvider` and the i18n
1515
* provider, over a fake adapter that records each read. What the rows print is
1616
* pinned beside the reads: the same text the whole list gave, in en and in
17-
* zh-CN (an inline locale map, and a translation bundle key), and the machine
18-
* name while nothing has landed.
17+
* zh-CN (an inline locale map, and a label the server translated under the
18+
* request's language, which the row draws as served: objectstack#30244), and
19+
* the machine name while nothing has landed.
1920
*/
2021

2122
import { describe, it, expect, vi, afterEach } from 'vitest';
@@ -42,19 +43,32 @@ const ITEMS: Record<string, Array<Record<string, unknown>>> = {
4243
],
4344
};
4445

45-
function makeAdapter(calls: string[]) {
46+
/**
47+
* What the server serves under `language`: it translates a label its catalog
48+
* carries (the report's), so a zh-CN session reads the zh-CN text.
49+
*/
50+
function servedItems(language: string): Record<string, Array<Record<string, unknown>>> {
51+
if (language !== 'zh-CN') return ITEMS;
52+
return {
53+
...ITEMS,
54+
report: ITEMS.report.map((it) => (it.name === 'q3_results' ? { ...it, label: '第三季度结果' } : it)),
55+
};
56+
}
57+
58+
function makeAdapter(calls: string[], language = 'en') {
59+
const items = servedItems(language);
4660
return {
4761
clearCache: vi.fn(),
4862
invalidateCache: vi.fn(),
4963
getClient: () => ({
5064
meta: {
5165
getItems: (type: string) => {
5266
calls.push(type);
53-
return Promise.resolve({ type, items: structuredClone(ITEMS[type] ?? []) });
67+
return Promise.resolve({ type, items: structuredClone(items[type] ?? []) });
5468
},
5569
getItem: (type: string, name: string) => {
5670
calls.push(`${type}/${name}`);
57-
const item = (ITEMS[type] ?? []).find((it) => it.name === name);
71+
const item = (items[type] ?? []).find((it) => it.name === name);
5872
if (!item) return Promise.reject(Object.assign(new Error('not found'), { status: 404, code: 'NOT_FOUND' }));
5973
return Promise.resolve({ item: structuredClone(item) });
6074
},
@@ -69,7 +83,7 @@ function wrapperFor(calls: string[], language = 'en') {
6983
detectBrowserLanguage: false,
7084
resources: { 'zh-CN': { showcase: { reports: { q3_results: { label: '第三季度结果' } } } } },
7185
});
72-
const adapter = makeAdapter(calls);
86+
const adapter = makeAdapter(calls, language);
7387
return ({ children }: { children: ReactNode }) => (
7488
<I18nProvider instance={i18n} persistLanguage={false}>
7589
<MetadataProvider adapter={adapter}>{children}</MetadataProvider>
@@ -101,7 +115,7 @@ describe('recent dashboard / page / report rows read their item by name (objects
101115
expect(calls.filter((c) => c.includes('/')).sort()).toEqual(['dashboard/pipeline', 'page/welcome', 'report/q3_results']);
102116
});
103117

104-
it('zh-CN: an inline locale map and a translation bundle key read as the list gave them', async () => {
118+
it('zh-CN: an inline locale map and a served translation read as the list gave them', async () => {
105119
const calls: string[] = [];
106120
const { result } = renderHook(() => useRecentItemLabel(), { wrapper: wrapperFor(calls, 'zh-CN') });
107121
await waitFor(() => expect(ROWS.map((row) => result.current(row))).toEqual(['Pipeline', '欢迎', '第三季度结果']));

‎ui/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.test.tsx‎

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,14 @@ import { useRecentItemLabel } from '../useRecentItemLabel';
2525
import type { RecentItem } from '../../context/RecentItemsProvider';
2626
import { recentViewId } from '../useTrackRouteAsRecent';
2727

28-
function metadata() {
28+
/**
29+
* The served lists, per language: the server translates what it serves under
30+
* the request's language, so a zh-CN session holds the zh-CN answer
31+
* (`MetadataProvider` remounts on a language switch and reads again).
32+
*/
33+
const SERVED_REPORT = { en: 'Q3 Results', 'zh-CN': '第三季度结果' } as const;
34+
35+
function metadata(language = 'en', served: { reports?: unknown[]; pages?: unknown[]; dashboards?: unknown[] } = {}) {
2936
return {
3037
apps: [],
3138
objects: [
@@ -37,9 +44,9 @@ function metadata() {
3744
listViews: { 'showcase_task.open_tasks': { name: 'showcase_task.open_tasks', label: 'Open tasks' } },
3845
},
3946
],
40-
dashboards: [],
41-
reports: [{ name: 'q3_results', label: 'Q3 Results' }],
42-
pages: [],
47+
dashboards: served.dashboards ?? [],
48+
reports: served.reports ?? [{ name: 'q3_results', label: SERVED_REPORT[language as keyof typeof SERVED_REPORT] ?? 'Q3 Results' }],
49+
pages: served.pages ?? [],
4350
loading: false,
4451
error: null,
4552
refresh: async () => {},
@@ -51,15 +58,22 @@ function metadata() {
5158
};
5259
}
5360

54-
function wrapper(language: string) {
55-
const i18n = createI18n({
56-
defaultLanguage: language,
57-
detectBrowserLanguage: false,
58-
resources: { 'zh-CN': { showcase: { reports: { q3_results: { label: '第三季度结果' } } } } },
59-
});
61+
/** The packaged catalog, as the console loads it from `/i18n/translations`. */
62+
const CATALOG = {
63+
'zh-CN': {
64+
showcase: {
65+
reports: { q3_results: { label: '第三季度结果' } },
66+
pages: { welcome_tour: { label: '欢迎' } },
67+
dashboards: { ops_board: { label: '运营看板' } },
68+
},
69+
},
70+
};
71+
72+
function wrapper(language: string, served?: Parameters<typeof metadata>[1]) {
73+
const i18n = createI18n({ defaultLanguage: language, detectBrowserLanguage: false, resources: CATALOG });
6074
return ({ children }: { children: ReactNode }) => (
6175
<I18nProvider instance={i18n} persistLanguage={false}>
62-
<MetadataCtx.Provider value={metadata() as never}>{children}</MetadataCtx.Provider>
76+
<MetadataCtx.Provider value={metadata(language, served) as never}>{children}</MetadataCtx.Provider>
6377
</I18nProvider>
6478
);
6579
}
@@ -68,7 +82,7 @@ const at = '2026-10-06T09:00:00.000Z';
6882
const REPORT: RecentItem = { id: 'report:q3_results', type: 'report', name: 'q3_results', href: '/r', visitedAt: at };
6983

7084
describe('useRecentItemLabel (objectui#11678)', () => {
71-
it('a report: its metadata label in en, its bundle label in zh-CN', () => {
85+
it('a report: its served label, in each language the server served it in', () => {
7286
const en = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('en') });
7387
expect(en.result.current(REPORT)).toBe('Q3 Results');
7488
const zh = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('zh-CN') });
@@ -144,3 +158,57 @@ describe('useRecentItemLabel — a list view (objectui#12604)', () => {
144158
expect(result.current(view('my_saved_view'))).toBe('Tasks');
145159
});
146160
});
161+
162+
/**
163+
* objectstack#30244 — a served report, page or dashboard is named as served.
164+
*
165+
* An override (a `.custom` package's, or a published edit) renamed each one,
166+
* and the `/meta` read serves the new name by the server's label order for an
167+
* overridden item. The entry re-applied the packaged catalog's translation of
168+
* the old name over it, because the served label reached the catalog lookup
169+
* only as its fallback.
170+
*
171+
* Directions, written before the run: the renamed cells RED before the change
172+
* (the packaged zh-CN string), GREEN after; the not-served controls GREEN on
173+
* both sides.
174+
*/
175+
describe('useRecentItemLabel — a served report, page or dashboard is named as served (objectstack#30244)', () => {
176+
const item = (type: 'report' | 'page' | 'dashboard', name: string): RecentItem => ({
177+
id: `${type}:${name}`,
178+
type,
179+
name,
180+
href: `/${type}/${name}`,
181+
visitedAt: at,
182+
});
183+
const RENAMED = {
184+
reports: [{ name: 'q3_results', label: 'Quarter Review (renamed-30244)' }],
185+
pages: [{ name: 'welcome_tour', label: 'Getting Started (renamed-30244)' }],
186+
dashboards: [{ name: 'ops_board', label: 'Ops Cockpit (renamed-30244)', widgets: [] }],
187+
};
188+
189+
it.each([
190+
['report', 'q3_results', 'Quarter Review (renamed-30244)'],
191+
['page', 'welcome_tour', 'Getting Started (renamed-30244)'],
192+
['dashboard', 'ops_board', 'Ops Cockpit (renamed-30244)'],
193+
] as const)('zh-CN: a renamed %s shows the served label, not the packaged translation', (type, name, served) => {
194+
const { result } = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('zh-CN', RENAMED) });
195+
expect(result.current(item(type, name))).toBe(served);
196+
});
197+
198+
it('zh-CN: control — an item the read did not serve, or served with no label, is named by the bundle', () => {
199+
const { result } = renderHook(() => useRecentItemLabel(), {
200+
wrapper: wrapper('zh-CN', { reports: [{ name: 'q3_results' }], pages: [], dashboards: [] }),
201+
});
202+
expect(result.current(item('report', 'q3_results'))).toBe('第三季度结果');
203+
expect(result.current(item('page', 'welcome_tour'))).toBe('欢迎');
204+
expect(result.current(item('dashboard', 'ops_board'))).toBe('运营看板');
205+
});
206+
207+
it('en: control — no served label and no catalogue entry shows the machine name', () => {
208+
const { result } = renderHook(() => useRecentItemLabel(), {
209+
wrapper: wrapper('en', { reports: [], pages: [], dashboards: [] }),
210+
});
211+
expect(result.current(item('report', 'q3_results'))).toBe('q3_results');
212+
expect(result.current(item('dashboard', 'ops_board'))).toBe('ops_board');
213+
});
214+
});

‎ui/packages/app-shell/src/hooks/useNavTargetLabel.ts‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@
3737
* Nothing is written back and nothing is memoised across metadata versions, so
3838
* a renamed target shows its new name on the next render (the ruling accepts a
3939
* reload). A metadata label that is an inline locale map resolves through the
40-
* spec's own `resolveI18nLabel` in the active language, and the result passes
41-
* through the same convention-based bundle lookup (`useObjectLabel`) the rest
42-
* of the console applies to object / view / dashboard names, so the nav shows
43-
* the name the target's own page shows.
40+
* spec's own `resolveI18nLabel` in the active language. An object's or view's
41+
* result passes through the same convention-based bundle lookup
42+
* (`useObjectLabel`) the rest of the console applies to their names; a served
43+
* dashboard's label is already translated by the server and is drawn as given,
44+
* as its page header draws it (objectstack#30244). Either way the nav shows the
45+
* name the target's own page shows.
4446
*
4547
* A target with no label answers `undefined` — the renderer then falls through
4648
* the spec's ladder and, at the bottom, to the target's machine name.
@@ -186,9 +188,14 @@ export function useNavTargetLabel(options?: NavTargetLabelOptions): NavTargetLab
186188
const def = bundledFirst(bundledReads, 'dashboard', target.dashboardName, () =>
187189
byName ? readItemsByName('dashboard', [target.dashboardName], () => metadata.dashboards) : metadata.dashboards,
188190
);
189-
const text =
190-
metadataText(def?.label, language) ?? draftText(drafts, 'dashboard', target.dashboardName, language);
191-
return nonEmpty(dashboardLabel({ name: target.dashboardName, label: text ?? '' }));
191+
// A served dashboard's label is already translated
192+
// (objectstack#30244): drawn as given. A pending draft's label,
193+
// which no server translated, still goes through the bundle.
194+
return metadataText(def?.label, language)
195+
?? nonEmpty(dashboardLabel({
196+
name: target.dashboardName,
197+
label: draftText(drafts, 'dashboard', target.dashboardName, language) ?? '',
198+
}));
192199
}
193200
case 'reportIndex':
194201
return nonEmpty(t('sidebar.allReports', { defaultValue: 'All reports' }));

‎ui/packages/app-shell/src/hooks/useRecentItemLabel.ts‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,20 @@
2525
* its object rung is the spec's step 3 for an UNLABELLED NAVIGATION ENTRY
2626
* ("the object's label", the singular), a ruling this entry does not reopen;
2727
* - a dashboard: {@link useNavTargetLabel} — the text the sidebar shows for an
28-
* unlabelled navigation entry that opens the same target (the metadata
29-
* label, an inline locale map read in the active language, through the
30-
* `useObjectLabel` bundle lookup);
28+
* unlabelled navigation entry that opens the same target (the served
29+
* label, an inline locale map read in the active language, drawn as given;
30+
* the `useObjectLabel` bundle lookup only when the read carries none);
3131
* - a list view (objectui#12604): {@link useNavTargetLabel} again — the text
3232
* the sidebar shows for an unlabelled navigation entry that opens the same
3333
* view (the view's label from its object's `listViews`, through the
3434
* `viewLabel` bundle lookup). A view the cache does not hold — one a user
3535
* saved for themself — is drawn as the list it opens: the object's, as an
3636
* object entry is;
37-
* - a page or a report: the same pair for that kind — the page's / report's
38-
* cached metadata `label`, through `useObjectLabel`'s `pageLabel` /
39-
* `reportLabel`. `useNavTargetLabel` has no page or report target because
40-
* the navigation ruling names none, so the pair is composed here from the
41-
* same two helpers it uses;
37+
* - a page or a report: the same rule for that kind — the page's / report's
38+
* cached metadata `label` as served, else `useObjectLabel`'s `pageLabel` /
39+
* `reportLabel` under its name. `useNavTargetLabel` has no page or report
40+
* target because the navigation ruling names none, so the rule is composed
41+
* here from the same two helpers it uses;
4242
* - a Studio package (objectui#11863): the `name` its row in `packages` has —
4343
* the package list the rendering surface loaded, as the Studio landing does
4444
* with `fetchPackages()`. A package has no metadata cache behind it, so the
@@ -111,15 +111,20 @@ export function useRecentItemLabel(
111111
return targetLabel({ kind: 'view', objectName: item.object, viewName: item.name }) ?? listTitle(item.object);
112112
case 'dashboard':
113113
return targetLabel({ kind: 'dashboard', dashboardName: item.name }) ?? item.name;
114+
// A served page's or report's label is already translated
115+
// (objectstack#30244): drawn as given, the bundle asked only when the
116+
// read carries none.
114117
case 'page': {
115118
const pages = () => readItemsByName('page', [item.name], () => metadata.pages);
116-
const text = metadataText(bundledFirst(bundledReads, 'page', item.name, pages)?.label, language);
117-
return nonEmpty(pageLabel({ name: item.name, label: text ?? '' })) ?? item.name;
119+
return metadataText(bundledFirst(bundledReads, 'page', item.name, pages)?.label, language)
120+
?? nonEmpty(pageLabel({ name: item.name, label: '' }))
121+
?? item.name;
118122
}
119123
case 'report': {
120124
const reports = readItemsByName('report', [item.name], () => metadata.reports);
121-
const text = metadataText(findByName(reports, item.name)?.label, language);
122-
return nonEmpty(reportLabel({ name: item.name, label: text ?? '' })) ?? item.name;
125+
return metadataText(findByName(reports, item.name)?.label, language)
126+
?? nonEmpty(reportLabel({ name: item.name, label: '' }))
127+
?? item.name;
123128
}
124129
case 'package':
125130
return nonEmpty(packages?.find((p) => p.id === item.name)?.name) ?? item.name;

0 commit comments

Comments
 (0)