Skip to content

Commit 40381fd

Browse files
authored
Merge pull request #688 from objectstack-ai/copilot/unify-add-edit-view-panel
2 parents 50649de + e6fa5a5 commit 40381fd

8 files changed

Lines changed: 592 additions & 19 deletions

File tree

ROADMAP.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,13 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
116116

117117
### P1.8 Console — View Config Panel (Phase 20)
118118

119-
- [ ] Inline ViewConfigPanel for all view types (Airtable-style right sidebar)
120-
- [ ] Column visibility toggle from config panel
121-
- [ ] Sort/filter/group config from right sidebar
119+
- [x] Inline ViewConfigPanel for all view types (Airtable-style right sidebar)
120+
- [x] Column visibility toggle from config panel
121+
- [x] Sort/filter/group config from right sidebar
122+
- [x] Type-specific options in config panel (kanban/calendar/map/gallery/timeline/gantt)
123+
- [x] Unified create/edit mode (`mode="create"|"edit"`) — single panel entry point
124+
- [x] Unified data model (`UnifiedViewConfig`) for view configuration
125+
- [x] ViewDesigner retained as "Advanced Editor" with weaker entry point
122126
- [ ] View appearance settings (density, row color, conditional formatting)
123127

124128
---

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ describe('ObjectView Component', () => {
227227
expect(screen.queryByTitle('console.objectView.designTools')).not.toBeInTheDocument();
228228
});
229229

230-
it('navigates to view designer with relative path from nested view route', () => {
230+
it('opens config panel in create mode when Add View is clicked from nested view route', () => {
231231
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
232232
mockUseParams.mockReturnValue({ objectName: 'opportunity', viewId: 'pipeline' });
233233

@@ -240,10 +240,12 @@ describe('ObjectView Component', () => {
240240
const addViewBtn = screen.getByText('console.objectView.addView');
241241
fireEvent.click(addViewBtn);
242242

243-
expect(mockNavigate).toHaveBeenCalledWith('../../views/new', { relative: 'path' });
243+
// Should open config panel instead of navigating
244+
expect(mockNavigate).not.toHaveBeenCalledWith('../../views/new', { relative: 'path' });
245+
expect(screen.getByTestId('view-config-panel')).toBeInTheDocument();
244246
});
245247

246-
it('navigates to view designer with relative path from root object route', () => {
248+
it('opens config panel in create mode when Add View is clicked from root object route', () => {
247249
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
248250
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
249251

@@ -255,6 +257,23 @@ describe('ObjectView Component', () => {
255257
const addViewBtn = screen.getByText('console.objectView.addView');
256258
fireEvent.click(addViewBtn);
257259

260+
// Should open config panel instead of navigating
261+
expect(mockNavigate).not.toHaveBeenCalledWith('views/new', { relative: 'path' });
262+
expect(screen.getByTestId('view-config-panel')).toBeInTheDocument();
263+
});
264+
265+
it('navigates to view designer when Advanced Editor is clicked', () => {
266+
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
267+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
268+
269+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
270+
271+
const designBtn = screen.getByTitle('console.objectView.designTools');
272+
fireEvent.click(designBtn);
273+
274+
const advancedBtn = screen.getByText('console.objectView.advancedEditor');
275+
fireEvent.click(advancedBtn);
276+
258277
expect(mockNavigate).toHaveBeenCalledWith('views/new', { relative: 'path' });
259278
});
260279

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

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,4 +763,233 @@ describe('ViewConfigPanel', () => {
763763
const fb = screen.getByTestId('mock-filter-builder');
764764
expect(fb).toHaveAttribute('data-field-count', '5');
765765
});
766+
767+
// ── Create mode (mode="create") tests ──
768+
769+
it('renders in create mode with "Create View" title', () => {
770+
render(
771+
<ViewConfigPanel
772+
open={true}
773+
onClose={vi.fn()}
774+
mode="create"
775+
activeView={mockActiveView}
776+
objectDef={mockObjectDef}
777+
/>
778+
);
779+
780+
const panel = screen.getByTestId('view-config-panel');
781+
expect(panel).toHaveAttribute('aria-label', 'console.objectView.createView');
782+
});
783+
784+
it('shows save/discard footer immediately in create mode', () => {
785+
render(
786+
<ViewConfigPanel
787+
open={true}
788+
onClose={vi.fn()}
789+
mode="create"
790+
activeView={mockActiveView}
791+
objectDef={mockObjectDef}
792+
/>
793+
);
794+
795+
expect(screen.getByTestId('view-config-footer')).toBeInTheDocument();
796+
});
797+
798+
it('calls onCreate (not onSave) when saving in create mode', () => {
799+
const onCreate = vi.fn();
800+
const onSave = vi.fn();
801+
render(
802+
<ViewConfigPanel
803+
open={true}
804+
onClose={vi.fn()}
805+
mode="create"
806+
activeView={mockActiveView}
807+
objectDef={mockObjectDef}
808+
onSave={onSave}
809+
onCreate={onCreate}
810+
/>
811+
);
812+
813+
// Change title first
814+
const titleInput = screen.getByTestId('view-title-input');
815+
fireEvent.change(titleInput, { target: { value: 'New Kanban' } });
816+
817+
fireEvent.click(screen.getByTestId('view-config-save'));
818+
819+
expect(onCreate).toHaveBeenCalledOnce();
820+
expect(onCreate.mock.calls[0][0]).toMatchObject({ label: 'New Kanban' });
821+
expect(onSave).not.toHaveBeenCalled();
822+
});
823+
824+
it('calls onClose when discarding in create mode', () => {
825+
const onClose = vi.fn();
826+
render(
827+
<ViewConfigPanel
828+
open={true}
829+
onClose={onClose}
830+
mode="create"
831+
activeView={mockActiveView}
832+
objectDef={mockObjectDef}
833+
/>
834+
);
835+
836+
fireEvent.click(screen.getByTestId('view-config-discard'));
837+
expect(onClose).toHaveBeenCalledOnce();
838+
});
839+
840+
it('starts with default label in create mode', () => {
841+
render(
842+
<ViewConfigPanel
843+
open={true}
844+
onClose={vi.fn()}
845+
mode="create"
846+
activeView={mockActiveView}
847+
objectDef={mockObjectDef}
848+
/>
849+
);
850+
851+
const titleInput = screen.getByTestId('view-title-input');
852+
// In create mode, draft starts with the "New View" i18n key
853+
expect(titleInput).toHaveValue('console.objectView.newView');
854+
});
855+
856+
// ── Type-specific options tests ──
857+
858+
it('shows kanban groupByField when view type is kanban', () => {
859+
render(
860+
<ViewConfigPanel
861+
open={true}
862+
onClose={vi.fn()}
863+
activeView={{ ...mockActiveView, type: 'kanban' }}
864+
objectDef={mockObjectDef}
865+
/>
866+
);
867+
868+
expect(screen.getByTestId('type-options-section')).toBeInTheDocument();
869+
expect(screen.getByTestId('type-opt-kanban-groupByField')).toBeInTheDocument();
870+
});
871+
872+
it('shows calendar fields when view type is calendar', () => {
873+
render(
874+
<ViewConfigPanel
875+
open={true}
876+
onClose={vi.fn()}
877+
activeView={{ ...mockActiveView, type: 'calendar' }}
878+
objectDef={mockObjectDef}
879+
/>
880+
);
881+
882+
expect(screen.getByTestId('type-opt-calendar-startDateField')).toBeInTheDocument();
883+
expect(screen.getByTestId('type-opt-calendar-titleField')).toBeInTheDocument();
884+
});
885+
886+
it('shows map fields when view type is map', () => {
887+
render(
888+
<ViewConfigPanel
889+
open={true}
890+
onClose={vi.fn()}
891+
activeView={{ ...mockActiveView, type: 'map' }}
892+
objectDef={mockObjectDef}
893+
/>
894+
);
895+
896+
expect(screen.getByTestId('type-opt-map-latitudeField')).toBeInTheDocument();
897+
expect(screen.getByTestId('type-opt-map-longitudeField')).toBeInTheDocument();
898+
});
899+
900+
it('shows gallery imageField when view type is gallery', () => {
901+
render(
902+
<ViewConfigPanel
903+
open={true}
904+
onClose={vi.fn()}
905+
activeView={{ ...mockActiveView, type: 'gallery' }}
906+
objectDef={mockObjectDef}
907+
/>
908+
);
909+
910+
expect(screen.getByTestId('type-opt-gallery-imageField')).toBeInTheDocument();
911+
});
912+
913+
it('shows timeline dateField and titleField when view type is timeline', () => {
914+
render(
915+
<ViewConfigPanel
916+
open={true}
917+
onClose={vi.fn()}
918+
activeView={{ ...mockActiveView, type: 'timeline' }}
919+
objectDef={mockObjectDef}
920+
/>
921+
);
922+
923+
expect(screen.getByTestId('type-opt-timeline-dateField')).toBeInTheDocument();
924+
expect(screen.getByTestId('type-opt-timeline-titleField')).toBeInTheDocument();
925+
});
926+
927+
it('shows gantt dateField and titleField when view type is gantt', () => {
928+
render(
929+
<ViewConfigPanel
930+
open={true}
931+
onClose={vi.fn()}
932+
activeView={{ ...mockActiveView, type: 'gantt' }}
933+
objectDef={mockObjectDef}
934+
/>
935+
);
936+
937+
expect(screen.getByTestId('type-opt-gantt-dateField')).toBeInTheDocument();
938+
expect(screen.getByTestId('type-opt-gantt-titleField')).toBeInTheDocument();
939+
});
940+
941+
it('does not show type options section for grid view', () => {
942+
render(
943+
<ViewConfigPanel
944+
open={true}
945+
onClose={vi.fn()}
946+
activeView={{ ...mockActiveView, type: 'grid' }}
947+
objectDef={mockObjectDef}
948+
/>
949+
);
950+
951+
// Grid type options section is a hidden placeholder
952+
const section = screen.getByTestId('type-options-section');
953+
expect(section.className).toContain('hidden');
954+
});
955+
956+
it('updates kanban groupByField via type option select', () => {
957+
const onViewUpdate = vi.fn();
958+
render(
959+
<ViewConfigPanel
960+
open={true}
961+
onClose={vi.fn()}
962+
activeView={{ ...mockActiveView, type: 'kanban' }}
963+
objectDef={mockObjectDef}
964+
onViewUpdate={onViewUpdate}
965+
/>
966+
);
967+
968+
const select = screen.getByTestId('type-opt-kanban-groupByField');
969+
fireEvent.change(select, { target: { value: 'stage' } });
970+
971+
expect(onViewUpdate).toHaveBeenCalledWith('kanban', expect.objectContaining({ groupByField: 'stage' }));
972+
});
973+
974+
it('shows type options when view type changes from grid to kanban', () => {
975+
const onViewUpdate = vi.fn();
976+
render(
977+
<ViewConfigPanel
978+
open={true}
979+
onClose={vi.fn()}
980+
activeView={mockActiveView}
981+
objectDef={mockObjectDef}
982+
onViewUpdate={onViewUpdate}
983+
/>
984+
);
985+
986+
// Initially grid — type options hidden
987+
expect(screen.getByTestId('type-options-section').className).toContain('hidden');
988+
989+
// Change to kanban
990+
fireEvent.change(screen.getByTestId('view-type-select'), { target: { value: 'kanban' } });
991+
992+
// Now kanban groupBy should appear
993+
expect(screen.getByTestId('type-opt-kanban-groupByField')).toBeInTheDocument();
994+
});
766995
});

apps/console/src/components/ObjectView.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
6565

6666
// Inline view config panel state (Airtable-style right sidebar)
6767
const [showViewConfigPanel, setShowViewConfigPanel] = useState(false);
68+
const [viewConfigPanelMode, setViewConfigPanelMode] = useState<'create' | 'edit'>('edit');
6869

6970
// Draft state for view config edits — cached locally, saved on demand
7071
const [viewDraft, setViewDraft] = useState<Record<string, any> | null>(null);
@@ -88,6 +89,21 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
8889
console.warn('[ViewConfigPanel] dataSource.updateViewConfig is not available. View config saved locally only.');
8990
}
9091
}, [dataSource, objectName]);
92+
93+
/** Create a new view via the config panel */
94+
const handleViewCreate = useCallback(async (config: Record<string, any>) => {
95+
try {
96+
if (dataSource?.create) {
97+
const payload = { objectName, ...config };
98+
await dataSource.create('sys_view', payload);
99+
}
100+
setShowViewConfigPanel(false);
101+
setViewConfigPanelMode('edit');
102+
setRefreshKey(k => k + 1);
103+
} catch (err) {
104+
console.error('[ViewConfigPanel] Failed to create view:', err);
105+
}
106+
}, [dataSource, objectName]);
91107

92108
// Record count tracking for footer
93109
const [recordCount, setRecordCount] = useState<number | undefined>(undefined);
@@ -434,14 +450,19 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
434450
{t('console.objectView.metadataInspector')}
435451
</DropdownMenuItem>
436452
<DropdownMenuSeparator />
437-
<DropdownMenuItem onClick={() => setShowViewConfigPanel(prev => !prev)}>
453+
<DropdownMenuItem onClick={() => { setViewConfigPanelMode('edit'); setShowViewConfigPanel(prev => !prev); }}>
438454
<Settings2 className="h-4 w-4 mr-2" />
439455
{t('console.objectView.editView')}
440456
</DropdownMenuItem>
441-
<DropdownMenuItem onClick={() => navigate(viewId ? '../../views/new' : 'views/new', { relative: 'path' })}>
457+
<DropdownMenuItem onClick={() => { setViewConfigPanelMode('create'); setShowViewConfigPanel(true); }}>
442458
<Plus className="h-4 w-4 mr-2" />
443459
{t('console.objectView.addView')}
444460
</DropdownMenuItem>
461+
<DropdownMenuSeparator />
462+
<DropdownMenuItem onClick={() => navigate(viewId ? '../../views/new' : 'views/new', { relative: 'path' })}>
463+
<Wrench className="h-4 w-4 mr-2" />
464+
{t('console.objectView.advancedEditor')}
465+
</DropdownMenuItem>
445466
</DropdownMenuContent>
446467
</DropdownMenu>
447468
)}
@@ -463,7 +484,7 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
463484
onViewChange={handleViewChange}
464485
viewTypeIcons={VIEW_TYPE_ICONS}
465486
config={{ ...objectDef.viewTabBar, reorderable: isAdmin ? true : objectDef.viewTabBar?.reorderable }}
466-
onAddView={isAdmin ? () => navigate(viewId ? '../../views/new' : 'views/new', { relative: 'path' }) : undefined}
487+
onAddView={isAdmin ? () => { setViewConfigPanelMode('create'); setShowViewConfigPanel(true); } : undefined}
467488
onRenameView={(id, newName) => {
468489
// Rename is wired for future backend integration
469490
console.info('[ViewTabBar] Rename view:', id, newName);
@@ -528,12 +549,14 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
528549
{/* Inline View Config Panel — Airtable-style right sidebar */}
529550
<ViewConfigPanel
530551
open={showViewConfigPanel && isAdmin}
531-
onClose={() => setShowViewConfigPanel(false)}
552+
onClose={() => { setShowViewConfigPanel(false); setViewConfigPanelMode('edit'); }}
553+
mode={viewConfigPanelMode}
532554
activeView={activeView}
533555
objectDef={objectDef}
534556
recordCount={recordCount}
535557
onSave={handleViewConfigSave}
536558
onViewUpdate={handleViewUpdate}
559+
onCreate={handleViewCreate}
537560
/>
538561
</div>
539562

0 commit comments

Comments
 (0)