Skip to content

Commit 5cdb0c9

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat: ADR-0047 implementation (objectui) — spec tabs render, visualization switcher, interface pages (#1678)
* feat(list): ADR-0047 phase 2 — spec tabs reach the renderer, whitelist∩capability visualization switcher - ObjectView forwards viewDef.tabs (spec ViewTab metadata was stored and served but never rendered) and viewDef.appearance (allowedVisualizations whitelist), and turns on the dormant ViewSwitcher when the author whitelisted more than one type - ListView.handleTabChange accepts the canonical ViewFilterRule[] tab filter shape (was silently ignored — only the legacy FilterGroup worked); mapOperator learns the spec snake_case operators - availableViews: effective options = author whitelist ∩ capability- resolvable types (kanban needs groupBy, calendar a date field, …) instead of trusting the whitelist blindly; chart included - UserFilters tabs element normalizes spec ViewTab presets ({name,filter,isDefault} → {id,filters,default}) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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> * fix(adr-0047): verification-round fixes — switcher schema fallback, hollow-view hydration, getView type bug - ListView reads showViewSwitcher from the schema too (ObjectView/ InterfaceListPage stamp it there; the prop-only read left the switcher permanently hidden) - InterfaceListPage: prefer source-view candidates that actually carry columns, and hydrate hollow ADR-0017 expansion items through listViewOverrides()/getView() — the same path ObjectView uses - data-objectstack getView(): pass 'view' as the metadata type instead of the object name (hit /meta/<object>/<view> and always 404ed — latent since ADR-0017) All verified live in the browser: data mode (tabs + dropdowns + Grid/ Kanban/Gallery/Calendar switcher, tab filter 10→2 records) and interface mode (inherited columns, page-curated dropdowns only, locked grid, closed toolbar, dropdown filter 10→2 records). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 78d1a56 commit 5cdb0c9

6 files changed

Lines changed: 315 additions & 48 deletions

File tree

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
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+
// ADR-0017 expansion can serve a default-view item with an empty config
39+
// while the full body lives on `objectDef.list` — prefer candidates that
40+
// actually carry columns over hollow name matches.
41+
const candidates = sourceView
42+
? [
43+
views[`${objectDef?.name}.${sourceView}`],
44+
views[sourceView],
45+
...(sourceView === 'default' || sourceView === 'list' ? [objectDef?.list] : []),
46+
]
47+
: [objectDef?.list, ...Object.values(views)];
48+
const present = candidates.filter(Boolean);
49+
return present.find((v: any) => v?.columns) ?? present[0];
50+
}
51+
52+
export function InterfaceListPage({ page, className }: InterfaceListPageProps) {
53+
const { t } = useObjectTranslation();
54+
const { objects } = useMetadata();
55+
const dataSource = useAdapter();
56+
57+
const cfg = page?.interfaceConfig || {};
58+
const objectDef = React.useMemo(
59+
() => (objects || []).find((o: any) => o.name === cfg.source),
60+
[objects, cfg.source],
61+
);
62+
const resolvedView = React.useMemo(
63+
() => resolveSourceView(objectDef, cfg.sourceView),
64+
[objectDef, cfg.sourceView],
65+
);
66+
67+
// The view list endpoint can serve hollow expansion items (no columns);
68+
// the full body lives behind the per-view overlay API — the same
69+
// hydration ObjectView performs. Only fetch when the resolution came up
70+
// hollow.
71+
const [hydratedView, setHydratedView] = React.useState<any>(null);
72+
React.useEffect(() => {
73+
let cancelled = false;
74+
setHydratedView(null);
75+
if (!objectDef || !cfg.source || resolvedView?.columns) return;
76+
const viewKey = resolvedView?.name
77+
?? (cfg.sourceView ? `${cfg.source}.${cfg.sourceView}` : undefined);
78+
if (!viewKey) return;
79+
(async () => {
80+
try {
81+
const ds: any = dataSource;
82+
let full: any = null;
83+
if (typeof ds?.listViewOverrides === 'function') {
84+
const all = await ds.listViewOverrides(cfg.source);
85+
full = all?.[viewKey] ?? null;
86+
}
87+
if (!full?.columns && typeof ds?.getView === 'function') {
88+
full = await ds.getView(cfg.source, viewKey);
89+
}
90+
if (!cancelled && full && typeof full === 'object') setHydratedView(full);
91+
} catch { /* hollow view stays hollow — renderer falls back to defaults */ }
92+
})();
93+
return () => { cancelled = true; };
94+
}, [objectDef, cfg.source, cfg.sourceView, resolvedView, dataSource]);
95+
96+
const viewDef = React.useMemo(
97+
() => (hydratedView ? { ...resolvedView, ...hydratedView } : resolvedView),
98+
[resolvedView, hydratedView],
99+
);
100+
101+
const schema = React.useMemo(() => {
102+
if (!objectDef) return undefined;
103+
const view = viewDef || {};
104+
const appearance = cfg.appearance ?? view.appearance;
105+
const allowed: string[] = appearance?.allowedVisualizations || [];
106+
const userActions = cfg.userActions || {};
107+
108+
// Inherited data semantics (the iron rule: all from the view) + the
109+
// page's own always-on criteria (`filterBy`).
110+
const filters = [
111+
...(Array.isArray(view.filter) ? view.filter : []),
112+
...(Array.isArray(cfg.filterBy) ? cfg.filterBy : []),
113+
];
114+
115+
return {
116+
type: 'list-view' as const,
117+
objectName: objectDef.name,
118+
viewType: (allowed[0] ?? view.type ?? 'grid'),
119+
fields: view.columns,
120+
...(filters.length ? { filters } : {}),
121+
...(view.sort?.length ? { sort: view.sort } : {}),
122+
grouping: view.grouping,
123+
rowColor: view.rowColor,
124+
pagination: view.pagination,
125+
searchableFields: view.searchableFields,
126+
emptyState: view.emptyState,
127+
kanban: view.kanban,
128+
calendar: view.calendar,
129+
gallery: view.gallery,
130+
timeline: view.timeline,
131+
gantt: view.gantt,
132+
133+
// Presentation policy — the page layer (ADR-0047).
134+
userFilters: cfg.userFilters ?? view.userFilters,
135+
appearance,
136+
showViewSwitcher: allowed.length > 1,
137+
showRecordCount: cfg.showRecordCount,
138+
139+
// userActions toggles → toolbar flags. Interface mode is closed by
140+
// default: the advanced filter builder and view-management tools are
141+
// only present when the author opted in.
142+
showSearch: userActions.search !== false,
143+
showSort: userActions.sort !== false,
144+
showFilters: userActions.filter === true,
145+
showDensity: userActions.rowHeight === true,
146+
showHideFields: false,
147+
showGroup: false,
148+
showColor: false,
149+
allowExport: false,
150+
inlineEdit: false,
151+
};
152+
}, [objectDef, viewDef, cfg]);
153+
154+
if (!objectDef || !schema) {
155+
return (
156+
<div className="h-full flex items-center justify-center p-8">
157+
<Empty>
158+
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
159+
<Database className="h-6 w-6 text-muted-foreground" />
160+
</div>
161+
<EmptyTitle>{t('empty.objectNotFound', { defaultValue: 'Source object not found' })}</EmptyTitle>
162+
<EmptyDescription>
163+
{t('empty.interfacePageSourceMissing', {
164+
defaultValue: 'This interface page references "{{name}}", which is not available.',
165+
name: cfg.source || '?',
166+
})}
167+
</EmptyDescription>
168+
</Empty>
169+
</div>
170+
);
171+
}
172+
173+
return (
174+
<div className={className ?? 'h-full flex flex-col'} data-testid="interface-list-page">
175+
<div className="px-4 pt-4 pb-2 shrink-0">
176+
<h1 className="text-lg font-semibold leading-tight">
177+
{typeof page.label === 'string' ? page.label : page.name}
178+
</h1>
179+
{typeof page.description === 'string' && page.description && (
180+
<p className="text-sm text-muted-foreground mt-0.5">{page.description}</p>
181+
)}
182+
</div>
183+
<div className="flex-1 min-h-0 overflow-auto">
184+
<ListView schema={schema} dataSource={dataSource} />
185+
</div>
186+
</div>
187+
);
188+
}

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

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,9 +1122,21 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
11221122
persistViewPatch(viewDef.id, viewDef, { columnState: state });
11231123
},
11241124
inlineEdit: viewDef.inlineEdit ?? viewDef.editRecordsInline ?? listSchema.inlineEdit,
1125-
appearance: viewDef.showDescription != null
1126-
? { showDescription: viewDef.showDescription }
1127-
: listSchema.appearance,
1125+
// ADR-0047 — spec `appearance` (incl. allowedVisualizations, the
1126+
// runtime visualization whitelist) flows from the view metadata;
1127+
// the legacy bare `showDescription` flag is folded in on top.
1128+
appearance: viewDef.appearance
1129+
? (viewDef.showDescription != null
1130+
? { ...viewDef.appearance, showDescription: viewDef.showDescription }
1131+
: viewDef.appearance)
1132+
: (viewDef.showDescription != null
1133+
? { showDescription: viewDef.showDescription }
1134+
: listSchema.appearance),
1135+
// Offer the visualization switcher only when the author
1136+
// whitelisted more than one type; ListView intersects the
1137+
// whitelist with capability-resolvable types.
1138+
showViewSwitcher:
1139+
((viewDef.appearance ?? listSchema.appearance)?.allowedVisualizations?.length ?? 0) > 1,
11281140
// Propagate toolbar/display flags for all view types
11291141
showSearch: viewDef.showSearch ?? listSchema.showSearch,
11301142
showSort: viewDef.showSort ?? listSchema.showSort,
@@ -1202,7 +1214,11 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
12021214
?? resolveManagedByEmptyState((objectDef as any)?.managedBy, t),
12031215
),
12041216
aria: viewDef.aria ?? listSchema.aria,
1205-
tabs: listSchema.tabs,
1217+
// ADR-0047 — per-view filter tabs from metadata (ViewTab presets)
1218+
// take precedence over schema-level tabs. Dropping viewDef.tabs
1219+
// here was the one-line gap that kept spec tab metadata from
1220+
// ever reaching the TabBar.
1221+
tabs: viewDef.tabs ?? listSchema.tabs,
12061222
// Propagate filter/sort as default filters/sort for data flow
12071223
...((() => {
12081224
const combined = [

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}

packages/data-objectstack/src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,8 +1379,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
13791379
try {
13801380
const cacheKey = `view:${objectName}:${viewId}`;
13811381
return await this.metadataCache.get(cacheKey, async () => {
1382-
// Try meta.getItem for view metadata
1383-
const result: any = await this.client.meta.getItem(objectName, viewId);
1382+
// Views are an independent metadata type (ADR-0017) — the first
1383+
// getItem argument is the metadata TYPE, not the object name.
1384+
// (Passing objectName here hit /meta/<object>/<view> and always 404ed.)
1385+
const result: any = await this.client.meta.getItem('view', viewId);
13841386
if (result && result.item) return result.item;
13851387
return result ?? null;
13861388
});

0 commit comments

Comments
 (0)