diff --git a/.changeset/30311-served-object-view-labels.md b/.changeset/30311-served-object-view-labels.md new file mode 100644 index 00000000000..a578ae5db24 --- /dev/null +++ b/.changeset/30311-served-object-view-labels.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): the Console sidebar, recent list and object list header draw a served object or list view label as served + +Clause-②: no +Metadata-protocol: none + +- **Now.** An unlabelled sidebar entry that opens an object or a list view, a recent object or list view row (in the sidebar and on Home), the object list page's title, the app header's title on an object route and an object's `/data` page title 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` object extension's or view override'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. A list is titled by the served `pluralLabel`, else the served `label`. +- **Unchanged.** A list view the client derived (no served read names it), an object the cache does not hold yet, and a pending draft's label are still named by the catalog key, then by the machine name as before. An unedited item shows the same text as before, because the server serves the catalog's translation for it. Record pages, record forms and drawers still look the object's name up in the catalog; they are a follow-up. diff --git a/ui/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.objectPlural-11733.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.objectPlural-11733.test.tsx index 771b51dce62..745a1bbc328 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.objectPlural-11733.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.objectPlural-11733.test.tsx @@ -38,11 +38,21 @@ import type { RecentItem } from '../../context/RecentItemsProvider'; type Lang = 'en' | 'zh-CN'; -function metadata() { +/** + * The served objects, per language: the server translates what it serves + * under the request's language from the same catalog the console loads, so a + * zh-CN session holds the zh-CN answer (objectstack#30311). + */ +const SERVED_PROJECT = { + en: { name: 'showcase_project', label: 'Project', pluralLabel: 'Projects' }, + 'zh-CN': { name: 'showcase_project', label: '项目', pluralLabel: '项目清单' }, +} as const; + +function metadata(lang: Lang) { return { apps: [], objects: [ - { name: 'showcase_project', label: 'Project', pluralLabel: 'Projects' }, + SERVED_PROJECT[lang], { name: 'showcase_note', label: 'Note' }, ], dashboards: [], @@ -69,7 +79,7 @@ function wrapper(lang: Lang) { expect(i18n.language).toBe(lang); return ({ children }: { children: ReactNode }) => ( - {children} + {children} ); } diff --git a/ui/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.test.tsx index 2a62e772742..b0112fd9078 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.test.tsx @@ -22,6 +22,7 @@ import { renderHook } from '@testing-library/react'; import { MetadataCtx } from '@object-ui/react'; import { createI18n, I18nProvider } from '@object-ui/i18n'; import { useRecentItemLabel } from '../useRecentItemLabel'; +import { DraftTargetLabels, useNavTargetLabel } from '../useNavTargetLabel'; import type { RecentItem } from '../../context/RecentItemsProvider'; import { recentViewId } from '../useTrackRouteAsRecent'; @@ -32,10 +33,22 @@ import { recentViewId } from '../useTrackRouteAsRecent'; */ const SERVED_REPORT = { en: 'Q3 Results', 'zh-CN': '第三季度结果' } as const; -function metadata(language = 'en', served: { reports?: unknown[]; pages?: unknown[]; dashboards?: unknown[] } = {}) { +type Served = { + reports?: unknown[]; + pages?: unknown[]; + dashboards?: unknown[]; + /** The object list as the console composes it: served objects, served views merged in. */ + objects?: unknown[]; + /** `getItemsByType`: the `/meta/view` and `/meta/object` reads as served, before the merge. */ + byType?: { view?: unknown[]; object?: unknown[] }; + /** The names-only scope read (`readObjectScope`), answered while the object list is idle. */ + scope?: { objects: unknown[]; served: unknown[]; views: unknown[] }; +}; + +function metadata(language = 'en', served: Served = {}) { return { apps: [], - objects: [ + objects: served.objects ?? [ { name: 'showcase_task', label: 'Tasks', @@ -53,8 +66,11 @@ function metadata(language = 'en', served: { reports?: unknown[]; pages?: unknow invalidate: () => {}, ensureType: async () => [], getItem: async () => null, - getItemsByType: () => [], - getTypeStatus: () => 'ready' as const, + getItemsByType: (type: string) => (type === 'view' || type === 'object' ? served.byType?.[type] : undefined) ?? [], + getTypeStatus: (type?: string) => (served.scope && type === 'object' ? ('idle' as const) : ('ready' as const)), + ...(served.scope + ? { readObjectScope: () => ({ ...served.scope, missing: [], settled: true }) } + : {}), }; } @@ -65,15 +81,21 @@ const CATALOG = { reports: { q3_results: { label: '第三季度结果' } }, pages: { welcome_tour: { label: '欢迎' } }, dashboards: { ops_board: { label: '运营看板' } }, + objects: { + showcase_task: { label: '任务', pluralLabel: '任务列表', _views: { open_tasks: { label: '未完成任务' } } }, + showcase_draft: { label: '草稿对象' }, + }, }, }, }; -function wrapper(language: string, served?: Parameters[1]) { +function wrapper(language: string, served?: Served, drafts: Array<{ type: string; name: string; label?: unknown }> = []) { const i18n = createI18n({ defaultLanguage: language, detectBrowserLanguage: false, resources: CATALOG }); return ({ children }: { children: ReactNode }) => ( - {children} + + {children} + ); } @@ -212,3 +234,116 @@ describe('useRecentItemLabel — a served report, page or dashboard is named as expect(result.current(item('dashboard', 'ops_board'))).toBe('ops_board'); }); }); + +/** + * objectstack#30311 — a served object or list view is named as served, in the + * sidebar's unlabelled entries (`useNavTargetLabel`) and the recent list. + * + * A `.custom` override renamed the object and its view, and the `/meta` read + * serves the new names by the server's label order for an overridden item. + * The object and view rungs re-applied the packaged catalog's translation of + * the old names over them, because the served label reached the catalog + * lookup only as its fallback. + * + * Directions, written before the run: the renamed cells RED before the change + * (the packaged zh-CN string), GREEN after; the controls (a view the client + * derived, an object the cache does not hold, a pending draft, en) GREEN on + * both sides. + */ +describe('useRecentItemLabel / useNavTargetLabel — a served object or list view is named as served (objectstack#30311)', () => { + const VIEW_ID = 'showcase_task.open_tasks'; + const RENAMED_VIEW = { name: VIEW_ID, object: 'showcase_task', label: 'Still to do (renamed-30311)' }; + // The object as served (the `/meta/object` document), and as the console + // composes it (the served view merged into its `listViews`). + const servedObject = (extra: Record = {}) => ({ name: 'showcase_task', label: 'Work items (renamed-30311)', ...extra }); + const RENAMED: Served = { + objects: [{ ...servedObject(), listViews: { [VIEW_ID]: RENAMED_VIEW } }], + byType: { view: [RENAMED_VIEW], object: [servedObject()] }, + }; + const objectEntry: RecentItem = { id: 'object:showcase_task', type: 'object', name: 'showcase_task', href: '/o', visitedAt: at }; + const viewEntry: RecentItem = { + id: recentViewId('showcase_task', VIEW_ID), + type: 'view', + object: 'showcase_task', + name: VIEW_ID, + href: `/apps/showcase/showcase_task/view/${VIEW_ID}`, + visitedAt: at, + }; + + it('zh-CN: a renamed object shows the served label in the recent list and an unlabelled sidebar entry', () => { + const recent = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('zh-CN', RENAMED) }); + expect(recent.result.current(objectEntry)).toBe('Work items (renamed-30311)'); + const nav = renderHook(() => useNavTargetLabel({ byName: true }), { wrapper: wrapper('zh-CN', RENAMED) }); + expect(nav.result.current({ kind: 'object', objectName: 'showcase_task' })).toBe('Work items (renamed-30311)'); + }); + + it('zh-CN: a recent object row reads the served plural first', () => { + const served: Served = { objects: [servedObject({ pluralLabel: 'All work items (renamed-30311)' })] }; + const { result } = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('zh-CN', served) }); + expect(result.current(objectEntry)).toBe('All work items (renamed-30311)'); + }); + + it('zh-CN: a renamed served view shows the served label in the recent list and an unlabelled sidebar entry', () => { + const recent = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('zh-CN', RENAMED) }); + expect(recent.result.current(viewEntry)).toBe('Still to do (renamed-30311)'); + const nav = renderHook(() => useNavTargetLabel({ byName: true }), { wrapper: wrapper('zh-CN', RENAMED) }); + expect(nav.result.current({ kind: 'view', objectName: 'showcase_task', viewName: 'open_tasks' })).toBe('Still to do (renamed-30311)'); + }); + + it('zh-CN: by name, the served reads are the names-only scope’s own', () => { + const scoped: Served = { + objects: [], + byType: { view: [], object: [] }, + scope: { objects: RENAMED.objects!, served: [servedObject()], views: [RENAMED_VIEW] }, + }; + const nav = renderHook(() => useNavTargetLabel({ byName: true }), { wrapper: wrapper('zh-CN', scoped) }); + expect(nav.result.current({ kind: 'view', objectName: 'showcase_task', viewName: 'open_tasks' })).toBe('Still to do (renamed-30311)'); + expect(nav.result.current({ kind: 'object', objectName: 'showcase_task' })).toBe('Work items (renamed-30311)'); + }); + + it('zh-CN: a view the served object document embeds in its own listViews is served too', () => { + const embedded = { [VIEW_ID]: { label: 'Still to do (renamed-30311)' } }; + const served: Served = { + objects: [servedObject({ listViews: embedded })], + byType: { view: [], object: [servedObject({ listViews: embedded })] }, + }; + const nav = renderHook(() => useNavTargetLabel(), { wrapper: wrapper('zh-CN', served) }); + expect(nav.result.current({ kind: 'view', objectName: 'showcase_task', viewName: VIEW_ID })).toBe('Still to do (renamed-30311)'); + }); + + it('zh-CN: control — a view the client derived (no served read names it) is named by the bundle', () => { + const derived: Served = { + objects: [{ ...servedObject(), listViews: { [VIEW_ID]: { name: VIEW_ID, label: 'Open tasks' } } }], + byType: { view: [], object: [servedObject()] }, + }; + const { result } = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('zh-CN', derived) }); + expect(result.current(viewEntry)).toBe('未完成任务'); + }); + + it('zh-CN: control — an object the cache does not hold is named by the bundle', () => { + const empty: Served = { objects: [], byType: { view: [], object: [] } }; + const recent = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('zh-CN', empty) }); + expect(recent.result.current(objectEntry)).toBe('任务列表'); + const nav = renderHook(() => useNavTargetLabel(), { wrapper: wrapper('zh-CN', empty) }); + expect(nav.result.current({ kind: 'object', objectName: 'showcase_task' })).toBe('任务'); + }); + + it('zh-CN: control — a pending draft object’s label, which no server translated, still goes through the bundle', () => { + const drafts = [ + { type: 'object', name: 'showcase_draft', label: 'Draft object' }, + { type: 'object', name: 'showcase_other_draft', label: 'Other draft' }, + ]; + const { result } = renderHook(() => useNavTargetLabel(), { wrapper: wrapper('zh-CN', { objects: [] }, drafts) }); + expect(result.current({ kind: 'object', objectName: 'showcase_draft' })).toBe('草稿对象'); + expect(result.current({ kind: 'object', objectName: 'showcase_other_draft' })).toBe('Other draft'); + }); + + it('en: unchanged — the served object and view labels', () => { + const recent = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('en', RENAMED) }); + expect(recent.result.current(objectEntry)).toBe('Work items (renamed-30311)'); + expect(recent.result.current(viewEntry)).toBe('Still to do (renamed-30311)'); + const plain = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('en') }); + expect(plain.result.current(objectEntry)).toBe('Tasks'); + expect(plain.result.current(viewEntry)).toBe('Open tasks'); + }); +}); diff --git a/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts b/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts index 03c91ceff93..b3c47beafbc 100644 --- a/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts +++ b/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts @@ -37,12 +37,16 @@ * Nothing is written back and nothing is memoised across metadata versions, so * a renamed target shows its new name on the next render (the ruling accepts a * reload). A metadata label that is an inline locale map resolves through the - * spec's own `resolveI18nLabel` in the active language. An object's or view's - * result passes through the same convention-based bundle lookup - * (`useObjectLabel`) the rest of the console applies to their names; a served - * dashboard's label is already translated by the server and is drawn as given, - * as its page header draws it (objectstack#30244). Either way the nav shows the - * name the target's own page shows. + * spec's own `resolveI18nLabel` in the active language. A served label is + * already translated by the server, by its label order for an overridden item, + * and is drawn as given: an object's (every object in the cache is a served + * document), a served view's (`isServedView`, the question the object page's + * view tabs ask) and a dashboard's (objectstack#30244, objectstack#30311). The + * convention-based bundle lookup (`useObjectLabel`) is asked only for text no + * server translated: a view the client derived, a pending draft's label, or a + * target with no served label. Running the bundle over a served label was a + * second translation pass in which the base package's string won a rename + * back. * * A target with no label answers `undefined` — the renderer then falls through * the spec's ladder and, at the bottom, to the target's machine name. @@ -74,6 +78,7 @@ import type { NavLabelTarget, NavTargetLabelResolver } from '@object-ui/layout'; import { useMetadata } from '../providers/MetadataProvider.js'; import { bundledFirst, useBundledReads } from '../providers/bundledReads.js'; import { readObjectsByName, useReadItemsByName } from './useObjectPresence.js'; +import { isServedView } from './useServedViewItems.js'; /** * A metadata label (plain string or inline locale map) as display text, or @@ -90,6 +95,29 @@ function nonEmpty(text: string | undefined): string | undefined { return typeof text === 'string' && text.trim() !== '' ? text : undefined; } +/** `useObjectLabel().objectPluralLabel` — the client bundle's list-title lookup. */ +export type ObjectPluralLabelLookup = (objectDef: { name: string; label: string; pluralLabel?: string }) => string; + +/** + * What a served object's LIST is called (objectstack#30311): its served + * `pluralLabel`, else its served `label`, drawn as given. The server + * translated both, by its label order for an overridden item, so running the + * bundle over them was a second translation pass in which the base package's + * string won a rename back. The bundle's `objectPluralLabel` under the + * object's name is asked only when the definition carries neither; `''` when + * nothing names it. Shared by every surface that titles an object's list — + * the object page's header, the app header, a recent object row. + */ +export function servedObjectPluralLabel( + objectDef: { name: string; label?: unknown; pluralLabel?: unknown }, + language: string | undefined, + objectPluralLabel: ObjectPluralLabelLookup, +): string { + // `pluralLabel` is a plain string in the spec, so only a string is one. + const plural = typeof objectDef.pluralLabel === 'string' ? nonEmpty(objectDef.pluralLabel) : undefined; + return plural ?? metadataText(objectDef.label, language) ?? objectPluralLabel({ name: objectDef.name, label: '' }); +} + /** The two members this hook reads off a cached metadata record. */ export interface LabelledRecord { name?: unknown; @@ -141,6 +169,41 @@ function listViewsOf(def: LabelledRecord | undefined): Record) : {}; } +/** One identity for an absent read. */ +const NO_ITEMS: readonly unknown[] = Object.freeze([]); + +/** + * What a `view` target reads (objectstack#30311): its object, with the views + * merged in, and the two served reads `isServedView` asks of it — the + * `/meta/view` documents and the object documents as served, before the merge. + * By name, all three come from the one names-only scope read + * `readObjectsByName` makes; otherwise from the whole lists, as + * `useServedViewItems` reads them outside an app scope. + */ +function readViewTarget( + metadata: ReturnType, + byName: boolean, + objectName: string, +): { objects: readonly unknown[]; served: { views: readonly unknown[]; objects: readonly unknown[] } } { + const { readObjectScope, getTypeStatus } = metadata; + if (byName && readObjectScope && getTypeStatus?.('object') === 'idle' && objectName) { + // `views` rides beside the published shape for this package's own readers. + const scope = readObjectScope({ names: [objectName] }) as { objects: unknown[]; served?: unknown; views?: unknown }; + return { + objects: scope.objects, + served: { + views: Array.isArray(scope.views) ? scope.views : NO_ITEMS, + objects: Array.isArray(scope.served) ? scope.served : NO_ITEMS, + }, + }; + } + const objects = byName ? readObjectsByName(metadata, [objectName], { views: true }) : metadata.objects; + return { + objects: objects ?? NO_ITEMS, + served: { views: metadata.getItemsByType?.('view') ?? NO_ITEMS, objects: metadata.getItemsByType?.('object') ?? NO_ITEMS }, + }; +} + /** * How the resolver reads an object (objectstack#29121). By default from the * whole `objects` list, as the Studio's and the search page's callers do. With @@ -171,15 +234,24 @@ export function useNavTargetLabel(options?: NavTargetLabelOptions): NavTargetLab case 'object': { const objects = byName ? readObjectsByName(metadata, [target.objectName]) : metadata.objects; const def = findByName(objects, target.objectName); - const text = - metadataText(def?.label, language) ?? draftText(drafts, 'object', target.objectName, language); - return nonEmpty(objectLabel({ name: target.objectName, label: text ?? '' })); + // A served object's label is already translated (objectstack#30311): + // drawn as given. A pending draft's label, which no server + // translated, still goes through the bundle. + return metadataText(def?.label, language) + ?? nonEmpty(objectLabel({ + name: target.objectName, + label: draftText(drafts, 'object', target.objectName, language) ?? '', + })); } case 'view': { - const objects = byName ? readObjectsByName(metadata, [target.objectName], { views: true }) : metadata.objects; + const { objects, served } = readViewTarget(metadata, byName, target.objectName); const views = listViewsOf(findByName(objects, target.objectName)); const id = resolveViewId(target.viewName, Object.keys(views), target.objectName); const text = metadataText(id ? views[id]?.label : undefined, language); + // A served view's label is already translated (objectstack#30311), + // as its tab on the object page draws it; a view the client derived + // still goes through the bundle. + if (text !== undefined && isServedView(served, target.objectName, id)) return text; return nonEmpty(viewLabel(target.objectName, target.viewName, text ?? '')); } case 'dashboard': { diff --git a/ui/packages/app-shell/src/hooks/useRecentItemLabel.ts b/ui/packages/app-shell/src/hooks/useRecentItemLabel.ts index a601a8bc26b..36603b8125c 100644 --- a/ui/packages/app-shell/src/hooks/useRecentItemLabel.ts +++ b/ui/packages/app-shell/src/hooks/useRecentItemLabel.ts @@ -19,19 +19,22 @@ * already asks for that kind: * * - an object: what the page the entry opens — the object's LIST — is titled, - * `useObjectLabel`'s `objectPluralLabel` over the object's cached metadata - * (the translated plural, else the declared `pluralLabel`, else the label - * the singular rung reads; objectui#11733). ⛔ Not {@link useNavTargetLabel}: - * its object rung is the spec's step 3 for an UNLABELLED NAVIGATION ENTRY - * ("the object's label", the singular), a ruling this entry does not reopen; + * the served `pluralLabel`, else the served `label` (objectui#11733). The + * server translated both, by its label order for an overridden item, so + * they are drawn as given (objectstack#30311); `useObjectLabel`'s + * `objectPluralLabel` under the object's name is asked only when the cache + * holds neither. ⛔ Not {@link useNavTargetLabel}: its object rung is the + * spec's step 3 for an UNLABELLED NAVIGATION ENTRY ("the object's label", + * the singular), a ruling this entry does not reopen; * - a dashboard: {@link useNavTargetLabel} — the text the sidebar shows for an * unlabelled navigation entry that opens the same target (the served * label, an inline locale map read in the active language, drawn as given; * the `useObjectLabel` bundle lookup only when the read carries none); * - a list view (objectui#12604): {@link useNavTargetLabel} again — the text * the sidebar shows for an unlabelled navigation entry that opens the same - * view (the view's label from its object's `listViews`, through the - * `viewLabel` bundle lookup). A view the cache does not hold — one a user + * view (a served view's label from its object's `listViews` as given, a + * view the client derived through the `viewLabel` bundle lookup). A view + * the cache does not hold — one a user * saved for themself — is drawn as the list it opens: the object's, as an * object entry is; * - a page or a report: the same rule for that kind — the page's / report's @@ -62,7 +65,14 @@ import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; import type { RecentItem } from '../context/RecentItemsProvider.js'; import { useMetadata } from '../providers/MetadataProvider.js'; import { bundledFirst, useBundledReads } from '../providers/bundledReads.js'; -import { findByName, metadataText, useNavTargetLabel, type LabelledRecord, type NavTargetLabelOptions } from './useNavTargetLabel.js'; +import { + findByName, + metadataText, + servedObjectPluralLabel, + useNavTargetLabel, + type LabelledRecord, + type NavTargetLabelOptions, +} from './useNavTargetLabel.js'; import { readObjectsByName, useReadItemsByName } from './useObjectPresence.js'; const BY_NAME: NavTargetLabelOptions = { byName: true }; @@ -98,11 +108,9 @@ export function useRecentItemLabel( /** What an object's list is titled — an `object` entry's label, and a view's fallback. */ const listTitle = (objectName: string): string => { const def = findByName(readObjectsByName(metadata, [objectName]), objectName) as (LabelledRecord & { pluralLabel?: unknown }) | undefined; - // The singular half is read exactly as `useNavTargetLabel` reads it; - // `pluralLabel` is a plain string in the spec, so only a string is one. - const text = metadataText(def?.label, language); - const plural = typeof def?.pluralLabel === 'string' ? def.pluralLabel : undefined; - return nonEmpty(objectPluralLabel({ name: objectName, label: text ?? '', pluralLabel: plural })) ?? objectName; + // A served object's labels are already translated (objectstack#30311): + // drawn as given, as the list page the entry opens titles itself. + return nonEmpty(servedObjectPluralLabel({ ...def, name: objectName }, language, objectPluralLabel)) ?? objectName; }; switch (item.type) { case 'object': diff --git a/ui/packages/app-shell/src/layout/AppHeader.tsx b/ui/packages/app-shell/src/layout/AppHeader.tsx index 7689e8b7586..6f0aa789089 100644 --- a/ui/packages/app-shell/src/layout/AppHeader.tsx +++ b/ui/packages/app-shell/src/layout/AppHeader.tsx @@ -96,7 +96,7 @@ import { getIcon } from '../utils/getIcon.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 { metadataText, servedObjectPluralLabel } from '../hooks/useNavTargetLabel.js'; import { KEYBOARD_SHORTCUTS_PARAM } from '../urlParams.js'; import { useAiSurfaceEnabled } from '../hooks/useAiSurface.js'; import { useStudioEntryGate } from '../hooks/useStudioDesignGate.js'; @@ -765,7 +765,10 @@ export function AppHeader({ } else if (routeType) { const currentObject = safeObjects.find((o: any) => o.name === routeType); if (currentObject) { - routeTitle = objectPluralLabel(currentObject); + // A served object's label is already translated (objectstack#30311): + // drawn as given, as the object page's own header draws it. + const listTitle = servedObjectPluralLabel(currentObject, language, objectPluralLabel); + routeTitle = listTitle; if (pathParts[3] === 'record' && pathParts[4]) { const shortId = pathParts[4].length > 12 ? `${pathParts[4].slice(0, 8)}…` : pathParts[4]; const trimmedTitle = recordTitle?.trim(); @@ -799,7 +802,7 @@ export function AppHeader({ currentObject.name, servedViews, viewLabel, - objectPluralLabel(currentObject), + listTitle, language, ); } diff --git a/ui/packages/app-shell/src/layout/__tests__/AppHeader.labelLessViewTitle-12805.test.tsx b/ui/packages/app-shell/src/layout/__tests__/AppHeader.labelLessViewTitle-12805.test.tsx index 01e98e8b9ff..2d2e51e74df 100644 --- a/ui/packages/app-shell/src/layout/__tests__/AppHeader.labelLessViewTitle-12805.test.tsx +++ b/ui/packages/app-shell/src/layout/__tests__/AppHeader.labelLessViewTitle-12805.test.tsx @@ -172,13 +172,15 @@ const BUNDLE = { * item with no `label`), the `/meta/object` answer (its own `listViews`: one * with no `label`, one with a `label`, one with only a `title`), and the * object as `MetadataProvider` merges the two. + * The served object document carries the session language's text, as the + * server translates it (objectstack#30311: a served label is drawn as served), + * so a zh-CN session is served the zh-CN label and plural. */ -function world() { +function world(language: 'en' | 'zh-CN' = 'en') { const viewItem = { name: DEFAULT_VIEW_ID, object: OBJECT_NAME, viewKind: 'list', config: { type: 'grid' } }; const doc = { name: OBJECT_NAME, - label: 'QA Gate', - pluralLabel: 'QA Gates', + ...(language === 'en' ? { label: 'QA Gate', pluralLabel: PLURAL.en } : { label: '质检关', pluralLabel: PLURAL['zh-CN'] }), listViews: { pending: { type: 'grid' }, open: { type: 'grid', label: AUTHORED_LABEL }, @@ -221,21 +223,21 @@ const LANGUAGES = ['en', 'zh-CN'] as const; describe('AppHeader — a label-less view is titled by its object\'s plural label (objectui#12805)', () => { it.each(LANGUAGES)('%s: a served view with no label, bare URL', (language) => { - renderAt(`/apps/showcase_app/${OBJECT_NAME}/view/default`, language, world()); + renderAt(`/apps/showcase_app/${OBJECT_NAME}/view/default`, language, world(language)); expect(screen.getAllByText(PLURAL[language]).length).toBeGreaterThan(0); expect(screen.queryAllByText('Default')).toHaveLength(0); }); it.each(LANGUAGES)('%s: a served view with no label, qualified URL', (language) => { - renderAt(`/apps/showcase_app/${OBJECT_NAME}/view/${DEFAULT_VIEW_ID}`, language, world()); + renderAt(`/apps/showcase_app/${OBJECT_NAME}/view/${DEFAULT_VIEW_ID}`, language, world(language)); expect(screen.getAllByText(PLURAL[language]).length).toBeGreaterThan(0); expect(screen.queryAllByText('Recheck Gate.Default')).toHaveLength(0); }); it.each(LANGUAGES)('%s: a view the object document embeds with no label', (language) => { - renderAt(`/apps/showcase_app/${OBJECT_NAME}/view/pending`, language, world()); + renderAt(`/apps/showcase_app/${OBJECT_NAME}/view/pending`, language, world(language)); expect(screen.getAllByText(PLURAL[language]).length).toBeGreaterThan(0); expect(screen.queryAllByText('Pending')).toHaveLength(0); diff --git a/ui/packages/app-shell/src/layout/__tests__/AppHeader.objectCrumbPlural-11696.test.tsx b/ui/packages/app-shell/src/layout/__tests__/AppHeader.objectCrumbPlural-11696.test.tsx index d2295d85acc..6d44f8e845f 100644 --- a/ui/packages/app-shell/src/layout/__tests__/AppHeader.objectCrumbPlural-11696.test.tsx +++ b/ui/packages/app-shell/src/layout/__tests__/AppHeader.objectCrumbPlural-11696.test.tsx @@ -167,15 +167,28 @@ const BUNDLE = { }, }; -/** The objects as served: two declare a plural, one does not. */ -const OBJECTS = [ - { name: 'showcase_project', label: 'Project', pluralLabel: 'Projects' }, - { name: 'showcase_task', label: 'Task', pluralLabel: 'Tasks' }, - { name: 'showcase_note', label: 'Note' }, -]; +/** + * The objects as served: two declare a plural, one does not. + * The served object document carries the session language's text, as the + * server translates it (objectstack#30311: a served label is drawn as served), + * so a zh-CN session is served the zh-CN label and plural. + */ +const SERVED_OBJECTS = { + en: [ + { name: 'showcase_project', label: 'Project', pluralLabel: 'Projects' }, + { name: 'showcase_task', label: 'Task', pluralLabel: 'Tasks' }, + { name: 'showcase_note', label: 'Note' }, + ], + 'zh-CN': [ + { name: 'showcase_project', label: '项目', pluralLabel: '项目清单' }, + { name: 'showcase_task', label: '任务', pluralLabel: '任务清单' }, + { name: 'showcase_note', label: 'Note' }, + ], +}; function renderAt(pathname: string, language: 'en' | 'zh-CN', recordTitle?: string) { route.pathname = pathname; + const OBJECTS = SERVED_OBJECTS[language]; meta.value = { apps: [], objects: OBJECTS, diff --git a/ui/packages/app-shell/src/layout/__tests__/AppHeader.servedLabels-11295.test.tsx b/ui/packages/app-shell/src/layout/__tests__/AppHeader.servedLabels-11295.test.tsx index 839db83675d..4202f15a89e 100644 --- a/ui/packages/app-shell/src/layout/__tests__/AppHeader.servedLabels-11295.test.tsx +++ b/ui/packages/app-shell/src/layout/__tests__/AppHeader.servedLabels-11295.test.tsx @@ -406,3 +406,49 @@ describe('AppHeader — a served report or page is named as served (objectstack# expect(screen.getAllByText('Q3 Results').length).toBeGreaterThan(0); }); }); + +/** + * objectstack#30311 — a served object is named as served in the header's + * title on its list route. + * + * A `.custom` override renamed the object, and the `/meta/object` read serves + * the new name by the server's label order for an overridden item. The title + * passed the served definition through the bundle's `objectPluralLabel`, whose + * packaged translation of the old name won. + * + * Directions, written before the run: the renamed cells RED before the change + * (the packaged string), GREEN after; the controls GREEN on both sides. + */ +describe('AppHeader — a served object is named as served (objectstack#30311)', () => { + const RENAMED_OBJECT = 'Work items (renamed-30311)'; + const PACKAGED_OBJECT = { en: 'Task', 'zh-CN': '任务' } as const; + + function served(object: { label?: string; pluralLabel?: string }) { + const objects = world(EDITED_VIEW, EDITED_DASHBOARD).map((o) => { + const { label: _label, ...rest } = o; + return { ...rest, ...object }; + }); + meta.value = { ...meta.value, objects }; + return objects; + } + + it.each(LANGUAGES)('%s: the title draws the renamed object as served, not the packaged string', (language) => { + renderAt('/apps/showcase_app/showcase_task', language, served({ label: RENAMED_OBJECT })); + + expect(screen.getAllByText(RENAMED_OBJECT).length).toBeGreaterThan(0); + expect(screen.queryAllByText(PACKAGED_OBJECT[language])).toHaveLength(0); + }); + + it('zh-CN: the served plural is read first', () => { + renderAt('/apps/showcase_app/showcase_task', 'zh-CN', served({ label: RENAMED_OBJECT, pluralLabel: 'All work (renamed-30311)' })); + + expect(screen.getAllByText('All work (renamed-30311)').length).toBeGreaterThan(0); + expect(screen.queryAllByText('任务')).toHaveLength(0); + }); + + it('zh-CN: control — an object served with no label is named by the bundle', () => { + renderAt('/apps/showcase_app/showcase_task', 'zh-CN', served({})); + + expect(screen.getAllByText('任务').length).toBeGreaterThan(0); + }); +}); diff --git a/ui/packages/app-shell/src/views/ObjectDataPage.listTitlePlural-11696.test.tsx b/ui/packages/app-shell/src/views/ObjectDataPage.listTitlePlural-11696.test.tsx index bc0f5a51c9a..b9b3a98e4e4 100644 --- a/ui/packages/app-shell/src/views/ObjectDataPage.listTitlePlural-11696.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectDataPage.listTitlePlural-11696.test.tsx @@ -81,10 +81,21 @@ const FIELDS = { name: { type: 'text', label: 'Name' }, }; -const OBJECTS = [ - { name: 'showcase_project', label: 'Project', pluralLabel: 'Projects', fields: FIELDS }, - { name: 'showcase_note', label: 'Note', fields: FIELDS }, -]; +/** + * The served objects, per language: the server translates what it serves under + * the request's language from the same catalog the console loads, so a zh-CN + * session holds the zh-CN answer (objectstack#30311). + */ +const OBJECTS: Record>> = { + en: [ + { name: 'showcase_project', label: 'Project', pluralLabel: 'Projects', fields: FIELDS }, + { name: 'showcase_note', label: 'Note', fields: FIELDS }, + ], + 'zh-CN': [ + { name: 'showcase_project', label: '项目', pluralLabel: '项目清单', fields: FIELDS }, + { name: 'showcase_note', label: 'Note', fields: FIELDS }, + ], +}; /** The catalog as the console loads it: `zh-CN` translates both scalars. */ const BUNDLE: Record> = { @@ -111,7 +122,7 @@ afterEach(() => { }); /** The `/data` page's title text for `objectName`, in `lang` (the badge excluded). */ -async function dataPageTitle(lang: Lang, objectName: string): Promise { +async function dataPageTitle(lang: Lang, objectName: string, objects = OBJECTS[lang]): Promise { const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); // The instance really booted in the language under test. expect(i18n.language).toBe(lang); @@ -130,7 +141,7 @@ async function dataPageTitle(lang: Lang, objectName: string): Promise { } + element={} /> @@ -156,3 +167,22 @@ describe('ObjectDataPage — the /data page is titled with the plural (objectui# expect(await dataPageTitle('en', 'showcase_note')).toBe('Note'); }); }); + +/** + * objectstack#30311 — a served object's /data page is titled as served. + * Directions, written before the run: the renamed cell RED before the change + * (`项目清单`), GREEN after; the control GREEN on both sides. + */ +describe('ObjectDataPage — a served object’s /data page is titled as served (objectstack#30311)', () => { + const renamed = (extra: Record) => [{ name: 'showcase_project', fields: FIELDS, ...extra }]; + + it('zh-CN: a renamed object titles the page with the served plural, not the packaged string', async () => { + const title = await dataPageTitle('zh-CN', 'showcase_project', renamed({ label: 'Venture (renamed-30311)', pluralLabel: 'Ventures (renamed-30311)' })); + expect(title).toContain('Ventures (renamed-30311)'); + expect(title).not.toContain('项目清单'); + }); + + it('zh-CN: control — an object served with no label is titled by the bundle', async () => { + expect(await dataPageTitle('zh-CN', 'showcase_project', renamed({}))).toContain('项目清单'); + }); +}); diff --git a/ui/packages/app-shell/src/views/ObjectDataPage.tsx b/ui/packages/app-shell/src/views/ObjectDataPage.tsx index d8f37661c3f..c2bd5762169 100644 --- a/ui/packages/app-shell/src/views/ObjectDataPage.tsx +++ b/ui/packages/app-shell/src/views/ObjectDataPage.tsx @@ -78,6 +78,7 @@ import { PREVIEW_QUERY_VALUE, } from '../preview/PreviewModeContext.js'; import { useTenancyPosture } from '../hooks/useTenancyPosture.js'; +import { servedObjectPluralLabel } from '../hooks/useNavTargetLabel.js'; import type { RowCrudPredicates } from '../utils/crudAffordances.js'; /** Field types the auto-derived user-filter bar offers as dropdowns. */ @@ -234,7 +235,7 @@ export function buildSaveAsViewSpec( export function ObjectDataPage({ dataSource, objects }: any) { const { appName, objectName } = useParams(); - const { t } = useObjectTranslation(); + const { t, language } = useObjectTranslation(); const { objectLabel, objectPluralLabel, fieldLabel } = useObjectLabel(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -600,7 +601,8 @@ export function ObjectDataPage({ dataSource, objects }: any) { {/* The page lists the object's records, so it is titled with the plural, as the object crumb above it is (objectui#11696). The record drawer below keeps the singular. */} - {objectPluralLabel(objectDef)} + {/* A served object's label is already translated (objectstack#30311): drawn as given. */} + {servedObjectPluralLabel(objectDef, language, objectPluralLabel)} {t('console.objectData.badge', { defaultValue: 'Data' })} diff --git a/ui/packages/app-shell/src/views/ObjectView.labelLessViewTab-12774.test.tsx b/ui/packages/app-shell/src/views/ObjectView.labelLessViewTab-12774.test.tsx index d2af79d232f..1a4b94577ed 100644 --- a/ui/packages/app-shell/src/views/ObjectView.labelLessViewTab-12774.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.labelLessViewTab-12774.test.tsx @@ -132,12 +132,19 @@ const BUNDLE = { 'zh-CN': { showcase: { objects: { recheck_gate: { label: '质检关', pluralLabel: PLURAL['zh-CN'], _views: { timeline: { label: CATALOGUED['zh-CN'] } } } } } }, }; -/** The `/meta/object` answer, BEFORE the merge: its own `listViews`. */ -function servedObject() { +/** The object's label and plural as the server serves them per language. */ +const SERVED = { en: { label: 'QA Gate', pluralLabel: PLURAL.en }, 'zh-CN': { label: '质检关', pluralLabel: PLURAL['zh-CN'] } } as const; + +/** + * The `/meta/object` answer, BEFORE the merge: its own `listViews`. + * The served object document carries the session language's text, as the + * server translates it (objectstack#30311: a served label is drawn as served), + * so a zh-CN session is served the zh-CN label and plural. + */ +function servedObject(language: 'en' | 'zh-CN') { return { name: OBJECT_NAME, - label: 'QA Gate', - pluralLabel: 'QA Gates', + ...SERVED[language], fields: { name: { type: 'text', label: 'Name' } }, listViews: { [EMBEDDED_KEY]: { type: 'grid', columns: ['name'] }, @@ -154,8 +161,8 @@ function servedObject() { * entries the console expands itself — `board` with no label and no catalogue * entry, `timeline` with no label and a catalogue entry. */ -function world(source: 'viewItem' | 'container') { - const doc = servedObject(); +function world(source: 'viewItem' | 'container', language: 'en' | 'zh-CN') { + const doc = servedObject(language); const viewItem = { name: DEFAULT_VIEW_ID, object: OBJECT_NAME, viewKind: 'list', config: { type: 'grid', columns: ['name'] } }; const container = { name: OBJECT_NAME, @@ -199,7 +206,7 @@ function makeDataSource() { } async function renderIn(language: 'en' | 'zh-CN', source: 'viewItem' | 'container') { - const { objects, metadata } = world(source); + const { objects, metadata } = world(source, language); render( diff --git a/ui/packages/app-shell/src/views/ObjectView.listLinksPlural-11733.test.tsx b/ui/packages/app-shell/src/views/ObjectView.listLinksPlural-11733.test.tsx index 65fcaf6c4e5..4dce244a1b2 100644 --- a/ui/packages/app-shell/src/views/ObjectView.listLinksPlural-11733.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.listLinksPlural-11733.test.tsx @@ -126,10 +126,21 @@ const FIELDS = { */ const UNLABELLED_VIEW = { grid: { type: 'grid', columns: ['name'] } }; -const OBJECTS = [ - { name: 'showcase_project', label: 'Project', pluralLabel: 'Projects', fields: FIELDS, listViews: UNLABELLED_VIEW }, - { name: 'showcase_note', label: 'Note', fields: FIELDS, listViews: UNLABELLED_VIEW }, -]; +/** + * The served objects, per language: the server translates what it serves under + * the request's language from the same catalog the console loads, so a zh-CN + * session holds the zh-CN answer (objectstack#30311). + */ +const OBJECTS: Record>> = { + en: [ + { name: 'showcase_project', label: 'Project', pluralLabel: 'Projects', fields: FIELDS, listViews: UNLABELLED_VIEW }, + { name: 'showcase_note', label: 'Note', fields: FIELDS, listViews: UNLABELLED_VIEW }, + ], + 'zh-CN': [ + { name: 'showcase_project', label: '项目', pluralLabel: '项目清单', fields: FIELDS, listViews: UNLABELLED_VIEW }, + { name: 'showcase_note', label: 'Note', fields: FIELDS, listViews: UNLABELLED_VIEW }, + ], +}; /** The catalog as the console loads it: `zh-CN` translates both scalars. */ const BUNDLE: Record> = { @@ -179,7 +190,7 @@ async function mountList(lang: Lang, objectName: string): Promise { {}} />} + element={ {}} />} /> } /> diff --git a/ui/packages/app-shell/src/views/ObjectView.listTitlePlural-11696.test.tsx b/ui/packages/app-shell/src/views/ObjectView.listTitlePlural-11696.test.tsx index 934495ec220..f6a8f7ea6a8 100644 --- a/ui/packages/app-shell/src/views/ObjectView.listTitlePlural-11696.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.listTitlePlural-11696.test.tsx @@ -103,10 +103,21 @@ const FIELDS = { /** One authored list view with no `label`, as a list whose view the author left unnamed. */ const LISTVIEWS = { all: { type: 'grid', columns: ['name'] } }; -const OBJECTS = [ - { name: 'showcase_project', label: 'Project', pluralLabel: 'Projects', fields: FIELDS, listViews: LISTVIEWS }, - { name: 'showcase_note', label: 'Note', fields: FIELDS, listViews: LISTVIEWS }, -]; +/** + * The served objects, per language: the server translates what it serves under + * the request's language from the same catalog the console loads, so a zh-CN + * session holds the zh-CN answer (objectstack#30311). + */ +const OBJECTS: Record>> = { + en: [ + { name: 'showcase_project', label: 'Project', pluralLabel: 'Projects', fields: FIELDS, listViews: LISTVIEWS }, + { name: 'showcase_note', label: 'Note', fields: FIELDS, listViews: LISTVIEWS }, + ], + 'zh-CN': [ + { name: 'showcase_project', label: '项目', pluralLabel: '项目清单', fields: FIELDS, listViews: LISTVIEWS }, + { name: 'showcase_note', label: 'Note', fields: FIELDS, listViews: LISTVIEWS }, + ], +}; /** The catalog as the console loads it: `zh-CN` translates both scalars. */ const BUNDLE: Record> = { @@ -132,7 +143,7 @@ afterEach(() => { }); /** The list page's title text for `objectName`, in `lang`. */ -async function listTitle(lang: Lang, objectName: string): Promise { +async function listTitle(lang: Lang, objectName: string, objects = OBJECTS[lang]): Promise { const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); // The instance really booted in the language under test. expect(i18n.language).toBe(lang); @@ -151,7 +162,7 @@ async function listTitle(lang: Lang, objectName: string): Promise { {}} />} + element={ {}} />} /> @@ -178,3 +189,27 @@ describe('ObjectView — the list page is titled with the plural (objectui#11696 expect(await listTitle('en', 'showcase_note')).toBe('Note'); }); }); + +/** + * objectstack#30311 — a served object's list is titled as served. + * + * A `.custom` override renamed the object, and the `/meta/object` read serves + * the new name by the server's label order for an overridden item; the title + * passed it through the bundle's `objectPluralLabel`, whose packaged + * translation of the old name won. Directions, written before the run: the + * renamed cell RED before the change (`项目清单`), GREEN after; the control + * GREEN on both sides. + */ +describe('ObjectView — a served object’s list is titled as served (objectstack#30311)', () => { + const renamed = (extra: Record) => + [{ name: 'showcase_project', fields: FIELDS, listViews: LISTVIEWS, ...extra }]; + + it('zh-CN: a renamed object titles its list with the served plural, not the packaged string', async () => { + const title = await listTitle('zh-CN', 'showcase_project', renamed({ label: 'Venture (renamed-30311)', pluralLabel: 'Ventures (renamed-30311)' })); + expect(title).toBe('Ventures (renamed-30311)'); + }); + + it('zh-CN: control — an object served with no label is titled by the bundle', async () => { + expect(await listTitle('zh-CN', 'showcase_project', renamed({}))).toBe('项目清单'); + }); +}); diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index e61105bacb4..5df196351be 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -25,6 +25,7 @@ import { useObjectListFavorite } from '../layout/SidebarItemMenu.js'; import { useMetaItemRefusal } from '../hooks/useMetaItemRefusal.js'; import { useTenancyPosture } from '../hooks/useTenancyPosture.js'; import { useServedViewItems } from '../hooks/useServedViewItems.js'; +import { servedObjectPluralLabel } from '../hooks/useNavTargetLabel.js'; import type { DataSource } from '@object-ui/types'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { ViewConfigPanel } from './ViewConfigPanel.js'; @@ -256,14 +257,22 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // and keeps an `object:` favorite only for an object the app does not list. // The entry the list is starred under is named with the plural the title // reads (objectui#11733). + // What this list is titled: a served object's label is already translated + // (objectstack#30311), so it is drawn as given; the bundle is asked only + // when the definition carries none. The header, the favorite and an + // unlabelled view's tab all read this one function, so they agree. + const listTitleOf = useCallback( + (def: { name: string; label?: unknown; pluralLabel?: unknown }) => servedObjectPluralLabel(def, language, objectPluralLabel), + [language, objectPluralLabel], + ); const listFavorite = useObjectListFavorite({ appSegment: appName, objectName, - label: objectPluralLabel(objectDef), + label: listTitleOf(objectDef), }); // A view stored with no `label` is named by the object's plural label, on // its tab and on the record page's way back alike (objectui#12774). - const unlabelledViewText = objectPluralLabel(objectDef); + const unlabelledViewText = listTitleOf(objectDef); // Refresh trigger — bumped after view CRUD or this page's own data writes. const [ownRefreshKey, setRefreshKey] = useState(0); @@ -1182,7 +1191,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co titleSwitcher={pageHeaderShown ? titleSwitcher : null} titleLabel={titleLabel} objectListHref={appName && objectName ? `/apps/${appName}/${objectName}` : undefined} - objectPluralLabel={objectPluralLabel} + objectPluralLabel={listTitleOf} objectDesc={objectDesc} createButton={createButton} importShown={importShown} diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.standardNavEntries-11201.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.standardNavEntries-11201.test.tsx index a896ad7fae0..e6d40accf4b 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.standardNavEntries-11201.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.standardNavEntries-11201.test.tsx @@ -60,10 +60,19 @@ const OBJECT_DEFS = [{ name: 'account', label: 'Account' }, { name: 'draft_thing const ZH_CN: TranslationData = { objects: { account: { label: '客户' } } } as TranslationData; -function metadata() { +/** + * The served objects, per language: the server translates what it serves under + * the request's language from the same catalog the console loads, so a zh-CN + * session holds the zh-CN label (objectstack#30311). + */ +function servedObjects(language: string) { + return language === 'zh-CN' ? OBJECT_DEFS.map((o) => (o.name === 'account' ? { ...o, label: '客户' } : o)) : OBJECT_DEFS; +} + +function metadata(language: string) { return { apps: [], - objects: OBJECT_DEFS, + objects: servedObjects(language), dashboards: [], reports: [], pages: [], @@ -108,7 +117,7 @@ function renderSidebar(items: Array>, language: string) render( - +