Skip to content

Commit 19b9d21

Browse files
os-zhuangclaude
andcommitted
feat(app-shell): ADR-0047 phase 4 — PageView renders interfaceConfig (interface mode)
InterfaceListPage binds interfaceConfig.source/sourceView to the merged object definition, inherits columns/filter/sort from the referenced view (the iron rule), and overlays the page's presentation policy: userFilters, appearance.allowedVisualizations (single entry = no switcher), userActions toggles (closed-by-default toolbar — no advanced filter builder, no view management, no export). PageView routes pages carrying interfaceConfig.source to this surface instead of the region renderer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5757537 commit 19b9d21

2 files changed

Lines changed: 165 additions & 8 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* Interface List Page — ADR-0047 interface mode.
3+
*
4+
* Renders a page whose `interfaceConfig` binds a single source view into an
5+
* author-curated list surface. Where ObjectView (data mode) shows ALL of an
6+
* object's list views as switcher tabs and lets users create views, this
7+
* surface is deliberately closed:
8+
*
9+
* • the page REFERENCES one view (`interfaceConfig.sourceView`) — columns,
10+
* base filter and sort are inherited, never restated (the iron rule);
11+
* • end users get exactly the `userFilters` the author enabled;
12+
* • the visualization comes from `appearance.allowedVisualizations`
13+
* (a single entry renders no switcher);
14+
* • `userActions` toggles map onto the toolbar — advanced filtering and
15+
* view management are absent by default.
16+
*/
17+
18+
import * as React from 'react';
19+
import { ListView } from '@object-ui/plugin-list';
20+
import { useAdapter } from '@object-ui/react';
21+
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
22+
import { Database } from 'lucide-react';
23+
import { useObjectTranslation } from '@object-ui/i18n';
24+
import { useMetadata } from '../providers/MetadataProvider';
25+
26+
interface InterfaceListPageProps {
27+
page: any;
28+
className?: string;
29+
}
30+
31+
/**
32+
* Resolve the source list view from the merged object definition.
33+
* Views merged from ADR-0017 ViewItems are keyed `<object>.<key>`;
34+
* the page author writes the bare key (`sourceView: 'default'`).
35+
*/
36+
function resolveSourceView(objectDef: any, sourceView?: string): any | undefined {
37+
const views: Record<string, any> = objectDef?.listViews || objectDef?.list_views || {};
38+
if (sourceView) {
39+
return (
40+
views[`${objectDef.name}.${sourceView}`]
41+
?? views[sourceView]
42+
?? (sourceView === 'default' || sourceView === 'list' ? objectDef?.list : undefined)
43+
);
44+
}
45+
return objectDef?.list ?? Object.values(views)[0];
46+
}
47+
48+
export function InterfaceListPage({ page, className }: InterfaceListPageProps) {
49+
const { t } = useObjectTranslation();
50+
const { objects } = useMetadata();
51+
const dataSource = useAdapter();
52+
53+
const cfg = page?.interfaceConfig || {};
54+
const objectDef = React.useMemo(
55+
() => (objects || []).find((o: any) => o.name === cfg.source),
56+
[objects, cfg.source],
57+
);
58+
const viewDef = React.useMemo(
59+
() => resolveSourceView(objectDef, cfg.sourceView),
60+
[objectDef, cfg.sourceView],
61+
);
62+
63+
const schema = React.useMemo(() => {
64+
if (!objectDef) return undefined;
65+
const view = viewDef || {};
66+
const appearance = cfg.appearance ?? view.appearance;
67+
const allowed: string[] = appearance?.allowedVisualizations || [];
68+
const userActions = cfg.userActions || {};
69+
70+
// Inherited data semantics (the iron rule: all from the view) + the
71+
// page's own always-on criteria (`filterBy`).
72+
const filters = [
73+
...(Array.isArray(view.filter) ? view.filter : []),
74+
...(Array.isArray(cfg.filterBy) ? cfg.filterBy : []),
75+
];
76+
77+
return {
78+
type: 'list-view' as const,
79+
objectName: objectDef.name,
80+
viewType: (allowed[0] ?? view.type ?? 'grid'),
81+
fields: view.columns,
82+
...(filters.length ? { filters } : {}),
83+
...(view.sort?.length ? { sort: view.sort } : {}),
84+
grouping: view.grouping,
85+
rowColor: view.rowColor,
86+
pagination: view.pagination,
87+
searchableFields: view.searchableFields,
88+
emptyState: view.emptyState,
89+
kanban: view.kanban,
90+
calendar: view.calendar,
91+
gallery: view.gallery,
92+
timeline: view.timeline,
93+
gantt: view.gantt,
94+
95+
// Presentation policy — the page layer (ADR-0047).
96+
userFilters: cfg.userFilters ?? view.userFilters,
97+
appearance,
98+
showViewSwitcher: allowed.length > 1,
99+
showRecordCount: cfg.showRecordCount,
100+
101+
// userActions toggles → toolbar flags. Interface mode is closed by
102+
// default: the advanced filter builder and view-management tools are
103+
// only present when the author opted in.
104+
showSearch: userActions.search !== false,
105+
showSort: userActions.sort !== false,
106+
showFilters: userActions.filter === true,
107+
showDensity: userActions.rowHeight === true,
108+
showHideFields: false,
109+
showGroup: false,
110+
showColor: false,
111+
allowExport: false,
112+
inlineEdit: false,
113+
};
114+
}, [objectDef, viewDef, cfg]);
115+
116+
if (!objectDef || !schema) {
117+
return (
118+
<div className="h-full flex items-center justify-center p-8">
119+
<Empty>
120+
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
121+
<Database className="h-6 w-6 text-muted-foreground" />
122+
</div>
123+
<EmptyTitle>{t('empty.objectNotFound', { defaultValue: 'Source object not found' })}</EmptyTitle>
124+
<EmptyDescription>
125+
{t('empty.interfacePageSourceMissing', {
126+
defaultValue: 'This interface page references "{{name}}", which is not available.',
127+
name: cfg.source || '?',
128+
})}
129+
</EmptyDescription>
130+
</Empty>
131+
</div>
132+
);
133+
}
134+
135+
return (
136+
<div className={className ?? 'h-full flex flex-col'} data-testid="interface-list-page">
137+
<div className="px-4 pt-4 pb-2 shrink-0">
138+
<h1 className="text-lg font-semibold leading-tight">
139+
{typeof page.label === 'string' ? page.label : page.name}
140+
</h1>
141+
{typeof page.description === 'string' && page.description && (
142+
<p className="text-sm text-muted-foreground mt-0.5">{page.description}</p>
143+
)}
144+
</div>
145+
<div className="flex-1 min-h-0 overflow-auto">
146+
<ListView schema={schema} dataSource={dataSource} />
147+
</div>
148+
</div>
149+
);
150+
}

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

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { useAuth } from '@object-ui/auth';
1919
import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
2020
import { useMetadata } from '../providers/MetadataProvider';
2121
import { ConsoleActionRuntimeProvider } from '../hooks/useConsoleActionRuntime';
22+
import { InterfaceListPage } from './InterfaceListPage';
2223

2324
export function PageView() {
2425
const { t } = useObjectTranslation();
@@ -91,14 +92,20 @@ export function PageView() {
9192
{t('common.editInStudio', { defaultValue: 'Edit in studio' })}
9293
</button>
9394
)}
94-
<SchemaRenderer
95-
key={refreshKey}
96-
schema={{
97-
...page,
98-
type: (page as any).type || 'page',
99-
context: { ...(page as any).context, params, refreshKey },
100-
}}
101-
/>
95+
{(page as any).interfaceConfig?.source ? (
96+
// ADR-0047 interface mode: the page binds a source view into a
97+
// curated list surface — rendered directly, not via regions.
98+
<InterfaceListPage key={refreshKey} page={page} />
99+
) : (
100+
<SchemaRenderer
101+
key={refreshKey}
102+
schema={{
103+
...page,
104+
type: (page as any).type || 'page',
105+
context: { ...(page as any).context, params, refreshKey },
106+
}}
107+
/>
108+
)}
102109
</div>
103110
<MetadataPanel
104111
open={showDebug}

0 commit comments

Comments
 (0)