diff --git a/.changeset/13470-report-inline-locale-label.md b/.changeset/13470-report-inline-locale-label.md new file mode 100644 index 0000000000..3b970af639 --- /dev/null +++ b/.changeset/13470-report-inline-locale-label.md @@ -0,0 +1,10 @@ +--- +'@object-ui/app-shell': patch +--- + +Clause-②: no +Metadata-protocol: none + +A report whose `label` or `description` is an inline locale map (`{ en, 'zh-CN' }`) now opens, and shows both in the viewer's language (objectui#13470). + +`@objectstack/spec` types both keys as `I18nLabel`, so the map is valid metadata and is served unchanged, but the report page drew it straight into its heading, the line under it and the report's crumb in the app bar: React refused the object (error #31) and the page was blank for every viewer. A drill on the whole report titled its drawer `[object Object]`. Each of these now resolves the map to the viewer's language with the reader the app's report index already names a report with. A plain-string label or description draws exactly as before. diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx index 89d975afc0..ba344912a1 100644 --- a/packages/app-shell/src/layout/AppHeader.tsx +++ b/packages/app-shell/src/layout/AppHeader.tsx @@ -96,6 +96,7 @@ import { useMobileViewSwitcher } from './MobileViewSwitcherContext.js'; import { useNavigationContext } from '../context/NavigationContext.js'; import { useCommandPalette, useCommandPaletteProviderMounted } from '../context/CommandPaletteProvider.js'; import { useUrlOverlay } from '../hooks/useUrlOverlay.js'; +import { metadataText } from '../hooks/useNavTargetLabel.js'; import { KEYBOARD_SHORTCUTS_PARAM } from '../urlParams.js'; import { useAiSurfaceEnabled } from '../hooks/useAiSurface.js'; import { useStudioDesignGate } from '../hooks/useStudioDesignGate.js'; @@ -469,7 +470,7 @@ export function AppHeader({ // no AI (Community Edition) so it can't dead-end on a chat with no agent. // Same signal as the FAB and the `/ai` route guard. const { enabled: aiEnabled } = useAiSurfaceEnabled(); - const { t } = useObjectTranslation(); + const { t, language } = useObjectTranslation(); const { objectPluralLabel, dashboardLabel, pageLabel, reportLabel, viewLabel, appLabel } = useObjectLabel(); // The dashboard, page and report lists are LAZY getters: reading one starts // its `/meta/:type` read. They are read below only on the route type that @@ -703,7 +704,9 @@ export function AppHeader({ if (pathParts[3]) { const reportName = pathParts[3]; const reportDef = preferLocal(metadata.reports as any[], reportName, (currentApp as any)?._packageId); - const fallback = reportDef?.label || humanizeSlug(reportName); + // A report's `label` may be an inline locale map; drawn raw, the crumb + // blanked the report page (objectui#13470). + const fallback = metadataText(reportDef?.label, language) ?? humanizeSlug(reportName); extraSegments.push({ label: reportLabel({ name: reportName, label: fallback }) }); } } else if (routeType === 'system') { diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.reportCrumbInlineLocale-13470.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.reportCrumbInlineLocale-13470.test.tsx new file mode 100644 index 0000000000..dfbef73c21 --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/AppHeader.reportCrumbInlineLocale-13470.test.tsx @@ -0,0 +1,185 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#13470 — a report route's crumb names a report whose `label` is the + * spec's inline locale map (`I18nLabel`, `{ 'zh-CN', en }`) in the viewer's + * language. + * + * `ReportSchema.label` is `string | I18nLabel`. The crumb handed the served + * label to `reportLabel` as its fallback, which returns an unmatched fallback + * untouched, so the map reached JSX and React refused it (error #31): the bar, + * and the report page under it, went blank. Fixing `ReportView` alone left the + * page blank through this crumb. + * + * Harness: `AppHeader.dashboardReportCrumb-12273`'s, with `useObjectLabel` + * REAL (as in `AppHeader.servedLabels-11295`): the bundle carries no + * `reports` key, so the crumb is the resolved served label. The zh and en legs + * read different strings out of one map; `PLAIN_STRING_CONTROL` is a report + * labelled with a plain string, drawn unchanged. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import React from 'react'; + +const route = vi.hoisted(() => ({ pathname: '/apps/srm/report/srm_delivery_scorecard' })); + +vi.mock('react-router-dom', () => ({ + useLocation: () => ({ pathname: route.pathname, search: '', hash: '', state: null, key: 't' }), + useParams: () => ({ appName: 'srm' }), + useNavigate: () => vi.fn(), + useSearchParams: () => [new URLSearchParams(), vi.fn()] as const, + Link: ({ children, to, ...p }: Record & { children?: React.ReactNode }) => ( + {children} + ), +})); + +vi.mock('@object-ui/components', async (importOriginal) => { + const actual = await importOriginal>(); + const strip = (p: Record | undefined) => { + const { asChild: _a, variant: _v, size: _s, align: _al, sideOffset: _so, onOpenChange: _o, ...rest } = p ?? {}; + return rest; + }; + const Pass = ({ children, ...p }: Record & { children?: React.ReactNode }) => ( +
{children}
+ ); + return { + ...actual, + Button: ({ children, asChild: _a, variant: _v, size: _s, ...p }: Record & { children?: React.ReactNode }) => ( + + ), + 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, + cn: (...c: unknown[]) => 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 report's inline map, and what each viewer must read out of it. */ +const LABEL_MAP = { 'zh-CN': '供应商交付与质量(按期间)', en: 'Supplier Delivery and Quality by Period' }; +const PLAIN_LABEL = 'Delivery Scorecard'; + +vi.mock('../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ + apps: [{ name: 'srm', label: 'SRM' }], + dashboards: [], + pages: [], + reports: [ + { name: 'srm_delivery_scorecard', label: LABEL_MAP }, + { name: 'delivery_plain', label: PLAIN_LABEL }, + ], + }), +})); +vi.mock('../../context/NavigationContext.js', () => ({ + useNavigationContext: () => ({ currentAppName: 'srm', 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: false, isResolved: true }), + }; +}); +const fakeAdapter = { find: () => Promise.resolve({ data: [] }), getClient: () => undefined }; +vi.mock('../../providers/AdapterProvider', () => ({ useAdapter: () => fakeAdapter })); + +import { I18nProvider } from '@object-ui/i18n'; +import { AppHeader } from '../AppHeader'; + +afterEach(cleanup); + +type Lang = 'en' | 'zh-CN'; + +function renderAt(pathname: string, lang: Lang) { + route.pathname = pathname; + return render( + + + , + ); +} + +/** The crumbs after the app pill, in order: each separator's crumb text. */ +function crumbLabels(): string[] { + return [...document.querySelectorAll('span[aria-hidden]')] + .filter((s) => s.textContent?.trim() === '/') + .map((sep) => (sep.parentElement?.textContent ?? '').replace('/', '').trim()); +} + +describe('a report route\'s crumb resolves an inline locale label (objectui#13470)', () => { + it.each<[Lang, string]>([ + ['en', LABEL_MAP.en], + ['zh-CN', LABEL_MAP['zh-CN']], + ])('LABEL_MAP_RESOLVES — %s: the crumb is the viewer\'s label', (lang, expected) => { + renderAt('/apps/srm/report/srm_delivery_scorecard', lang); + expect(crumbLabels()).toEqual([expected]); + }); + + it.each(['en', 'zh-CN'])('PLAIN_STRING_CONTROL — %s: a plain-string label draws unchanged', (lang) => { + renderAt('/apps/srm/report/delivery_plain', lang); + expect(crumbLabels()).toEqual([PLAIN_LABEL]); + }); +}); diff --git a/packages/app-shell/src/views/ReportView.inlineLocaleLabel-13470.test.tsx b/packages/app-shell/src/views/ReportView.inlineLocaleLabel-13470.test.tsx new file mode 100644 index 0000000000..348a560f0d --- /dev/null +++ b/packages/app-shell/src/views/ReportView.inlineLocaleLabel-13470.test.tsx @@ -0,0 +1,161 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#13470 — a report whose `label` and `description` are the spec's + * inline locale map (`I18nLabel`, `{ 'zh-CN', en }`) opens, in the viewer's + * language. + * + * ## The defect + * + * `ReportSchema` in `@objectstack/spec` types both keys as `I18nLabelSchema`, + * so the map parses and publishes clean, and the `/meta` read serves it + * unchanged. `ReportView` drew `label` as the page heading and `description` + * under it as given: React refused the object as a child (error #31) and the + * report page was blank for every viewer. The drill drawer's title fell back + * to `String(label)`, which reads `[object Object]`. + * + * ## Discrimination + * + * The report carries the map on both keys, as the card's repro does. Putting + * either key back raw blanks the page, so every leg goes red; putting the + * drill title back raw leaves the page up and turns only its own leg red. The + * zh-CN and en legs read different strings out of the same map, which a + * resolver that took the first entry, or always `en`, cannot both pass. + * `PLAIN_STRING_CONTROL` is a report written with plain strings, asserted + * unchanged in both languages. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import type { DataSource } from '@object-ui/types'; + +// The report body stands in as one button that drills on the whole report: an +// empty group key, so the drawer is titled by the report itself. +vi.mock('@object-ui/plugin-report', async (importOriginal) => ({ + ...(await importOriginal>()), + ReportRenderer: ({ onDrill }: { onDrill?: (args: unknown) => void }) => ( + + ), +})); +vi.mock('@object-ui/plugin-dashboard', async (importOriginal) => ({ + ...(await importOriginal>()), + DrillDownDrawer: ({ open, title }: { open: boolean; title: string }) => + open ?
{title}
: null, +})); +vi.mock('./ReportConfigPanel', () => ({ ReportConfigPanel: () => null })); + +const meta = vi.hoisted(() => ({ value: null as unknown })); +vi.mock('../providers/MetadataProvider', () => ({ useMetadata: () => meta.value })); + +vi.mock('react-router-dom', () => ({ + useParams: () => ({ reportName: 'srm_delivery_scorecard' }), + useNavigate: () => vi.fn(), + useLocation: () => ({ pathname: '/report/srm_delivery_scorecard', search: '' }), +})); + +vi.mock('./useOpenRecordList', () => ({ useOpenRecordList: () => vi.fn() })); +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false }), +})); +vi.mock('./metadata-admin/useMetadata', () => ({ useMetadataClient: () => ({ get: vi.fn() }) })); +vi.mock('./runtime-metadata-persistence', () => ({ persistRuntimeMetadata: vi.fn() })); +vi.mock('../providers/AdapterProvider', () => ({ useAdapter: () => ({}) })); +vi.mock('../providers/ExpressionProvider', () => ({ useExpressionContext: () => ({ app: undefined }) })); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), +})); + +import { ReportView } from './ReportView'; + +afterEach(cleanup); + +/** Each inline map, and what each viewer must read out of it. */ +const LABEL_MAP = { 'zh-CN': '供应商交付与质量(按期间)', en: 'Supplier Delivery and Quality by Period' }; +const DESCRIPTION_MAP = { 'zh-CN': '按期间统计的准时交付率与质量。', en: 'On-time delivery and quality, per period.' }; + +/** The card's repro report: a dataset-bound summary with both keys as maps. */ +const MAP_REPORT = { + name: 'srm_delivery_scorecard', + label: LABEL_MAP, + description: DESCRIPTION_MAP, + type: 'summary', + dataset: 'srm_delivery_metrics', + rows: ['period', 'supplier'], + values: ['line_count', 'otd_rate'], +}; + +/** PLAIN_STRING_CONTROL — the same report written with plain strings. */ +const PLAIN = { label: 'Delivery Scorecard', description: 'Shown to buyers first.' }; +const PLAIN_REPORT = { ...MAP_REPORT, ...PLAIN }; + +type Lang = 'en' | 'zh-CN'; +const pick = (map: Record, lang: Lang) => (lang === 'zh-CN' ? map['zh-CN'] : map.en); + +function renderReport(report: Record, lang: Lang) { + meta.value = { + apps: [], + objects: [], + dashboards: [], + reports: [report], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: vi.fn(async () => null), + getItemsByType: () => [], + getTypeStatus: () => 'ready', + }; + const dataSource = { find: vi.fn(async () => ({ data: [] })) } as unknown as DataSource; + return render( + + + , + ); +} + +describe.each(['en', 'zh-CN'])('objectui#13470 — a report with inline locale maps, viewer in %s', (lang) => { + it('HEADING_MAP_RESOLVES — the page heading is the viewer\'s label', async () => { + renderReport(MAP_REPORT, lang); + await screen.findByTestId('report-body'); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(pick(LABEL_MAP, lang)); + }); + + it('DESCRIPTION_MAP_RESOLVES — the line under the heading is the viewer\'s description', async () => { + renderReport(MAP_REPORT, lang); + await screen.findByTestId('report-body'); + expect(screen.getByText(pick(DESCRIPTION_MAP, lang))).toBeInTheDocument(); + }); + + it('DRILL_TITLE_MAP_RESOLVES — a whole-report drill titles its drawer with the viewer\'s label', async () => { + renderReport(MAP_REPORT, lang); + fireEvent.click(await screen.findByTestId('report-body')); + expect(await screen.findByTestId('drill-title')).toHaveTextContent(pick(LABEL_MAP, lang)); + }); + + it('PLAIN_STRING_CONTROL — a plain-string label and description draw unchanged', async () => { + renderReport(PLAIN_REPORT, lang); + fireEvent.click(await screen.findByTestId('report-body')); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(PLAIN.label); + expect(screen.getByText(PLAIN.description)).toBeInTheDocument(); + expect(await screen.findByTestId('drill-title')).toHaveTextContent(PLAIN.label); + }); +}); diff --git a/packages/app-shell/src/views/ReportView.tsx b/packages/app-shell/src/views/ReportView.tsx index 51e88f65ca..b43d7d91fa 100644 --- a/packages/app-shell/src/views/ReportView.tsx +++ b/packages/app-shell/src/views/ReportView.tsx @@ -24,6 +24,7 @@ import type { DatasetDrillArgs } from '@object-ui/plugin-report'; import { DrillDownDrawer } from '@object-ui/plugin-dashboard'; import { DrillNavigationProvider } from '@object-ui/react'; import { useOpenRecordList } from './useOpenRecordList.js'; +import { metadataText } from '../hooks/useNavTargetLabel.js'; // Fallback fields when no schema is available const FALLBACK_FIELDS = [ @@ -38,7 +39,7 @@ const FALLBACK_FIELDS = [ ]; export function ReportView({ dataSource }: { dataSource?: DataSource }) { - const { t } = useObjectTranslation(); + const { t, language } = useObjectTranslation(); const { reportName } = useParams<{ reportName: string }>(); const { showDebug } = useMetadataInspector(); const adapter = useAdapter(); @@ -110,10 +111,11 @@ export function ReportView({ dataSource }: { dataSource?: DataSource }) { const handleDatasetDrill = useCallback( async ({ dataset, groupKey, object, objectFilter }: DatasetDrillArgs) => { try { - // Drawer header: the clicked group's display values (e.g. "West / Q3"). + // Drawer header: the clicked group's display values (e.g. "West / Q3"), + // else the report's label in the viewer's language (objectui#13470). const titleParts = Object.values(groupKey).filter((v) => v != null).map((v) => String(v)); const title = titleParts.join(' / ') - || String(reportData?.label ?? reportData?.name ?? 'Details'); + || (metadataText(reportData?.label, language) ?? String(reportData?.name ?? 'Details')); // Fast path (ADR-0021 D2): the renderer already resolved the dataset's // base object + an exact object FIELD → RAW value filter from the @@ -163,7 +165,7 @@ export function ReportView({ dataSource }: { dataSource?: DataSource }) { console.warn('ReportView: drill failed', err); } }, - [metadataClient, objects, reportData], + [metadataClient, objects, reportData, language], ); // Derive available fields from object schema for filter/sort editors @@ -390,6 +392,15 @@ export function ReportView({ dataSource }: { dataSource?: DataSource }) { // Use live-edited schema for preview (persists after closing panel until metadata refreshes) const previewReport = editSchema || reportData; + // `label` and `description` are the spec's `string | I18nLabel`, so an + // inline locale map (`{ en, 'zh-CN' }`) is valid metadata and is served + // unchanged. Drawn raw, React refused the object and the page went blank + // (objectui#13470). Each resolves to the viewer's language through + // `metadataText`, the reader the app's report index names a report with. + const heading = metadataText(previewReport.title, language) + ?? metadataText(previewReport.label, language) + ?? 'Report Viewer'; + const description = metadataText(previewReport.description, language); // Every report renders through the spec ReportRenderer dispatcher: it routes // dataset-bound reports to DatasetReportRenderer (aggregation, charts, KPIs, // drill protocol end-to-end), bridges stored pre-9.0 spec JSON to the @@ -402,9 +413,9 @@ export function ReportView({ dataSource }: { dataSource?: DataSource }) {
-

{previewReport.title || previewReport.label || 'Report Viewer'}

- {previewReport.description && ( -

{previewReport.description}

+

{heading}

+ {description && ( +

{description}

)}