Skip to content

Commit 8099d4f

Browse files
authored
Merge branch 'main' into copilot/implement-app-creation-flow
2 parents 5d349ee + 18d479d commit 8099d4f

File tree

12 files changed

+3511
-1698
lines changed

12 files changed

+3511
-1698
lines changed

ROADMAP.md

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ObjectUI Development Roadmap
22

3-
> **Last Updated:** February 22, 2026
3+
> **Last Updated:** February 23, 2026
44
> **Current Version:** v0.5.x
55
> **Spec Version:** @objectstack/spec v3.0.9
66
> **Client Version:** @objectstack/client v3.0.9
@@ -343,6 +343,40 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
343343
- [x] Add `DashboardConfig` types to `@object-ui/types`
344344
- [x] Add Zod schema validation for `DashboardConfig`
345345

346+
### P1.11 Console — Schema-Driven View Config Panel Migration
347+
348+
> Migrated the Console ViewConfigPanel from imperative implementation (~1655 lines) to Schema-Driven architecture using `ConfigPanelRenderer` + `useConfigDraft` + `ConfigPanelSchema`, reducing to ~170 lines declarative wrapper + schema factory.
349+
350+
**Phase 1 — Infrastructure & Utils Extraction:**
351+
- [x] Extract operator mapping (`SPEC_TO_BUILDER_OP`, `BUILDER_TO_SPEC_OP`), `normalizeFieldType`, `parseSpecFilter`, `toSpecFilter` to shared `view-config-utils.ts`
352+
- [x] Extract `parseCommaSeparated`, `parseNumberList`, `VIEW_TYPE_LABELS`, `ROW_HEIGHT_OPTIONS` to shared utils
353+
- [x] Add `deriveFieldOptions`, `toFilterGroup`, `toSortItems` bridge helpers
354+
- [x] Enhance `ConfigPanelRenderer` with accessibility props (`panelRef`, `role`, `ariaLabel`, `tabIndex`)
355+
- [x] Enhance `ConfigPanelRenderer` with test ID override props (`testId`, `closeTitle`, `footerTestId`, `saveTestId`, `discardTestId`)
356+
357+
**Phase 2 — Schema Factory (All Sections):**
358+
- [x] Page Config section: label, description, viewType, toolbar toggles (7 switches), navigation mode/width/openNewTab, selection, addRecord sub-editor, export + sub-config, showRecordCount, allowPrinting
359+
- [x] Data section: source, sortBy (expandable), groupBy, prefixField, columns selector (expandable w/ reorder), filterBy (expandable), pagination, searchable/filterable/hidden fields (expandable), quickFilters (expandable), virtualScroll, type-specific options (kanban/calendar/map/gallery/timeline/gantt)
360+
- [x] Appearance section: color, fieldTextColor, rowHeight (icon group), wrapHeaders, showDescription, collapseAllByDefault, striped, bordered, resizable, densityMode, conditionalFormatting (expandable), emptyState (title/message/icon)
361+
- [x] User Actions section: inlineEdit, addDeleteRecordsInline, rowActions (expandable), bulkActions (expandable)
362+
- [x] Sharing section: sharingEnabled, sharingVisibility (visibleWhen: sharing.enabled)
363+
- [x] Accessibility section: ariaLabel, ariaDescribedBy, ariaLive
364+
- [x] `ExpandableWidget` component for hook-safe expandable sub-sections within custom render functions
365+
366+
**Phase 3 — ViewConfigPanel Wrapper:**
367+
- [x] Rewrite ViewConfigPanel as thin wrapper (~170 lines) using `useConfigDraft` + `buildViewConfigSchema` + `ConfigPanelRenderer`
368+
- [x] Stabilize source reference with `useMemo` keyed to `activeView.id` (prevents draft reset on parent re-renders)
369+
- [x] Create/edit mode support preserved (onCreate/onSave, discard behavior)
370+
- [x] All spec format bridging preserved (filter/sort conversion)
371+
372+
**Phase 4 — Testing & Validation:**
373+
- [x] All 122 existing ViewConfigPanel tests pass (test mock updated for ConfigPanelRenderer + useConfigDraft)
374+
- [x] All 23 ObjectView integration tests pass (test ID and title props forwarded)
375+
- [x] 53 new schema-driven tests (utils + schema factory coverage)
376+
- [x] Full affected test suite: 2457 tests across 81 files, all pass
377+
378+
**Code Reduction:** ~1655 lines imperative → ~170 lines declarative wrapper + ~1100 lines schema factory + ~180 lines shared utils = **>50% net reduction in component code** with significantly improved maintainability
379+
346380
### P1.9 Console — Content Area Layout & Responsiveness
347381

348382
- [x] Add `min-w-0` / `overflow-hidden` to flex layout chain (SidebarInset → AppShell → ObjectView → PluginObjectView) to prevent content overflow
@@ -528,6 +562,30 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
528562
- [ ] Support App `engine` field (`{ objectstack: string }`) for version pinning (v3.0.9)
529563
- [ ] Integrate v3.0.9 package upgrade protocol (`PackageArtifact`, `ArtifactChecksum`, `UpgradeContext`)
530564

565+
### P2.6 ListView Spec Protocol Gaps (Remaining)
566+
567+
> Remaining gaps from the ListView Spec Protocol analysis. Items here require non-trivial implementation (new UI components, schema reconciliation, or grid-level changes).
568+
569+
**P0 — Core Protocol:**
570+
- [ ] `data` (ViewDataSchema): ListView ignores `data` entirely — `provider: api/value` modes not consumed. Needs DataProvider abstraction to support inline data, API endpoints, and value-mode data.
571+
- [ ] `grouping` rendering: Group button visible but disabled — needs GroupBy field picker popover + wired `useGroupedData` hook for grouped row rendering in Grid/Kanban/Gallery views. Requires changes in `plugin-grid`, `plugin-list`.
572+
- [ ] `rowColor` rendering: Color button visible but disabled — needs color-field picker popover + wired `useRowColor` hook for row background coloring. Requires changes in `plugin-grid`, `plugin-list`.
573+
574+
**P1 — Structural Alignment:**
575+
- [ ] `quickFilters` structure reconciliation: spec uses `{ field, operator, value }` but ObjectUI uses `{ id, label, filters[] }`. Needs adapter layer or dual-format support.
576+
- [ ] `conditionalFormatting` expression reconciliation: spec uses expression-based `{ condition, style }`, ObjectUI uses field/operator/value rules. Both paths work independently but format adapter needed for full interop.
577+
- [ ] `exportOptions` schema reconciliation: spec uses simple `string[]` format list, ObjectUI uses `{ formats, maxRecords, includeHeaders, fileNamePrefix }` object. Needs normalization adapter.
578+
- [ ] Column `pinned`: bridge passes through but ObjectGrid doesn't implement frozen/pinned columns. Needs CSS `position: sticky` column rendering.
579+
- [ ] Column `summary`: no footer aggregation UI (count, sum, avg). Needs column footer row component.
580+
- [ ] Column `link`: no click-to-navigate rendering on link columns. Needs cell renderer for link-type columns.
581+
- [ ] Column `action`: no action dispatch on column click. Needs cell renderer for action-type columns.
582+
583+
**P2 — Advanced Features:**
584+
- [ ] `rowActions`: row-level action menu UI — dropdown menu per row with configurable actions
585+
- [ ] `bulkActions`: bulk action bar UI — action bar shown on multi-select with configurable batch actions
586+
- [ ] `sharing` schema reconciliation: spec uses `personal/collaborative` model vs ObjectUI `visibility` model. Needs schema adapter.
587+
- [ ] `pagination.pageSizeOptions` backend integration: UI selector exists but backend query needs to use selected page size dynamically.
588+
531589
### P2.5 PWA & Offline (Real Sync)
532590

533591
- [ ] Background sync queue → real server sync (replace simulation)
@@ -613,6 +671,6 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
613671

614672
---
615673

616-
**Roadmap Status:** 🎯 Active — AppShell · Designer Interaction · View Config Live Preview Sync (P1.8.1) · Dashboard Config Panel · Airtable UX Parity
674+
**Roadmap Status:** 🎯 Active — AppShell · Designer Interaction · View Config Live Preview Sync (P1.8.1) · Dashboard Config Panel · Schema-Driven View Config Panel ✅ · Airtable UX Parity
617675
**Next Review:** March 15, 2026
618676
**Contact:** hello@objectui.org | https://github.com/objectstack-ai/objectui

apps/console/src/__tests__/ViewConfigPanel.test.tsx

Lines changed: 179 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -22,83 +22,187 @@ vi.mock('@object-ui/i18n', () => ({
2222
}));
2323

2424
// Mock components to simple HTML elements
25-
vi.mock('@object-ui/components', () => ({
26-
Button: ({ children, onClick, title, ...props }: any) => (
27-
<button onClick={onClick} title={title} {...props}>{children}</button>
28-
),
29-
Switch: ({ checked, onCheckedChange, ...props }: any) => (
30-
<button
31-
role="switch"
32-
aria-checked={checked}
33-
onClick={() => onCheckedChange?.(!checked)}
34-
{...props}
35-
/>
36-
),
37-
Input: ({ value, onChange, readOnly, placeholder, ...props }: any) => (
38-
<input
39-
value={value}
40-
onChange={onChange}
41-
readOnly={readOnly}
42-
placeholder={placeholder}
43-
{...props}
44-
/>
45-
),
46-
Checkbox: ({ checked, onCheckedChange, ...props }: any) => (
47-
<input
48-
type="checkbox"
49-
checked={!!checked}
50-
onChange={() => onCheckedChange?.(!checked)}
51-
{...props}
52-
/>
53-
),
54-
ConfigRow: ({ label, value, onClick, children, className }: any) => {
55-
const Wrapper = onClick ? 'button' : 'div';
56-
return (
57-
<Wrapper className={className} onClick={onClick} type={onClick ? 'button' : undefined}>
58-
<span>{label}</span>
59-
{children || <span>{value}</span>}
60-
</Wrapper>
61-
);
62-
},
63-
SectionHeader: ({ title, collapsible, collapsed, onToggle, testId }: any) => {
64-
if (collapsible) {
25+
vi.mock('@object-ui/components', () => {
26+
const React = require('react');
27+
28+
// useConfigDraft mock — mirrors real implementation
29+
function useConfigDraft(source: any, options?: any) {
30+
const [draft, setDraft] = React.useState({ ...source });
31+
const [isDirty, setIsDirty] = React.useState(options?.mode === 'create');
32+
33+
React.useEffect(() => {
34+
setDraft({ ...source });
35+
setIsDirty(options?.mode === 'create');
36+
}, [source]); // eslint-disable-line react-hooks/exhaustive-deps
37+
38+
const updateField = React.useCallback((field: string, value: any) => {
39+
setDraft((prev: any) => ({ ...prev, [field]: value }));
40+
setIsDirty(true);
41+
options?.onUpdate?.(field, value);
42+
}, [options?.onUpdate]);
43+
44+
const discard = React.useCallback(() => {
45+
setDraft({ ...source });
46+
setIsDirty(false);
47+
}, [source]);
48+
49+
return { draft, isDirty, updateField, discard, setDraft };
50+
}
51+
52+
// ConfigPanelRenderer mock — renders schema sections with proper collapse/visibility
53+
function ConfigPanelRenderer({ open, onClose, schema, draft, isDirty, onFieldChange, onSave, onDiscard, panelRef, role, ariaLabel, tabIndex, testId, saveLabel, discardLabel, className }: any) {
54+
const [collapsed, setCollapsed] = React.useState<Record<string, boolean>>({});
55+
if (!open) return null;
56+
57+
return React.createElement('div', {
58+
ref: panelRef,
59+
'data-testid': testId || 'config-panel',
60+
role,
61+
'aria-label': ariaLabel,
62+
tabIndex,
63+
className: `absolute inset-y-0 right-0 w-full sm:w-72 lg:w-80 sm:relative sm:inset-auto border-l bg-background flex flex-col shrink-0 z-20 ${className || ''}`,
64+
},
65+
// Header with breadcrumb
66+
React.createElement('div', { className: 'px-4 py-3 border-b flex items-center justify-between shrink-0' },
67+
React.createElement('div', { 'data-testid': 'panel-breadcrumb', className: 'flex items-center gap-1 text-sm truncate' },
68+
...schema.breadcrumb.map((seg: string, i: number) => [
69+
i > 0 && React.createElement('span', { key: `sep-${i}`, className: 'text-muted-foreground' }, '›'),
70+
React.createElement('span', {
71+
key: `seg-${i}`,
72+
className: i === schema.breadcrumb.length - 1 ? 'text-foreground font-semibold' : 'text-muted-foreground',
73+
}, seg),
74+
]).flat().filter(Boolean),
75+
),
76+
React.createElement('button', {
77+
onClick: onClose,
78+
title: 'console.objectView.closePanel',
79+
className: 'h-7 w-7 p-0',
80+
}, '×'),
81+
),
82+
// Scrollable sections
83+
React.createElement('div', { className: 'flex-1 overflow-auto px-4 pb-4' },
84+
...schema.sections.map((section: any) => {
85+
if (section.visibleWhen && !section.visibleWhen(draft)) return null;
86+
const isCollapsed = collapsed[section.key] ?? section.defaultCollapsed ?? false;
87+
88+
return React.createElement('div', { key: section.key },
89+
// Section header
90+
section.collapsible
91+
? React.createElement('button', {
92+
'data-testid': `section-${section.key}`,
93+
onClick: () => setCollapsed((prev: any) => ({ ...prev, [section.key]: !isCollapsed })),
94+
type: 'button',
95+
'aria-expanded': !isCollapsed,
96+
}, React.createElement('h3', null, section.title))
97+
: React.createElement('div', null, React.createElement('h3', null, section.title)),
98+
// Section hint
99+
section.hint && React.createElement('p', { className: 'text-[10px] text-muted-foreground mb-1' }, section.hint),
100+
// Section fields
101+
!isCollapsed && React.createElement('div', { className: 'space-y-0.5' },
102+
...section.fields.map((field: any) => {
103+
if (field.visibleWhen && !field.visibleWhen(draft)) return null;
104+
if (field.type === 'custom' && field.render) {
105+
return React.createElement(React.Fragment, { key: field.key },
106+
field.render(draft[field.key], (v: any) => onFieldChange(field.key, v), draft),
107+
);
108+
}
109+
return null;
110+
}),
111+
),
112+
);
113+
}),
114+
),
115+
// Footer
116+
isDirty && React.createElement('div', {
117+
'data-testid': 'view-config-footer',
118+
className: 'px-4 py-3 border-t flex items-center justify-end gap-2 shrink-0 bg-background',
119+
},
120+
React.createElement('button', { 'data-testid': 'view-config-discard', onClick: onDiscard }, discardLabel),
121+
React.createElement('button', { 'data-testid': 'view-config-save', onClick: onSave }, saveLabel),
122+
),
123+
);
124+
}
125+
126+
return {
127+
useConfigDraft,
128+
ConfigPanelRenderer,
129+
Button: ({ children, onClick, title, ...props }: any) => (
130+
<button onClick={onClick} title={title} {...props}>{children}</button>
131+
),
132+
Switch: ({ checked, onCheckedChange, ...props }: any) => (
133+
<button
134+
role="switch"
135+
aria-checked={checked}
136+
onClick={() => onCheckedChange?.(!checked)}
137+
{...props}
138+
/>
139+
),
140+
Input: ({ value, onChange, readOnly, placeholder, ...props }: any) => (
141+
<input
142+
value={value}
143+
onChange={onChange}
144+
readOnly={readOnly}
145+
placeholder={placeholder}
146+
{...props}
147+
/>
148+
),
149+
Checkbox: ({ checked, onCheckedChange, ...props }: any) => (
150+
<input
151+
type="checkbox"
152+
checked={!!checked}
153+
onChange={() => onCheckedChange?.(!checked)}
154+
{...props}
155+
/>
156+
),
157+
ConfigRow: ({ label, value, onClick, children, className }: any) => {
158+
const Wrapper = onClick ? 'button' : 'div';
65159
return (
66-
<button data-testid={testId} onClick={onToggle} type="button" aria-expanded={!collapsed}>
67-
<h3>{title}</h3>
68-
</button>
160+
<Wrapper className={className} onClick={onClick} type={onClick ? 'button' : undefined}>
161+
<span>{label}</span>
162+
{children || <span>{value}</span>}
163+
</Wrapper>
69164
);
70-
}
71-
return <div data-testid={testId}><h3>{title}</h3></div>;
72-
},
73-
FilterBuilder: ({ fields, value, onChange }: any) => {
74-
let counter = 0;
75-
return (
76-
<div data-testid="mock-filter-builder" data-field-count={fields?.length || 0} data-condition-count={value?.conditions?.length || 0}>
77-
<button data-testid="filter-builder-add" onClick={() => {
78-
const newConditions = [...(value?.conditions || []), { id: `mock-filter-${Date.now()}-${++counter}`, field: fields?.[0]?.value || '', operator: 'equals', value: '' }];
79-
onChange?.({ ...value, conditions: newConditions });
80-
}}>Add filter</button>
81-
{value?.conditions?.map((c: any, i: number) => (
82-
<span key={c.id || i} data-testid={`filter-condition-${i}`}>{c.field} {c.operator} {String(c.value)}</span>
83-
))}
84-
</div>
85-
);
86-
},
87-
SortBuilder: ({ fields, value, onChange }: any) => {
88-
let counter = 0;
89-
return (
90-
<div data-testid="mock-sort-builder" data-field-count={fields?.length || 0} data-sort-count={value?.length || 0}>
91-
<button data-testid="sort-builder-add" onClick={() => {
92-
const newItems = [...(value || []), { id: `mock-sort-${Date.now()}-${++counter}`, field: fields?.[0]?.value || '', order: 'asc' }];
93-
onChange?.(newItems);
94-
}}>Add sort</button>
95-
{value?.map((s: any, i: number) => (
96-
<span key={s.id || i} data-testid={`sort-item-${i}`}>{s.field} {s.order}</span>
97-
))}
98-
</div>
99-
);
100-
},
101-
}));
165+
},
166+
SectionHeader: ({ title, collapsible, collapsed, onToggle, testId }: any) => {
167+
if (collapsible) {
168+
return (
169+
<button data-testid={testId} onClick={onToggle} type="button" aria-expanded={!collapsed}>
170+
<h3>{title}</h3>
171+
</button>
172+
);
173+
}
174+
return <div data-testid={testId}><h3>{title}</h3></div>;
175+
},
176+
FilterBuilder: ({ fields, value, onChange }: any) => {
177+
let counter = 0;
178+
return (
179+
<div data-testid="mock-filter-builder" data-field-count={fields?.length || 0} data-condition-count={value?.conditions?.length || 0}>
180+
<button data-testid="filter-builder-add" onClick={() => {
181+
const newConditions = [...(value?.conditions || []), { id: `mock-filter-${Date.now()}-${++counter}`, field: fields?.[0]?.value || '', operator: 'equals', value: '' }];
182+
onChange?.({ ...value, conditions: newConditions });
183+
}}>Add filter</button>
184+
{value?.conditions?.map((c: any, i: number) => (
185+
<span key={c.id || i} data-testid={`filter-condition-${i}`}>{c.field} {c.operator} {String(c.value)}</span>
186+
))}
187+
</div>
188+
);
189+
},
190+
SortBuilder: ({ fields, value, onChange }: any) => {
191+
let counter = 0;
192+
return (
193+
<div data-testid="mock-sort-builder" data-field-count={fields?.length || 0} data-sort-count={value?.length || 0}>
194+
<button data-testid="sort-builder-add" onClick={() => {
195+
const newItems = [...(value || []), { id: `mock-sort-${Date.now()}-${++counter}`, field: fields?.[0]?.value || '', order: 'asc' }];
196+
onChange?.(newItems);
197+
}}>Add sort</button>
198+
{value?.map((s: any, i: number) => (
199+
<span key={s.id || i} data-testid={`sort-item-${i}`}>{s.field} {s.order}</span>
200+
))}
201+
</div>
202+
);
203+
},
204+
};
205+
});
102206

103207
const mockActiveView = {
104208
id: 'all',

0 commit comments

Comments
 (0)