Skip to content
Merged
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Everything below has been built, tested, and verified. These items are stable an
- **Animation:** 7 presets, reduced-motion aware, page transitions (9 types with View Transitions API).
- **Notifications:** Toast/banner/snackbar with full CRUD integration.
- **View Enhancements:** Gallery, column summary, grouping, row color, density modes, view sharing, ViewTabBar (reorder, pin, context menu, type-switch, personal/shared grouping).
- **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.
- **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.

### Enterprise Features ✅

Expand Down
4 changes: 2 additions & 2 deletions ROADMAP_CONSOLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1021,7 +1021,7 @@ These were the initial tasks to bring the console prototype to production-qualit
| **Global Undo/Redo (Ctrl+Z)** | ✅ Done (global UndoManager + batch ops + persistent stack) | Post v1.0 | Phase 16 (L1+L2) |
| Notification center | ✅ Partial (ActivityFeed with filter preferences) | Post v1.0 | Phase 17 (L2) |
| Activity feed | ✅ Done | Post v1.0 | Phase 17 (L1) |
| **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 |
| **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 |

### 5.5 Kanban & Visual Views

Expand Down Expand Up @@ -1103,7 +1103,7 @@ These were the initial tasks to bring the console prototype to production-qualit

2027 Q1+ — v2.1: INLINE VIEW DESIGNER (✅ Complete)
═══════════════════════════════════════════════════════════
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
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
```

### Milestone Summary
Expand Down
212 changes: 181 additions & 31 deletions apps/console/src/__tests__/ViewConfigPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,42 @@ vi.mock('@object-ui/components', () => ({
{...props}
/>
),
Checkbox: ({ checked, onCheckedChange, ...props }: any) => (
<input
type="checkbox"
checked={!!checked}
onChange={() => onCheckedChange?.(!checked)}
{...props}
/>
),
FilterBuilder: ({ fields, value, onChange, ...props }: any) => {
let counter = 0;
return (
<div data-testid="mock-filter-builder" data-field-count={fields?.length || 0} data-condition-count={value?.conditions?.length || 0}>
<button data-testid="filter-builder-add" onClick={() => {
const newConditions = [...(value?.conditions || []), { id: `mock-filter-${Date.now()}-${++counter}`, field: fields?.[0]?.value || '', operator: 'equals', value: '' }];
onChange?.({ ...value, conditions: newConditions });
}}>Add filter</button>
{value?.conditions?.map((c: any, i: number) => (
<span key={c.id || i} data-testid={`filter-condition-${i}`}>{c.field} {c.operator} {String(c.value)}</span>
))}
</div>
);
},
SortBuilder: ({ fields, value, onChange, ...props }: any) => {
let counter = 0;
return (
<div data-testid="mock-sort-builder" data-field-count={fields?.length || 0} data-sort-count={value?.length || 0}>
<button data-testid="sort-builder-add" onClick={() => {
const newItems = [...(value || []), { id: `mock-sort-${Date.now()}-${++counter}`, field: fields?.[0]?.value || '', order: 'asc' }];
onChange?.(newItems);
}}>Add sort</button>
{value?.map((s: any, i: number) => (
<span key={s.id || i} data-testid={`sort-item-${i}`}>{s.field} {s.order}</span>
))}
</div>
);
},
}));

const mockActiveView = {
Expand Down Expand Up @@ -140,7 +176,7 @@ describe('ViewConfigPanel', () => {
expect(screen.getByText('console.objectView.noDescription')).toBeInTheDocument();
});

it('displays column count', () => {
it('displays column checkboxes for each field', () => {
render(
<ViewConfigPanel
open={true}
Expand All @@ -150,8 +186,15 @@ describe('ViewConfigPanel', () => {
/>
);

// 3 columns configured
expect(screen.getByText('console.objectView.columnsConfigured'.replace('{{count}}', '3'))).toBeInTheDocument();
// 3 fields → 3 checkboxes
expect(screen.getByTestId('column-selector')).toBeInTheDocument();
expect(screen.getByTestId('col-checkbox-name')).toBeInTheDocument();
expect(screen.getByTestId('col-checkbox-stage')).toBeInTheDocument();
expect(screen.getByTestId('col-checkbox-amount')).toBeInTheDocument();
// Columns in activeView should be checked
expect(screen.getByTestId('col-checkbox-name')).toBeChecked();
expect(screen.getByTestId('col-checkbox-stage')).toBeChecked();
expect(screen.getByTestId('col-checkbox-amount')).toBeChecked();
});

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

it('shows "None" for empty filters and columns', () => {
it('shows inline builders with zero items for empty view', () => {
render(
<ViewConfigPanel
open={true}
Expand All @@ -235,9 +278,10 @@ describe('ViewConfigPanel', () => {
/>
);

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

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

it('calls onOpenEditor when clicking columns row', () => {
const onOpenEditor = vi.fn();
it('renders inline FilterBuilder with correct conditions from activeView', () => {
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={mockActiveView}
objectDef={mockObjectDef}
onOpenEditor={onOpenEditor}
/>
);

// Click the columns row — it's a button with the columns label
const columnsRow = screen.getByText('console.objectView.columns').closest('button');
expect(columnsRow).toBeTruthy();
fireEvent.click(columnsRow!);

expect(onOpenEditor).toHaveBeenCalledWith('columns');
const fb = screen.getByTestId('mock-filter-builder');
expect(fb).toHaveAttribute('data-condition-count', '1');
expect(fb).toHaveAttribute('data-field-count', '3');
expect(screen.getByTestId('filter-condition-0')).toHaveTextContent('stage = active');
});

it('calls onOpenEditor when clicking filters row', () => {
const onOpenEditor = vi.fn();
it('renders inline SortBuilder with correct items from activeView', () => {
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={mockActiveView}
objectDef={mockObjectDef}
onOpenEditor={onOpenEditor}
/>
);

const filterRow = screen.getByText('console.objectView.filterBy').closest('button');
expect(filterRow).toBeTruthy();
fireEvent.click(filterRow!);
const sb = screen.getByTestId('mock-sort-builder');
expect(sb).toHaveAttribute('data-sort-count', '1');
expect(sb).toHaveAttribute('data-field-count', '3');
expect(screen.getByTestId('sort-item-0')).toHaveTextContent('name asc');
});

expect(onOpenEditor).toHaveBeenCalledWith('filters');
it('updates draft when adding a filter via FilterBuilder', () => {
const onViewUpdate = vi.fn();
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={{ id: 'empty', label: 'Empty', type: 'grid' }}
objectDef={mockObjectDef}
onViewUpdate={onViewUpdate}
/>
);

fireEvent.click(screen.getByTestId('filter-builder-add'));
expect(onViewUpdate).toHaveBeenCalledWith('filter', expect.any(Array));
});

it('calls onOpenEditor when clicking sort row', () => {
const onOpenEditor = vi.fn();
it('updates draft when adding a sort via SortBuilder', () => {
const onViewUpdate = vi.fn();
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={mockActiveView}
activeView={{ id: 'empty', label: 'Empty', type: 'grid' }}
objectDef={mockObjectDef}
onOpenEditor={onOpenEditor}
onViewUpdate={onViewUpdate}
/>
);

const sortRow = screen.getByText('console.objectView.sortBy').closest('button');
expect(sortRow).toBeTruthy();
fireEvent.click(sortRow!);
fireEvent.click(screen.getByTestId('sort-builder-add'));
expect(onViewUpdate).toHaveBeenCalledWith('sort', expect.any(Array));
});

it('toggles column checkbox and calls onViewUpdate with updated columns', () => {
const onViewUpdate = vi.fn();
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={mockActiveView}
objectDef={mockObjectDef}
onViewUpdate={onViewUpdate}
/>
);

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

it('saves draft via onSave when Save button is clicked', () => {
Expand Down Expand Up @@ -551,4 +618,87 @@ describe('ViewConfigPanel', () => {
expect(screen.getByTestId('toggle-allowExport')).toHaveAttribute('aria-checked', 'false');
expect(screen.getByTestId('toggle-addRecordViaForm')).toHaveAttribute('aria-checked', 'true');
});

// ── Real-time draft propagation tests (issue fix) ──

it('keeps dirty state when re-rendered with same view ID but updated activeView', () => {
const onViewUpdate = vi.fn();
const { rerender } = render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={mockActiveView}
objectDef={mockObjectDef}
onViewUpdate={onViewUpdate}
/>
);

// Toggle showSearch — panel becomes dirty
fireEvent.click(screen.getByTestId('toggle-showSearch'));
expect(screen.getByTestId('view-config-footer')).toBeInTheDocument();

// Simulate parent re-rendering with the same view ID but merged draft
// (this happens when onViewUpdate propagates to parent viewDraft → activeView)
rerender(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={{ ...mockActiveView, showSearch: false }}
objectDef={mockObjectDef}
onViewUpdate={onViewUpdate}
/>
);

// Draft footer should still be visible (isDirty should NOT reset for same view ID)
expect(screen.getByTestId('view-config-footer')).toBeInTheDocument();
});

it('resets dirty state when activeView changes to a different view ID', () => {
const { rerender } = render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={mockActiveView}
objectDef={mockObjectDef}
/>
);

// Make the panel dirty
fireEvent.click(screen.getByTestId('toggle-showSearch'));
expect(screen.getByTestId('view-config-footer')).toBeInTheDocument();

// Switch to a completely different view
rerender(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={{ id: 'pipeline', label: 'Pipeline', type: 'kanban', columns: ['name'] }}
objectDef={mockObjectDef}
/>
);

// Draft should reset — footer should be gone
expect(screen.queryByTestId('view-config-footer')).not.toBeInTheDocument();
});

it('calls onViewUpdate for each real-time field change to enable live preview', () => {
const onViewUpdate = vi.fn();
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={mockActiveView}
objectDef={mockObjectDef}
onViewUpdate={onViewUpdate}
/>
);

// Toggle multiple switches
fireEvent.click(screen.getByTestId('toggle-showSearch'));
fireEvent.click(screen.getByTestId('toggle-showFilters'));

expect(onViewUpdate).toHaveBeenCalledTimes(2);
expect(onViewUpdate).toHaveBeenCalledWith('showSearch', false);
expect(onViewUpdate).toHaveBeenCalledWith('showFilters', false);
});
});
24 changes: 19 additions & 5 deletions apps/console/src/components/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,15 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
? { ...baseView, ...viewDraft }
: baseView;

/** Real-time draft field update — propagates each toggle/input change immediately */
const handleViewUpdate = useCallback((field: string, value: any) => {
setViewDraft(prev => ({
...(prev || {}),
id: baseView?.id,
[field]: value,
}));
}, [baseView?.id]);

const handleViewChange = (newViewId: string) => {
// The plugin ObjectView returns the view ID directly via onViewChange
const matchedView = views.find((v: any) => v.id === newViewId);
Expand Down Expand Up @@ -324,21 +333,21 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
);
}, [activeView, objectDef, objectName, refreshKey]);

// Build the ObjectViewSchema for the plugin
// Build the ObjectViewSchema for the plugin — reads from activeView (which merges draft)
const objectViewSchema = useMemo(() => ({
type: 'object-view' as const,
objectName: objectDef.name,
layout: 'page' as const,
showSearch: true,
showFilters: true,
showSearch: activeView?.showSearch !== false,
showFilters: activeView?.showFilters !== false,
showCreate: false, // We render our own create button in the header
showRefresh: true,
onNavigate: (recordId: string | number, mode: 'view' | 'edit') => {
if (mode === 'edit') {
onEdit?.({ _id: recordId, id: recordId });
}
},
}), [objectDef.name, onEdit]);
}), [objectDef.name, onEdit, activeView?.showSearch, activeView?.showFilters]);

return (
<div className="h-full flex flex-col bg-background">
Expand Down Expand Up @@ -471,7 +480,11 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
key={refreshKey}
schema={objectViewSchema}
dataSource={dataSource}
views={views}
views={views.map((v: any) =>
v.id === activeViewId && viewDraft && viewDraft.id === v.id
? { ...v, ...viewDraft }
: v
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

views={views.map(...)} recreates the active view object on every parent render (via { ...v, ...viewDraft }), even when viewDraft hasn't changed. @object-ui/plugin-view's ObjectView effect depends on activeView, so this can trigger unnecessary data refetches / state resets. Consider memoizing the merged views array (and reusing the merged active view object) with useMemo keyed on [views, activeViewId, viewDraft] to keep references stable when nothing changed.

Suggested change
views={views.map((v: any) =>
v.id === activeViewId && viewDraft && viewDraft.id === v.id
? { ...v, ...viewDraft }
: v
views={useMemo(
() =>
views.map((v: any) =>
v.id === activeViewId && viewDraft && viewDraft.id === v.id
? { ...v, ...viewDraft }
: v
),
[views, activeViewId, viewDraft]

Copilot uses AI. Check for mistakes.
)}
activeViewId={activeViewId}
onViewChange={handleViewChange}
onEdit={(record: any) => onEdit?.(record)}
Expand Down Expand Up @@ -503,6 +516,7 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
recordCount={recordCount}
onOpenEditor={handleOpenEditor}
onSave={handleViewConfigSave}
onViewUpdate={handleViewUpdate}
/>
</div>

Expand Down
Loading