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
10 changes: 7 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,13 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind

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

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

---
Expand Down
25 changes: 22 additions & 3 deletions apps/console/src/__tests__/ObjectView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ describe('ObjectView Component', () => {
expect(screen.queryByTitle('console.objectView.designTools')).not.toBeInTheDocument();
});

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

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

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

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

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

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

it('navigates to view designer when Advanced Editor is clicked', () => {
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
mockUseParams.mockReturnValue({ objectName: 'opportunity' });

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

const designBtn = screen.getByTitle('console.objectView.designTools');
fireEvent.click(designBtn);

const advancedBtn = screen.getByText('console.objectView.advancedEditor');
fireEvent.click(advancedBtn);

expect(mockNavigate).toHaveBeenCalledWith('views/new', { relative: 'path' });
});

Expand Down
229 changes: 229 additions & 0 deletions apps/console/src/__tests__/ViewConfigPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -763,4 +763,233 @@ describe('ViewConfigPanel', () => {
const fb = screen.getByTestId('mock-filter-builder');
expect(fb).toHaveAttribute('data-field-count', '5');
});

// ── Create mode (mode="create") tests ──

it('renders in create mode with "Create View" title', () => {
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
mode="create"
activeView={mockActiveView}
objectDef={mockObjectDef}
/>
);

const panel = screen.getByTestId('view-config-panel');
expect(panel).toHaveAttribute('aria-label', 'console.objectView.createView');
});

it('shows save/discard footer immediately in create mode', () => {
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
mode="create"
activeView={mockActiveView}
objectDef={mockObjectDef}
/>
);

expect(screen.getByTestId('view-config-footer')).toBeInTheDocument();
});

it('calls onCreate (not onSave) when saving in create mode', () => {
const onCreate = vi.fn();
const onSave = vi.fn();
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
mode="create"
activeView={mockActiveView}
objectDef={mockObjectDef}
onSave={onSave}
onCreate={onCreate}
/>
);

// Change title first
const titleInput = screen.getByTestId('view-title-input');
fireEvent.change(titleInput, { target: { value: 'New Kanban' } });

fireEvent.click(screen.getByTestId('view-config-save'));

expect(onCreate).toHaveBeenCalledOnce();
expect(onCreate.mock.calls[0][0]).toMatchObject({ label: 'New Kanban' });
expect(onSave).not.toHaveBeenCalled();
});

it('calls onClose when discarding in create mode', () => {
const onClose = vi.fn();
render(
<ViewConfigPanel
open={true}
onClose={onClose}
mode="create"
activeView={mockActiveView}
objectDef={mockObjectDef}
/>
);

fireEvent.click(screen.getByTestId('view-config-discard'));
expect(onClose).toHaveBeenCalledOnce();
});

it('starts with default label in create mode', () => {
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
mode="create"
activeView={mockActiveView}
objectDef={mockObjectDef}
/>
);

const titleInput = screen.getByTestId('view-title-input');
// In create mode, draft starts with the "New View" i18n key
expect(titleInput).toHaveValue('console.objectView.newView');
});

// ── Type-specific options tests ──

it('shows kanban groupByField when view type is kanban', () => {
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={{ ...mockActiveView, type: 'kanban' }}
objectDef={mockObjectDef}
/>
);

expect(screen.getByTestId('type-options-section')).toBeInTheDocument();
expect(screen.getByTestId('type-opt-kanban-groupByField')).toBeInTheDocument();
});

it('shows calendar fields when view type is calendar', () => {
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={{ ...mockActiveView, type: 'calendar' }}
objectDef={mockObjectDef}
/>
);

expect(screen.getByTestId('type-opt-calendar-startDateField')).toBeInTheDocument();
expect(screen.getByTestId('type-opt-calendar-titleField')).toBeInTheDocument();
});

it('shows map fields when view type is map', () => {
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={{ ...mockActiveView, type: 'map' }}
objectDef={mockObjectDef}
/>
);

expect(screen.getByTestId('type-opt-map-latitudeField')).toBeInTheDocument();
expect(screen.getByTestId('type-opt-map-longitudeField')).toBeInTheDocument();
});

it('shows gallery imageField when view type is gallery', () => {
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={{ ...mockActiveView, type: 'gallery' }}
objectDef={mockObjectDef}
/>
);

expect(screen.getByTestId('type-opt-gallery-imageField')).toBeInTheDocument();
});

it('shows timeline dateField and titleField when view type is timeline', () => {
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={{ ...mockActiveView, type: 'timeline' }}
objectDef={mockObjectDef}
/>
);

expect(screen.getByTestId('type-opt-timeline-dateField')).toBeInTheDocument();
expect(screen.getByTestId('type-opt-timeline-titleField')).toBeInTheDocument();
});

it('shows gantt dateField and titleField when view type is gantt', () => {
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={{ ...mockActiveView, type: 'gantt' }}
objectDef={mockObjectDef}
/>
);

expect(screen.getByTestId('type-opt-gantt-dateField')).toBeInTheDocument();
expect(screen.getByTestId('type-opt-gantt-titleField')).toBeInTheDocument();
});

it('does not show type options section for grid view', () => {
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={{ ...mockActiveView, type: 'grid' }}
objectDef={mockObjectDef}
/>
);

// Grid type options section is a hidden placeholder
const section = screen.getByTestId('type-options-section');
expect(section.className).toContain('hidden');
});

it('updates kanban groupByField via type option select', () => {
const onViewUpdate = vi.fn();
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={{ ...mockActiveView, type: 'kanban' }}
objectDef={mockObjectDef}
onViewUpdate={onViewUpdate}
/>
);

const select = screen.getByTestId('type-opt-kanban-groupByField');
fireEvent.change(select, { target: { value: 'stage' } });

expect(onViewUpdate).toHaveBeenCalledWith('kanban', expect.objectContaining({ groupByField: 'stage' }));
});

it('shows type options when view type changes from grid to kanban', () => {
const onViewUpdate = vi.fn();
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={mockActiveView}
objectDef={mockObjectDef}
onViewUpdate={onViewUpdate}
/>
);

// Initially grid — type options hidden
expect(screen.getByTestId('type-options-section').className).toContain('hidden');

// Change to kanban
fireEvent.change(screen.getByTestId('view-type-select'), { target: { value: 'kanban' } });

// Now kanban groupBy should appear
expect(screen.getByTestId('type-opt-kanban-groupByField')).toBeInTheDocument();
});
});
31 changes: 27 additions & 4 deletions apps/console/src/components/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {

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

// Draft state for view config edits — cached locally, saved on demand
const [viewDraft, setViewDraft] = useState<Record<string, any> | null>(null);
Expand All @@ -88,6 +89,21 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
console.warn('[ViewConfigPanel] dataSource.updateViewConfig is not available. View config saved locally only.');
}
}, [dataSource, objectName]);

/** Create a new view via the config panel */
const handleViewCreate = useCallback(async (config: Record<string, any>) => {
try {
if (dataSource?.create) {
const payload = { objectName, ...config };
await dataSource.create('sys_view', payload);
}
setShowViewConfigPanel(false);
setViewConfigPanelMode('edit');
setRefreshKey(k => k + 1);
} catch (err) {
console.error('[ViewConfigPanel] Failed to create view:', err);
}
}, [dataSource, objectName]);
Comment on lines +94 to +106

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

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

The handleViewCreate function doesn't provide user feedback upon successful view creation or when an error occurs. Consider adding toast notifications (success/error) to inform users about the operation outcome, similar to the pattern used in ViewDesignerPage.

Copilot uses AI. Check for mistakes.

// Record count tracking for footer
const [recordCount, setRecordCount] = useState<number | undefined>(undefined);
Expand Down Expand Up @@ -434,14 +450,19 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
{t('console.objectView.metadataInspector')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowViewConfigPanel(prev => !prev)}>
<DropdownMenuItem onClick={() => { setViewConfigPanelMode('edit'); setShowViewConfigPanel(prev => !prev); }}>
<Settings2 className="h-4 w-4 mr-2" />
{t('console.objectView.editView')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate(viewId ? '../../views/new' : 'views/new', { relative: 'path' })}>
<DropdownMenuItem onClick={() => { setViewConfigPanelMode('create'); setShowViewConfigPanel(true); }}>
<Plus className="h-4 w-4 mr-2" />
{t('console.objectView.addView')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate(viewId ? '../../views/new' : 'views/new', { relative: 'path' })}>
<Wrench className="h-4 w-4 mr-2" />
{t('console.objectView.advancedEditor')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
Expand All @@ -463,7 +484,7 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
onViewChange={handleViewChange}
viewTypeIcons={VIEW_TYPE_ICONS}
config={{ ...objectDef.viewTabBar, reorderable: isAdmin ? true : objectDef.viewTabBar?.reorderable }}
onAddView={isAdmin ? () => navigate(viewId ? '../../views/new' : 'views/new', { relative: 'path' }) : undefined}
onAddView={isAdmin ? () => { setViewConfigPanelMode('create'); setShowViewConfigPanel(true); } : undefined}
onRenameView={(id, newName) => {
// Rename is wired for future backend integration
console.info('[ViewTabBar] Rename view:', id, newName);
Expand Down Expand Up @@ -528,12 +549,14 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
{/* Inline View Config Panel — Airtable-style right sidebar */}
<ViewConfigPanel
open={showViewConfigPanel && isAdmin}
onClose={() => setShowViewConfigPanel(false)}
onClose={() => { setShowViewConfigPanel(false); setViewConfigPanelMode('edit'); }}
mode={viewConfigPanelMode}
activeView={activeView}
objectDef={objectDef}
recordCount={recordCount}
onSave={handleViewConfigSave}
onViewUpdate={handleViewUpdate}
onCreate={handleViewCreate}
/>
</div>

Expand Down
Loading