diff --git a/ROADMAP_CONSOLE.md b/ROADMAP_CONSOLE.md index 186bc5779a..40177a19a5 100644 --- a/ROADMAP_CONSOLE.md +++ b/ROADMAP_CONSOLE.md @@ -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 @@ -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 | --- diff --git a/apps/console/src/__tests__/ObjectView.test.tsx b/apps/console/src/__tests__/ObjectView.test.tsx index 15d93ce074..1fbe334870 100644 --- a/apps/console/src/__tests__/ObjectView.test.tsx +++ b/apps/console/src/__tests__/ObjectView.test.tsx @@ -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) => , - Input: (props: any) => , + Button: ({ children, onClick, title, ...rest }: any) => , + Input: (props: any) => , + Switch: ({ checked, onCheckedChange, ...props }: any) => ( + , Tabs: ({ value, onValueChange, children }: any) => ( @@ -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(); + + // 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(); + + // 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(); + + // 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), + ); + }); + errorSpy.mockRestore(); + }); }); diff --git a/apps/console/src/components/ObjectView.tsx b/apps/console/src/components/ObjectView.tsx index 441455e2b4..ca5fc7a55a 100644 --- a/apps/console/src/components/ObjectView.tsx +++ b/apps/console/src/components/ObjectView.tsx @@ -72,7 +72,22 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) { const handleViewConfigSave = useCallback((draft: Record) => { 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); diff --git a/packages/types/src/data.ts b/packages/types/src/data.ts index b775708c03..4aa18b825b 100644 --- a/packages/types/src/data.ts +++ b/packages/types/src/data.ts @@ -237,6 +237,19 @@ export interface DataSource { */ getView?(objectName: string, viewId: string): Promise; + /** + * 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): Promise | void>; + /** * Get an application definition by name or ID. * Used by app shells to render server-defined navigation, branding, and layout.