Skip to content

Commit 24d18fc

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(app-shell): ADR-0048 Phase 2 — prefer-local metadata resolution (#1688)
Two installed packages may legitimately ship the same bare name (e.g. a `page` named `home`); the install-time namespace gate keeps their namespaces distinct and resolution disambiguates by container. Add `preferLocal(list, name, ownerPackageId)`: prefer the item whose `_packageId` matches the active app's package, falling back to the first name match (preserving prior behaviour when the owner is unknown — runtime/DB apps with no `_packageId`). Wire it at the bare-name resolution sites: PageView, DashboardView, ReportView (rendered defs) and AppHeader (breadcrumb labels). The active app is read from `useExpressionContext().app` / AppHeader's `currentApp`. v1 = namespace as the route key (`/apps/crm`), so URLs are unchanged. Test: utils/__tests__/preferLocal.test.ts (6). app-shell views/utils/layout suites green (308). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d82a580 commit 24d18fc

7 files changed

Lines changed: 89 additions & 8 deletions

File tree

packages/app-shell/src/layout/AppHeader.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
6161
import type { BreadcrumbItem as BreadcrumbItemType } from '@object-ui/types';
6262
import { useAuth, getUserInitials } from '@object-ui/auth';
6363
import { useMetadata } from '../providers/MetadataProvider';
64-
import { resolveI18nLabel } from '../utils';
64+
import { resolveI18nLabel, preferLocal } from '../utils';
6565
import { getIcon } from '../utils/getIcon';
6666
import { useMobileViewSwitcher } from './MobileViewSwitcherContext';
6767
import { useNavigationContext } from '../context/NavigationContext';
@@ -483,23 +483,24 @@ export function AppHeader({
483483
extraSegments.push({ label: t('console.breadcrumb.dashboards'), href: baseHref });
484484
if (pathParts[3]) {
485485
const dashboardName = pathParts[3];
486-
const dashboardDef = (metadataDashboards || []).find((d: any) => d.name === dashboardName);
486+
// ADR-0048 Phase 2 — prefer the current app's package (container-scoped).
487+
const dashboardDef = preferLocal(metadataDashboards as any[], dashboardName, (currentApp as any)?._packageId);
487488
const fallback = dashboardDef?.label || humanizeSlug(dashboardName);
488489
extraSegments.push({ label: dashboardLabel({ name: dashboardName, label: fallback }) });
489490
}
490491
} else if (routeType === 'page') {
491492
extraSegments.push({ label: t('console.breadcrumb.pages'), href: baseHref });
492493
if (pathParts[3]) {
493494
const pageName = pathParts[3];
494-
const pageDef = (metadataPages || []).find((p: any) => p.name === pageName);
495+
const pageDef = preferLocal(metadataPages as any[], pageName, (currentApp as any)?._packageId);
495496
const fallback = pageDef?.label || humanizeSlug(pageName);
496497
extraSegments.push({ label: pageLabel({ name: pageName, label: fallback }) });
497498
}
498499
} else if (routeType === 'report') {
499500
extraSegments.push({ label: t('console.breadcrumb.reports'), href: baseHref });
500501
if (pathParts[3]) {
501502
const reportName = pathParts[3];
502-
const reportDef = (metadataReports || []).find((r: any) => r.name === reportName);
503+
const reportDef = preferLocal(metadataReports as any[], reportName, (currentApp as any)?._packageId);
503504
const fallback = reportDef?.label || humanizeSlug(reportName);
504505
extraSegments.push({ label: reportLabel({ name: reportName, label: fallback }) });
505506
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { preferLocal } from '../preferLocal';
3+
4+
/**
5+
* ADR-0048 Phase 2 — prefer-local (container-scoped) resolution.
6+
*/
7+
describe('preferLocal', () => {
8+
const crmHome = { name: 'home', _packageId: 'com.acme.crm', title: 'CRM Home' };
9+
const hrHome = { name: 'home', _packageId: 'com.acme.hr', title: 'HR Home' };
10+
const list = [crmHome, hrHome];
11+
12+
it('prefers the item owned by the current package', () => {
13+
expect(preferLocal(list, 'home', 'com.acme.crm')).toBe(crmHome);
14+
expect(preferLocal(list, 'home', 'com.acme.hr')).toBe(hrHome);
15+
});
16+
17+
it('falls back to first match when the package owns no such item', () => {
18+
expect(preferLocal(list, 'home', 'com.acme.unknown')).toBe(crmHome);
19+
});
20+
21+
it('falls back to first match when no owner package is given (legacy behaviour)', () => {
22+
expect(preferLocal(list, 'home')).toBe(crmHome);
23+
expect(preferLocal(list, 'home', undefined)).toBe(crmHome);
24+
});
25+
26+
it('returns undefined for missing name or list', () => {
27+
expect(preferLocal(list, undefined, 'com.acme.crm')).toBeUndefined();
28+
expect(preferLocal(undefined, 'home', 'com.acme.crm')).toBeUndefined();
29+
expect(preferLocal(null, 'home')).toBeUndefined();
30+
});
31+
32+
it('returns undefined when nothing matches the name', () => {
33+
expect(preferLocal(list, 'dashboard', 'com.acme.crm')).toBeUndefined();
34+
});
35+
36+
it('resolves a single unambiguous item regardless of owner', () => {
37+
expect(preferLocal([crmHome], 'home', 'com.acme.hr')).toBe(crmHome);
38+
});
39+
});

packages/app-shell/src/utils/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export type {
1313
export { deriveRelatedLists } from './deriveRelatedLists';
1414
export type { DerivedRelatedList } from './deriveRelatedLists';
1515

16+
export { preferLocal } from './preferLocal';
17+
1618
/**
1719
* Resolves an I18nLabel to a plain string.
1820
* I18nLabel can be either a string or an object { key, defaultValue?, params? }.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* ADR-0048 Phase 2 — prefer-local (container-scoped) metadata resolution.
3+
*
4+
* Two installed packages may legitimately ship the same bare name (e.g. a
5+
* `page` named `home`) — the install-time namespace gate keeps their namespaces
6+
* distinct, and resolution disambiguates them by container. When resolving a
7+
* bare name we prefer the item owned by the *current* container (the active
8+
* app's package), so a user inside `/apps/crm` sees crm's `home`, not whichever
9+
* package's `home` happened to load first.
10+
*
11+
* Falls back to the first name match when the container owns no such item or
12+
* its owning package is unknown (runtime/DB apps with no `_packageId`), so this
13+
* never regresses the prior first-match behaviour.
14+
*/
15+
export function preferLocal<T extends { name?: unknown; _packageId?: unknown }>(
16+
list: readonly T[] | undefined | null,
17+
name: string | undefined | null,
18+
ownerPackageId?: unknown,
19+
): T | undefined {
20+
if (!list || name == null) return undefined;
21+
if (ownerPackageId) {
22+
const local = list.find((x) => x.name === name && x._packageId === ownerPackageId);
23+
if (local) return local;
24+
}
25+
return list.find((x) => x.name === name);
26+
}

packages/app-shell/src/views/DashboardView.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ import {
4040
import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
4141
import { SkeletonDashboard } from '../skeletons';
4242
import { useMetadata } from '../providers/MetadataProvider';
43-
import { resolveI18nLabel } from '../utils';
43+
import { useExpressionContext } from '../providers/ExpressionProvider';
44+
import { resolveI18nLabel, preferLocal } from '../utils';
4445
import { useAdapter } from '../providers/AdapterProvider';
4546
import { useMetadataClient } from './metadata-admin/useMetadata';
4647
import { persistRuntimeMetadata } from './runtime-metadata-persistence';
@@ -177,7 +178,9 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
177178
}, [dashboardName]);
178179

179180
const { dashboards, objects: metadataObjects, refresh } = useMetadata();
180-
const dashboard = dashboards?.find((d: any) => d.name === dashboardName);
181+
// ADR-0048 Phase 2 — prefer the dashboard owned by the current app's package.
182+
const { app: activeApp } = useExpressionContext();
183+
const dashboard = preferLocal(dashboards as any[], dashboardName, (activeApp as any)?._packageId);
181184

182185
// Local schema state for live preview — initialized from metadata
183186
const [editSchema, setEditSchema] = useState<DashboardSchema | null>(null);

packages/app-shell/src/views/PageView.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { useObjectTranslation } from '@object-ui/i18n';
1818
import { useAuth } from '@object-ui/auth';
1919
import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
2020
import { useMetadata } from '../providers/MetadataProvider';
21+
import { useExpressionContext } from '../providers/ExpressionProvider';
22+
import { preferLocal } from '../utils/preferLocal';
2123
import { ConsoleActionRuntimeProvider } from '../hooks/useConsoleActionRuntime';
2224
import { InterfaceListPage } from './InterfaceListPage';
2325

@@ -34,11 +36,15 @@ export function PageView() {
3436
const isAdmin = user?.role === 'admin';
3537

3638
const { pages, objects } = useMetadata();
39+
// ADR-0048 Phase 2 — prefer the page owned by the current app's package so
40+
// two packages shipping `page/<same-name>` each resolve within their own
41+
// container instead of by load order.
42+
const { app: activeApp } = useExpressionContext();
3743
const dataSource = useAdapter();
3844
// Bumped after a successful page action so embedded data (lists, etc.)
3945
// re-fetch. Threaded into the page context AND used to remount the renderer.
4046
const [refreshKey, setRefreshKey] = useState(0);
41-
const page = pages?.find((p: any) => p.name === pageName);
47+
const page = preferLocal(pages as any[], pageName, (activeApp as any)?._packageId);
4248

4349
if (!page) {
4450
return (

packages/app-shell/src/views/ReportView.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { Pencil, BarChart3, Loader2 } from 'lucide-react';
1414
import { useObjectTranslation } from '@object-ui/i18n';
1515
import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
1616
import { useMetadata } from '../providers/MetadataProvider';
17+
import { useExpressionContext } from '../providers/ExpressionProvider';
18+
import { preferLocal } from '../utils/preferLocal';
1719
import { useAdapter } from '../providers/AdapterProvider';
1820
import { useMetadataClient } from './metadata-admin/useMetadata';
1921
import { persistRuntimeMetadata } from './runtime-metadata-persistence';
@@ -51,7 +53,9 @@ export function ReportView({ dataSource }: { dataSource?: DataSource }) {
5153

5254
// Find report definition from API-driven metadata
5355
const { reports, objects, loading, refresh } = useMetadata();
54-
const initialReport = reports?.find((r: any) => r.name === reportName);
56+
// ADR-0048 Phase 2 — prefer the report owned by the current app's package.
57+
const { app: activeApp } = useExpressionContext();
58+
const initialReport = preferLocal(reports as any[], reportName, (activeApp as any)?._packageId);
5559
const [reportData, setReportData] = useState(initialReport);
5660

5761
// Local schema state for live preview — initialized from metadata

0 commit comments

Comments
 (0)