Skip to content

Commit 71dd9e8

Browse files
authored
Merge pull request #678 from objectstack-ai/copilot/save-draft-config-to-backend
2 parents 74cbdf1 + 6d644fa commit 71dd9e8

File tree

4 files changed

+127
-5
lines changed

4 files changed

+127
-5
lines changed

ROADMAP_CONSOLE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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
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
11071107
```
11081108

11091109
### Milestone Summary
@@ -1118,7 +1118,7 @@ These were the initial tasks to bring the console prototype to production-qualit
11181118
| **v1.1** | v1.1.0 | ✅ Complete | Kanban + Forms + Import/Export (Phases 13-15); all L1 ✅ |
11191119
| **v1.2** | v1.2.0 | ✅ L1 Complete | Undo/Redo + Collaboration (Phases 16-17); L1 integrated into console |
11201120
| **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 |
1121-
| **v2.1** | v2.1.0 | ✅ Complete | Inline ViewConfigPanel (Phase 20): Airtable-style right sidebar with full interactive editing support |
1121+
| **v2.1** | v2.1.0 | ✅ Complete | Inline ViewConfigPanel (Phase 20): Airtable-style right sidebar with full interactive editing support, backend persistence via DataSource.updateViewConfig |
11221122

11231123
---
11241124

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

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,16 @@ vi.mock('@object-ui/components', async (importOriginal) => {
2424
return {
2525
...actual,
2626
cn: (...inputs: any[]) => inputs.filter(Boolean).join(' '),
27-
Button: ({ children, onClick, title }: any) => <button onClick={onClick} title={title}>{children}</button>,
28-
Input: (props: any) => <input {...props} data-testid="mock-input" />,
27+
Button: ({ children, onClick, title, ...rest }: any) => <button onClick={onClick} title={title} {...rest}>{children}</button>,
28+
Input: (props: any) => <input {...props} />,
29+
Switch: ({ checked, onCheckedChange, ...props }: any) => (
30+
<button
31+
role="switch"
32+
aria-checked={checked}
33+
onClick={() => onCheckedChange?.(!checked)}
34+
{...props}
35+
/>
36+
),
2937
ToggleGroup: ({ children, value, onValueChange }: any) => <div data-value={value} onChange={onValueChange}>{children}</div>,
3038
ToggleGroupItem: ({ children, value }: any) => <button data-value={value}>{children}</button>,
3139
Tabs: ({ value, onValueChange, children }: any) => (
@@ -338,4 +346,90 @@ describe('ObjectView Component', () => {
338346
const footer = await screen.findByTestId('record-count-footer');
339347
expect(footer).toBeInTheDocument();
340348
});
349+
350+
it('calls dataSource.updateViewConfig when saving view config', async () => {
351+
const mockUpdateViewConfig = vi.fn().mockResolvedValue({});
352+
const dsWithUpdate = {
353+
...mockDataSource,
354+
updateViewConfig: mockUpdateViewConfig,
355+
};
356+
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
357+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
358+
359+
render(<ObjectView dataSource={dsWithUpdate} objects={mockObjects} onEdit={vi.fn()} />);
360+
361+
// Open config panel
362+
fireEvent.click(screen.getByTitle('console.objectView.designTools'));
363+
fireEvent.click(screen.getByText('console.objectView.editView'));
364+
expect(screen.getByTestId('view-config-panel')).toBeInTheDocument();
365+
366+
// Wait for draft to be initialized from activeView, then modify
367+
const titleInput = await screen.findByDisplayValue('All Opportunities');
368+
fireEvent.change(titleInput, { target: { value: 'My Custom View' } });
369+
370+
// Save button should appear after dirty state
371+
const saveBtn = await screen.findByTestId('view-config-save');
372+
fireEvent.click(saveBtn);
373+
374+
expect(mockUpdateViewConfig).toHaveBeenCalledOnce();
375+
expect(mockUpdateViewConfig).toHaveBeenCalledWith(
376+
'opportunity',
377+
'all',
378+
expect.objectContaining({ label: 'My Custom View' }),
379+
);
380+
});
381+
382+
it('logs warning when dataSource.updateViewConfig is not available', async () => {
383+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
384+
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
385+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
386+
387+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
388+
389+
// Open config panel
390+
fireEvent.click(screen.getByTitle('console.objectView.designTools'));
391+
fireEvent.click(screen.getByText('console.objectView.editView'));
392+
393+
// Make a change and save
394+
const titleInput = await screen.findByDisplayValue('All Opportunities');
395+
fireEvent.change(titleInput, { target: { value: 'Changed' } });
396+
const saveBtn = await screen.findByTestId('view-config-save');
397+
fireEvent.click(saveBtn);
398+
399+
expect(warnSpy).toHaveBeenCalledWith(
400+
expect.stringContaining('updateViewConfig is not available'),
401+
);
402+
warnSpy.mockRestore();
403+
});
404+
405+
it('logs error when dataSource.updateViewConfig rejects', async () => {
406+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
407+
const dsWithFailingUpdate = {
408+
...mockDataSource,
409+
updateViewConfig: vi.fn().mockRejectedValue(new Error('Network error')),
410+
};
411+
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
412+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
413+
414+
render(<ObjectView dataSource={dsWithFailingUpdate} objects={mockObjects} onEdit={vi.fn()} />);
415+
416+
// Open config panel
417+
fireEvent.click(screen.getByTitle('console.objectView.designTools'));
418+
fireEvent.click(screen.getByText('console.objectView.editView'));
419+
420+
// Make a change and save
421+
const titleInput = await screen.findByDisplayValue('All Opportunities');
422+
fireEvent.change(titleInput, { target: { value: 'Failed' } });
423+
const saveBtn = await screen.findByTestId('view-config-save');
424+
fireEvent.click(saveBtn);
425+
426+
// Wait for the promise rejection to be caught
427+
await vi.waitFor(() => {
428+
expect(errorSpy).toHaveBeenCalledWith(
429+
expect.stringContaining('Failed to persist view config'),
430+
expect.any(Error),
431+
);
432+
});
433+
errorSpy.mockRestore();
434+
});
341435
});

apps/console/src/components/ObjectView.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,22 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
7272
const handleViewConfigSave = useCallback((draft: Record<string, any>) => {
7373
setViewDraft(draft);
7474
setRefreshKey(k => k + 1);
75-
}, []);
75+
76+
// Persist to backend if dataSource supports it
77+
if (dataSource?.updateViewConfig) {
78+
const objName = objectName;
79+
const vid = draft.id;
80+
if (objName && vid) {
81+
dataSource.updateViewConfig(objName, vid, draft).catch((err: any) => {
82+
console.error('[ViewConfigPanel] Failed to persist view config:', err);
83+
});
84+
} else {
85+
console.warn('[ViewConfigPanel] Cannot persist view config: missing objectName or viewId.');
86+
}
87+
} else {
88+
console.warn('[ViewConfigPanel] dataSource.updateViewConfig is not available. View config saved locally only.');
89+
}
90+
}, [dataSource, objectName]);
7691

7792
const handleOpenEditor = useCallback((editor: EditorPanelType) => {
7893
console.info('[ViewConfigPanel] Open editor:', editor);

packages/types/src/data.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,19 @@ export interface DataSource<T = any> {
237237
*/
238238
getView?(objectName: string, viewId: string): Promise<any | null>;
239239

240+
/**
241+
* Persist a view configuration to the backend.
242+
* Called when a user saves view settings (columns, filters, sort, toggles, etc.)
243+
* from the inline ViewConfigPanel.
244+
* Optional — implementations that do not support view persistence may omit this.
245+
*
246+
* @param objectName - Object name
247+
* @param viewId - View identifier (e.g., 'all', 'pipeline')
248+
* @param config - The full view configuration to persist
249+
* @returns Promise resolving to the persisted config (or void)
250+
*/
251+
updateViewConfig?(objectName: string, viewId: string, config: Record<string, any>): Promise<Record<string, any> | void>;
252+
240253
/**
241254
* Get an application definition by name or ID.
242255
* Used by app shells to render server-defined navigation, branding, and layout.

0 commit comments

Comments
 (0)