Skip to content

Commit b3a87eb

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(app-shell): ADR-0047 — usable interface-page config panel (#1704)
* feat(app-shell): ADR-0047 — usable interface-page config panel Three protocol-driven fixes so the interface/list page inspector is usable (Airtable-parity), keeping the panel schema-driven: • SchemaForm: honour FormSectionSpec.collapsible/collapsed — render collapsible sections in a Collapsible with a chevron (defaultOpen = !collapsed). The spec already declares these flags; the renderer ignored them, so everything rendered flat/expanded. • Widget: array-of-enum → a real multi-select (MultiSelectWidget) of toggleable chips instead of the free-text tag fallback. Drives appearance.allowedVisualizations (pick, don't type). • InterfaceListPage: derive a default viz binding from the object when a viz is whitelisted but unbound (kanban groupField from the first select/status field, calendar from a date field, gallery from an image field) — like defaultColumnsFromObject. Fixes 'added kanban but the switcher only shows Grid': ListView only offers a viz when its binding resolves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(app-shell): ADR-0047 interface-panel — multiselect, collapsible sections, default viz bindings • MultiSelectWidget: chips from enum, ordered toggle, clear→undefined, readOnly • SchemaForm: collapsed section hides body until expanded; collapsed:false opens; object/CEL visibleOn hides a section when false • InterfaceListPage default viz bindings (exported): kanban groupField from first select/status field, calendar date field, gallery cover; skips hidden/system fields Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a02f068 commit b3a87eb

6 files changed

Lines changed: 418 additions & 19 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
defaultKanbanFromObject,
6+
defaultCalendarFromObject,
7+
defaultGalleryFromObject,
8+
} from './InterfaceListPage';
9+
10+
/**
11+
* ADR-0047: when a page whitelists a visualization (appearance.allowedVisualizations)
12+
* but the source view carries no binding for it, InterfaceListPage derives a
13+
* sensible default from the object's fields — so the switcher actually offers
14+
* and renders the viz (Airtable auto-picks a stack field on switch). Without
15+
* this, ListView.availableViews silently drops the whitelisted viz.
16+
*/
17+
const taskObject = {
18+
fields: {
19+
id: { type: 'autonumber' },
20+
title: { type: 'text' },
21+
status: { type: 'select' },
22+
due_date: { type: 'date' },
23+
cover: { type: 'image' },
24+
created_at: { type: 'datetime' },
25+
},
26+
};
27+
28+
describe('InterfaceListPage default viz bindings', () => {
29+
it('kanban: picks the first select field as the group field (both aliases)', () => {
30+
expect(defaultKanbanFromObject(taskObject)).toEqual({ groupField: 'status', groupByField: 'status' });
31+
});
32+
33+
it('kanban: falls back to a status-like field name when no select type exists', () => {
34+
const obj = { fields: { title: { type: 'text' }, stage: { type: 'text' } } };
35+
expect(defaultKanbanFromObject(obj)).toEqual({ groupField: 'stage', groupByField: 'stage' });
36+
});
37+
38+
it('kanban: undefined when nothing groupable', () => {
39+
expect(defaultKanbanFromObject({ fields: { title: { type: 'text' }, note: { type: 'text' } } })).toBeUndefined();
40+
});
41+
42+
it('calendar: picks the first date field (skipping system audit columns)', () => {
43+
expect(defaultCalendarFromObject(taskObject)).toEqual({ startDateField: 'due_date' });
44+
});
45+
46+
it('gallery: picks the first image field', () => {
47+
expect(defaultGalleryFromObject(taskObject)).toEqual({ coverField: 'cover' });
48+
});
49+
50+
it('ignores hidden and system fields', () => {
51+
const obj = { fields: { created_at: { type: 'datetime' }, hidden_sel: { type: 'select', hidden: true }, real_sel: { type: 'select' } } };
52+
expect(defaultKanbanFromObject(obj)).toEqual({ groupField: 'real_sel', groupByField: 'real_sel' });
53+
});
54+
});

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

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,59 @@ function defaultColumnsFromObject(objectDef: any): string[] {
8282
return [];
8383
}
8484

85+
/**
86+
* Default visualization bindings derived from the object's fields.
87+
*
88+
* ADR-0047: an interface page sets `appearance.allowedVisualizations` to
89+
* whitelist renderers, but a viz only renders when its field binding
90+
* resolves (kanban needs a group field, calendar a date, gallery a cover).
91+
* The page config has nowhere to set those, so — like `defaultColumnsFromObject`
92+
* — we auto-pick a sensible binding from the object (Airtable does the same
93+
* when you switch to Kanban). Without this, a whitelisted kanban is silently
94+
* dropped from the switcher and the author gets no feedback.
95+
*/
96+
function firstFieldMatching(
97+
objectDef: any,
98+
pred: (name: string, f: any) => boolean,
99+
): string | undefined {
100+
const fields = objectDef?.fields;
101+
if (!fields || typeof fields !== 'object') return undefined;
102+
const hit = Object.entries(fields).find(
103+
([name, f]: [string, any]) => f && !f.hidden && !SYSTEM_FIELDS.has(name) && pred(name, f),
104+
);
105+
return hit?.[0];
106+
}
107+
108+
const SELECT_TYPES = new Set(['select', 'multiselect', 'radio', 'enum', 'boolean']);
109+
const DATE_TYPES = new Set(['date', 'datetime', 'time']);
110+
const IMAGE_TYPES = new Set(['image', 'file', 'attachment', 'avatar', 'photo']);
111+
112+
export function defaultKanbanFromObject(objectDef: any): { groupField: string; groupByField: string } | undefined {
113+
const field =
114+
firstFieldMatching(objectDef, (_n, f) => SELECT_TYPES.has(f.type)) ??
115+
firstFieldMatching(objectDef, (n) => /status|stage|state|priority|category|kind/i.test(n));
116+
// ListView reads `groupField` to render and `groupByField || groupField`
117+
// to decide the viz is available — set both so it resolves either way.
118+
return field ? { groupField: field, groupByField: field } : undefined;
119+
}
120+
121+
function defaultDateField(objectDef: any): string | undefined {
122+
return (
123+
firstFieldMatching(objectDef, (_n, f) => DATE_TYPES.has(f.type)) ??
124+
firstFieldMatching(objectDef, (n) => /date|due|start|end|deadline|schedule/i.test(n))
125+
);
126+
}
127+
128+
export function defaultCalendarFromObject(objectDef: any): { startDateField: string } | undefined {
129+
const field = defaultDateField(objectDef);
130+
return field ? { startDateField: field } : undefined;
131+
}
132+
133+
export function defaultGalleryFromObject(objectDef: any): { coverField: string } | undefined {
134+
const field = firstFieldMatching(objectDef, (_n, f) => IMAGE_TYPES.has(f.type));
135+
return field ? { coverField: field } : undefined;
136+
}
137+
85138
export function InterfaceListPage({ page, className }: InterfaceListPageProps) {
86139
const { t } = useObjectTranslation();
87140
const { objects } = useMetadata();
@@ -163,8 +216,22 @@ export function InterfaceListPage({ page, className }: InterfaceListPageProps) {
163216
const view = viewDef || {};
164217
const appearance = cfg.appearance ?? view.appearance;
165218
const allowed: string[] = appearance?.allowedVisualizations || [];
219+
const allowedSet = new Set(allowed);
166220
const userActions = cfg.userActions || {};
167221

222+
// Viz field bindings: the referenced view's config wins; otherwise, when
223+
// the author whitelisted a viz, derive a sensible default binding from the
224+
// object so the switcher actually offers (and renders) it. Only derive for
225+
// whitelisted types — an un-whitelisted viz is never reachable.
226+
const kanban =
227+
view.kanban ?? (allowedSet.has('kanban') ? defaultKanbanFromObject(objectDef) : undefined);
228+
const calendar =
229+
view.calendar ?? (allowedSet.has('calendar') ? defaultCalendarFromObject(objectDef) : undefined);
230+
const timeline =
231+
view.timeline ?? (allowedSet.has('timeline') ? defaultCalendarFromObject(objectDef) : undefined);
232+
const gallery =
233+
view.gallery ?? (allowedSet.has('gallery') ? defaultGalleryFromObject(objectDef) : undefined);
234+
168235
// Inherited data semantics (the iron rule: all from the view) + the
169236
// page's own always-on criteria (`filterBy`).
170237
const filters = [
@@ -190,10 +257,10 @@ export function InterfaceListPage({ page, className }: InterfaceListPageProps) {
190257
pagination: view.pagination,
191258
searchableFields: view.searchableFields,
192259
emptyState: view.emptyState,
193-
kanban: view.kanban,
194-
calendar: view.calendar,
195-
gallery: view.gallery,
196-
timeline: view.timeline,
260+
kanban,
261+
calendar,
262+
gallery,
263+
timeline,
197264
gantt: view.gantt,
198265

199266
// Presentation policy — the page layer (ADR-0047).
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi, afterEach } from 'vitest';
4+
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
5+
import { WIDGETS } from './widgets';
6+
7+
afterEach(cleanup);
8+
9+
const MultiSelect = WIDGETS['multiselect'];
10+
11+
// `appearance.allowedVisualizations` serialises (Zod z.enum) as
12+
// { type: 'array', items: { type: 'string', enum: [...] } }.
13+
const vizSchema = {
14+
type: 'array',
15+
items: { type: 'string', enum: ['grid', 'kanban', 'calendar', 'gallery'] },
16+
};
17+
18+
/**
19+
* ADR-0047 multi-select widget — picks from a fixed option set (array of
20+
* enum) instead of the free-text tag fallback the generic array renderer
21+
* used. Author picks the real allowed values; can't mistype them.
22+
*/
23+
describe('multiselect widget', () => {
24+
it('renders a chip per enum value with humanised labels', () => {
25+
render(<MultiSelect value={[]} onChange={() => {}} schema={vizSchema} />);
26+
expect(screen.getByRole('checkbox', { name: 'Grid' })).toBeInTheDocument();
27+
expect(screen.getByRole('checkbox', { name: 'Kanban' })).toBeInTheDocument();
28+
expect(screen.getByRole('checkbox', { name: 'Calendar' })).toBeInTheDocument();
29+
expect(screen.getByRole('checkbox', { name: 'Gallery' })).toBeInTheDocument();
30+
});
31+
32+
it('reflects the selected subset via aria-checked', () => {
33+
render(<MultiSelect value={['grid', 'kanban']} onChange={() => {}} schema={vizSchema} />);
34+
expect(screen.getByRole('checkbox', { name: 'Grid' })).toHaveAttribute('aria-checked', 'true');
35+
expect(screen.getByRole('checkbox', { name: 'Kanban' })).toHaveAttribute('aria-checked', 'true');
36+
expect(screen.getByRole('checkbox', { name: 'Calendar' })).toHaveAttribute('aria-checked', 'false');
37+
});
38+
39+
it('toggling an option adds it, preserving enum declaration order', () => {
40+
const onChange = vi.fn();
41+
render(<MultiSelect value={['kanban']} onChange={onChange} schema={vizSchema} />);
42+
fireEvent.click(screen.getByRole('checkbox', { name: 'Grid' }));
43+
// grid precedes kanban in the enum → ordered output, not insertion order.
44+
expect(onChange).toHaveBeenCalledWith(['grid', 'kanban']);
45+
});
46+
47+
it('toggling a selected option removes it', () => {
48+
const onChange = vi.fn();
49+
render(<MultiSelect value={['grid', 'kanban']} onChange={onChange} schema={vizSchema} />);
50+
fireEvent.click(screen.getByRole('checkbox', { name: 'Grid' }));
51+
expect(onChange).toHaveBeenCalledWith(['kanban']);
52+
});
53+
54+
it('clearing the last option emits undefined (omit, not empty array)', () => {
55+
const onChange = vi.fn();
56+
render(<MultiSelect value={['grid']} onChange={onChange} schema={vizSchema} />);
57+
fireEvent.click(screen.getByRole('checkbox', { name: 'Grid' }));
58+
expect(onChange).toHaveBeenCalledWith(undefined);
59+
});
60+
61+
it('is read-only when readOnly — clicks do not emit', () => {
62+
const onChange = vi.fn();
63+
render(<MultiSelect value={['grid']} onChange={onChange} schema={vizSchema} readOnly />);
64+
fireEvent.click(screen.getByRole('checkbox', { name: 'Kanban' }));
65+
expect(onChange).not.toHaveBeenCalled();
66+
});
67+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, afterEach } from 'vitest';
4+
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
5+
import { SchemaForm } from './SchemaForm';
6+
7+
afterEach(cleanup);
8+
9+
const schema = {
10+
type: 'object',
11+
properties: {
12+
openField: { type: 'string', title: 'Open Field' },
13+
advancedField: { type: 'string', title: 'Advanced Field' },
14+
},
15+
};
16+
17+
/**
18+
* ADR-0047: the spec form already declares which sections are collapsible
19+
* and which start collapsed (Advanced, type-specific options). The renderer
20+
* must honour those flags so the panel opens lean — previously they were
21+
* ignored and every section rendered flat/expanded.
22+
*/
23+
describe('SchemaForm — collapsible sections', () => {
24+
const form = {
25+
type: 'simple' as const,
26+
sections: [
27+
{ label: 'Basics', fields: [{ field: 'openField' }] },
28+
{ label: 'Advanced', collapsible: true, collapsed: true, fields: [{ field: 'advancedField' }] },
29+
],
30+
};
31+
32+
it('a collapsed section hides its body until the header is clicked', () => {
33+
render(<SchemaForm schema={schema} form={form} value={{ openField: '', advancedField: '' }} onChange={() => {}} />);
34+
// Non-collapsible section's field is always present.
35+
expect(screen.getByText('Open Field')).toBeInTheDocument();
36+
// Collapsed section: header present, body (its field) not yet mounted.
37+
expect(screen.getByText('Advanced')).toBeInTheDocument();
38+
expect(screen.queryByText('Advanced Field')).not.toBeInTheDocument();
39+
// Expand it.
40+
fireEvent.click(screen.getByText('Advanced'));
41+
expect(screen.getByText('Advanced Field')).toBeInTheDocument();
42+
});
43+
44+
it('a collapsible section with collapsed:false renders open', () => {
45+
const openForm = {
46+
type: 'simple' as const,
47+
sections: [
48+
{ label: 'Interface', collapsible: true, collapsed: false, fields: [{ field: 'openField' }] },
49+
],
50+
};
51+
render(<SchemaForm schema={schema} form={openForm} value={{ openField: '' }} onChange={() => {}} />);
52+
expect(screen.getByText('Open Field')).toBeInTheDocument();
53+
});
54+
55+
it('hides a section whose visibleOn predicate is false (object/CEL form)', () => {
56+
const condForm = {
57+
type: 'simple' as const,
58+
sections: [
59+
{ label: 'Basics', fields: [{ field: 'openField' }] },
60+
{
61+
label: 'Layout',
62+
visibleOn: { dialect: 'cel', source: "data.type != 'list'" },
63+
fields: [{ field: 'advancedField' }],
64+
},
65+
],
66+
};
67+
render(<SchemaForm schema={{ ...schema, properties: { ...schema.properties, type: { type: 'string' } } }} form={condForm} value={{ type: 'list', openField: '', advancedField: '' }} onChange={() => {}} />);
68+
// type == 'list' → "data.type != 'list'" is false → Layout hidden.
69+
expect(screen.queryByText('Layout')).not.toBeInTheDocument();
70+
expect(screen.queryByText('Advanced Field')).not.toBeInTheDocument();
71+
expect(screen.getByText('Open Field')).toBeInTheDocument();
72+
});
73+
});

packages/app-shell/src/views/metadata-admin/SchemaForm.tsx

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ import {
4949
TabsTrigger,
5050
TabsContent,
5151
} from '@object-ui/components';
52+
import {
53+
Collapsible,
54+
CollapsibleTrigger,
55+
CollapsibleContent,
56+
} from '@object-ui/components';
5257
import { evaluatePredicate } from './predicate';
5358
import { WIDGETS, type WidgetContext } from './widgets';
5459
import { detectLocale, t, tFormat, translateValidationMessage } from './i18n';
@@ -199,6 +204,11 @@ function inferWidget(
199204
if (schema) {
200205
const type = schema.type;
201206

207+
// Array of enum → multi-select of the allowed values (picker, not free
208+
// text). Checked before the generic string-array case because a Zod
209+
// `z.enum` serialises as `items: { type: 'string', enum: [...] }`.
210+
if (type === 'array' && Array.isArray(schema.items?.enum)) return 'multiselect';
211+
202212
// Array of strings → string-tags
203213
if (type === 'array' && schema.items?.type === 'string') return 'string-tags';
204214

@@ -541,21 +551,7 @@ function SectionedSchemaForm({
541551
});
542552
if (fields.length === 0) return null;
543553
const cols = s.columns ?? 1;
544-
return (
545-
<section
546-
key={idx}
547-
className="space-y-3 rounded-md border border-border/40 bg-card/30 p-4"
548-
>
549-
{s.label && (
550-
<header>
551-
<h3 className="text-sm font-semibold text-foreground/90">
552-
{s.label}
553-
</h3>
554-
{s.description && (
555-
<p className="text-xs text-muted-foreground">{s.description}</p>
556-
)}
557-
</header>
558-
)}
554+
const fieldsGrid = (
559555
<div
560556
className="grid gap-4"
561557
style={{
@@ -601,6 +597,56 @@ function SectionedSchemaForm({
601597
);
602598
})}
603599
</div>
600+
);
601+
602+
// Collapsible section (FormSectionSpec.collapsible) — the spec marks
603+
// rarely-used groups (Advanced, type-specific options) collapsible and
604+
// often `collapsed: true`. Honour both so the panel opens lean and the
605+
// author expands only what they need. Non-collapsible sections render
606+
// as a plain bordered block (unchanged).
607+
if (s.collapsible && s.label) {
608+
return (
609+
<Collapsible
610+
key={idx}
611+
defaultOpen={!s.collapsed}
612+
className="rounded-md border border-border/40 bg-card/30"
613+
>
614+
<CollapsibleTrigger className="group flex w-full items-center justify-between gap-2 p-4 text-left">
615+
<span>
616+
<span className="block text-sm font-semibold text-foreground/90">
617+
{s.label}
618+
</span>
619+
{s.description && (
620+
<span className="mt-0.5 block text-xs text-muted-foreground">
621+
{s.description}
622+
</span>
623+
)}
624+
</span>
625+
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-data-[state=closed]:-rotate-90" />
626+
</CollapsibleTrigger>
627+
<CollapsibleContent className="space-y-3 px-4 pb-4">
628+
{fieldsGrid}
629+
</CollapsibleContent>
630+
</Collapsible>
631+
);
632+
}
633+
634+
return (
635+
<section
636+
key={idx}
637+
className="space-y-3 rounded-md border border-border/40 bg-card/30 p-4"
638+
>
639+
{s.label && (
640+
<header>
641+
<h3 className="text-sm font-semibold text-foreground/90">
642+
{s.label}
643+
</h3>
644+
{s.description && (
645+
<p className="text-xs text-muted-foreground">{s.description}</p>
646+
)}
647+
</header>
648+
)}
649+
{fieldsGrid}
604650
</section>
605651
);
606652
};

0 commit comments

Comments
 (0)