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();