From 4c0d8279da15d15d51eb8f2d97857e3c1a529cbf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 07:04:01 +0000 Subject: [PATCH 1/2] fix(app-shell): resolve inline locale-map view and page labels before drawing them viewTabLabel resolves the view's I18nLabel in the viewer's language before either branch, so a map label never reaches the header, tab bar, mobile switcher or record back-link as an object. InterfaceListPage resolves its page label and description the same way. Claude-Session: https://claude.ai/code/session_01WVwANq56oyBZtcDWbWGfva Co-authored-by: Claude --- ui/packages/app-shell/src/layout/AppHeader.tsx | 1 + .../app-shell/src/views/InterfaceListPage.tsx | 17 ++++++++++++----- ui/packages/app-shell/src/views/ObjectView.tsx | 16 ++++++++-------- ui/packages/app-shell/src/views/viewTabLabel.ts | 14 ++++++++++++-- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/ui/packages/app-shell/src/layout/AppHeader.tsx b/ui/packages/app-shell/src/layout/AppHeader.tsx index 83fc2adb4d0..1ee9c0e4f1f 100644 --- a/ui/packages/app-shell/src/layout/AppHeader.tsx +++ b/ui/packages/app-shell/src/layout/AppHeader.tsx @@ -756,6 +756,7 @@ export function AppHeader({ servedViews, viewLabel, objectPluralLabel(currentObject), + language, ); } } diff --git a/ui/packages/app-shell/src/views/InterfaceListPage.tsx b/ui/packages/app-shell/src/views/InterfaceListPage.tsx index 3b887aa2086..4d4f8c8e02e 100644 --- a/ui/packages/app-shell/src/views/InterfaceListPage.tsx +++ b/ui/packages/app-shell/src/views/InterfaceListPage.tsx @@ -35,6 +35,7 @@ import { isSystemManagedField } from '@object-ui/types'; import { leadWithNameField } from '@object-ui/core'; import type { ActionBarSchema, ListViewSchema } from '@object-ui/types'; import { useMetadata } from '../providers/MetadataProvider.js'; +import { metadataText } from '../hooks/useNavTargetLabel.js'; import { useTenancyPosture } from '../hooks/useTenancyPosture.js'; import { useNavigationContext } from '../context/NavigationContext.js'; import { matchAppBySegment } from '../utils/index.js'; @@ -270,7 +271,7 @@ export function defaultMapFromObject(objectDef: any): { locationField: string } } export function InterfaceListPage({ page, className, onConfigChange, reserveEditAffordance }: InterfaceListPageProps) { - const { t } = useObjectTranslation(); + const { t, language } = useObjectTranslation(); // objectui#12980 — the source object, read by name, not the whole registry: // reading `objects` would load every object to find one. Inside an app that // names a package it rides the app's scope, as `nav:menu`'s capability gate @@ -640,15 +641,21 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit // declare is refused here. const buttonBar: ActionBarSchema = { type: 'action:bar', location: 'list_toolbar', actions: buttonActions, size: 'sm', variant: 'outline' }; + // The page's `label` and `description` are `I18nLabel`s — a string or an + // inline locale map — resolved in the viewer's language (#25901). Only a page + // whose label resolves to no text falls back to its machine name. + const pageLabel = metadataText(page.label, language); + const pageDescription = metadataText(page.description, language); + return (

- {typeof page.label === 'string' ? page.label : page.name} + {pageLabel ?? page.name}

- {typeof page.description === 'string' && page.description && ( -

{page.description}

+ {pageDescription && ( +

{pageDescription}

)}
{buttonActions.length > 0 && ( @@ -672,7 +679,7 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit { if (!o) closeRecordDrawer(); }} - title={typeof page.label === 'string' ? page.label : (cfg.source || 'Record')} + title={pageLabel ?? (cfg.source || 'Record')} > {(record: any) => ( : undefined, }; }); - }, [views, objectDef.name, viewLabel, unlabelledViewText, servedViews]); + }, [views, objectDef.name, viewLabel, unlabelledViewText, servedViews, language]); useMobileViewSwitcherRegistration({ views: mobileViewSwitcherItems, activeViewId: activeViewId ?? '', @@ -2878,7 +2878,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co const originState = { from: { pathname: location.pathname + (location.search || ''), - label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText), + label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), }, }; if (viewId) { @@ -2887,7 +2887,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co navigate(`record/${encodeURIComponent(String(recordId))}`, { state: originState }); } }, - [navigate, viewId, location.pathname, location.search, objectDef, activeView?.id, activeView?.name, activeView?.label, viewLabel, unlabelledViewText, servedViews] + [navigate, viewId, location.pathname, location.search, objectDef, activeView?.id, activeView?.name, activeView?.label, viewLabel, unlabelledViewText, servedViews, language] ); /** * The list surface's half of the record-link mechanism (objectui#4490): @@ -3015,7 +3015,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co const isSystem = !saved; return { id: view.id, - label: viewTabLabel(view, objectDef.name, servedViews, viewLabel, unlabelledViewText), + label: viewTabLabel(view, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), type: view.type, hasActiveFilters: Array.isArray(view.filter) && view.filter.length > 0, hasActiveSort: Array.isArray(view.sort) && view.sort.length > 0, @@ -3881,7 +3881,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co const originState = { from: { pathname: location.pathname + (location.search || ''), - label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText), + label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), }, }; if (viewId) { @@ -3891,7 +3891,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co } } }, - }), [objectDef, onEdit, activeViewSearch, activeViewFilter, activeViewSort, activeView?.id, activeView?.name, activeView?.label, navigate, viewId, isAdmin, location.pathname, location.search, viewLabel, unlabelledViewText, servedViews]); + }), [objectDef, onEdit, activeViewSearch, activeViewFilter, activeViewSort, activeView?.id, activeView?.name, activeView?.label, navigate, viewId, isAdmin, location.pathname, location.search, viewLabel, unlabelledViewText, servedViews, language]); // The header's primary action, drawn the same in both of the header's // faces (below). [#5153] `objectCanCreate && createVisible` — the diff --git a/ui/packages/app-shell/src/views/viewTabLabel.ts b/ui/packages/app-shell/src/views/viewTabLabel.ts index 3513864108c..89eb2ede80d 100644 --- a/ui/packages/app-shell/src/views/viewTabLabel.ts +++ b/ui/packages/app-shell/src/views/viewTabLabel.ts @@ -7,6 +7,7 @@ */ import { isServedView, type useServedViewItems } from '../hooks/useServedViewItems.js'; +import { metadataText } from '../hooks/useNavTargetLabel.js'; /** `useObjectLabel().viewLabel` — the client bundle's view-label lookup. */ export type ViewLabelLookup = (objectName: string, viewName: string, fallback: string) => string; @@ -34,14 +35,23 @@ export type ViewLabelLookup = (objectName: string, viewName: string, fallback: s * qualified name, which is what a label-less view's tab drew before * (objectui#12774), nor a string humanized from it, which is what the header * drew before (objectui#12805). + * + * The `label` is an `I18nLabel` — a string or an inline locale map — and is + * resolved in the viewer's `language` before either branch reads it (#25901). + * The server overlays only catalog text, so a map with no catalog entry is + * served as authored; handed on raw, it reached every caller as an object, and + * the app header — mounted outside the route's error boundary — threw on it and + * took the whole console down. A map that resolves to no text counts as no + * `label`, so `unlabelled` is drawn only when nothing resolves. */ export function viewTabLabel( - view: { id?: string; name?: string; label?: string }, + view: { id?: string; name?: string; label?: unknown }, objectName: string, servedViews: ReturnType, viewLabel: ViewLabelLookup, unlabelled: string, + language: string | undefined, ): string { - const text = view.label || unlabelled; + const text = metadataText(view.label, language) ?? unlabelled; return isServedView(servedViews, objectName, view.id) ? text : viewLabel(objectName, view.name || view.id || '', text); } From 615a9c05cfeb2bcc43d98f598fa7692db605ace9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 07:06:58 +0000 Subject: [PATCH 2/2] test(app-shell): pin inline locale-map view and page labels; add changeset Claude-Session: https://claude.ai/code/session_01WVwANq56oyBZtcDWbWGfva Co-authored-by: Claude --- .changeset/25901-inline-view-label.md | 13 + .../AppHeader.inlineViewLabel-25901.test.tsx | 230 ++++++++++++++++++ ...terfaceListPage.inlineLabel-25901.test.tsx | 127 ++++++++++ .../viewTabLabel.inlineLabel-25901.test.ts | 71 ++++++ 4 files changed, 441 insertions(+) create mode 100644 .changeset/25901-inline-view-label.md create mode 100644 ui/packages/app-shell/src/layout/__tests__/AppHeader.inlineViewLabel-25901.test.tsx create mode 100644 ui/packages/app-shell/src/views/__tests__/InterfaceListPage.inlineLabel-25901.test.tsx create mode 100644 ui/packages/app-shell/src/views/viewTabLabel.inlineLabel-25901.test.ts diff --git a/.changeset/25901-inline-view-label.md b/.changeset/25901-inline-view-label.md new file mode 100644 index 00000000000..b405e0301e4 --- /dev/null +++ b/.changeset/25901-inline-view-label.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): a list view or interface page whose `label` is an inline locale map is named in the viewer's language, and a map view label no longer takes the Console down + +Clause-②: no +Metadata-protocol: none + +- **View labels.** `ListViewSchema` types a view's `label` as `I18nLabel`, and the server overlays only catalog text, so a map with no `_views` catalog entry reaches the Console as authored. `viewTabLabel` returned that map untouched. The app header then rendered it as a React child on a `/view/NAME` route and threw. The header sits outside the route's error boundary, so the whole Console went blank. The object page's tab bar, the mobile view switcher and the record page's way back read the same value. `viewTabLabel` now resolves the map in the viewer's language first, so all of them draw a string. A map that resolves to no text is drawn as the object's plural label, like a view with no `label`. +- **Interface list pages.** A page whose `label` is a map showed its machine name as the heading and as the record drawer's title, and a map `description` was dropped. Both now resolve in the viewer's language. The machine name is used only when no label resolves. + +What to do: nothing. Metadata that already carries inline locale-map labels now renders as authored. diff --git a/ui/packages/app-shell/src/layout/__tests__/AppHeader.inlineViewLabel-25901.test.tsx b/ui/packages/app-shell/src/layout/__tests__/AppHeader.inlineViewLabel-25901.test.tsx new file mode 100644 index 00000000000..1efa306e743 --- /dev/null +++ b/ui/packages/app-shell/src/layout/__tests__/AppHeader.inlineViewLabel-25901.test.tsx @@ -0,0 +1,230 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25901 — on a `/apps/APP/OBJECT/view/NAME` route, a view whose `label` is an + * inline locale map is titled in the viewer's language. + * + * ## The defect + * + * `ListViewSchema` types a view's `label` as `I18nLabel`: a string or an + * inline locale map. The server overlays only catalog text + * (`translateObjectListViews`), so a map with no `_views.KEY.label` catalog + * entry is served as authored. `viewTabLabel` handed it on untouched — drawn + * as given on the served branch, offered to the bundle as the fallback on the + * other — and the header rendered the object as a React child. The header is + * mounted outside the route's error boundary, so the throw unmounted the whole + * console. Measured before the change with this file's harness: both cells + * below threw `Objects are not valid as a React child (found: object with keys + * {en, zh-CN})` and left the body empty. + * + * ## The rule + * + * `viewTabLabel` resolves the label with `metadataText` in the viewer's + * language before either branch, so every caller — this header, the tab bar, + * the mobile switcher, the record page's way back — receives a string. A map + * that resolves to no text counts as no label: the object's plural label. + * + * Harness: `AppHeader.labelLessViewTitle-12805`'s, with an empty translation + * bundle so no catalog entry can stand in for the map. + * + * Directions, written before the run: the map cells RED before the change (the + * throw), GREEN after; the empty-map cell draws the plural label. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; + +const route = vi.hoisted(() => ({ pathname: '/apps/showcase_app/recheck_gate/view/default' })); +const meta = vi.hoisted(() => ({ value: null as any })); + +vi.mock('react-router-dom', () => ({ + useLocation: () => ({ pathname: route.pathname, search: '', hash: '', state: null, key: 't' }), + useParams: () => ({ appName: 'showcase_app' }), + useNavigate: () => vi.fn(), + useSearchParams: () => [new URLSearchParams(), vi.fn()] as const, + Link: ({ children, to, ...p }: any) => {children}, +})); + +vi.mock('@object-ui/components', async (importOriginal) => { + const actual = await importOriginal>(); + const stripProps = (p: any) => { + const { asChild, variant, size, align, sideOffset, onOpenChange, ...rest } = p ?? {}; + return rest; + }; + const Pass = ({ children, ...p }: any) =>
{children}
; + return { + ...actual, + Button: ({ children, asChild, variant, size, ...p }: any) => ( + + ), + DropdownMenu: Pass, + DropdownMenuTrigger: Pass, + DropdownMenuContent: () => null, + DropdownMenuItem: () => null, + DropdownMenuLabel: () => null, + DropdownMenuSeparator: () => null, + DropdownMenuGroup: Pass, + Avatar: Pass, + AvatarImage: () => null, + AvatarFallback: Pass, + Popover: Pass, + PopoverTrigger: Pass, + PopoverContent: () => null, + Tabs: Pass, + TabsList: Pass, + TabsTrigger: ({ children }: any) => , + TabsContent: Pass, + cn: (...c: any[]) => c.filter(Boolean).join(' '), + }; +}); + +vi.mock('lucide-react', () => { + const Icon = () => ; + return new Proxy({ __esModule: true } as Record, { + get: (target, prop) => { + if (prop === 'then' || prop === '__esModule' || typeof prop === 'symbol') return target[prop]; + return Icon; + }, + has: (_target, prop) => prop !== 'then', + }); +}); + +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useOffline: () => ({ isOnline: true }), +})); +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + PresenceAvatars: () => null, + useTenantPresence: () => [], +})); +vi.mock('../ModeToggle', () => ({ ModeToggle: () => null })); +vi.mock('../LocaleSwitcher', () => ({ LocaleSwitcher: () => null })); +vi.mock('../ConnectionStatus', () => ({ ConnectionStatus: () => null })); +vi.mock('../AppSwitcher', () => ({ AppSwitcher: () => null })); +vi.mock('../LocalizedSidebarTrigger', () => ({ LocalizedSidebarTrigger: () => null })); +vi.mock('../PreviewBadge', () => ({ PreviewBadge: () => null })); +vi.mock('../WorkspaceSwitcher', () => ({ WorkspaceSwitcher: () => null })); + +// ONE module-scope fetch double, never torn down: the header's pollers fire and +// forget, and may read after a test body returns (see the sibling suites). +vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [], items: [], agents: [], requests: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), +); + +// The header reads its lists through this module; the served-view question goes +// to the same context through `@object-ui/react` (provided below) — one value. +vi.mock('../../providers/MetadataProvider', () => ({ useMetadata: () => meta.value })); +vi.mock('../../context/NavigationContext.js', () => ({ + useNavigationContext: () => ({ currentAppName: 'showcase_app', recordTitle: undefined }), +})); +vi.mock('@object-ui/auth', async (importOriginal) => { + // ONE stable identity — a fresh closure per render re-arms every + // `[getAuthConfig]` effect forever (see the sibling suites). + const getAuthConfig = () => Promise.resolve({ features: { multiOrgEnabled: false } }); + return { + ...(await importOriginal>()), + useAuth: () => ({ + user: { id: 'u1', name: 'Zhang San', email: 'zs@example.com' }, + signOut: vi.fn(), + isAuthEnabled: true, + organizations: [], + activeOrganization: null, + isOrganizationsLoading: false, + switchOrganization: vi.fn(), + getAuthConfig, + }), + getUserInitials: () => 'ZS', + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), + }; +}); +const fakeAdapter = { find: () => Promise.resolve({ data: [] }), getClient: () => undefined }; +vi.mock('../../providers/AdapterProvider', () => ({ useAdapter: () => fakeAdapter })); + +import { I18nProvider } from '@object-ui/i18n'; +import { MetadataCtx } from '@object-ui/react'; +import { AppHeader } from '../AppHeader'; + +afterEach(cleanup); + +const OBJECT_NAME = 'recheck_gate'; +const PLURAL = 'QA Gates'; +const SERVED_LABEL = { en: 'My gates', 'zh-CN': '我的关卡' } as const; +const DERIVED_LABEL = { en: 'Derived gates', 'zh-CN': '派生关卡' } as const; + +/** No catalog entry for any view: the map is the only text there is. */ +const BUNDLE = { en: {}, 'zh-CN': {} }; + +/** + * The `/meta/object` answer embeds `served_map` (a served view) and + * `empty_map`; the object as `MetadataProvider` merges it also carries + * `derived_map`, which no server read served. + */ +function world() { + const doc = { + name: OBJECT_NAME, + label: 'QA Gate', + pluralLabel: PLURAL, + listViews: { + served_map: { type: 'grid', label: SERVED_LABEL }, + empty_map: { type: 'grid', label: {} }, + }, + }; + const objects = [{ ...doc, listViews: { ...doc.listViews, derived_map: { type: 'grid', label: DERIVED_LABEL } } }]; + const objectAnswer = [doc]; + meta.value = { + apps: [], + objects, + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: (type: string) => (type === 'object' ? objectAnswer : []), + getTypeStatus: () => 'ready', + }; + return objects; +} + +function renderAt(pathname: string, language: 'en' | 'zh-CN', objects: any[]) { + route.pathname = pathname; + return render( + + + + + , + ); +} + +const LANGUAGES = ['en', 'zh-CN'] as const; + +describe('AppHeader — a view labelled with an inline locale map is titled in the viewer\'s language (#25901)', () => { + it.each(LANGUAGES)('%s: a served view', (language) => { + renderAt(`/apps/showcase_app/${OBJECT_NAME}/view/served_map`, language, world()); + + expect(screen.getAllByText(SERVED_LABEL[language]).length).toBeGreaterThan(0); + }); + + it.each(LANGUAGES)('%s: a view no server read served', (language) => { + renderAt(`/apps/showcase_app/${OBJECT_NAME}/view/derived_map`, language, world()); + + expect(screen.getAllByText(DERIVED_LABEL[language]).length).toBeGreaterThan(0); + }); + + it('a map that resolves to no text is titled by the object\'s plural label', () => { + renderAt(`/apps/showcase_app/${OBJECT_NAME}/view/empty_map`, 'en', world()); + + expect(screen.getAllByText(PLURAL).length).toBeGreaterThan(0); + }); +}); diff --git a/ui/packages/app-shell/src/views/__tests__/InterfaceListPage.inlineLabel-25901.test.tsx b/ui/packages/app-shell/src/views/__tests__/InterfaceListPage.inlineLabel-25901.test.tsx new file mode 100644 index 00000000000..d7d08e3643c --- /dev/null +++ b/ui/packages/app-shell/src/views/__tests__/InterfaceListPage.inlineLabel-25901.test.tsx @@ -0,0 +1,127 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25901 — an interface list page whose `label` / `description` is an inline + * locale map draws them in the viewer's language. + * + * `@objectstack/spec` types both as `I18nLabel`. The page read + * `typeof page.label === 'string' ? page.label : page.name` for its heading and + * its record drawer's title, and drew `page.description` only when it was a + * string: a map label showed the page's machine name, a map description was + * dropped. Both now go through `metadataText`, the helper the header and the + * report page resolve the same values with; the machine name is the heading + * only when no label resolves. + * + * Harness: `InterfaceListPage.packageScope-12980`'s — the real + * `MetadataProvider` over a client double, in an app with no package — under a + * real `I18nProvider` set to the viewer's language. + * + * Directions, written before the run: the map cells RED before the change (the + * machine name, no description), GREEN after; the string control GREEN on + * both sides. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import React from 'react'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { AdapterCtx, useMetadata } from '@object-ui/react'; +import { I18nProvider } from '@object-ui/i18n'; + +import { MetadataProvider } from '../../providers/MetadataProvider'; +import { InterfaceListPage } from '../InterfaceListPage'; +import { readPageObjectScope } from '../../console/appObjectScope'; + +const OBJECTS = [{ name: 'crm_account', label: 'Accounts', fields: { name: { type: 'text', label: 'Name' } } }]; +const APPS = [{ name: 'crm', label: 'CRM' }]; +const MAP_PAGE = { + name: 'accounts_page', + label: { en: 'Key accounts', 'zh-CN': '重点客户' }, + description: { en: 'Accounts we review weekly', 'zh-CN': '每周复盘的客户' }, + type: 'app', + interfaceConfig: { source: 'crm_account' }, +}; +const STRING_PAGE = { ...MAP_PAGE, label: 'Key accounts', description: 'Accounts we review weekly' }; + +function adapter(page: unknown) { + return { + clearCache: vi.fn(), + getObjectSchema(this: { getClient(): any }, name: string) { + return this.getClient() + .meta.getItem('object', name) + .then((res: any) => (res && typeof res === 'object' && 'item' in res ? res.item : res)); + }, + getClient: () => ({ + meta: { + getItems: (type: string) => { + const items = type === 'object' ? OBJECTS : type === 'app' ? APPS : type === 'page' ? [page] : []; + return Promise.resolve({ type, items: structuredClone(items) }); + }, + getItem: (type: string, name: string) => { + const item = (type === 'object' ? OBJECTS : []).find((o) => o.name === name); + return item ? Promise.resolve({ type, name, item: structuredClone(item) }) : Promise.reject(new Error('404')); + }, + }, + }), + } as unknown as Parameters[0]['adapter']; +} + +function PageRoute({ page }: { page: any }) { + const metadata = useMetadata(); + const app = (metadata.apps as any[]).find((a) => a?.name === 'crm'); + if (!app) return null; + const { ready } = readPageObjectScope(metadata.readObjectScope!, app, page); + if (!ready) return
; + return ; +} + +async function boot(page: any, language: 'en' | 'zh-CN') { + const ds = { find: vi.fn(async () => ({ data: [], total: 0 })), getObjectSchema: vi.fn(async () => OBJECTS[0]) }; + render( + + + + + + } /> + + + + + , + ); + await waitFor(() => expect(screen.getByTestId('interface-list-page')).toBeInTheDocument()); +} + +beforeEach(() => { + sessionStorage.clear(); + localStorage.clear(); +}); +afterEach(cleanup); + +describe('InterfaceListPage — inline locale-map label and description (#25901)', () => { + it.each([ + ['en', 'Key accounts', 'Accounts we review weekly'], + ['zh-CN', '重点客户', '每周复盘的客户'], + ] as const)('%s: the heading and description are the map\'s text, never the machine name', async (language, label, description) => { + await boot(MAP_PAGE, language); + + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(label); + expect(screen.getByText(description)).toBeInTheDocument(); + expect(screen.queryByText(MAP_PAGE.name)).not.toBeInTheDocument(); + }); + + it('a map that resolves to no text falls back to the machine name', async () => { + await boot({ ...MAP_PAGE, label: {}, description: {} }, 'en'); + + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(MAP_PAGE.name); + }); + + it('control — a string label and description are drawn as given', async () => { + await boot(STRING_PAGE, 'zh-CN'); + + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Key accounts'); + expect(screen.getByText('Accounts we review weekly')).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/app-shell/src/views/viewTabLabel.inlineLabel-25901.test.ts b/ui/packages/app-shell/src/views/viewTabLabel.inlineLabel-25901.test.ts new file mode 100644 index 00000000000..ffe3d3b528b --- /dev/null +++ b/ui/packages/app-shell/src/views/viewTabLabel.inlineLabel-25901.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25901 — `viewTabLabel` resolves a view's `I18nLabel` in the viewer's + * language before either branch, so no caller receives an inline locale map. + * + * The header, the object page's tab bar, the mobile view switcher and the + * record page's way back all draw this function's answer as text. Before the + * change a map label came back as the map itself — on the served branch drawn + * as given, on the other offered to the bundle as the fallback, which the + * bundle returns untouched on a miss — and React threw on it + * (`AppHeader.inlineViewLabel-25901` pins the header, where the throw took the + * whole console down). + * + * The served/derived split is unchanged: a served view's resolved text is drawn + * as given, any other view's goes through `viewLabel` with the resolved text as + * its fallback, so a catalog entry still wins there. + */ +import { describe, it, expect, vi } from 'vitest'; +import { viewTabLabel, type ViewLabelLookup } from './viewTabLabel'; + +const OBJECT = 'recheck_gate'; +const UNLABELLED = 'QA Gates'; +const LABEL = { en: 'My gates', 'zh-CN': '我的关卡' }; + +/** The `/meta/object` read served `served_map`; `derived_map` it did not. */ +const SERVED = Object.freeze({ + views: [] as readonly unknown[], + objects: [{ name: OBJECT, listViews: { served_map: { label: LABEL } } }] as readonly unknown[], +}); + +/** A bundle with no entry for any view: every lookup answers its fallback. */ +const missingBundle: ViewLabelLookup = (_object, _view, fallback) => fallback; + +describe('viewTabLabel resolves an inline locale-map label (#25901)', () => { + it.each([ + ['en', 'My gates'], + ['zh-CN', '我的关卡'], + ['zh', '我的关卡'], + ])('a served view, %s', (language, expected) => { + const lookup = vi.fn(missingBundle); + const text = viewTabLabel({ id: 'served_map', name: 'served_map', label: LABEL }, OBJECT, SERVED, lookup, UNLABELLED, language); + + expect(text).toBe(expected); + // Drawn as given: the served branch never asks the bundle. + expect(lookup).not.toHaveBeenCalled(); + }); + + it('a view no server read served hands the bundle the RESOLVED text as its fallback', () => { + const lookup = vi.fn(missingBundle); + const text = viewTabLabel({ id: 'derived_map', name: 'derived_map', label: LABEL }, OBJECT, SERVED, lookup, UNLABELLED, 'zh-CN'); + + expect(text).toBe('我的关卡'); + expect(lookup).toHaveBeenCalledWith(OBJECT, 'derived_map', '我的关卡'); + }); + + it('a catalog entry still wins for a view no server read served', () => { + const catalog: ViewLabelLookup = () => 'Catalog gates'; + expect(viewTabLabel({ id: 'derived_map', name: 'derived_map', label: LABEL }, OBJECT, SERVED, catalog, UNLABELLED, 'zh-CN')).toBe('Catalog gates'); + }); + + it('a map that resolves to no text is drawn as the unlabelled text', () => { + expect(viewTabLabel({ id: 'served_map', name: 'served_map', label: {} }, OBJECT, SERVED, missingBundle, UNLABELLED, 'en')).toBe(UNLABELLED); + expect(viewTabLabel({ id: 'served_map', name: 'served_map', label: { en: ' ' } }, OBJECT, SERVED, missingBundle, UNLABELLED, 'en')).toBe(UNLABELLED); + }); + + it('control — a string label is drawn as before', () => { + expect(viewTabLabel({ id: 'served_map', name: 'served_map', label: 'Open gates' }, OBJECT, SERVED, missingBundle, UNLABELLED, 'zh-CN')).toBe('Open gates'); + expect(viewTabLabel({ id: 'served_map', name: 'served_map' }, OBJECT, SERVED, missingBundle, UNLABELLED, 'en')).toBe(UNLABELLED); + }); +});