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, backend persistence via DataSource.updateViewConfig
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
276 changes: 244 additions & 32 deletions apps/console/src/__tests__/ViewConfigPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,50 @@ 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 = {
id: 'all',
label: 'All Records',
type: 'grid',
columns: ['name', 'stage', 'amount'],
filter: [{ field: 'stage', operator: '=', value: 'active' }],
filter: ['stage', '=', 'active'], // spec-style single triplet
sort: [{ field: 'name', order: 'asc' }],
};

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 equals 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,149 @@ 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);
});

// ── Spec-style filter bridge tests ──

it('parses nested spec-style filter array [[field,op,val],[field,op,val]]', () => {
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={{
...mockActiveView,
filter: [['stage', '=', 'active'], ['name', '!=', 'Test']],
}}
objectDef={mockObjectDef}
/>
);

const fb = screen.getByTestId('mock-filter-builder');
expect(fb).toHaveAttribute('data-condition-count', '2');
expect(screen.getByTestId('filter-condition-0')).toHaveTextContent('stage equals active');
expect(screen.getByTestId('filter-condition-1')).toHaveTextContent('name notEquals Test');
});

it('parses and/or logic prefix: ["or", [...], [...]]', () => {
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={{
...mockActiveView,
filter: ['or', ['stage', '=', 'active'], ['stage', '=', 'pending']],
}}
objectDef={mockObjectDef}
/>
);

const fb = screen.getByTestId('mock-filter-builder');
expect(fb).toHaveAttribute('data-condition-count', '2');
});

it('normalizes field types for FilterBuilder (currency→number)', () => {
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={mockActiveView}
objectDef={{
...mockObjectDef,
fields: {
name: { label: 'Name', type: 'text' },
revenue: { label: 'Revenue', type: 'currency' },
created: { label: 'Created', type: 'datetime' },
active: { label: 'Active', type: 'boolean' },
status: { label: 'Status', type: 'picklist' },
},
}}
/>
);

// The mock FilterBuilder receives normalized fields via data-field-count
const fb = screen.getByTestId('mock-filter-builder');
expect(fb).toHaveAttribute('data-field-count', '5');
});
});
Loading
Loading