Skip to content

Commit f5e8941

Browse files
authored
Merge pull request #677 from objectstack-ai/copilot/fix-object-view-realtime-response
fix: wire ViewConfigPanel draft to ObjectView for real-time preview + inline filter/sort/column editors
2 parents 71dd9e8 + 13cdee3 commit f5e8941

6 files changed

Lines changed: 542 additions & 81 deletions

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ Everything below has been built, tested, and verified. These items are stable an
4949
- **Animation:** 7 presets, reduced-motion aware, page transitions (9 types with View Transitions API).
5050
- **Notifications:** Toast/banner/snackbar with full CRUD integration.
5151
- **View Enhancements:** Gallery, column summary, grouping, row color, density modes, view sharing, ViewTabBar (reorder, pin, context menu, type-switch, personal/shared grouping).
52-
- **Inline View Config Panel:** Airtable-style right sidebar for view configuration (Page, Data, Appearance, User Filters, Actions, Advanced), breadcrumb header, record count footer, responsive mobile overlay, ARIA accessibility, auto-close on view switch — no page navigation required. Full interactive editing support: inline title editing, Switch toggles for all boolean settings, ViewType select, clickable rows to open sub-editors (columns/filters/sort), local draft state with Save/Discard workflow, i18n in all 10 locales.
52+
- **Inline View Config Panel:** Airtable-style right sidebar for view configuration (Page, Data, Appearance, User Filters, Actions, Advanced), breadcrumb header, record count footer, responsive mobile overlay, ARIA accessibility, auto-close on view switch — no page navigation required. Full interactive editing support: inline title editing, Switch toggles for all boolean settings, ViewType select, clickable rows to open sub-editors (columns/filters/sort), local draft state with Save/Discard workflow, real-time draft preview in main view, i18n in all 10 locales.
5353

5454
### Enterprise Features ✅
5555

ROADMAP_CONSOLE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,7 +1021,7 @@ These were the initial tasks to bring the console prototype to production-qualit
10211021
| **Global Undo/Redo (Ctrl+Z)** | ✅ Done (global UndoManager + batch ops + persistent stack) | Post v1.0 | Phase 16 (L1+L2) |
10221022
| Notification center | ✅ Partial (ActivityFeed with filter preferences) | Post v1.0 | Phase 17 (L2) |
10231023
| Activity feed | ✅ Done | Post v1.0 | Phase 17 (L1) |
1024-
| **Inline View Config Panel** | ✅ Done (Airtable-style right sidebar, breadcrumb header, record count footer, responsive mobile overlay, ARIA accessibility, auto-close on view switch, full interactive editing: Switch toggles, inline title edit, ViewType select, clickable sub-editor rows, draft state with Save/Discard) | Post v1.0 | Phase 20 |
1024+
| **Inline View Config Panel** | ✅ Done (Airtable-style right sidebar, breadcrumb header, record count footer, responsive mobile overlay, ARIA accessibility, auto-close on view switch, full interactive editing: Switch toggles, inline title edit, ViewType select, clickable sub-editor rows, draft state with Save/Discard, real-time draft preview in main view) | Post v1.0 | Phase 20 |
10251025

10261026
### 5.5 Kanban & Visual Views
10271027

@@ -1103,7 +1103,7 @@ These were the initial tasks to bring the console prototype to production-qualit
11031103
11041104
2027 Q1+ — v2.1: INLINE VIEW DESIGNER (✅ Complete)
11051105
═══════════════════════════════════════════════════════════
1106-
Phase 20: Inline ViewConfigPanel ██████████████ ✅ Complete: Airtable-style right sidebar, full interactive editing (Switch toggles, inline title, ViewType select, sub-editor rows, draft Save/Discard), ARIA accessibility, backend persistence via DataSource.updateViewConfig
1106+
Phase 20: Inline ViewConfigPanel ██████████████ ✅ Complete: Airtable-style right sidebar, full interactive editing (Switch toggles, inline title, ViewType select, sub-editor rows, draft Save/Discard, real-time draft preview), ARIA accessibility
11071107
```
11081108

11091109
### Milestone Summary

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

Lines changed: 244 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,50 @@ vi.mock('@object-ui/components', () => ({
4343
{...props}
4444
/>
4545
),
46+
Checkbox: ({ checked, onCheckedChange, ...props }: any) => (
47+
<input
48+
type="checkbox"
49+
checked={!!checked}
50+
onChange={() => onCheckedChange?.(!checked)}
51+
{...props}
52+
/>
53+
),
54+
FilterBuilder: ({ fields, value, onChange, ...props }: any) => {
55+
let counter = 0;
56+
return (
57+
<div data-testid="mock-filter-builder" data-field-count={fields?.length || 0} data-condition-count={value?.conditions?.length || 0}>
58+
<button data-testid="filter-builder-add" onClick={() => {
59+
const newConditions = [...(value?.conditions || []), { id: `mock-filter-${Date.now()}-${++counter}`, field: fields?.[0]?.value || '', operator: 'equals', value: '' }];
60+
onChange?.({ ...value, conditions: newConditions });
61+
}}>Add filter</button>
62+
{value?.conditions?.map((c: any, i: number) => (
63+
<span key={c.id || i} data-testid={`filter-condition-${i}`}>{c.field} {c.operator} {String(c.value)}</span>
64+
))}
65+
</div>
66+
);
67+
},
68+
SortBuilder: ({ fields, value, onChange, ...props }: any) => {
69+
let counter = 0;
70+
return (
71+
<div data-testid="mock-sort-builder" data-field-count={fields?.length || 0} data-sort-count={value?.length || 0}>
72+
<button data-testid="sort-builder-add" onClick={() => {
73+
const newItems = [...(value || []), { id: `mock-sort-${Date.now()}-${++counter}`, field: fields?.[0]?.value || '', order: 'asc' }];
74+
onChange?.(newItems);
75+
}}>Add sort</button>
76+
{value?.map((s: any, i: number) => (
77+
<span key={s.id || i} data-testid={`sort-item-${i}`}>{s.field} {s.order}</span>
78+
))}
79+
</div>
80+
);
81+
},
4682
}));
4783

4884
const mockActiveView = {
4985
id: 'all',
5086
label: 'All Records',
5187
type: 'grid',
5288
columns: ['name', 'stage', 'amount'],
53-
filter: [{ field: 'stage', operator: '=', value: 'active' }],
89+
filter: ['stage', '=', 'active'], // spec-style single triplet
5490
sort: [{ field: 'name', order: 'asc' }],
5591
};
5692

@@ -140,7 +176,7 @@ describe('ViewConfigPanel', () => {
140176
expect(screen.getByText('console.objectView.noDescription')).toBeInTheDocument();
141177
});
142178

143-
it('displays column count', () => {
179+
it('displays column checkboxes for each field', () => {
144180
render(
145181
<ViewConfigPanel
146182
open={true}
@@ -150,8 +186,15 @@ describe('ViewConfigPanel', () => {
150186
/>
151187
);
152188

153-
// 3 columns configured
154-
expect(screen.getByText('console.objectView.columnsConfigured'.replace('{{count}}', '3'))).toBeInTheDocument();
189+
// 3 fields → 3 checkboxes
190+
expect(screen.getByTestId('column-selector')).toBeInTheDocument();
191+
expect(screen.getByTestId('col-checkbox-name')).toBeInTheDocument();
192+
expect(screen.getByTestId('col-checkbox-stage')).toBeInTheDocument();
193+
expect(screen.getByTestId('col-checkbox-amount')).toBeInTheDocument();
194+
// Columns in activeView should be checked
195+
expect(screen.getByTestId('col-checkbox-name')).toBeChecked();
196+
expect(screen.getByTestId('col-checkbox-stage')).toBeChecked();
197+
expect(screen.getByTestId('col-checkbox-amount')).toBeChecked();
155198
});
156199

157200
it('displays object source name', () => {
@@ -225,7 +268,7 @@ describe('ViewConfigPanel', () => {
225268
expect(screen.getByTestId('view-type-select')).toHaveValue('kanban');
226269
});
227270

228-
it('shows "None" for empty filters and columns', () => {
271+
it('shows inline builders with zero items for empty view', () => {
229272
render(
230273
<ViewConfigPanel
231274
open={true}
@@ -235,9 +278,10 @@ describe('ViewConfigPanel', () => {
235278
/>
236279
);
237280

238-
// Should show "None" for columns, filters
239-
const noneTexts = screen.getAllByText('console.objectView.none');
240-
expect(noneTexts.length).toBeGreaterThanOrEqual(2);
281+
// FilterBuilder should have 0 conditions
282+
expect(screen.getByTestId('mock-filter-builder')).toHaveAttribute('data-condition-count', '0');
283+
// SortBuilder should have 0 items
284+
expect(screen.getByTestId('mock-sort-builder')).toHaveAttribute('data-sort-count', '0');
241285
});
242286

243287
it('has correct ARIA attributes when open', () => {
@@ -403,62 +447,85 @@ describe('ViewConfigPanel', () => {
403447
expect(onViewUpdate).toHaveBeenCalledWith('type', 'kanban');
404448
});
405449

406-
it('calls onOpenEditor when clicking columns row', () => {
407-
const onOpenEditor = vi.fn();
450+
it('renders inline FilterBuilder with correct conditions from activeView', () => {
408451
render(
409452
<ViewConfigPanel
410453
open={true}
411454
onClose={vi.fn()}
412455
activeView={mockActiveView}
413456
objectDef={mockObjectDef}
414-
onOpenEditor={onOpenEditor}
415457
/>
416458
);
417459

418-
// Click the columns row — it's a button with the columns label
419-
const columnsRow = screen.getByText('console.objectView.columns').closest('button');
420-
expect(columnsRow).toBeTruthy();
421-
fireEvent.click(columnsRow!);
422-
423-
expect(onOpenEditor).toHaveBeenCalledWith('columns');
460+
const fb = screen.getByTestId('mock-filter-builder');
461+
expect(fb).toHaveAttribute('data-condition-count', '1');
462+
expect(fb).toHaveAttribute('data-field-count', '3');
463+
expect(screen.getByTestId('filter-condition-0')).toHaveTextContent('stage equals active');
424464
});
425465

426-
it('calls onOpenEditor when clicking filters row', () => {
427-
const onOpenEditor = vi.fn();
466+
it('renders inline SortBuilder with correct items from activeView', () => {
428467
render(
429468
<ViewConfigPanel
430469
open={true}
431470
onClose={vi.fn()}
432471
activeView={mockActiveView}
433472
objectDef={mockObjectDef}
434-
onOpenEditor={onOpenEditor}
435473
/>
436474
);
437475

438-
const filterRow = screen.getByText('console.objectView.filterBy').closest('button');
439-
expect(filterRow).toBeTruthy();
440-
fireEvent.click(filterRow!);
476+
const sb = screen.getByTestId('mock-sort-builder');
477+
expect(sb).toHaveAttribute('data-sort-count', '1');
478+
expect(sb).toHaveAttribute('data-field-count', '3');
479+
expect(screen.getByTestId('sort-item-0')).toHaveTextContent('name asc');
480+
});
441481

442-
expect(onOpenEditor).toHaveBeenCalledWith('filters');
482+
it('updates draft when adding a filter via FilterBuilder', () => {
483+
const onViewUpdate = vi.fn();
484+
render(
485+
<ViewConfigPanel
486+
open={true}
487+
onClose={vi.fn()}
488+
activeView={{ id: 'empty', label: 'Empty', type: 'grid' }}
489+
objectDef={mockObjectDef}
490+
onViewUpdate={onViewUpdate}
491+
/>
492+
);
493+
494+
fireEvent.click(screen.getByTestId('filter-builder-add'));
495+
expect(onViewUpdate).toHaveBeenCalledWith('filter', expect.any(Array));
443496
});
444497

445-
it('calls onOpenEditor when clicking sort row', () => {
446-
const onOpenEditor = vi.fn();
498+
it('updates draft when adding a sort via SortBuilder', () => {
499+
const onViewUpdate = vi.fn();
447500
render(
448501
<ViewConfigPanel
449502
open={true}
450503
onClose={vi.fn()}
451-
activeView={mockActiveView}
504+
activeView={{ id: 'empty', label: 'Empty', type: 'grid' }}
452505
objectDef={mockObjectDef}
453-
onOpenEditor={onOpenEditor}
506+
onViewUpdate={onViewUpdate}
454507
/>
455508
);
456509

457-
const sortRow = screen.getByText('console.objectView.sortBy').closest('button');
458-
expect(sortRow).toBeTruthy();
459-
fireEvent.click(sortRow!);
510+
fireEvent.click(screen.getByTestId('sort-builder-add'));
511+
expect(onViewUpdate).toHaveBeenCalledWith('sort', expect.any(Array));
512+
});
513+
514+
it('toggles column checkbox and calls onViewUpdate with updated columns', () => {
515+
const onViewUpdate = vi.fn();
516+
render(
517+
<ViewConfigPanel
518+
open={true}
519+
onClose={vi.fn()}
520+
activeView={mockActiveView}
521+
objectDef={mockObjectDef}
522+
onViewUpdate={onViewUpdate}
523+
/>
524+
);
460525

461-
expect(onOpenEditor).toHaveBeenCalledWith('sort');
526+
// Uncheck the 'stage' column
527+
fireEvent.click(screen.getByTestId('col-checkbox-stage'));
528+
expect(onViewUpdate).toHaveBeenCalledWith('columns', ['name', 'amount']);
462529
});
463530

464531
it('saves draft via onSave when Save button is clicked', () => {
@@ -551,4 +618,149 @@ describe('ViewConfigPanel', () => {
551618
expect(screen.getByTestId('toggle-allowExport')).toHaveAttribute('aria-checked', 'false');
552619
expect(screen.getByTestId('toggle-addRecordViaForm')).toHaveAttribute('aria-checked', 'true');
553620
});
621+
622+
// ── Real-time draft propagation tests (issue fix) ──
623+
624+
it('keeps dirty state when re-rendered with same view ID but updated activeView', () => {
625+
const onViewUpdate = vi.fn();
626+
const { rerender } = render(
627+
<ViewConfigPanel
628+
open={true}
629+
onClose={vi.fn()}
630+
activeView={mockActiveView}
631+
objectDef={mockObjectDef}
632+
onViewUpdate={onViewUpdate}
633+
/>
634+
);
635+
636+
// Toggle showSearch — panel becomes dirty
637+
fireEvent.click(screen.getByTestId('toggle-showSearch'));
638+
expect(screen.getByTestId('view-config-footer')).toBeInTheDocument();
639+
640+
// Simulate parent re-rendering with the same view ID but merged draft
641+
// (this happens when onViewUpdate propagates to parent viewDraft → activeView)
642+
rerender(
643+
<ViewConfigPanel
644+
open={true}
645+
onClose={vi.fn()}
646+
activeView={{ ...mockActiveView, showSearch: false }}
647+
objectDef={mockObjectDef}
648+
onViewUpdate={onViewUpdate}
649+
/>
650+
);
651+
652+
// Draft footer should still be visible (isDirty should NOT reset for same view ID)
653+
expect(screen.getByTestId('view-config-footer')).toBeInTheDocument();
654+
});
655+
656+
it('resets dirty state when activeView changes to a different view ID', () => {
657+
const { rerender } = render(
658+
<ViewConfigPanel
659+
open={true}
660+
onClose={vi.fn()}
661+
activeView={mockActiveView}
662+
objectDef={mockObjectDef}
663+
/>
664+
);
665+
666+
// Make the panel dirty
667+
fireEvent.click(screen.getByTestId('toggle-showSearch'));
668+
expect(screen.getByTestId('view-config-footer')).toBeInTheDocument();
669+
670+
// Switch to a completely different view
671+
rerender(
672+
<ViewConfigPanel
673+
open={true}
674+
onClose={vi.fn()}
675+
activeView={{ id: 'pipeline', label: 'Pipeline', type: 'kanban', columns: ['name'] }}
676+
objectDef={mockObjectDef}
677+
/>
678+
);
679+
680+
// Draft should reset — footer should be gone
681+
expect(screen.queryByTestId('view-config-footer')).not.toBeInTheDocument();
682+
});
683+
684+
it('calls onViewUpdate for each real-time field change to enable live preview', () => {
685+
const onViewUpdate = vi.fn();
686+
render(
687+
<ViewConfigPanel
688+
open={true}
689+
onClose={vi.fn()}
690+
activeView={mockActiveView}
691+
objectDef={mockObjectDef}
692+
onViewUpdate={onViewUpdate}
693+
/>
694+
);
695+
696+
// Toggle multiple switches
697+
fireEvent.click(screen.getByTestId('toggle-showSearch'));
698+
fireEvent.click(screen.getByTestId('toggle-showFilters'));
699+
700+
expect(onViewUpdate).toHaveBeenCalledTimes(2);
701+
expect(onViewUpdate).toHaveBeenCalledWith('showSearch', false);
702+
expect(onViewUpdate).toHaveBeenCalledWith('showFilters', false);
703+
});
704+
705+
// ── Spec-style filter bridge tests ──
706+
707+
it('parses nested spec-style filter array [[field,op,val],[field,op,val]]', () => {
708+
render(
709+
<ViewConfigPanel
710+
open={true}
711+
onClose={vi.fn()}
712+
activeView={{
713+
...mockActiveView,
714+
filter: [['stage', '=', 'active'], ['name', '!=', 'Test']],
715+
}}
716+
objectDef={mockObjectDef}
717+
/>
718+
);
719+
720+
const fb = screen.getByTestId('mock-filter-builder');
721+
expect(fb).toHaveAttribute('data-condition-count', '2');
722+
expect(screen.getByTestId('filter-condition-0')).toHaveTextContent('stage equals active');
723+
expect(screen.getByTestId('filter-condition-1')).toHaveTextContent('name notEquals Test');
724+
});
725+
726+
it('parses and/or logic prefix: ["or", [...], [...]]', () => {
727+
render(
728+
<ViewConfigPanel
729+
open={true}
730+
onClose={vi.fn()}
731+
activeView={{
732+
...mockActiveView,
733+
filter: ['or', ['stage', '=', 'active'], ['stage', '=', 'pending']],
734+
}}
735+
objectDef={mockObjectDef}
736+
/>
737+
);
738+
739+
const fb = screen.getByTestId('mock-filter-builder');
740+
expect(fb).toHaveAttribute('data-condition-count', '2');
741+
});
742+
743+
it('normalizes field types for FilterBuilder (currency→number)', () => {
744+
render(
745+
<ViewConfigPanel
746+
open={true}
747+
onClose={vi.fn()}
748+
activeView={mockActiveView}
749+
objectDef={{
750+
...mockObjectDef,
751+
fields: {
752+
name: { label: 'Name', type: 'text' },
753+
revenue: { label: 'Revenue', type: 'currency' },
754+
created: { label: 'Created', type: 'datetime' },
755+
active: { label: 'Active', type: 'boolean' },
756+
status: { label: 'Status', type: 'picklist' },
757+
},
758+
}}
759+
/>
760+
);
761+
762+
// The mock FilterBuilder receives normalized fields via data-field-count
763+
const fb = screen.getByTestId('mock-filter-builder');
764+
expect(fb).toHaveAttribute('data-field-count', '5');
765+
});
554766
});

0 commit comments

Comments
 (0)