Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { ChartGalleryDashboard } from './src/dashboards/index.js';
import { ShowcaseTaskDataset, ShowcaseProjectDataset } from './src/datasets/index.js';
import { allReports } from './src/reports/index.js';
import { allActions } from './src/actions/index.js';
import { ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage } from './src/pages/index.js';
import { ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage } from './src/pages/index.js';
import { allFlows } from './src/flows/index.js';
import { allWebhooks } from './src/webhooks/index.js';
import { allJobs } from './src/jobs/index.js';
Expand Down Expand Up @@ -141,7 +141,7 @@ export default defineStack({
apps: [ShowcaseApp],
portals: allPortals,
views: [TaskViews, ProjectViews],
pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage],
pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage],
dashboards: [ChartGalleryDashboard],
datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset],
reports: allReports,
Expand Down
2 changes: 2 additions & 0 deletions examples/app-showcase/src/apps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export const ShowcaseApp = App.create({
children: [
{ id: 'nav_gallery', type: 'page', pageName: 'showcase_component_gallery', label: 'Component Gallery', icon: 'layout-template' },
{ id: 'nav_project_workspace', type: 'page', pageName: 'showcase_project_workspace', label: 'New Project + Tasks', icon: 'folder-plus' },
// ADR-0047 interface mode: same object as nav_tasks, curated surface.
{ id: 'nav_task_workbench', type: 'page', pageName: 'showcase_task_workbench', label: 'Task Workbench', icon: 'sliders-horizontal' },
],
},
],
Expand Down
1 change: 1 addition & 0 deletions examples/app-showcase/src/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Page } from '@objectstack/spec/ui';

export { ProjectWorkspacePage } from './project-workspace.page.js';
export { ProjectDetailPage } from './project-detail.page.js';
export { TaskWorkbenchPage } from './task-workbench.page.js';

/**
* Component Gallery — a custom page that places a spread of standard page
Expand Down
66 changes: 66 additions & 0 deletions examples/app-showcase/src/pages/task-workbench.page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Page } from '@objectstack/spec/ui';

/**
* Task Workbench — the canonical **interface page** example (ADR-0047).
*
* Demonstrates the second run mode for object UI: where the object nav
* entry ("Tasks") shows every list view as switcher tabs and lets users
* create their own views (data mode), this page is an author-curated
* surface (interface mode):
*
* • it REFERENCES the object's default list view (`sourceView` —
* columns/filter/sort are inherited, never restated here);
* • end users get exactly the quick filters the author enabled
* (status + priority dropdowns) — nothing else;
* • the visualization is locked to grid (no switcher);
* • view creation / advanced filtering are not offered.
*
* Mirrors Airtable's Interfaces right panel: Data (source), User filters
* (Elements: dropdowns), Appearance (Visualizations), User actions.
*/
export const TaskWorkbenchPage: Page = {
name: 'showcase_task_workbench',
label: 'Task Workbench',
type: 'list',
object: 'showcase_task',
kind: 'full',
template: 'default',
isDefault: false,
// Interface pages carry no regions — the list surface is generated from
// `interfaceConfig` (ADR-0047), not composed from components.
regions: [],
interfaceConfig: {
source: 'showcase_task',

// ADR-0047 iron rule: the page inherits columns/filter/sort from the
// referenced view and adds presentation policy only.
sourceView: 'default',

// End-user quick filters — the only filtering surface on this page.
userFilters: {
element: 'dropdown',
fields: [
{ field: 'status' },
{ field: 'priority', showCount: true },
],
},

// Locked visualization: a single-entry whitelist renders no switcher.
appearance: {
showDescription: true,
allowedVisualizations: ['grid'],
},

userActions: {
sort: true,
search: true,
filter: false, // no advanced filter builder on a curated page
rowHeight: false,
addRecordForm: false,
},

showRecordCount: true,
},
};
29 changes: 29 additions & 0 deletions examples/app-showcase/src/views/task.view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,35 @@ export const TaskViews = defineView({
{ field: 'due_date' },
{ field: 'progress' },
],

// ADR-0047 — in-view filter tabs (ViewTab presets). Each tab applies
// its own filter rules on top of the view's base criteria; the first
// tab is the unfiltered default.
tabs: [
{ name: 'all_tasks', label: 'All', isDefault: true },
{ name: 'in_progress', label: 'In Progress', filter: [{ field: 'status', operator: 'equals', value: 'in_progress' }] },
{ name: 'urgent', label: 'Urgent', icon: 'flame', filter: [{ field: 'priority', operator: 'equals', value: 'urgent' }] },
{ name: 'done', label: 'Done', filter: [{ field: 'status', operator: 'equals', value: 'done' }] },
],

// ADR-0047 — end-user quick-filter dropdowns (Airtable "User filters").
// Options/labels are inferred from the field definitions; `priority`
// shows per-option record counts.
userFilters: {
element: 'dropdown',
fields: [
{ field: 'status' },
{ field: 'priority', showCount: true },
{ field: 'done', type: 'boolean' },
],
},

// ADR-0047 — runtime visualization whitelist (Airtable "Appearance →
// Visualizations"). Users can flip between these renderers; types
// whose bindings don't resolve are hidden by the client regardless.
appearance: {
allowedVisualizations: ['grid', 'kanban', 'gallery', 'calendar'],
},
},

listViews: {
Expand Down
83 changes: 83 additions & 0 deletions packages/objectql/src/metadata-diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ADR-0047 — reference-integrity diagnostics for list views.
*
* `computeViewReferenceDiagnostics` covers what the per-type Zod schema
* cannot: every field referenced by the user-facing filter surface must
* exist on the source object, and binding-dependent visualizations must
* have resolvable bindings.
*/

import { describe, expect, it } from 'vitest';
import { computeViewReferenceDiagnostics } from './metadata-diagnostics.js';

const objectDef = {
fields: {
name: { type: 'text' },
industry: { type: 'select' },
status: { type: 'select' },
is_active: { type: 'boolean' },
due_date: { type: 'date' },
},
};

describe('computeViewReferenceDiagnostics (ADR-0047)', () => {
it('passes when every reference resolves', () => {
const result = computeViewReferenceDiagnostics({
userFilters: {
element: 'dropdown',
fields: [{ field: 'industry' }, { field: 'is_active' }],
tabs: [{ name: 't', filter: [{ field: 'status', operator: 'equals', value: 'x' }] }],
},
tabs: [{ name: 'a', filter: [{ field: 'industry', operator: 'equals', value: 'technology' }] }],
filterableFields: ['status'],
kanban: { groupByField: 'status', columns: ['name'] },
}, objectDef);
expect(result.valid).toBe(true);
});

it('flags userFilters fields missing on the object', () => {
const result = computeViewReferenceDiagnostics({
userFilters: { element: 'dropdown', fields: [{ field: 'no_such_field' }] },
}, objectDef);
expect(result.valid).toBe(false);
expect(result.errors?.[0]).toMatchObject({
path: 'userFilters.fields.0.field',
code: 'reference_not_found',
});
});

it('flags tab filter rules pointing at unknown fields', () => {
const result = computeViewReferenceDiagnostics({
tabs: [{ name: 'bad', filter: [{ field: 'ghost', operator: 'equals', value: 1 }] }],
}, objectDef);
expect(result.valid).toBe(false);
expect(result.errors?.[0].path).toBe('tabs.0.filter.0.field');
});

it('flags kanban groupBy on a non-select-like field', () => {
const result = computeViewReferenceDiagnostics({
kanban: { groupByField: 'due_date', columns: ['name'] },
}, objectDef);
expect(result.valid).toBe(false);
expect(result.errors?.[0]).toMatchObject({
path: 'kanban.groupByField',
code: 'invalid_binding',
});
});

it('supports array-shaped field definitions', () => {
const result = computeViewReferenceDiagnostics({
filterableFields: ['priority', 'missing'],
}, { fields: [{ name: 'priority', type: 'select' }] });
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors?.[0].path).toBe('filterableFields.1');
});

it('is permissive when the view has no filter surface', () => {
expect(computeViewReferenceDiagnostics({}, objectDef).valid).toBe(true);
expect(computeViewReferenceDiagnostics({}, {}).valid).toBe(true);
});
});
80 changes: 80 additions & 0 deletions packages/objectql/src/metadata-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,83 @@ export function decorateMetadataItems<T>(type: string, items: T[]): T[] {
if (!Array.isArray(items)) return items;
return items.map((item) => decorateMetadataItem(type, item));
}

// ---------------------------------------------------------------------------
// ADR-0047 — reference-integrity diagnostics for list views
// ---------------------------------------------------------------------------

/** Minimal object-definition shape the reference checker needs. */
interface ObjectDefLike {
fields?: Record<string, { type?: string }> | Array<{ name: string; type?: string }>;
}

function fieldMap(objectDef: ObjectDefLike): Map<string, { type?: string }> {
const map = new Map<string, { type?: string }>();
const fields = objectDef?.fields;
if (Array.isArray(fields)) {
for (const f of fields) if (f?.name) map.set(f.name, f);
} else if (fields && typeof fields === 'object') {
for (const [name, f] of Object.entries(fields)) map.set(name, f ?? {});
}
return map;
}

/**
* Cross-document reference checks Zod cannot express: every field a list
* view's user-facing filter surface points at must exist on the source
* object, and binding-dependent visualizations must have resolvable
* bindings (kanban → select-like `groupByField`).
*
* Pure function — callers (read decoration, the ADR-0033 AI apply loop)
* supply the already-resolved object definition. Returns `{ valid: true }`
* when every reference resolves; errors use the same wire shape as
* {@link computeMetadataDiagnostics} so consumers can merge the two.
*
* Spec-shape validation stays in `computeMetadataDiagnostics`; this only
* covers what a schema alone cannot see.
*/
export function computeViewReferenceDiagnostics(
view: Record<string, unknown>,
objectDef: ObjectDefLike,
): MetadataDiagnostics {
const fields = fieldMap(objectDef);
const errors: NonNullable<MetadataDiagnostics['errors']> = [];
const requireField = (name: unknown, path: string) => {
if (typeof name !== 'string' || !name) return;
if (!fields.has(name)) {
errors.push({
path,
message: `Field "${name}" does not exist on the source object`,
code: 'reference_not_found',
});
}
};

const userFilters = view?.userFilters as
| { fields?: Array<{ field?: string }>; tabs?: Array<{ filter?: Array<{ field?: string }> }> }
| undefined;
userFilters?.fields?.forEach((f, i) => requireField(f?.field, `userFilters.fields.${i}.field`));
userFilters?.tabs?.forEach((t, i) =>
t?.filter?.forEach((r, j) => requireField(r?.field, `userFilters.tabs.${i}.filter.${j}.field`)));

(view?.tabs as Array<{ filter?: Array<{ field?: string }> }> | undefined)?.forEach((t, i) =>
t?.filter?.forEach((r, j) => requireField(r?.field, `tabs.${i}.filter.${j}.field`)));

(view?.filterableFields as string[] | undefined)?.forEach((f, i) =>
requireField(f, `filterableFields.${i}`));

const kanban = view?.kanban as { groupByField?: string } | undefined;
if (kanban?.groupByField) {
requireField(kanban.groupByField, 'kanban.groupByField');
const def = fields.get(kanban.groupByField);
if (def && def.type && !['select', 'multi-select', 'boolean', 'lookup', 'master_detail'].includes(def.type)) {
errors.push({
path: 'kanban.groupByField',
message: `Field "${kanban.groupByField}" (type "${def.type}") cannot group a kanban — use a select-like field`,
code: 'invalid_binding',
});
}
}

return errors.length ? { valid: false, errors } : { valid: true };
}
37 changes: 36 additions & 1 deletion packages/objectql/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import { z } from 'zod';
import {
computeMetadataDiagnostics,
computeViewReferenceDiagnostics,
decorateMetadataItem,
decorateMetadataItems,
type MetadataDiagnostics,
Expand Down Expand Up @@ -1568,10 +1569,44 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
// declaration; we must consult the in-memory artifact registry
// directly and let its protection envelope override.
const artifactItem = this.lookupArtifactItem(request.type, request.name);
const decorated = decorateMetadataItem(
let decorated = decorateMetadataItem(
request.type,
mergeArtifactProtection(item, artifactItem),
);
// ADR-0047 — list views additionally get reference-integrity
// diagnostics (userFilters/tabs fields must exist on the source
// object, kanban groupBy must be select-like). Zod cannot see
// across documents; merge the cross-document errors into the
// same `_diagnostics` envelope. Defensive: a failed lookup must
// never break a read.
if ((request.type === 'view' || request.type === 'views') && decorated && typeof decorated === 'object') {
try {
const viewDoc = decorated as Record<string, any>;
const sourceObject = viewDoc?.object
?? viewDoc?.data?.object
?? viewDoc?.objectName
?? viewDoc?.list?.data?.object;
const objectDef = typeof sourceObject === 'string'
? this.engine.registry.getObject(sourceObject)
: undefined;
if (objectDef) {
const refs = computeViewReferenceDiagnostics(viewDoc, objectDef as any);
if (!refs.valid) {
const prior = viewDoc._diagnostics;
decorated = {
...viewDoc,
_diagnostics: {
valid: false,
errors: [
...(prior && prior.valid === false && Array.isArray(prior.errors) ? prior.errors : []),
...(refs.errors ?? []),
],
},
} as typeof decorated;
}
}
} catch { /* reference diagnostics are best-effort */ }
}
// ADR-0010 — surface lock/provenance flags so Studio can render
// the correct affordances without a second round trip.
const artifactBacked = this.isArtifactBacked(request.type, request.name);
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/src/ui/page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1017,7 +1017,7 @@ describe('InterfacePageConfigSchema', () => {
allowedVisualizations: ['grid', 'gallery', 'kanban'],
},
userFilters: {
elements: ['grid', 'gallery', 'kanban'],
element: 'tabs',
tabs: [
{ name: 'my_customers', label: 'my customers', isDefault: true },
{ name: 'all_records', label: 'All records' },
Expand Down Expand Up @@ -1085,7 +1085,7 @@ describe('PageSchema with interfaceConfig', () => {
allowedVisualizations: ['grid', 'gallery', 'kanban'],
},
userFilters: {
elements: ['grid', 'gallery', 'kanban'],
element: 'tabs',
tabs: [
{ name: 'my_customers', label: 'my customers', isDefault: true, pinned: true },
{ name: 'all_records', label: 'All records' },
Expand Down
Loading