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
11 changes: 11 additions & 0 deletions .changeset/30311-served-object-view-labels.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand All @@ -69,7 +79,7 @@ function wrapper(lang: Lang) {
expect(i18n.language).toBe(lang);
return ({ children }: { children: ReactNode }) => (
<I18nProvider instance={i18n} persistLanguage={false}>
<MetadataCtx.Provider value={metadata() as never}>{children}</MetadataCtx.Provider>
<MetadataCtx.Provider value={metadata(lang) as never}>{children}</MetadataCtx.Provider>
</I18nProvider>
);
}
Expand Down
147 changes: 141 additions & 6 deletions ui/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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',
Expand All @@ -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 }) }
: {}),
};
}

Expand All @@ -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<typeof metadata>[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 }) => (
<I18nProvider instance={i18n} persistLanguage={false}>
<MetadataCtx.Provider value={metadata(language, served) as never}>{children}</MetadataCtx.Provider>
<MetadataCtx.Provider value={metadata(language, served) as never}>
<DraftTargetLabels.Provider value={drafts}>{children}</DraftTargetLabels.Provider>
</MetadataCtx.Provider>
</I18nProvider>
);
}
Expand Down Expand Up @@ -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<string, unknown> = {}) => ({ 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');
});
});
Loading
Loading