diff --git a/.changeset/11336-served-embedded-listviews.md b/.changeset/11336-served-embedded-listviews.md new file mode 100644 index 0000000000..a20bdf1956 --- /dev/null +++ b/.changeset/11336-served-embedded-listviews.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': patch +--- + +An object page's view tab, and the breadcrumb that names the open view, draw the label of a view the object document embeds as the server served it (objectui#11336). + +Since `@objectstack/spec` 17.6.0 (objectstack#21072) the server translates the `listViews` an object document embeds, from `objects.OBJECT._views.KEY` and with a published edit kept over the packaged catalog, as it already did for `/meta/view` documents. The console still ran its own catalog over those labels a second time, so a published edit to such a view drew as the packaged string. A view counts as served now when its key is in the served `/meta/object` document's own `listViews`, read before the console merges view documents into it, as well as when a `/meta/view` document carries its name. Its label is drawn as given. A view only the console derives, such as a stack container's expansion, is still named by the client catalog. + +Against a server older than that release, a view the object document embeds arrives untranslated and is now drawn as authored. + +**Clause-②: no.** Nothing on the package entry changes. The predicate (`isServedView`) and its hook (`useServedViewItems`) are not exported from `@object-ui/app-shell`. diff --git a/packages/app-shell/src/hooks/__tests__/useServedViewItems.objectListViews-11336.test.tsx b/packages/app-shell/src/hooks/__tests__/useServedViewItems.objectListViews-11336.test.tsx new file mode 100644 index 0000000000..b00992d61f --- /dev/null +++ b/packages/app-shell/src/hooks/__tests__/useServedViewItems.objectListViews-11336.test.tsx @@ -0,0 +1,120 @@ +/** + * 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#11336 — `isServedView` answers for BOTH serving reads. + * + * Since objectstack#21072 (`@objectstack/spec` 17.6.0) the server's + * `translateObject` translates the `listViews` an object document embeds, so a + * key of the served `/meta/object` document's own `listViews` names a view that + * arrived translated, as a `/meta/view` document's name already did + * (objectui#11295). The label sites draw both as given; the integration pins + * are `ObjectView.servedViewLabel-11295` and `AppHeader.servedLabels-11295`. + * This file pins the predicate's edges, which those worlds do not reach, and + * the identity the hook hands out (AGENTS.md #10). + */ + +import * as React from 'react'; +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { MetadataCtx, type MetadataContextValue } from '@object-ui/react'; +import { useServedViewItems, isServedView } from '../useServedViewItems'; + +const VIEW_DOC = { name: 'showcase_task.in_progress', object: 'showcase_task', viewKind: 'list', label: 'In Progress' }; +const TASK_DOC = { name: 'showcase_task', label: 'Task', listViews: { mine: { type: 'grid', label: 'My Tasks' } } }; +const PROJECT_DOC = { name: 'showcase_project', label: 'Project', listViews: { open: { type: 'grid', label: 'Open' } } }; + +function metadataWith(read: { view: unknown[]; object: unknown[] }): MetadataContextValue { + return { + apps: [], + objects: [], + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: (type: string) => (type === 'view' ? read.view : type === 'object' ? read.object : []), + getTypeStatus: () => 'ready', + }; +} + +function servedFrom(view: unknown[], object: unknown[]) { + const value = metadataWith({ view, object }); + return renderHook(() => useServedViewItems(), { + wrapper: ({ children }) => {children}, + }).result.current; +} + +describe('isServedView — both serving reads (objectui#11336)', () => { + const served = servedFrom([VIEW_DOC], [TASK_DOC, PROJECT_DOC]); + + it('a `/meta/view` document answers by its name', () => { + expect(isServedView(served, 'showcase_task', 'showcase_task.in_progress')).toBe(true); + }); + + it('a key of the served object document\'s own `listViews` answers', () => { + expect(isServedView(served, 'showcase_task', 'mine')).toBe(true); + }); + + it('the key answers only under the object whose document embeds it', () => { + expect(isServedView(served, 'showcase_project', 'mine')).toBe(false); + expect(isServedView(served, 'showcase_project', 'open')).toBe(true); + expect(isServedView(served, undefined, 'mine')).toBe(false); + }); + + it('a view only the client derived — a container expansion — is not served', () => { + expect(isServedView(served, 'showcase_task', 'showcase_task.board')).toBe(false); + }); + + it('the legacy `list_views` spelling is not served: the server translates `listViews` only', () => { + const legacy = servedFrom([], [{ name: 'showcase_task', list_views: { mine: { label: 'My Tasks' } } }]); + expect(isServedView(legacy, 'showcase_task', 'mine')).toBe(false); + }); + + it('a non-record entry and an inherited key are not served', () => { + const odd = servedFrom([], [{ name: 'showcase_task', listViews: { mine: 'My Tasks' } }]); + expect(isServedView(odd, 'showcase_task', 'mine')).toBe(false); + expect(isServedView(served, 'showcase_task', 'toString')).toBe(false); + }); + + it('no view id is not served', () => { + expect(isServedView(served, 'showcase_task', undefined)).toBe(false); + }); +}); + +describe('useServedViewItems — one record per pair of read payloads (AGENTS.md #10)', () => { + it('keeps its identity while both reads keep theirs, and moves when either is refetched', () => { + const read = { view: [VIEW_DOC] as unknown[], object: [TASK_DOC] as unknown[] }; + const value = metadataWith(read); + const { result, rerender } = renderHook(() => useServedViewItems(), { + wrapper: ({ children }) => {children}, + }); + const first = result.current; + + rerender(); + expect(result.current).toBe(first); + + read.object = [TASK_DOC, PROJECT_DOC]; + rerender(); + expect(result.current).not.toBe(first); + expect(isServedView(result.current, 'showcase_project', 'open')).toBe(true); + }); + + it('outside a provider both reads are empty, under one identity', () => { + const { result, rerender } = renderHook(() => useServedViewItems()); + const first = result.current; + + rerender(); + expect(result.current).toBe(first); + expect(isServedView(first, 'showcase_task', 'mine')).toBe(false); + }); +}); diff --git a/packages/app-shell/src/hooks/useServedViewItems.ts b/packages/app-shell/src/hooks/useServedViewItems.ts index 568e833c2f..3eb10f0c9c 100644 --- a/packages/app-shell/src/hooks/useServedViewItems.ts +++ b/packages/app-shell/src/hooks/useServedViewItems.ts @@ -7,7 +7,8 @@ */ /** - * Which list views reached this console already translated (objectui#11295). + * Which list views reached this console already translated (objectui#11295, + * objectui#11336). * * ## Why a view label needs to know where it came from * @@ -20,29 +21,41 @@ * (`translateView` in `@objectstack/spec`), and since objectstack#20731 it * keeps a published edit over the packaged catalog: an explicit override * beats the packaged default; - * - the `listViews` an OBJECT document embeds, read from `/meta/object`. The - * server's `translateObject` does not translate those, so the client bundle - * (`objects.._views..label`) is their only translation; + * - the `listViews` an OBJECT document embeds, read from `/meta/object`. Since + * objectstack#21072 (`@objectstack/spec` 17.6.0) the server's + * `translateObject` translates those too: from the same catalog address, + * `objects.._views..label` with `` the entry's record + * key, and by the same rule, a published edit kept over the packaged + * catalog; * - entries the client derives itself — a stack container's expansion, the - * "all records" fallback tab — which no server translated either. + * "all records" fallback tab — which no server translated. * - * Running the client bundle over the first kind is a SECOND translation pass: + * Running the client bundle over a served label is a SECOND translation pass: * the served label became the bundle's fallback, the bundle entry won it, and a * published edit drew as the packaged string (measured on objectstack#20730: * `进行中` on the zh-CN tab and breadcrumb over a served `In Progress * (edited-20730)`). Dropping the pass for every view would instead leave the - * second and third kinds untranslated. So the label sites ask, per view, the - * one question that tells them apart: did the `/meta/view` read serve a - * document under this view's identity? + * third kind untranslated. So the label sites ask, per view, the one question + * that tells them apart: did a server read serve this view under its identity? * * ## The signal * - * The answer is read from the `view` cache the `/meta/view` read fills — - * `MetadataProvider`'s, the same request every merged view item came from — by - * the view's identity (its `name`, which is the tab id for every served kind). - * Identity only: ⛔ no label text is compared with anything. + * Two reads answer it, both out of `MetadataProvider`'s cache — the same + * requests every merged view came from — and both by identity only: ⛔ no label + * text is compared with anything. * - * Outside a `MetadataProvider` the read is empty, so every view keeps the + * - the `view` cache the `/meta/view` read fills, by the view's `name`, which + * is the tab id for every served view document; + * - the `object` cache the `/meta/object` read fills, as served: by the + * object's `name`, then the tab id as a key of that document's own + * `listViews`. It is read BEFORE the merge, because the merged `listViews` + * the label sites hold mixes the document's own entries with view documents + * and a container's expansion, and only the former were translated by + * `translateObject`. Only the canonical `listViews` key is read: the server + * translates no other spelling, so a view a stored document still embeds + * under the legacy `list_views` arrives untranslated and keeps the bundle. + * + * Outside a `MetadataProvider` both reads are empty, so every view keeps the * bundle composition it had before. */ @@ -52,26 +65,80 @@ import { useMetadata } from '@object-ui/react'; const NONE_SERVED: readonly unknown[] = Object.freeze([]); /** - * The documents the `/meta/view` read served, as the metadata cache holds them. - * - * Returns the cache's own array, so it keeps one identity until that read is - * refetched; it is the PAYLOAD, and a consumer may key a memo on it (AGENTS.md - * #10). An empty read answers {@link NONE_SERVED}: the no-provider fallback - * hands out a fresh `[]` per call, which would re-key every such memo on every - * render. + * What the two serving reads returned, as the metadata cache holds them: the + * `/meta/view` documents and the `/meta/object` documents. + */ +type ServedViewReads = { + readonly views: readonly unknown[]; + readonly objects: readonly unknown[]; +}; + +/** + * One record per pair of read payloads, held OUTSIDE React (AGENTS.md #10): + * its identity is a function of the two arrays the cache handed out, never of a + * memo React may discard, so a dependency list on it holds until either read is + * refetched. */ -export function useServedViewItems(): readonly unknown[] { - const items = useMetadata().getItemsByType('view'); +const READS_BY_PAYLOAD = new WeakMap>(); + +function servedViewReads(views: readonly unknown[], objects: readonly unknown[]): ServedViewReads { + let byObjects = READS_BY_PAYLOAD.get(views); + if (!byObjects) { + byObjects = new WeakMap(); + READS_BY_PAYLOAD.set(views, byObjects); + } + let reads = byObjects.get(objects); + if (!reads) { + reads = Object.freeze({ views, objects }); + byObjects.set(objects, reads); + } + return reads; +} + +/** + * The cache's own array for a read — the PAYLOAD — or {@link NONE_SERVED} when + * it is empty: the no-provider fallback hands out a fresh `[]` per call, which + * would re-key every memo on it on every render. + */ +function payloadOf(items: unknown): readonly unknown[] { return Array.isArray(items) && items.length > 0 ? items : NONE_SERVED; } /** - * Whether the `/meta/view` read served a document under `viewId` — i.e. - * whether that view's label arrived already translated, and is drawn as given. + * The documents the `/meta/view` and `/meta/object` reads served, as the + * metadata cache holds them. + * + * Keyed on the cache's own arrays, so it keeps one identity until either read + * is refetched, and a consumer may key a memo on it (AGENTS.md #10). + */ +export function useServedViewItems(): ServedViewReads { + const metadata = useMetadata(); + return servedViewReads(payloadOf(metadata.getItemsByType('view')), payloadOf(metadata.getItemsByType('object'))); +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +/** + * Whether a server read served the view `viewId` of the object `objectName` — + * a `/meta/view` document under that name, or an entry under that key in the + * served object document's own `listViews` — i.e. whether that view's label + * arrived already translated, and is drawn as given. */ -export function isServedView(servedViews: readonly unknown[], viewId: string | undefined): boolean { +export function isServedView( + served: ServedViewReads, + objectName: string | undefined, + viewId: string | undefined, +): boolean { if (!viewId) return false; - return servedViews.some( - (item) => !!item && typeof item === 'object' && (item as { name?: unknown }).name === viewId, + if (served.views.some((item) => isRecord(item) && item.name === viewId)) return true; + if (!objectName) return false; + const doc = served.objects.find((item) => isRecord(item) && item.name === objectName); + const embedded = isRecord(doc) ? doc.listViews : undefined; + return ( + isRecord(embedded) && + Object.prototype.hasOwnProperty.call(embedded, viewId) && + isRecord(embedded[viewId]) ); } diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx index 874e896ac5..351484acff 100644 --- a/packages/app-shell/src/layout/AppHeader.tsx +++ b/packages/app-shell/src/layout/AppHeader.tsx @@ -188,7 +188,8 @@ export function AppHeader({ const { t } = useObjectTranslation(); const { objectLabel, dashboardLabel, pageLabel, reportLabel, viewLabel, appLabel } = useObjectLabel(); const { apps: metadataApps, dashboards: metadataDashboards, pages: metadataPages, reports: metadataReports } = useMetadata(); - // The views the `/meta/view` read served, already translated (objectui#11295). + // The views a server read served, already translated — `/meta/view`, and the + // object document's own `listViews` (objectui#11295, objectui#11336). const servedViews = useServedViewItems(); const { currentAppName, recordTitle } = useNavigationContext(); const mobileSwitcher = useMobileViewSwitcher(); @@ -458,7 +459,7 @@ export function AppHeader({ const fallbackLabel = (viewDef && (viewDef.label || viewDef.title)) || humanizeSlug(viewName); // A served view's label is already translated — drawn as given, as its // tab draws it; any other view's goes through the bundle (objectui#11295). - const localizedViewLabel = viewDef && isServedView(servedViews, viewId) + const localizedViewLabel = viewDef && isServedView(servedViews, currentObject.name, viewId) ? fallbackLabel : viewLabel(currentObject.name, viewName, fallbackLabel); extraSegments.push({ label: localizedViewLabel }); diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.servedLabels-11295.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.servedLabels-11295.test.tsx index 951c8210a6..21e56901fe 100644 --- a/packages/app-shell/src/layout/__tests__/AppHeader.servedLabels-11295.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AppHeader.servedLabels-11295.test.tsx @@ -21,18 +21,23 @@ * reading). * * Now the view is matched by `resolveViewId` (the matcher the object page opens - * it with), and a view the `/meta/view` read served is drawn as given. A view - * embedded in the OBJECT document is not translated by the server, so it keeps - * the bundle — the control below. The dashboard crumb gets the same rule for a - * dashboard the `/meta` read served. + * it with), and a view the `/meta/view` read served is drawn as given. Since + * objectstack#21072 (`@objectstack/spec` 17.6.0) the server also translates the + * `listViews` an OBJECT document embeds, so a view the served `/meta/object` + * document carries is drawn as given too (objectui#11336). A view only the + * client derived — a stack container's expansion — was translated by no server, + * so it keeps the bundle — the control below. The dashboard crumb gets the same + * rule for a dashboard the `/meta` read served. * * Harness: `AppHeader.systemBreadcrumbs-10969`'s mocks, except that * `useObjectLabel` is REAL here — the bundle lookup is the subject. * * Directions, written before the run: the served-edit cells RED before the * change (bare URL: the slug in `en`, the bundle in `zh-CN`; qualified URL and - * dashboard: the bundle in both), GREEN after; the unedited-served and the - * object-embedded cells GREEN on both sides. + * dashboard: the bundle in both) — for objectui#11336, the embedded-edit cell + * RED with the object-document branch of `isServedView` removed — GREEN after; + * the unedited-served, the no-catalogue and the container cells GREEN on both + * sides. */ import '@testing-library/jest-dom/vitest'; import { describe, it, expect, vi, afterEach } from 'vitest'; @@ -162,7 +167,7 @@ const BUNDLE = { en: { showcase: { objects: { - showcase_task: { label: 'Task', _views: { in_progress: { label: 'In Progress' }, mine: { label: 'My Tasks' } } }, + showcase_task: { label: 'Task', _views: { in_progress: { label: 'In Progress' }, mine: { label: 'My Tasks' }, board: { label: 'Board View' } } }, }, dashboards: { ops_board: { label: 'Operations' } }, }, @@ -170,7 +175,7 @@ const BUNDLE = { 'zh-CN': { showcase: { objects: { - showcase_task: { label: '任务', _views: { in_progress: { label: '进行中' }, mine: { label: '我的任务' } } }, + showcase_task: { label: '任务', _views: { in_progress: { label: '进行中' }, mine: { label: '我的任务' }, board: { label: '看板' } } }, }, dashboards: { ops_board: { label: '运营看板' } }, }, @@ -185,22 +190,34 @@ const PACKAGED_DASHBOARD = { en: 'Operations', 'zh-CN': '运营看板' } as cons /** * One language's world: the `/meta/view` answer (one served view item), the - * object as `MetadataProvider` merges it (that view under its qualified id, - * plus `mine`, a view the OBJECT document embeds), and the `/meta/dashboard` - * answer. + * `/meta/object` answer (the object document as served, BEFORE the merge: its + * own `listViews` — `mine` with the label the server put in, and `other`, which + * no catalogue entry translates), the object as `MetadataProvider` merges the + * two (the view item under its qualified id beside the document's own views), + * and the `/meta/dashboard` answer. + * + * `source: 'container'` serves a stack container on `/meta/view` instead of the + * view item; the console expands its `board` itself (`.board`), and + * neither read served it translated. */ -function world(viewLabel: string, dashboardLabel: string) { +function world(viewLabel: string, dashboardLabel: string, mineLabel = 'My Tasks', source: 'viewItem' | 'container' = 'viewItem') { const viewItem = { name: VIEW_ID, object: 'showcase_task', viewKind: 'list', label: viewLabel, config: { type: 'grid' } }; - const objects = [ - { - name: 'showcase_task', - label: 'Task', - listViews: { - [VIEW_ID]: { type: 'grid', name: VIEW_ID, label: viewLabel }, - mine: { type: 'grid', name: 'mine', label: 'Mine' }, - }, + const container = { name: 'showcase_task', listViews: { board: { type: 'grid', label: 'Board' } } }; + const doc = { + name: 'showcase_task', + label: 'Task', + listViews: { + mine: { type: 'grid', label: mineLabel }, + other: { type: 'grid', label: 'Everything else' }, }, - ]; + }; + const fromViews = source === 'viewItem' + ? { [VIEW_ID]: { type: 'grid', name: VIEW_ID, label: viewLabel } } + : { 'showcase_task.board': { type: 'grid', name: 'showcase_task.board', label: 'Board' } }; + const objects = [{ ...doc, listViews: { ...fromViews, ...doc.listViews } }]; + // One array per read, as the provider's cache holds it. + const viewAnswer = [source === 'viewItem' ? viewItem : container]; + const objectAnswer = [doc]; meta.value = { apps: [], objects, @@ -213,7 +230,7 @@ function world(viewLabel: string, dashboardLabel: string) { invalidate: () => {}, ensureType: async () => [], getItem: async () => null, - getItemsByType: (type: string) => (type === 'view' ? [viewItem] : []), + getItemsByType: (type: string) => (type === 'view' ? viewAnswer : type === 'object' ? objectAnswer : []), getTypeStatus: () => 'ready', }; return objects; @@ -254,11 +271,34 @@ describe('AppHeader — a served view is named as served (objectui#11295)', () = expect(screen.getAllByText(PACKAGED_VIEW[language]).length).toBeGreaterThan(0); }); - it('control — a view the OBJECT document embeds is not served translated, so the bundle names it', () => { - renderAt('/apps/showcase_app/showcase_task/view/mine', 'zh-CN', world(EDITED_VIEW, EDITED_DASHBOARD)); + it.each(LANGUAGES)('%s: control — a view only the client derived (a container expansion) is named by the bundle', (language) => { + renderAt('/apps/showcase_app/showcase_task/view/board', language, world(EDITED_VIEW, EDITED_DASHBOARD, undefined, 'container')); + + expect(screen.getAllByText(language === 'en' ? 'Board View' : '看板').length).toBeGreaterThan(0); + expect(screen.queryAllByText('Board')).toHaveLength(0); + }); +}); + +describe('AppHeader — a view the object document embeds is named as served (objectui#11336)', () => { + it('zh-CN: the crumb draws the string the object read served', () => { + renderAt('/apps/showcase_app/showcase_task/view/mine', 'zh-CN', world(EDITED_VIEW, EDITED_DASHBOARD, '我的任务')); expect(screen.getAllByText('我的任务').length).toBeGreaterThan(0); - expect(screen.queryAllByText('Mine')).toHaveLength(0); + expect(screen.queryAllByText('My Tasks')).toHaveLength(0); + }); + + it.each(LANGUAGES)('%s: a published edit the object read served, not the packaged string', (language) => { + renderAt('/apps/showcase_app/showcase_task/view/mine', language, world(EDITED_VIEW, EDITED_DASHBOARD, 'My Tasks (edited-11336)')); + + expect(screen.getAllByText('My Tasks (edited-11336)').length).toBeGreaterThan(0); + expect(screen.queryAllByText(language === 'en' ? 'My Tasks' : '我的任务')).toHaveLength(0); + }); + + it('zh-CN: control — an embedded view with no catalogue entry keeps its authored label', () => { + renderAt('/apps/showcase_app/showcase_task/view/other', 'zh-CN', world(EDITED_VIEW, EDITED_DASHBOARD, '我的任务')); + + // Not the humanized slug (`Other`): the view was found, and drawn as authored. + expect(screen.getAllByText('Everything else').length).toBeGreaterThan(0); }); }); diff --git a/packages/app-shell/src/views/ObjectView.servedViewLabel-11295.test.tsx b/packages/app-shell/src/views/ObjectView.servedViewLabel-11295.test.tsx index 1132db4302..6b620be913 100644 --- a/packages/app-shell/src/views/ObjectView.servedViewLabel-11295.test.tsx +++ b/packages/app-shell/src/views/ObjectView.servedViewLabel-11295.test.tsx @@ -7,8 +7,8 @@ */ /** - * objectui#11295 — the object page's view tab draws a SERVED view's label as - * served. + * objectui#11295 / objectui#11336 — the object page's view tab draws a SERVED + * view's label as served. * * ## The defect * @@ -19,22 +19,31 @@ * back: measured on objectstack#20730, the `zh-CN` tab drew `进行中` over a * served `In Progress (edited-20730)`. * + * ## The object document's own views (objectui#11336) + * + * Since objectstack#21072 (`@objectstack/spec` 17.6.0) the server translates + * the `listViews` an OBJECT document embeds as well (`translateObject`, from + * `objects.._views.`, an explicit override kept over the catalog), + * so a tab the served `/meta/object` document carries is drawn as served too. + * * ## Why not every tab * - * A view the OBJECT document embeds (`listViews` on the object, read from - * `/meta/object`) is not translated by the server — its `translateObject` does - * not touch `listViews` — so the client bundle is that tab's only translation. - * The tab asks the `/meta/view` read whether it served the view - * (`useServedViewItems`); the object-embedded tab below is the control that it - * still translates. + * A view only the client derived — a stack container's expansion — was + * translated by no server, so the client bundle is that tab's only + * translation. The tab asks the two serving reads whether they served the view + * (`useServedViewItems`); the container tab below is the control that it still + * translates. * * Harness: the sibling relay pins' mocks (`ObjectView.viewDescriptionRelay-7199`), * with the REAL `ViewTabBar`, the real `useObjectLabel` under a real - * `I18nProvider`, and a real `MetadataCtx` carrying the `/meta/view` answer. + * `I18nProvider`, and a real `MetadataCtx` carrying the `/meta/view` and + * `/meta/object` answers. * * Directions, written before the run: the served-edit cells RED before the - * change (the bundle answered), GREEN after; the unedited-served and the - * object-embedded cells GREEN on both sides. + * change (the bundle answered) — for objectui#11336, the embedded-edit cells + * RED with the object-document branch of `isServedView` removed — GREEN after; + * the unedited-served, the no-catalogue and the container cells GREEN on both + * sides. */ import * as React from 'react'; @@ -118,31 +127,56 @@ const VIEW_ID = 'showcase_task.in_progress'; const EDITED = 'In Progress (edited-11295)'; const PACKAGED = { en: 'In Progress', 'zh-CN': '进行中' } as const; const EMBEDDED = { en: 'My Tasks', 'zh-CN': '我的任务' } as const; +const EMBEDDED_EDITED = 'My Tasks (edited-11336)'; +/** An embedded view the catalogue carries no entry for: served as authored. */ +const UNCATALOGUED = 'Everything else'; +const CONTAINER_VIEW_ID = 'showcase_task.board'; +const CONTAINER = { authored: 'Board', en: 'Board View', 'zh-CN': '看板' } as const; /** The packaged catalog, as the console loads it into `I18nProvider`. */ const BUNDLE = { - en: { showcase: { objects: { showcase_task: { label: 'Task', _views: { in_progress: { label: PACKAGED.en }, mine: { label: EMBEDDED.en } } } } } }, - 'zh-CN': { showcase: { objects: { showcase_task: { label: '任务', _views: { in_progress: { label: PACKAGED['zh-CN'] }, mine: { label: EMBEDDED['zh-CN'] } } } } } }, + en: { showcase: { objects: { showcase_task: { label: 'Task', _views: { in_progress: { label: PACKAGED.en }, mine: { label: EMBEDDED.en }, board: { label: CONTAINER.en } } } } } }, + 'zh-CN': { showcase: { objects: { showcase_task: { label: '任务', _views: { in_progress: { label: PACKAGED['zh-CN'] }, mine: { label: EMBEDDED['zh-CN'] }, board: { label: CONTAINER['zh-CN'] } } } } } }, }; /** - * The object as `MetadataProvider` merges it — the served view item under its - * qualified id, and `mine`, a view the object document embeds — plus the - * `/meta/view` answer that served the first one. + * The `/meta/object` answer: the object document as served, BEFORE the merge — + * its own `listViews`, `mine` with the label the server put in and `other`, + * which no catalogue entry translates. */ -function world(servedLabel: string) { - const viewItem = { name: VIEW_ID, object: OBJECT_NAME, viewKind: 'list', label: servedLabel, config: { type: 'grid', columns: ['name'] } }; - const objects = [ - { - name: OBJECT_NAME, - label: 'Task', - fields: { name: { type: 'text', label: 'Name' } }, - listViews: { - [VIEW_ID]: { type: 'grid', columns: ['name'], name: VIEW_ID, label: servedLabel, isDefault: true }, - mine: { type: 'grid', columns: ['name'], name: 'mine', label: 'Mine' }, - }, +function servedObject(mineLabel: string) { + return { + name: OBJECT_NAME, + label: 'Task', + fields: { name: { type: 'text', label: 'Name' } }, + listViews: { + mine: { type: 'grid', columns: ['name'], label: mineLabel }, + other: { type: 'grid', columns: ['name'], label: UNCATALOGUED }, }, - ]; + }; +} + +/** + * The two serving reads and the object as `MetadataProvider` merges them. + * + * `viewItem` world: the `/meta/view` answer serves one view item, merged under + * its qualified id beside the object document's own `listViews`. + * + * `container` world: the `/meta/view` answer is a stack container instead, whose + * `board` the console expands itself (`.board`) — served translated by + * neither read. + */ +function world(servedLabel: string, mineLabel: string, source: 'viewItem' | 'container') { + const doc = servedObject(mineLabel); + const viewItem = { name: VIEW_ID, object: OBJECT_NAME, viewKind: 'list', label: servedLabel, config: { type: 'grid', columns: ['name'] } }; + const container = { name: OBJECT_NAME, listViews: { board: { type: 'grid', columns: ['name'], label: CONTAINER.authored } } }; + const fromViews = source === 'viewItem' + ? { [VIEW_ID]: { type: 'grid', columns: ['name'], name: VIEW_ID, label: servedLabel, isDefault: true } } + : { [CONTAINER_VIEW_ID]: { type: 'grid', columns: ['name'], name: CONTAINER_VIEW_ID, label: CONTAINER.authored } }; + const objects = [{ ...doc, listViews: { ...fromViews, ...doc.listViews } }]; + // One array per read, as the provider's cache holds it. + const viewAnswer = [source === 'viewItem' ? viewItem : container]; + const objectAnswer = [doc]; const metadata = { apps: [], objects, @@ -155,7 +189,7 @@ function world(servedLabel: string) { invalidate: () => {}, ensureType: async () => [], getItem: async () => null, - getItemsByType: (type: string) => (type === 'view' ? [viewItem] : []), + getItemsByType: (type: string) => (type === 'view' ? viewAnswer : type === 'object' ? objectAnswer : []), getTypeStatus: () => 'ready' as const, }; return { objects, metadata }; @@ -171,8 +205,17 @@ function makeDataSource() { } as any; } -async function renderIn(language: 'en' | 'zh-CN', servedLabel: string) { - const { objects, metadata } = world(servedLabel); +/** + * `mineLabel` is what the object read served for `mine` — by default the + * catalogue's translation in `language`, as the server puts it in. + */ +async function renderIn( + language: 'en' | 'zh-CN', + servedLabel: string, + mineLabel: string = EMBEDDED[language], + source: 'viewItem' | 'container' = 'viewItem', +) { + const { objects, metadata } = world(servedLabel, mineLabel, source); render( @@ -189,9 +232,10 @@ async function renderIn(language: 'en' | 'zh-CN', servedLabel: string) { , ); - // The object-embedded tab is drawn in every world, so its arrival says the - // tab bar rendered — waiting on the served label would hang on a regression. - await waitFor(() => expect(screen.getAllByText(EMBEDDED[language]).length).toBeGreaterThan(0)); + // The uncatalogued embedded tab is drawn alike in every world and on both + // sides of either change, so its arrival says the tab bar rendered — waiting + // on a label under test would hang on a regression. + await waitFor(() => expect(screen.getAllByText(UNCATALOGUED).length).toBeGreaterThan(0)); } beforeEach(() => { @@ -225,10 +269,33 @@ describe('ObjectView — a served view tab is drawn as served (objectui#11295)', expect(screen.getAllByText(PACKAGED[language]).length).toBeGreaterThan(0); }); - it.each(LANGUAGES)('%s: control — a view the object document embeds is still named by the bundle', async (language) => { - await renderIn(language, EDITED); + it.each(LANGUAGES)('%s: control — a view only the client derived (a container expansion) is still named by the bundle', async (language) => { + await renderIn(language, EDITED, undefined, 'container'); + + expect(screen.getAllByText(CONTAINER[language]).length).toBeGreaterThan(0); + expect(screen.queryAllByText(CONTAINER.authored)).toHaveLength(0); + }); +}); + +describe('ObjectView — a tab the object document embeds is drawn as served (objectui#11336)', () => { + it('zh-CN: the tab draws the string the object read served', async () => { + await renderIn('zh-CN', PACKAGED['zh-CN']); + + expect(screen.getAllByText(EMBEDDED['zh-CN']).length).toBeGreaterThan(0); + expect(screen.queryAllByText(EMBEDDED.en)).toHaveLength(0); + }); + + it.each(LANGUAGES)('%s: a published edit the object read served renders as served, not as the packaged string', async (language) => { + await renderIn(language, PACKAGED[language], EMBEDDED_EDITED); + + expect(screen.getAllByText(EMBEDDED_EDITED).length).toBeGreaterThan(0); + expect(screen.queryAllByText(EMBEDDED[language])).toHaveLength(0); + }); + + it.each(LANGUAGES)('%s: control — an embedded view with no catalogue entry keeps its authored label', async (language) => { + await renderIn(language, PACKAGED[language]); - // `renderIn` already waited for the bundle's name; the authored one is gone. - expect(screen.queryAllByText('Mine')).toHaveLength(0); + // `renderIn` already waited for it; nothing else names that tab. + expect(screen.getAllByText(UNCATALOGUED).length).toBeGreaterThan(0); }); }); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index ec24e597bf..8b33cf0e5c 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -859,21 +859,22 @@ type ViewLabelLookup = (objectName: string, viewName: string, fallback: string) * A switcher tab's text — the desktop tab bar and the mobile switcher draw the * same string (objectui#11295). * - * A view the `/meta/view` read served arrives translated by the server, a - * published edit kept over the packaged catalog, so its text is drawn as - * given. Offering it to `viewLabel` as the fallback was a second translation - * pass in which the bundle won the edit back. Any other view — embedded in the - * object document, derived client-side — still goes through `viewLabel`, its - * only translation. `useServedViewItems` says which is which, and why. + * A view a server read served — a `/meta/view` document, or an entry the served + * object document embeds in its own `listViews` (objectui#11336) — arrives + * translated by the server, a published edit kept over the packaged catalog, so + * its text is drawn as given. Offering it to `viewLabel` as the fallback was a + * second translation pass in which the bundle won the edit back. Any other view + * — derived client-side — still goes through `viewLabel`, its only + * translation. `useServedViewItems` says which is which, and why. */ function viewTabLabel( view: { id: string; name?: string; label?: string }, objectName: string, - servedViews: readonly unknown[], + servedViews: ReturnType, viewLabel: ViewLabelLookup, ): string { const text = view.label || view.name || view.id; - return isServedView(servedViews, view.id) ? text : viewLabel(objectName, view.name || view.id, text); + return isServedView(servedViews, objectName, view.id) ? text : viewLabel(objectName, view.name || view.id, text); } /** @@ -885,11 +886,11 @@ function viewTabLabel( function viewOriginLabel( view: { id?: string; name?: string; label?: string }, objectName: string, - servedViews: readonly unknown[], + servedViews: ReturnType, viewLabel: ViewLabelLookup, ): string { const text = view.label ?? ''; - return isServedView(servedViews, view.id) ? text : viewLabel(objectName, view.name ?? '', text); + return isServedView(servedViews, objectName, view.id) ? text : viewLabel(objectName, view.name ?? '', text); } /** @@ -1379,8 +1380,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co const { showDebug } = useMetadataInspector(); const { t } = useObjectTranslation(); const { objectLabel, objectDescription: objectDesc, viewLabel, viewEmptyState, actionParamText, fieldLabel, fieldOptionLabel } = useObjectLabel(); - // The views the `/meta/view` read served, already translated: their labels - // are drawn as given, every other view's through `viewLabel` (objectui#11295). + // The views a server read served — `/meta/view`, and the object document's + // own `listViews` — already translated: their labels are drawn as given, + // every other view's through `viewLabel` (objectui#11295, objectui#11336). const servedViews = useServedViewItems(); // label + confirmText + successMessage through ONE call (objectui#4265). const localizeActionTexts = useActionTextLocalizer();