Skip to content

Commit 249395a

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat: ADR-0047 implementation (framework) — userFilters spec, reference diagnostics, showcase examples, authoring form (#1792)
* feat(spec): ADR-0047 phase 1 — UserFiltersSchema, visualization whitelist, view reference diagnostics - UserFilterFieldSchema/UserFiltersSchema in ui/view.zod.ts, attached as ListViewSchema.userFilters (blesses the shape objectui already renders) - 'chart' added to VisualizationTypeSchema (8-type parity with ListView.type); appearance.allowedVisualizations remains the runtime whitelist - InterfacePageConfig: userFilters now uses the real UserFiltersSchema (replaces the misplaced elements visualization enum, zero prior usage); new sourceView for the ADR-0047 iron rule (pages reference views) - computeViewReferenceDiagnostics in objectql: cross-document checks Zod cannot express (userFilters/tabs fields exist on source object, kanban groupBy is select-like), merged into _diagnostics on view getMetaItem Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(showcase): ADR-0047 phase 3 — userFilters/tabs/visualizations examples + skill decision rules - showcase_task default view gains filter tabs, userFilters dropdowns (status/priority/done) and an allowedVisualizations whitelist — the few-shot corpus for AI authors was previously empty - new Task Workbench interface page: references the default view (sourceView), locked grid, author-curated dropdowns, filter:false — the canonical interface-mode example; nav entry added next to the data-mode Tasks entry - objectstack-ui skill: two-run-modes decision table, default-to-data-mode rule, the iron rule, and the userFilters/tabs/appearance authoring block Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(spec): ADR-0047 phase 5 — End-user controls section in the view authoring form The Studio view inspector is spec-form-driven, so adding the section here renders the full authoring UI (userFilters fields/tabs repeaters with the element style selector, appearance.allowedVisualizations, userActions, addRecord, showRecordCount) with zero client code — verified in the browser against the showcase. Also registers TaskWorkbenchPage in the showcase stack config (the phase-3 nav entry referenced it). 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 1250841 commit 249395a

14 files changed

Lines changed: 520 additions & 15 deletions

File tree

examples/app-showcase/objectstack.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { ChartGalleryDashboard } from './src/dashboards/index.js';
1818
import { ShowcaseTaskDataset, ShowcaseProjectDataset } from './src/datasets/index.js';
1919
import { allReports } from './src/reports/index.js';
2020
import { allActions } from './src/actions/index.js';
21-
import { ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage } from './src/pages/index.js';
21+
import { ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage } from './src/pages/index.js';
2222
import { allFlows } from './src/flows/index.js';
2323
import { allWebhooks } from './src/webhooks/index.js';
2424
import { allJobs } from './src/jobs/index.js';
@@ -141,7 +141,7 @@ export default defineStack({
141141
apps: [ShowcaseApp],
142142
portals: allPortals,
143143
views: [TaskViews, ProjectViews],
144-
pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage],
144+
pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage],
145145
dashboards: [ChartGalleryDashboard],
146146
datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset],
147147
reports: allReports,

examples/app-showcase/src/apps/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export const ShowcaseApp = App.create({
5151
children: [
5252
{ id: 'nav_gallery', type: 'page', pageName: 'showcase_component_gallery', label: 'Component Gallery', icon: 'layout-template' },
5353
{ id: 'nav_project_workspace', type: 'page', pageName: 'showcase_project_workspace', label: 'New Project + Tasks', icon: 'folder-plus' },
54+
// ADR-0047 interface mode: same object as nav_tasks, curated surface.
55+
{ id: 'nav_task_workbench', type: 'page', pageName: 'showcase_task_workbench', label: 'Task Workbench', icon: 'sliders-horizontal' },
5456
],
5557
},
5658
],

examples/app-showcase/src/pages/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Page } from '@objectstack/spec/ui';
44

55
export { ProjectWorkspacePage } from './project-workspace.page.js';
66
export { ProjectDetailPage } from './project-detail.page.js';
7+
export { TaskWorkbenchPage } from './task-workbench.page.js';
78

89
/**
910
* Component Gallery — a custom page that places a spread of standard page
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { Page } from '@objectstack/spec/ui';
4+
5+
/**
6+
* Task Workbench — the canonical **interface page** example (ADR-0047).
7+
*
8+
* Demonstrates the second run mode for object UI: where the object nav
9+
* entry ("Tasks") shows every list view as switcher tabs and lets users
10+
* create their own views (data mode), this page is an author-curated
11+
* surface (interface mode):
12+
*
13+
* • it REFERENCES the object's default list view (`sourceView` —
14+
* columns/filter/sort are inherited, never restated here);
15+
* • end users get exactly the quick filters the author enabled
16+
* (status + priority dropdowns) — nothing else;
17+
* • the visualization is locked to grid (no switcher);
18+
* • view creation / advanced filtering are not offered.
19+
*
20+
* Mirrors Airtable's Interfaces right panel: Data (source), User filters
21+
* (Elements: dropdowns), Appearance (Visualizations), User actions.
22+
*/
23+
export const TaskWorkbenchPage: Page = {
24+
name: 'showcase_task_workbench',
25+
label: 'Task Workbench',
26+
type: 'list',
27+
object: 'showcase_task',
28+
kind: 'full',
29+
template: 'default',
30+
isDefault: false,
31+
// Interface pages carry no regions — the list surface is generated from
32+
// `interfaceConfig` (ADR-0047), not composed from components.
33+
regions: [],
34+
interfaceConfig: {
35+
source: 'showcase_task',
36+
37+
// ADR-0047 iron rule: the page inherits columns/filter/sort from the
38+
// referenced view and adds presentation policy only.
39+
sourceView: 'default',
40+
41+
// End-user quick filters — the only filtering surface on this page.
42+
userFilters: {
43+
element: 'dropdown',
44+
fields: [
45+
{ field: 'status' },
46+
{ field: 'priority', showCount: true },
47+
],
48+
},
49+
50+
// Locked visualization: a single-entry whitelist renders no switcher.
51+
appearance: {
52+
showDescription: true,
53+
allowedVisualizations: ['grid'],
54+
},
55+
56+
userActions: {
57+
sort: true,
58+
search: true,
59+
filter: false, // no advanced filter builder on a curated page
60+
rowHeight: false,
61+
addRecordForm: false,
62+
},
63+
64+
showRecordCount: true,
65+
},
66+
};

examples/app-showcase/src/views/task.view.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,35 @@ export const TaskViews = defineView({
2525
{ field: 'due_date' },
2626
{ field: 'progress' },
2727
],
28+
29+
// ADR-0047 — in-view filter tabs (ViewTab presets). Each tab applies
30+
// its own filter rules on top of the view's base criteria; the first
31+
// tab is the unfiltered default.
32+
tabs: [
33+
{ name: 'all_tasks', label: 'All', isDefault: true },
34+
{ name: 'in_progress', label: 'In Progress', filter: [{ field: 'status', operator: 'equals', value: 'in_progress' }] },
35+
{ name: 'urgent', label: 'Urgent', icon: 'flame', filter: [{ field: 'priority', operator: 'equals', value: 'urgent' }] },
36+
{ name: 'done', label: 'Done', filter: [{ field: 'status', operator: 'equals', value: 'done' }] },
37+
],
38+
39+
// ADR-0047 — end-user quick-filter dropdowns (Airtable "User filters").
40+
// Options/labels are inferred from the field definitions; `priority`
41+
// shows per-option record counts.
42+
userFilters: {
43+
element: 'dropdown',
44+
fields: [
45+
{ field: 'status' },
46+
{ field: 'priority', showCount: true },
47+
{ field: 'done', type: 'boolean' },
48+
],
49+
},
50+
51+
// ADR-0047 — runtime visualization whitelist (Airtable "Appearance →
52+
// Visualizations"). Users can flip between these renderers; types
53+
// whose bindings don't resolve are hidden by the client regardless.
54+
appearance: {
55+
allowedVisualizations: ['grid', 'kanban', 'gallery', 'calendar'],
56+
},
2857
},
2958

3059
listViews: {
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0047 — reference-integrity diagnostics for list views.
5+
*
6+
* `computeViewReferenceDiagnostics` covers what the per-type Zod schema
7+
* cannot: every field referenced by the user-facing filter surface must
8+
* exist on the source object, and binding-dependent visualizations must
9+
* have resolvable bindings.
10+
*/
11+
12+
import { describe, expect, it } from 'vitest';
13+
import { computeViewReferenceDiagnostics } from './metadata-diagnostics.js';
14+
15+
const objectDef = {
16+
fields: {
17+
name: { type: 'text' },
18+
industry: { type: 'select' },
19+
status: { type: 'select' },
20+
is_active: { type: 'boolean' },
21+
due_date: { type: 'date' },
22+
},
23+
};
24+
25+
describe('computeViewReferenceDiagnostics (ADR-0047)', () => {
26+
it('passes when every reference resolves', () => {
27+
const result = computeViewReferenceDiagnostics({
28+
userFilters: {
29+
element: 'dropdown',
30+
fields: [{ field: 'industry' }, { field: 'is_active' }],
31+
tabs: [{ name: 't', filter: [{ field: 'status', operator: 'equals', value: 'x' }] }],
32+
},
33+
tabs: [{ name: 'a', filter: [{ field: 'industry', operator: 'equals', value: 'technology' }] }],
34+
filterableFields: ['status'],
35+
kanban: { groupByField: 'status', columns: ['name'] },
36+
}, objectDef);
37+
expect(result.valid).toBe(true);
38+
});
39+
40+
it('flags userFilters fields missing on the object', () => {
41+
const result = computeViewReferenceDiagnostics({
42+
userFilters: { element: 'dropdown', fields: [{ field: 'no_such_field' }] },
43+
}, objectDef);
44+
expect(result.valid).toBe(false);
45+
expect(result.errors?.[0]).toMatchObject({
46+
path: 'userFilters.fields.0.field',
47+
code: 'reference_not_found',
48+
});
49+
});
50+
51+
it('flags tab filter rules pointing at unknown fields', () => {
52+
const result = computeViewReferenceDiagnostics({
53+
tabs: [{ name: 'bad', filter: [{ field: 'ghost', operator: 'equals', value: 1 }] }],
54+
}, objectDef);
55+
expect(result.valid).toBe(false);
56+
expect(result.errors?.[0].path).toBe('tabs.0.filter.0.field');
57+
});
58+
59+
it('flags kanban groupBy on a non-select-like field', () => {
60+
const result = computeViewReferenceDiagnostics({
61+
kanban: { groupByField: 'due_date', columns: ['name'] },
62+
}, objectDef);
63+
expect(result.valid).toBe(false);
64+
expect(result.errors?.[0]).toMatchObject({
65+
path: 'kanban.groupByField',
66+
code: 'invalid_binding',
67+
});
68+
});
69+
70+
it('supports array-shaped field definitions', () => {
71+
const result = computeViewReferenceDiagnostics({
72+
filterableFields: ['priority', 'missing'],
73+
}, { fields: [{ name: 'priority', type: 'select' }] });
74+
expect(result.valid).toBe(false);
75+
expect(result.errors).toHaveLength(1);
76+
expect(result.errors?.[0].path).toBe('filterableFields.1');
77+
});
78+
79+
it('is permissive when the view has no filter surface', () => {
80+
expect(computeViewReferenceDiagnostics({}, objectDef).valid).toBe(true);
81+
expect(computeViewReferenceDiagnostics({}, {}).valid).toBe(true);
82+
});
83+
});

packages/objectql/src/metadata-diagnostics.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,83 @@ export function decorateMetadataItems<T>(type: string, items: T[]): T[] {
114114
if (!Array.isArray(items)) return items;
115115
return items.map((item) => decorateMetadataItem(type, item));
116116
}
117+
118+
// ---------------------------------------------------------------------------
119+
// ADR-0047 — reference-integrity diagnostics for list views
120+
// ---------------------------------------------------------------------------
121+
122+
/** Minimal object-definition shape the reference checker needs. */
123+
interface ObjectDefLike {
124+
fields?: Record<string, { type?: string }> | Array<{ name: string; type?: string }>;
125+
}
126+
127+
function fieldMap(objectDef: ObjectDefLike): Map<string, { type?: string }> {
128+
const map = new Map<string, { type?: string }>();
129+
const fields = objectDef?.fields;
130+
if (Array.isArray(fields)) {
131+
for (const f of fields) if (f?.name) map.set(f.name, f);
132+
} else if (fields && typeof fields === 'object') {
133+
for (const [name, f] of Object.entries(fields)) map.set(name, f ?? {});
134+
}
135+
return map;
136+
}
137+
138+
/**
139+
* Cross-document reference checks Zod cannot express: every field a list
140+
* view's user-facing filter surface points at must exist on the source
141+
* object, and binding-dependent visualizations must have resolvable
142+
* bindings (kanban → select-like `groupByField`).
143+
*
144+
* Pure function — callers (read decoration, the ADR-0033 AI apply loop)
145+
* supply the already-resolved object definition. Returns `{ valid: true }`
146+
* when every reference resolves; errors use the same wire shape as
147+
* {@link computeMetadataDiagnostics} so consumers can merge the two.
148+
*
149+
* Spec-shape validation stays in `computeMetadataDiagnostics`; this only
150+
* covers what a schema alone cannot see.
151+
*/
152+
export function computeViewReferenceDiagnostics(
153+
view: Record<string, unknown>,
154+
objectDef: ObjectDefLike,
155+
): MetadataDiagnostics {
156+
const fields = fieldMap(objectDef);
157+
const errors: NonNullable<MetadataDiagnostics['errors']> = [];
158+
const requireField = (name: unknown, path: string) => {
159+
if (typeof name !== 'string' || !name) return;
160+
if (!fields.has(name)) {
161+
errors.push({
162+
path,
163+
message: `Field "${name}" does not exist on the source object`,
164+
code: 'reference_not_found',
165+
});
166+
}
167+
};
168+
169+
const userFilters = view?.userFilters as
170+
| { fields?: Array<{ field?: string }>; tabs?: Array<{ filter?: Array<{ field?: string }> }> }
171+
| undefined;
172+
userFilters?.fields?.forEach((f, i) => requireField(f?.field, `userFilters.fields.${i}.field`));
173+
userFilters?.tabs?.forEach((t, i) =>
174+
t?.filter?.forEach((r, j) => requireField(r?.field, `userFilters.tabs.${i}.filter.${j}.field`)));
175+
176+
(view?.tabs as Array<{ filter?: Array<{ field?: string }> }> | undefined)?.forEach((t, i) =>
177+
t?.filter?.forEach((r, j) => requireField(r?.field, `tabs.${i}.filter.${j}.field`)));
178+
179+
(view?.filterableFields as string[] | undefined)?.forEach((f, i) =>
180+
requireField(f, `filterableFields.${i}`));
181+
182+
const kanban = view?.kanban as { groupByField?: string } | undefined;
183+
if (kanban?.groupByField) {
184+
requireField(kanban.groupByField, 'kanban.groupByField');
185+
const def = fields.get(kanban.groupByField);
186+
if (def && def.type && !['select', 'multi-select', 'boolean', 'lookup', 'master_detail'].includes(def.type)) {
187+
errors.push({
188+
path: 'kanban.groupByField',
189+
message: `Field "${kanban.groupByField}" (type "${def.type}") cannot group a kanban — use a select-like field`,
190+
code: 'invalid_binding',
191+
});
192+
}
193+
}
194+
195+
return errors.length ? { valid: false, errors } : { valid: true };
196+
}

packages/objectql/src/protocol.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
import { z } from 'zod';
3333
import {
3434
computeMetadataDiagnostics,
35+
computeViewReferenceDiagnostics,
3536
decorateMetadataItem,
3637
decorateMetadataItems,
3738
type MetadataDiagnostics,
@@ -1568,10 +1569,44 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
15681569
// declaration; we must consult the in-memory artifact registry
15691570
// directly and let its protection envelope override.
15701571
const artifactItem = this.lookupArtifactItem(request.type, request.name);
1571-
const decorated = decorateMetadataItem(
1572+
let decorated = decorateMetadataItem(
15721573
request.type,
15731574
mergeArtifactProtection(item, artifactItem),
15741575
);
1576+
// ADR-0047 — list views additionally get reference-integrity
1577+
// diagnostics (userFilters/tabs fields must exist on the source
1578+
// object, kanban groupBy must be select-like). Zod cannot see
1579+
// across documents; merge the cross-document errors into the
1580+
// same `_diagnostics` envelope. Defensive: a failed lookup must
1581+
// never break a read.
1582+
if ((request.type === 'view' || request.type === 'views') && decorated && typeof decorated === 'object') {
1583+
try {
1584+
const viewDoc = decorated as Record<string, any>;
1585+
const sourceObject = viewDoc?.object
1586+
?? viewDoc?.data?.object
1587+
?? viewDoc?.objectName
1588+
?? viewDoc?.list?.data?.object;
1589+
const objectDef = typeof sourceObject === 'string'
1590+
? this.engine.registry.getObject(sourceObject)
1591+
: undefined;
1592+
if (objectDef) {
1593+
const refs = computeViewReferenceDiagnostics(viewDoc, objectDef as any);
1594+
if (!refs.valid) {
1595+
const prior = viewDoc._diagnostics;
1596+
decorated = {
1597+
...viewDoc,
1598+
_diagnostics: {
1599+
valid: false,
1600+
errors: [
1601+
...(prior && prior.valid === false && Array.isArray(prior.errors) ? prior.errors : []),
1602+
...(refs.errors ?? []),
1603+
],
1604+
},
1605+
} as typeof decorated;
1606+
}
1607+
}
1608+
} catch { /* reference diagnostics are best-effort */ }
1609+
}
15751610
// ADR-0010 — surface lock/provenance flags so Studio can render
15761611
// the correct affordances without a second round trip.
15771612
const artifactBacked = this.isArtifactBacked(request.type, request.name);

packages/spec/src/ui/page.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,7 +1017,7 @@ describe('InterfacePageConfigSchema', () => {
10171017
allowedVisualizations: ['grid', 'gallery', 'kanban'],
10181018
},
10191019
userFilters: {
1020-
elements: ['grid', 'gallery', 'kanban'],
1020+
element: 'tabs',
10211021
tabs: [
10221022
{ name: 'my_customers', label: 'my customers', isDefault: true },
10231023
{ name: 'all_records', label: 'All records' },
@@ -1085,7 +1085,7 @@ describe('PageSchema with interfaceConfig', () => {
10851085
allowedVisualizations: ['grid', 'gallery', 'kanban'],
10861086
},
10871087
userFilters: {
1088-
elements: ['grid', 'gallery', 'kanban'],
1088+
element: 'tabs',
10891089
tabs: [
10901090
{ name: 'my_customers', label: 'my customers', isDefault: true, pinned: true },
10911091
{ name: 'all_records', label: 'All records' },

0 commit comments

Comments
 (0)