Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/25901-inline-view-label.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions ui/packages/app-shell/src/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,7 @@ export function AppHeader({
servedViews,
viewLabel,
objectPluralLabel(currentObject),
language,
);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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) => <a href={String(to)} {...p}>{children}</a>,
}));

vi.mock('@object-ui/components', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const stripProps = (p: any) => {
const { asChild, variant, size, align, sideOffset, onOpenChange, ...rest } = p ?? {};
return rest;
};
const Pass = ({ children, ...p }: any) => <div {...stripProps(p)}>{children}</div>;
return {
...actual,
Button: ({ children, asChild, variant, size, ...p }: any) => (
<button type="button" {...p}>{children}</button>
),
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) => <button type="button">{children}</button>,
TabsContent: Pass,
cn: (...c: any[]) => c.filter(Boolean).join(' '),
};
});

vi.mock('lucide-react', () => {
const Icon = () => <span />;
return new Proxy({ __esModule: true } as Record<string | symbol, unknown>, {
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<Record<string, unknown>>()),
useOffline: () => ({ isOnline: true }),
}));
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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<Record<string, unknown>>()),
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(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false, resources: BUNDLE }} persistLanguage={false}>
<MetadataCtx.Provider value={meta.value}>
<AppHeader variant="app" appName="showcase_app" activeAppName="showcase_app" objects={objects} />
</MetadataCtx.Provider>
</I18nProvider>,
);
}

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);
});
});
17 changes: 12 additions & 5 deletions ui/packages/app-shell/src/views/InterfaceListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<div className={className ?? 'h-full flex flex-col'} data-testid="interface-list-page">
<div className={`pl-4 pt-4 pb-2 shrink-0 flex items-start justify-between gap-3 ${reserveEditAffordance ? 'pr-12' : 'pr-4'}`}>
<div className="min-w-0">
<h1 className="text-lg font-semibold leading-tight">
{typeof page.label === 'string' ? page.label : page.name}
{pageLabel ?? page.name}
</h1>
{typeof page.description === 'string' && page.description && (
<p className="text-sm text-muted-foreground mt-0.5">{page.description}</p>
{pageDescription && (
<p className="text-sm text-muted-foreground mt-0.5">{pageDescription}</p>
)}
</div>
{buttonActions.length > 0 && (
Expand All @@ -672,7 +679,7 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit
<NavigationOverlay
{...navOverlay}
setIsOpen={(o: boolean) => { if (!o) closeRecordDrawer(); }}
title={typeof page.label === 'string' ? page.label : (cfg.source || 'Record')}
title={pageLabel ?? (cfg.source || 'Record')}
>
{(record: any) => (
<RecordDetailView
Expand Down
16 changes: 8 additions & 8 deletions ui/packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1475,7 +1475,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const { showDebug } = useMetadataInspector();
const { t } = useObjectTranslation();
const { t, language } = useObjectTranslation();
const { objectLabel, objectPluralLabel, objectDescription: objectDesc, viewLabel, viewEmptyState, actionParamText, fieldLabel, fieldOptionLabel } = useObjectLabel();
// The views a server read served — `/meta/view`, and the object document's
// own `listViews` — already translated: their labels are drawn as given,
Expand Down Expand Up @@ -2477,11 +2477,11 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
const Icon = VIEW_TYPE_ICONS[view.type as keyof typeof VIEW_TYPE_ICONS];
return {
id: view.id,
label: viewTabLabel(view, objectDef.name, servedViews, viewLabel, unlabelledViewText),
label: viewTabLabel(view, objectDef.name, servedViews, viewLabel, unlabelledViewText, language),
icon: Icon ? <Icon className="h-4 w-4" /> : undefined,
};
});
}, [views, objectDef.name, viewLabel, unlabelledViewText, servedViews]);
}, [views, objectDef.name, viewLabel, unlabelledViewText, servedViews, language]);
useMobileViewSwitcherRegistration({
views: mobileViewSwitcherItems,
activeViewId: activeViewId ?? '',
Expand Down Expand Up @@ -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) {
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down
Loading
Loading