Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ROADMAP_CONSOLE.md
Original file line numberDiff line numberDiff line change
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), ARIA accessibility, backend persistence via DataSource.updateViewConfig
```

### Milestone Summary
Expand All@@ -1118,7 +1118,7 @@ These were the initial tasks to bring the console prototype to production-qualit
| **v1.1** | v1.1.0 | ✅ Complete | Kanban + Forms + Import/Export (Phases 13-15); all L1 ✅ |
| **v1.2** | v1.2.0 | ✅ L1 Complete | Undo/Redo + Collaboration (Phases 16-17); L1 integrated into console |
| **v2.0** | v2.0.0 | ✅ L2 Complete | All L2 features: batch undo, expression formatting, conditional triggers, multi-step actions, swimlane persistence, keyboard nav, file validation, thread resolution, notification prefs |
| **v2.1** | v2.1.0 | ✅ Complete | Inline ViewConfigPanel (Phase 20): Airtable-style right sidebar with full interactive editing support |
| **v2.1** | v2.1.0 | ✅ Complete | Inline ViewConfigPanel (Phase 20): Airtable-style right sidebar with full interactive editing support, backend persistence via DataSource.updateViewConfig |

---

Expand Down
98 changes: 96 additions & 2 deletions apps/console/src/__tests__/ObjectView.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,8 +24,16 @@ vi.mock('@object-ui/components', async (importOriginal) => {
return {
...actual,
cn: (...inputs: any[]) => inputs.filter(Boolean).join(' '),
Button: ({ children, onClick, title }: any) => <button onClick={onClick} title={title}>{children}</button>,
Input: (props: any) => <input {...props} data-testid="mock-input" />,
Button: ({ children, onClick, title, ...rest }: any) => <button onClick={onClick} title={title} {...rest}>{children}</button>,
Input: (props: any) => <input {...props} />,
Switch: ({ checked, onCheckedChange, ...props }: any) => (
<button
role="switch"
aria-checked={checked}
onClick={() => onCheckedChange?.(!checked)}
{...props}
/>
),
ToggleGroup: ({ children, value, onValueChange }: any) => <div data-value={value} onChange={onValueChange}>{children}</div>,
ToggleGroupItem: ({ children, value }: any) => <button data-value={value}>{children}</button>,
Tabs: ({ value, onValueChange, children }: any) => (
Expand DownExpand Up@@ -338,4 +346,90 @@ describe('ObjectView Component', () => {
const footer = await screen.findByTestId('record-count-footer');
expect(footer).toBeInTheDocument();
});

it('calls dataSource.updateViewConfig when saving view config', async () => {
const mockUpdateViewConfig = vi.fn().mockResolvedValue({});
const dsWithUpdate = {
...mockDataSource,
updateViewConfig: mockUpdateViewConfig,
};
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
mockUseParams.mockReturnValue({ objectName: 'opportunity' });

render(<ObjectView dataSource={dsWithUpdate} objects={mockObjects} onEdit={vi.fn()} />);

// Open config panel
fireEvent.click(screen.getByTitle('console.objectView.designTools'));
fireEvent.click(screen.getByText('console.objectView.editView'));
expect(screen.getByTestId('view-config-panel')).toBeInTheDocument();

// Wait for draft to be initialized from activeView, then modify
const titleInput = await screen.findByDisplayValue('All Opportunities');
fireEvent.change(titleInput, { target: { value: 'My Custom View' } });

// Save button should appear after dirty state
const saveBtn = await screen.findByTestId('view-config-save');
fireEvent.click(saveBtn);

expect(mockUpdateViewConfig).toHaveBeenCalledOnce();
expect(mockUpdateViewConfig).toHaveBeenCalledWith(
'opportunity',
'all',
expect.objectContaining({ label: 'My Custom View' }),
);
});

it('logs warning when dataSource.updateViewConfig is not available', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
mockUseParams.mockReturnValue({ objectName: 'opportunity' });

render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);

// Open config panel
fireEvent.click(screen.getByTitle('console.objectView.designTools'));
fireEvent.click(screen.getByText('console.objectView.editView'));

// Make a change and save
const titleInput = await screen.findByDisplayValue('All Opportunities');
fireEvent.change(titleInput, { target: { value: 'Changed' } });
const saveBtn = await screen.findByTestId('view-config-save');
fireEvent.click(saveBtn);

expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('updateViewConfig is not available'),
);
warnSpy.mockRestore();
});

it('logs error when dataSource.updateViewConfig rejects', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const dsWithFailingUpdate = {
...mockDataSource,
updateViewConfig: vi.fn().mockRejectedValue(new Error('Network error')),
};
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
mockUseParams.mockReturnValue({ objectName: 'opportunity' });

render(<ObjectView dataSource={dsWithFailingUpdate} objects={mockObjects} onEdit={vi.fn()} />);

// Open config panel
fireEvent.click(screen.getByTitle('console.objectView.designTools'));
fireEvent.click(screen.getByText('console.objectView.editView'));

// Make a change and save
const titleInput = await screen.findByDisplayValue('All Opportunities');
fireEvent.change(titleInput, { target: { value: 'Failed' } });
const saveBtn = await screen.findByTestId('view-config-save');
fireEvent.click(saveBtn);

// Wait for the promise rejection to be caught
await vi.waitFor(() => {
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to persist view config'),
expect.any(Error),
);
});
Comment on lines +426 to +432

CopilotAIFeb 21, 2026

Copy link

Choose a reason for hiding this comment

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

vi.waitFor is not part of the standard Vitest API used elsewhere in this repo; this test likely fails at runtime with vi.waitFor is not a function. Use waitFor from @testing-library/react (import it alongside render/screen/fireEvent) to await the async console.error assertion.

Copilot uses AI. Check for mistakes.
errorSpy.mockRestore();
});
});
17 changes: 16 additions & 1 deletion apps/console/src/components/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,22 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
const handleViewConfigSave = useCallback((draft: Record<string, any>) => {
setViewDraft(draft);
setRefreshKey(k => k + 1);
}, []);

// Persist to backend if dataSource supports it
if (dataSource?.updateViewConfig) {
const objName = objectName;
const vid = draft.id;
if (objName && vid) {
dataSource.updateViewConfig(objName, vid, draft).catch((err: any) => {
console.error('[ViewConfigPanel] Failed to persist view config:', err);
});
} else {
console.warn('[ViewConfigPanel] Cannot persist view config: missing objectName or viewId.');
}
} else {
console.warn('[ViewConfigPanel] dataSource.updateViewConfig is not available. View config saved locally only.');
}
}, [dataSource, objectName]);

const handleOpenEditor = useCallback((editor: EditorPanelType) => {
console.info('[ViewConfigPanel] Open editor:', editor);
Expand Down
13 changes: 13 additions & 0 deletions packages/types/src/data.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -237,6 +237,19 @@ export interface DataSource<T = any> {
*/
getView?(objectName: string, viewId: string): Promise<any | null>;

/**
* Persist a view configuration to the backend.
* Called when a user saves view settings (columns, filters, sort, toggles, etc.)
* from the inline ViewConfigPanel.
* Optional — implementations that do not support view persistence may omit this.
*
* @param objectName - Object name
* @param viewId - View identifier (e.g., 'all', 'pipeline')
* @param config - The full view configuration to persist
* @returns Promise resolving to the persisted config (or void)
*/
updateViewConfig?(objectName: string, viewId: string, config: Record<string, any>): Promise<Record<string, any> | void>;

/**
* Get an application definition by name or ID.
* Used by app shells to render server-defined navigation, branding, and layout.
Expand Down