diff --git a/packages/app-shell/src/views/InterfaceListPage.tsx b/packages/app-shell/src/views/InterfaceListPage.tsx new file mode 100644 index 000000000..b40ad0c95 --- /dev/null +++ b/packages/app-shell/src/views/InterfaceListPage.tsx @@ -0,0 +1,188 @@ +/** + * Interface List Page — ADR-0047 interface mode. + * + * Renders a page whose `interfaceConfig` binds a single source view into an + * author-curated list surface. Where ObjectView (data mode) shows ALL of an + * object's list views as switcher tabs and lets users create views, this + * surface is deliberately closed: + * + * • the page REFERENCES one view (`interfaceConfig.sourceView`) — columns, + * base filter and sort are inherited, never restated (the iron rule); + * • end users get exactly the `userFilters` the author enabled; + * • the visualization comes from `appearance.allowedVisualizations` + * (a single entry renders no switcher); + * • `userActions` toggles map onto the toolbar — advanced filtering and + * view management are absent by default. + */ + +import * as React from 'react'; +import { ListView } from '@object-ui/plugin-list'; +import { useAdapter } from '@object-ui/react'; +import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; +import { Database } from 'lucide-react'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { useMetadata } from '../providers/MetadataProvider'; + +interface InterfaceListPageProps { + page: any; + className?: string; +} + +/** + * Resolve the source list view from the merged object definition. + * Views merged from ADR-0017 ViewItems are keyed `.`; + * the page author writes the bare key (`sourceView: 'default'`). + */ +function resolveSourceView(objectDef: any, sourceView?: string): any | undefined { + const views: Record = objectDef?.listViews || objectDef?.list_views || {}; + // ADR-0017 expansion can serve a default-view item with an empty config + // while the full body lives on `objectDef.list` — prefer candidates that + // actually carry columns over hollow name matches. + const candidates = sourceView + ? [ + views[`${objectDef?.name}.${sourceView}`], + views[sourceView], + ...(sourceView === 'default' || sourceView === 'list' ? [objectDef?.list] : []), + ] + : [objectDef?.list, ...Object.values(views)]; + const present = candidates.filter(Boolean); + return present.find((v: any) => v?.columns) ?? present[0]; +} + +export function InterfaceListPage({ page, className }: InterfaceListPageProps) { + const { t } = useObjectTranslation(); + const { objects } = useMetadata(); + const dataSource = useAdapter(); + + const cfg = page?.interfaceConfig || {}; + const objectDef = React.useMemo( + () => (objects || []).find((o: any) => o.name === cfg.source), + [objects, cfg.source], + ); + const resolvedView = React.useMemo( + () => resolveSourceView(objectDef, cfg.sourceView), + [objectDef, cfg.sourceView], + ); + + // The view list endpoint can serve hollow expansion items (no columns); + // the full body lives behind the per-view overlay API — the same + // hydration ObjectView performs. Only fetch when the resolution came up + // hollow. + const [hydratedView, setHydratedView] = React.useState(null); + React.useEffect(() => { + let cancelled = false; + setHydratedView(null); + if (!objectDef || !cfg.source || resolvedView?.columns) return; + const viewKey = resolvedView?.name + ?? (cfg.sourceView ? `${cfg.source}.${cfg.sourceView}` : undefined); + if (!viewKey) return; + (async () => { + try { + const ds: any = dataSource; + let full: any = null; + if (typeof ds?.listViewOverrides === 'function') { + const all = await ds.listViewOverrides(cfg.source); + full = all?.[viewKey] ?? null; + } + if (!full?.columns && typeof ds?.getView === 'function') { + full = await ds.getView(cfg.source, viewKey); + } + if (!cancelled && full && typeof full === 'object') setHydratedView(full); + } catch { /* hollow view stays hollow — renderer falls back to defaults */ } + })(); + return () => { cancelled = true; }; + }, [objectDef, cfg.source, cfg.sourceView, resolvedView, dataSource]); + + const viewDef = React.useMemo( + () => (hydratedView ? { ...resolvedView, ...hydratedView } : resolvedView), + [resolvedView, hydratedView], + ); + + const schema = React.useMemo(() => { + if (!objectDef) return undefined; + const view = viewDef || {}; + const appearance = cfg.appearance ?? view.appearance; + const allowed: string[] = appearance?.allowedVisualizations || []; + const userActions = cfg.userActions || {}; + + // Inherited data semantics (the iron rule: all from the view) + the + // page's own always-on criteria (`filterBy`). + const filters = [ + ...(Array.isArray(view.filter) ? view.filter : []), + ...(Array.isArray(cfg.filterBy) ? cfg.filterBy : []), + ]; + + return { + type: 'list-view' as const, + objectName: objectDef.name, + viewType: (allowed[0] ?? view.type ?? 'grid'), + fields: view.columns, + ...(filters.length ? { filters } : {}), + ...(view.sort?.length ? { sort: view.sort } : {}), + grouping: view.grouping, + rowColor: view.rowColor, + pagination: view.pagination, + searchableFields: view.searchableFields, + emptyState: view.emptyState, + kanban: view.kanban, + calendar: view.calendar, + gallery: view.gallery, + timeline: view.timeline, + gantt: view.gantt, + + // Presentation policy — the page layer (ADR-0047). + userFilters: cfg.userFilters ?? view.userFilters, + appearance, + showViewSwitcher: allowed.length > 1, + showRecordCount: cfg.showRecordCount, + + // userActions toggles → toolbar flags. Interface mode is closed by + // default: the advanced filter builder and view-management tools are + // only present when the author opted in. + showSearch: userActions.search !== false, + showSort: userActions.sort !== false, + showFilters: userActions.filter === true, + showDensity: userActions.rowHeight === true, + showHideFields: false, + showGroup: false, + showColor: false, + allowExport: false, + inlineEdit: false, + }; + }, [objectDef, viewDef, cfg]); + + if (!objectDef || !schema) { + return ( +
+ +
+ +
+ {t('empty.objectNotFound', { defaultValue: 'Source object not found' })} + + {t('empty.interfacePageSourceMissing', { + defaultValue: 'This interface page references "{{name}}", which is not available.', + name: cfg.source || '?', + })} + +
+
+ ); + } + + return ( +
+
+

+ {typeof page.label === 'string' ? page.label : page.name} +

+ {typeof page.description === 'string' && page.description && ( +

{page.description}

+ )} +
+
+ +
+
+ ); +} diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index ae20c57d4..8603b919a 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -1122,9 +1122,21 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an persistViewPatch(viewDef.id, viewDef, { columnState: state }); }, inlineEdit: viewDef.inlineEdit ?? viewDef.editRecordsInline ?? listSchema.inlineEdit, - appearance: viewDef.showDescription != null - ? { showDescription: viewDef.showDescription } - : listSchema.appearance, + // ADR-0047 — spec `appearance` (incl. allowedVisualizations, the + // runtime visualization whitelist) flows from the view metadata; + // the legacy bare `showDescription` flag is folded in on top. + appearance: viewDef.appearance + ? (viewDef.showDescription != null + ? { ...viewDef.appearance, showDescription: viewDef.showDescription } + : viewDef.appearance) + : (viewDef.showDescription != null + ? { showDescription: viewDef.showDescription } + : listSchema.appearance), + // Offer the visualization switcher only when the author + // whitelisted more than one type; ListView intersects the + // whitelist with capability-resolvable types. + showViewSwitcher: + ((viewDef.appearance ?? listSchema.appearance)?.allowedVisualizations?.length ?? 0) > 1, // Propagate toolbar/display flags for all view types showSearch: viewDef.showSearch ?? listSchema.showSearch, showSort: viewDef.showSort ?? listSchema.showSort, @@ -1202,7 +1214,11 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an ?? resolveManagedByEmptyState((objectDef as any)?.managedBy, t), ), aria: viewDef.aria ?? listSchema.aria, - tabs: listSchema.tabs, + // ADR-0047 — per-view filter tabs from metadata (ViewTab presets) + // take precedence over schema-level tabs. Dropping viewDef.tabs + // here was the one-line gap that kept spec tab metadata from + // ever reaching the TabBar. + tabs: viewDef.tabs ?? listSchema.tabs, // Propagate filter/sort as default filters/sort for data flow ...((() => { const combined = [ diff --git a/packages/app-shell/src/views/PageView.tsx b/packages/app-shell/src/views/PageView.tsx index 373b4ee03..fe70722cd 100644 --- a/packages/app-shell/src/views/PageView.tsx +++ b/packages/app-shell/src/views/PageView.tsx @@ -19,6 +19,7 @@ import { useAuth } from '@object-ui/auth'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector'; import { useMetadata } from '../providers/MetadataProvider'; import { ConsoleActionRuntimeProvider } from '../hooks/useConsoleActionRuntime'; +import { InterfaceListPage } from './InterfaceListPage'; export function PageView() { const { t } = useObjectTranslation(); @@ -91,14 +92,20 @@ export function PageView() { {t('common.editInStudio', { defaultValue: 'Edit in studio' })} )} - + {(page as any).interfaceConfig?.source ? ( + // ADR-0047 interface mode: the page binds a source view into a + // curated list surface — rendered directly, not via regions. + + ) : ( + + )} implements DataSource { try { const cacheKey = `view:${objectName}:${viewId}`; return await this.metadataCache.get(cacheKey, async () => { - // Try meta.getItem for view metadata - const result: any = await this.client.meta.getItem(objectName, viewId); + // Views are an independent metadata type (ADR-0017) — the first + // getItem argument is the metadata TYPE, not the object name. + // (Passing objectName here hit /meta// and always 404ed.) + const result: any = await this.client.meta.getItem('view', viewId); if (result && result.item) return result.item; return result ?? null; }); diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 7a7280bb2..2d403569b 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -42,19 +42,21 @@ export interface ListViewProps { [key: string]: any; } -// Helper to convert FilterBuilder group to ObjectStack AST +// Helper to convert FilterBuilder group to ObjectStack AST. +// Accepts both the FilterBuilder vocabulary (camelCase) and the +// @objectstack/spec ViewFilterRule vocabulary (snake_case). function mapOperator(op: string) { switch (op) { case 'equals': case 'eq': return '='; - case 'notEquals': case 'ne': case 'neq': return '!='; + case 'notEquals': case 'not_equals': case 'ne': case 'neq': return '!='; case 'contains': return 'contains'; - case 'notContains': return 'notcontains'; + case 'notContains': case 'notcontains': return 'notcontains'; case 'greaterThan': case 'gt': return '>'; case 'greaterOrEqual': case 'gte': return '>='; case 'lessThan': case 'lt': return '<'; case 'lessOrEqual': case 'lte': return '<='; case 'in': return 'in'; - case 'notIn': return 'not in'; + case 'notIn': case 'not_in': case 'nin': return 'not in'; case 'before': return '<'; case 'after': return '>'; default: return op; @@ -316,9 +318,13 @@ export const ListView = React.forwardRef(({ onHiddenFieldsChange, onColumnStateChange, onRowClick, - showViewSwitcher = false, + showViewSwitcher: showViewSwitcherProp, ...props }, ref) => { + // The switcher can be enabled either by the host component (prop) or by + // the schema itself (ADR-0047 — ObjectView/InterfaceListPage stamp it on + // the schema when appearance.allowedVisualizations whitelists >1 type). + const showViewSwitcher = showViewSwitcherProp ?? (propSchema as any)?.showViewSwitcher ?? false; // i18n support for record count and other labels const { t } = useListViewTranslation(); const { fieldLabel: resolveFieldLabel, actionLabel: resolveActionLabel } = useListFieldLabel(); @@ -459,12 +465,20 @@ export const ListView = React.forwardRef(({ const handleTabChange = React.useCallback( (tab: ViewTab) => { setActiveTab(tab.name); - // Apply tab filter if defined + // Apply tab filter if defined. Two shapes are accepted: + // - @objectstack/spec ViewTab.filter: ViewFilterRule[] — an array of + // `{ field, operator, value }` rules (the canonical metadata shape) + // - legacy FilterGroup `{ logic, conditions }` if (tab.filter) { + const conditions = Array.isArray(tab.filter) + ? tab.filter + .filter((r: any) => r && typeof r.field === 'string') + .map((r: any) => ({ field: r.field, operator: r.operator ?? 'equals', value: r.value })) + : (tab.filter.conditions || []); const tabFilters: FilterGroup = { id: `tab-filter-${tab.name}`, - logic: tab.filter.logic || 'and', - conditions: tab.filter.conditions || [], + logic: (!Array.isArray(tab.filter) && tab.filter.logic) || 'and', + conditions, }; setCurrentFilters(tabFilters); onFilterChange?.(tabFilters); @@ -1040,52 +1054,58 @@ export const ListView = React.forwardRef(({ // Available view types based on schema configuration const availableViews = React.useMemo(() => { - // If appearance.allowedVisualizations is set, use it as whitelist - if (schema.appearance?.allowedVisualizations && schema.appearance.allowedVisualizations.length > 0) { - return schema.appearance.allowedVisualizations.filter((v: any) => - ['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'map'].includes(v) - ) as ViewType[]; - } + // Capability-resolvable types: a visualization is only offered when its + // required field bindings resolve (ADR-0047) — kanban needs a groupBy, + // calendar a start date, etc. `grid` always renders. + const resolvable: ViewType[] = ['grid']; - const views: ViewType[] = ['grid']; - // Check for Kanban capabilities (spec config takes precedence) - if (schema.kanban?.groupField || schema.options?.kanban?.groupField) { - views.push('kanban'); + if (schema.kanban?.groupByField || schema.kanban?.groupField || schema.options?.kanban?.groupField) { + resolvable.push('kanban'); } // Check for Gallery capabilities (spec config takes precedence) if (schema.gallery?.coverField || schema.gallery?.imageField || schema.options?.gallery?.imageField) { - views.push('gallery'); + resolvable.push('gallery'); } - + // Check for Calendar capabilities (spec config takes precedence) if (schema.calendar?.startDateField || schema.options?.calendar?.startDateField) { - views.push('calendar'); + resolvable.push('calendar'); } - + // Check for Timeline capabilities (spec config takes precedence) if (schema.timeline?.startDateField || schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField || schema.options?.calendar?.startDateField) { - views.push('timeline'); + resolvable.push('timeline'); } - + // Check for Gantt capabilities (spec config takes precedence) if (schema.gantt?.startDateField || schema.options?.gantt?.startDateField) { - views.push('gantt'); + resolvable.push('gantt'); } - + // Check for Map capabilities if (schema.options?.map?.locationField || (schema.options?.map?.latitudeField && schema.options?.map?.longitudeField)) { - views.push('map'); + resolvable.push('map'); } - - // Always allow switching back to the viewType defined in schema if it's one of the supported types - if (schema.viewType && !views.includes(schema.viewType as ViewType) && + + // Always allow switching back to the viewType defined in schema + if (schema.viewType && !resolvable.includes(schema.viewType as ViewType) && ['grid', 'kanban', 'calendar', 'timeline', 'gantt', 'map', 'gallery', 'chart'].includes(schema.viewType)) { - views.push(schema.viewType as ViewType); + resolvable.push(schema.viewType as ViewType); } - - return views; + + // appearance.allowedVisualizations is the author whitelist (ADR-0047): + // effective options = whitelist ∩ resolvable. Types whose bindings don't + // resolve are hidden even when whitelisted — a kanban without a groupBy + // field renders garbage, so it must not be offered. + const whitelist = schema.appearance?.allowedVisualizations; + if (Array.isArray(whitelist) && whitelist.length > 0) { + const filtered = whitelist.filter((v: any) => resolvable.includes(v)) as ViewType[]; + return filtered.length > 0 ? filtered : (['grid'] as ViewType[]); + } + + return resolvable; }, [schema.options, schema.viewType, schema.kanban, schema.calendar, schema.gantt, schema.gallery, schema.timeline, schema.appearance?.allowedVisualizations]); // Sync view from props diff --git a/packages/plugin-list/src/UserFilters.tsx b/packages/plugin-list/src/UserFilters.tsx index 3538ebc1c..642588ef2 100644 --- a/packages/plugin-list/src/UserFilters.tsx +++ b/packages/plugin-list/src/UserFilters.tsx @@ -61,6 +61,40 @@ export interface UserFiltersProps { className?: string; } +/** Map @objectstack/spec ViewFilterRule operators to ObjectQL AST operators. */ +function specOperatorToAst(op: string | undefined): string { + switch (op) { + case undefined: case 'equals': case 'eq': return '='; + case 'not_equals': case 'ne': case 'neq': return '!='; + case 'gte': return '>='; case 'lte': return '<='; + case 'gt': return '>'; case 'lt': return '<'; + case 'not_in': case 'nin': return 'not in'; + default: return op; + } +} + +/** + * Normalize tab presets to the client shape. Accepts both: + * - @objectstack/spec ViewTab: `{ name, label, filter: ViewFilterRule[], isDefault }` + * - legacy client shape: `{ id, label, filters: triplet[], default }` + */ +function normalizeTabPresets(tabs: any[]): Array<{ id: string; label: string; filters: any[]; default?: boolean }> { + return (tabs || []) + .filter((t: any) => t && (t.id || t.name)) + .map((t: any) => ({ + id: t.id ?? t.name, + label: typeof t.label === 'string' ? t.label : (t.label?.toString?.() ?? t.id ?? t.name), + filters: Array.isArray(t.filters) + ? t.filters + : (Array.isArray(t.filter) + ? t.filter + .filter((r: any) => r && typeof r.field === 'string') + .map((r: any) => [r.field, specOperatorToAst(r.operator), r.value]) + : []), + default: t.default ?? t.isDefault, + })); +} + /** * UserFilters — Airtable Interfaces-style filter bar. * @@ -92,7 +126,7 @@ export function UserFilters({ case 'tabs': return (