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
12 changes: 12 additions & 0 deletions .changeset/default-list-view-identity-3770.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@object-ui/app-shell": patch
---

Ask the view composer for a container's view identities instead of deriving `list.name || 'list'`, so the default list view's translated label resolves

A `defineView` container declares its default list under the `list` key. That key is a slot in the authoring document, not the view's identity: `expandViewContainer` — the same composer the framework's loader and the i18n extractor call — registers an unnamed default list as `<object>.default`. This renderer derived `list.name || 'list'` instead, a third spelling no producer emits, so a default-list-only object probed `objects.<object>._views.list.label`, missed the published `_views.default.label` key (objectstack#5164 ruling A, migrated in objectstack#6124) and fell back to the English metadata label — for the view's description and empty state too.

- `MetadataProvider.mergeViewsIntoObjects` now expands a stack-packaged container through `expandViewContainer` and routes the result through the same code path as first-class ViewItems. Both authoring gates therefore key `listViews` / `formViews` by the canonical `<object>.<key>` identity, and the container inherits the composer's folding (a `listViews` entry that merely restates `list` collapses into one view) and collision renaming instead of restating them locally.
- `ObjectView` resolves the primary view's id through the new `defaultListViewId` helper — one derivation shared by the view-override lookup and the view-switcher promotion, with no literal fallback.

The renamed id is also the key a view override is persisted under (`updateViewConfig(object, viewId, …)` writes a `view` metadata record named by the id). Nothing is orphaned: the retired `'list'` spelling is not a representable view identity at all — `ViewItemNameSchema` requires a dotted `<object>.<key>` name — while the record-gate path, which real backends serve, already used the qualified id. Stale `/view/list` links fall back to the object's default view, which is the same view they named.
89 changes: 76 additions & 13 deletions packages/app-shell/src/providers/MetadataProvider.merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,85 @@ describe('mergeViewsIntoObjects', () => {
formViews: { default: { type: 'simple' } },
};
const [obj] = mergeViewsIntoObjects(objects, [listAll, listCalendar, formDefault, container]);
// Only the canonical `<obj>.<key>` ids — the container's short `all`/`list`
// keys must NOT also appear (no double-listing).
// Only the two list ViewItems. Both gates key by the same canonical
// `<obj>.<key>` identity now (objectui#3770), so the container can no longer
// double-list them under short keys — but it still MUST be skipped: its own
// `list` is structurally distinct from `listViews.all`, so expanding it would
// add a third `crm_activity.default` list tab (and force the container's
// `formViews.default` into a `_2` rename), and the ViewItem rows are the
// authoritative ones the runtime heals personalization onto.
expect(Object.keys(obj.listViews).sort()).toEqual(['crm_activity.all', 'crm_activity.calendar']);
});

it('still consumes the legacy container for objects without ViewItems', () => {
const container = {
name: 'crm_activity',
list: { name: 'list', type: 'grid' },
listViews: { all: { type: 'grid' } },
formViews: { default: { type: 'simple' } },
};
const [obj] = mergeViewsIntoObjects(objects, [container]);
expect(Object.keys(obj.listViews).sort()).toEqual(['all', 'list']);
expect(obj.formViews.default).toBeTruthy();
expect(obj.formViews.list).toBeUndefined();
/**
* Container gate (objectui#3770). A stack-packaged container is served
* UNEXPANDED, so this merge asks `expandViewContainer` for each view's runtime
* identity instead of deriving one. The previous derivation was
* `list.name || 'list'` — a spelling no producer emits, which is why the
* default list's translation key never resolved (the composer, the framework
* loader and the i18n extractor all say `<object>.default`).
*/
describe('aggregated container — identities come from the view composer', () => {
it('keys an unnamed default list by the composer identity `<object>.default`', () => {
const container = {
name: 'crm_activity',
// No `name` — the default `list` implicitly claims `<object>.default`.
list: { label: 'All Activities', type: 'grid', columns: [{ field: 'subject' }] },
listViews: { calendar: { type: 'calendar' } },
};
const [obj] = mergeViewsIntoObjects(objects, [container]);
expect(Object.keys(obj.listViews).sort()).toEqual([
'crm_activity.calendar',
'crm_activity.default',
]);
// The retired dialect must be gone, not merely joined by the new key.
expect(obj.listViews.list).toBeUndefined();
// `name` is stamped so ObjectView's `view.name || view.id` — the argument
// `viewLabel` translates by — carries the composer identity.
expect(obj.list.name).toBe('crm_activity.default');
expect(obj.listViews['crm_activity.default'].isDefault).toBe(true);
expect(obj.listViews['crm_activity.default'].columns).toEqual([{ field: 'subject' }]);
});

it('honors an author-supplied `list.name` as the key', () => {
const container = {
name: 'crm_activity',
list: { name: 'my_list', type: 'grid', columns: [{ field: 'subject' }] },
};
const [obj] = mergeViewsIntoObjects(objects, [container]);
expect(Object.keys(obj.listViews)).toEqual(['crm_activity.my_list']);
expect(obj.list.name).toBe('crm_activity.my_list');
});

it('folds a `listViews` entry that merely restates `list` into ONE view', () => {
// The composer dedups by structural signature: `listViews.all` restating
// the default `list` collapses into `crm_activity.all`, which is then the
// default. Deriving the id locally could not know that — it would emit a
// second tab for the same view.
const restated = { type: 'grid', label: 'All', columns: [{ field: 'subject' }] };
const container = {
name: 'crm_activity',
list: restated,
listViews: { all: { ...restated } },
};
const [obj] = mergeViewsIntoObjects(objects, [container]);
expect(Object.keys(obj.listViews)).toEqual(['crm_activity.all']);
expect(obj.list.name).toBe('crm_activity.all');
expect(obj.listViews['crm_activity.all'].isDefault).toBe(true);
});

it('routes the container form family into formViews only', () => {
const container = {
name: 'crm_activity',
list: { type: 'grid', columns: [{ field: 'subject' }] },
formViews: { compact: { type: 'simple' } },
};
const [obj] = mergeViewsIntoObjects(objects, [container]);
expect(obj.formViews['crm_activity.compact']).toBeTruthy();
expect(obj.listViews['crm_activity.compact']).toBeUndefined();
// …and the list family is untouched by the form entry.
expect(Object.keys(obj.listViews)).toEqual(['crm_activity.default']);
});
});
});

Expand Down
98 changes: 56 additions & 42 deletions packages/app-shell/src/providers/MetadataProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useMemo,
type ReactNode,
} from 'react';
import { expandViewContainer } from '@objectstack/spec/ui';
import { type ObjectStackAdapter } from '@object-ui/data-objectstack';
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
import { resolveInlineMode } from '@object-ui/plugin-form';
Expand Down Expand Up @@ -141,10 +142,18 @@ function isNamedItem(item: unknown): item is { name: string } {
* (`isAggregatedViewContainer` / `expandViewContainer` in
* `@objectstack/spec/ui`), so it is NOT legacy and this branch is NOT dead
* code — delete it and stack-packaged views stop reaching the renderer.
* When an object already has expanded ViewItems the container is skipped
* for THAT object, since it restates the same views (and keying both would
* list every view twice — once under its short key, once under its
* canonical `<object>.<key>` name).
* A container is served UNEXPANDED on purpose (the platform's write
* chokepoint states it: "container bodies are left untouched —
* `expandViewContainer` derives identity itself"), so this branch asks the
* composer for each view's identity rather than deriving one of its own
* (objectui#3770). Both gates therefore produce the same canonical
* `<object>.<key>` ids — including the default `list`'s implicit
* `<object>.default` — and the container inherits the composer's folding and
* collision-renaming rules for free.
* When an object already has expanded ViewItems the container is skipped for
* THAT object: it restates the same identities, and the ViewItem rows are
* the authoritative ones (the runtime heals personalization overlays onto
* them).
*
* Existing `obj.listViews` / `obj.list_views` win to preserve overrides.
*/
Expand All @@ -160,6 +169,37 @@ function isViewItem(view: any): boolean {
return !!view && typeof view === 'object' && !!view.viewKind && !!view.object;
}

/**
* Route ONE view identity into its object's bucket — shared by both gates: the
* record gate's stored ViewItems and the views `expandViewContainer` materialises
* out of a stack-packaged container. Both shapes are
* `{ name, object, viewKind, label?, isDefault?, config }`, so both are keyed by
* the canonical `<object>.<key>` name the composer owns.
*
* The `config` body is flattened to the legacy NamedListView/FormView shape the
* renderer consumes (type/data/columns/sections at top level); the item-level
* label/isDefault ride along and `name` is stamped with the id so primary-view
* promotion (which matches on `list.name`) finds this entry by its listViews key.
* FORM-family views land in `formViews` only, never in the list-view switcher.
*/
function applyViewItem(bucket: ViewBucket, view: any): void {
const key = view.name || `${view.object}.${view.viewKind}`;
const body = view.config && typeof view.config === 'object' ? view.config : {};
const entry = {
...body,
name: key,
label: view.label ?? (body as any).label,
isDefault: !!view.isDefault,
};
if (view.viewKind === 'form') {
bucket.formViews[key] = entry;
if (view.isDefault || !bucket.form) bucket.form = entry;
} else {
bucket.listViews[key] = entry;
if (view.isDefault) bucket.primary = entry;
}
}

export function mergeViewsIntoObjects(objects: any[], views: any[]): any[] {
if (!objects.length || !views.length) return objects;
const byObject: Record<string, ViewBucket> = {};
Expand All @@ -173,23 +213,9 @@ export function mergeViewsIntoObjects(objects: any[], views: any[]): any[] {
for (const view of views) {
// ── Record gate: independent ViewItem ({ name, object, viewKind, config }) ──
if (isViewItem(view)) {
const bucket = (byObject[view.object] ||= { listViews: {}, formViews: {} });
// Canonical `<object>.<key>` name doubles as the view id, so `/view/<name>`
// URLs resolve directly against the switcher tab ids.
const key = view.name || `${view.object}.${view.viewKind}`;
const body = view.config && typeof view.config === 'object' ? view.config : {};
// Flatten `config` to the legacy NamedListView/FormView shape the
// renderer consumes (type/data/columns/sections at top level); carry the
// item-level label/isDefault and stamp `name` so primary-view promotion
// (which matches on `list.name`) finds this entry by its listViews key.
const entry = { ...body, name: key, label: view.label ?? (body as any).label, isDefault: !!view.isDefault };
if (view.viewKind === 'form') {
bucket.formViews[key] = entry;
if (view.isDefault || !bucket.form) bucket.form = entry;
} else {
bucket.listViews[key] = entry;
if (view.isDefault) bucket.primary = entry;
}
// The canonical `<object>.<key>` name doubles as the view id, so
// `/view/<name>` URLs resolve directly against the switcher tab ids.
applyViewItem((byObject[view.object] ||= { listViews: {}, formViews: {} }), view);
continue;
}
// ── Stack gate: aggregated container ({ list, form, listViews, formViews }) ──
Expand All @@ -198,27 +224,15 @@ export function mergeViewsIntoObjects(objects: any[], views: any[]): any[] {
// Expanded ViewItems supersede the bare container for this object.
if (hasViewItems.has(objName)) continue;
const bucket = (byObject[objName] ||= { listViews: {}, formViews: {} });
if (view.list) {
// Preserve the primary list view as `obj.list` per @objectstack/spec
// ViewSchema. Also mirror it into `listViews` under its name so legacy
// consumers (that only iterate `listViews`) still see it. Consumers
// honoring `obj.list` (e.g. ObjectView) should dedup by id.
bucket.primary = view.list;
const k = view.list.name || 'list';
bucket.listViews[k] = view.list;
}
if (view.form) {
bucket.form = view.form;
}
if (view.listViews && typeof view.listViews === 'object') {
for (const [k, v] of Object.entries(view.listViews as Record<string, any>)) {
bucket.listViews[k] = v;
}
}
if (view.formViews && typeof view.formViews === 'object') {
for (const [k, v] of Object.entries(view.formViews as Record<string, any>)) {
bucket.formViews[k] = v;
}
// Ask the composer which views this container declares and what each one's
// runtime identity is (objectui#3770) — the default `list` implicitly claims
// `<object>.default`, and a `listViews` entry that merely restates it folds
// into that entry's own name. The primary list keeps arriving on `obj.list`
// per @objectstack/spec ViewSchema (below) AND is mirrored into `listViews`
// under that identity, so consumers that only iterate `listViews` still see
// it and consumers honoring `obj.list` dedup by the same id.
for (const item of expandViewContainer(objName, view)) {
applyViewItem(bucket, item);
}
}
return objects.map(obj => {
Expand Down
48 changes: 48 additions & 0 deletions packages/app-shell/src/utils/viewIdentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* 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.
*/

import { expandViewContainer } from '@objectstack/spec/ui';

/**
* Runtime identity of an aggregated container's DEFAULT `list` view — asked of
* the spec's view composer, never spelled out here (objectui#3770).
*
* A `defineView` container declares its default list under the `list` key. That
* key is a SLOT in the authoring document, not the view's identity: the composer
* (`expandViewContainer`, the same function the framework's loader and the i18n
* extractor call) registers an unnamed default list as `<object>.default`, and a
* named one as `<object>.<list.name>`. The renderer used to derive `list.name ||
* 'list'` instead — a third spelling that no producer emits, so the default
* list's translation key (`objects.<object>._views.default.label`, canonical per
* objectstack#5164 ruling A) could never be reached and the label fell back to
* English. Deriving the identity from the composer keeps this consumer on the one
* spelling and inherits its rules (implicit `default`, author-supplied `name`,
* collision renaming) instead of restating them.
*
* Call sites that hold the whole container (`MetadataProvider`) call
* `expandViewContainer` directly and get every view's identity, folding
* included. This helper is for the call sites that hold only the default list
* body (`ObjectView`, reading `objectDef.list`).
*
* @param objectName - The bound object's name, as the runtime presents it.
* @param list - The container's default `list` body, or a merged entry derived
* from it (already carrying the composer's qualified `name`).
* @returns The qualified `<object>.<key>` view id, or `undefined` when `list` is
* not a view body.
*/
export function defaultListViewId(objectName: string, list: unknown): string | undefined {
if (!list || typeof list !== 'object') return undefined;
const declared = (list as { name?: unknown }).name;
// Already the composer's qualified identity — `MetadataProvider` stamps it
// onto every merged entry, and re-expanding it would double the prefix
// (`crm_lead.crm_lead.default`).
if (typeof declared === 'string' && declared.startsWith(`${objectName}.`)) {
return declared;
}
return expandViewContainer(objectName, { list })[0]?.name;
}
Loading
Loading