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/11336-served-embedded-listviews.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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 }) => <MetadataCtx.Provider value={value}>{children}</MetadataCtx.Provider>,
}).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 }) => <MetadataCtx.Provider value={value}>{children}</MetadataCtx.Provider>,
});
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);
});
});
123 changes: 95 additions & 28 deletions packages/app-shell/src/hooks/useServedViewItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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.<object>._views.<key>.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.<object>._views.<key>.label` with `<key>` 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.
*/

Expand All @@ -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<readonly unknown[], WeakMap<readonly unknown[], ServedViewReads>>();

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<string, unknown> {
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])
);
}
5 changes: 3 additions & 2 deletions packages/app-shell/src/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 });
Expand Down
Loading
Loading