From 7508e64a2c98f8f4930b7d46f67774a993106b30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 06:37:04 +0000 Subject: [PATCH 1/4] Initial plan From 31cd9e739919b1ff21549c3b19ddde0f5f4c52d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 06:45:32 +0000 Subject: [PATCH 2/4] feat: unify view create/edit via ViewConfigPanel with mode prop and type-specific options - Add UnifiedViewConfig type to @object-ui/types - Add mode="create"|"edit" prop to ViewConfigPanel - Add type-specific options (kanban/calendar/map/gallery/timeline/gantt) - Add onCreate callback for create mode - Update ObjectView: "Add View" opens panel in create mode - Keep ViewDesigner as "Advanced Editor" entry - Add i18n keys for new features - Add 14 new tests (49 total passing) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/__tests__/ViewConfigPanel.test.tsx | 229 ++++++++++++++++++ apps/console/src/components/ObjectView.tsx | 31 ++- .../src/components/ViewConfigPanel.tsx | 194 ++++++++++++++- packages/i18n/src/locales/en.ts | 13 + packages/types/src/designer.ts | 107 ++++++++ packages/types/src/index.ts | 2 + 6 files changed, 563 insertions(+), 13 deletions(-) diff --git a/apps/console/src/__tests__/ViewConfigPanel.test.tsx b/apps/console/src/__tests__/ViewConfigPanel.test.tsx index 2de1f11cc3..9e0d216497 100644 --- a/apps/console/src/__tests__/ViewConfigPanel.test.tsx +++ b/apps/console/src/__tests__/ViewConfigPanel.test.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + // 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( + + ); + + fireEvent.click(screen.getByTestId('view-config-discard')); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('starts with default label in create mode', () => { + render( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + expect(screen.getByTestId('type-opt-gallery-imageField')).toBeInTheDocument(); + }); + + it('shows timeline dateField and titleField when view type is timeline', () => { + render( + + ); + + 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( + + ); + + 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( + + ); + + // 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( + + ); + + 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( + + ); + + // 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(); + }); }); diff --git a/apps/console/src/components/ObjectView.tsx b/apps/console/src/components/ObjectView.tsx index acf600a998..b4063e0d0b 100644 --- a/apps/console/src/components/ObjectView.tsx +++ b/apps/console/src/components/ObjectView.tsx @@ -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 | null>(null); @@ -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) => { + 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]); // Record count tracking for footer const [recordCount, setRecordCount] = useState(undefined); @@ -434,14 +450,19 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) { {t('console.objectView.metadataInspector')} - setShowViewConfigPanel(prev => !prev)}> + { setViewConfigPanelMode('edit'); setShowViewConfigPanel(prev => !prev); }}> {t('console.objectView.editView')} - navigate(viewId ? '../../views/new' : 'views/new', { relative: 'path' })}> + { setViewConfigPanelMode('create'); setShowViewConfigPanel(true); }}> {t('console.objectView.addView')} + + navigate(viewId ? '../../views/new' : 'views/new', { relative: 'path' })}> + + {t('console.objectView.advancedEditor')} + )} @@ -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); @@ -528,12 +549,14 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) { {/* Inline View Config Panel — Airtable-style right sidebar */} setShowViewConfigPanel(false)} + onClose={() => { setShowViewConfigPanel(false); setViewConfigPanelMode('edit'); }} + mode={viewConfigPanelMode} activeView={activeView} objectDef={objectDef} recordCount={recordCount} onSave={handleViewConfigSave} onViewUpdate={handleViewUpdate} + onCreate={handleViewCreate} /> diff --git a/apps/console/src/components/ViewConfigPanel.tsx b/apps/console/src/components/ViewConfigPanel.tsx index c41a14f945..bb04ca3709 100644 --- a/apps/console/src/components/ViewConfigPanel.tsx +++ b/apps/console/src/components/ViewConfigPanel.tsx @@ -187,6 +187,8 @@ export interface ViewConfigPanelProps { open: boolean; /** Close callback */ onClose: () => void; + /** Panel mode: "edit" for existing views, "create" for new views */ + mode?: 'create' | 'edit'; /** The active view definition */ activeView: { id: string; @@ -219,6 +221,8 @@ export interface ViewConfigPanelProps { onViewUpdate?: (field: string, value: any) => void; /** Called to persist all draft changes */ onSave?: (draft: Record) => void; + /** Called when create-mode view is created */ + onCreate?: (config: Record) => void; } /** A single labeled row in the config panel */ @@ -247,10 +251,25 @@ function SectionHeader({ title }: { title: string }) { ); } -export function ViewConfigPanel({ open, onClose, activeView, objectDef, onViewUpdate, onSave }: ViewConfigPanelProps) { +export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, objectDef, onViewUpdate, onSave, onCreate }: ViewConfigPanelProps) { const { t } = useObjectTranslation(); const panelRef = useRef(null); + // Default empty view for create mode + const defaultNewView = useMemo(() => ({ + id: `view_${Date.now()}`, + label: t('console.objectView.newView'), + type: 'grid', + columns: [], + filter: [], + sort: [], + showSearch: true, + showFilters: true, + showSort: true, + }), []); + + const effectiveActiveView = mode === 'create' ? defaultNewView : activeView; + // Local draft state — clone of activeView, mutated by UI interactions const [draft, setDraft] = useState>({}); const [isDirty, setIsDirty] = useState(false); @@ -260,9 +279,9 @@ export function ViewConfigPanel({ open, onClose, activeView, objectDef, onViewUp // object so that real-time draft propagation (via onViewUpdate → parent // setViewDraft → merged activeView) does not reset isDirty to false. useEffect(() => { - setDraft({ ...activeView }); - setIsDirty(false); - }, [activeView.id]); + setDraft({ ...effectiveActiveView }); + setIsDirty(mode === 'create'); + }, [mode === 'create' ? mode : activeView.id]); // Focus the panel when it opens for keyboard accessibility useEffect(() => { @@ -280,16 +299,27 @@ export function ViewConfigPanel({ open, onClose, activeView, objectDef, onViewUp /** Discard all draft changes */ const handleDiscard = useCallback(() => { + if (mode === 'create') { + onClose(); + return; + } setDraft({ ...activeView }); setIsDirty(false); - }, [activeView]); + }, [activeView, mode, onClose]); /** Save draft via parent callback */ const handleSave = useCallback(() => { - onSave?.(draft); + if (mode === 'create') { + onCreate?.(draft); + } else { + onSave?.(draft); + } setIsDirty(false); - }, [draft, onSave]); + }, [draft, onSave, onCreate, mode]); + const panelTitle = mode === 'create' + ? t('console.objectView.createView') + : t('console.objectView.configureView'); const viewLabel = draft.label || draft.id || activeView.id; const viewType = draft.type || 'grid'; @@ -361,6 +391,12 @@ export function ViewConfigPanel({ open, onClose, activeView, objectDef, onViewUp updateDraft('columns', currentCols); }, [draft.columns, updateDraft]); + /** Handle type-specific option change (e.g., kanban.groupByField, calendar.startDateField) */ + const handleTypeOptionChange = useCallback((typeKey: string, optionKey: string, value: any) => { + const current = draft[typeKey] || {}; + updateDraft(typeKey, { ...current, [optionKey]: value }); + }, [draft, updateDraft]); + if (!open) return null; return ( @@ -368,14 +404,14 @@ export function ViewConfigPanel({ open, onClose, activeView, objectDef, onViewUp ref={panelRef} data-testid="view-config-panel" role="complementary" - aria-label={t('console.objectView.configureView')} + aria-label={panelTitle} tabIndex={-1} className="absolute inset-y-0 right-0 w-full sm:w-72 lg:w-80 sm:relative sm:inset-auto border-l bg-background flex flex-col shrink-0 z-20 transition-all overflow-hidden" > {/* Panel Header */}
- {t('console.objectView.configureView')} + {panelTitle}
+ {/* Type-Specific Options Section */} + {viewType !== 'grid' && ( + <> + +
+ {viewType === 'kanban' && ( +
+ {t('console.objectView.groupByField')} + +
+ )} + {viewType === 'calendar' && ( + <> +
+ {t('console.objectView.startDateField')} + +
+
+ {t('console.objectView.titleField')} + +
+ + )} + {viewType === 'map' && ( + <> +
+ {t('console.objectView.latitudeField')} + +
+
+ {t('console.objectView.longitudeField')} + +
+ + )} + {viewType === 'gallery' && ( +
+ {t('console.objectView.imageField')} + +
+ )} + {(viewType === 'timeline' || viewType === 'gantt') && ( + <> +
+ {t('console.objectView.dateField')} + +
+
+ {t('console.objectView.titleField')} + +
+ + )} +
+ + )} + {viewType === 'grid' && ( +
+ )} + {/* User Filters Section */}
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index f6fde1052f..ef093b2325 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -214,6 +214,19 @@ const en = { columnsConfigured: '{{count}} columns', save: 'Save', discard: 'Discard', + createView: 'Create View', + newView: 'New View', + advancedEditor: 'Advanced Editor', + typeOptions: 'Type Options', + groupByField: 'Group by field', + startDateField: 'Start date field', + titleField: 'Title field', + latitudeField: 'Latitude field', + longitudeField: 'Longitude field', + imageField: 'Image field', + dateField: 'Date field', + selectField: 'Select field...', + gridOptionsHint: 'Grid view uses the columns configured above.', }, localeSwitcher: { label: 'Language', diff --git a/packages/types/src/designer.ts b/packages/types/src/designer.ts index e7a56513f5..4af51160a9 100644 --- a/packages/types/src/designer.ts +++ b/packages/types/src/designer.ts @@ -443,6 +443,113 @@ export interface ViewDesignerSchema extends BaseSchema { onCancel?: string; } +// ============================================================================ +// Unified View Configuration +// ============================================================================ + +/** View type union */ +export type UnifiedViewType = 'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart'; + +/** + * Unified data model for view configuration. + * + * Used by both ViewConfigPanel (create/edit) and ViewDesigner (advanced editor). + * Columns may be simple field-name strings or rich ViewDesignerColumn objects; + * consumers should handle both. + */ +export interface UnifiedViewConfig { + /** View identifier */ + id?: string; + /** Display label */ + label?: string; + /** View type */ + type?: UnifiedViewType; + /** Column configuration — simple field names or rich ViewDesignerColumn objects */ + columns?: Array; + /** Filter conditions in @objectstack/spec JSON-rules array format */ + filter?: any[]; + /** Sort configuration */ + sort?: Array<{ field: string; order?: string; direction?: string; id?: string }>; + /** Description */ + description?: string; + /** Enable search bar */ + showSearch?: boolean; + /** Enable user filter controls */ + showFilters?: boolean; + /** Enable user sort controls */ + showSort?: boolean; + /** Allow data export */ + allowExport?: boolean; + /** Show view description */ + showDescription?: boolean; + /** Enable "add record via form" action */ + addRecordViaForm?: boolean; + /** Export options */ + exportOptions?: any; + + // -- Type-specific options (nested per @objectstack/spec protocol) ---------- + + /** Kanban-specific options */ + kanban?: { + groupByField?: string; + groupField?: string; + titleField?: string; + columns?: string[]; + }; + /** Calendar-specific options */ + calendar?: { + startDateField?: string; + endDateField?: string; + titleField?: string; + colorField?: string; + allDayField?: string; + defaultView?: string; + }; + /** Map-specific options */ + map?: { + locationField?: string; + titleField?: string; + latitudeField?: string; + longitudeField?: string; + zoom?: number; + center?: { lat: number; lng: number }; + }; + /** Gallery-specific options */ + gallery?: { + imageField?: string; + titleField?: string; + subtitleField?: string; + }; + /** Timeline-specific options */ + timeline?: { + dateField?: string; + titleField?: string; + descriptionField?: string; + }; + /** Gantt-specific options */ + gantt?: { + startDateField?: string; + endDateField?: string; + titleField?: string; + progressField?: string; + dependenciesField?: string; + colorField?: string; + }; + /** Chart-specific options */ + chart?: { + chartType?: string; + xAxisField?: string; + yAxisFields?: string[]; + aggregation?: string; + series?: any[]; + config?: any; + filter?: any; + }; + + /** Catch-all for additional properties */ + [key: string]: any; +} + // ============================================================================ // Multi-User Collaborative Editing // ============================================================================ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index c54afed1ce..df434892af 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -542,6 +542,8 @@ export type { CollaborationConfig, ViewDesignerColumn, ViewDesignerSchema, + UnifiedViewType, + UnifiedViewConfig, } from './designer'; // ============================================================================ From 4d7a6114956e57c7260af0dbbfed17f5fcd35221 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 06:48:30 +0000 Subject: [PATCH 3/4] fix: update ObjectView tests for new panel-based view creation flow - Update "Add View" tests to verify panel opens instead of navigation - Add test for "Advanced Editor" button navigation - Update ROADMAP.md with completed P1.8 items Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 10 +++++--- .../console/src/__tests__/ObjectView.test.tsx | 25 ++++++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 555539bee3..cf3391ee24 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) --- diff --git a/apps/console/src/__tests__/ObjectView.test.tsx b/apps/console/src/__tests__/ObjectView.test.tsx index 1fbe334870..2d9c23083e 100644 --- a/apps/console/src/__tests__/ObjectView.test.tsx +++ b/apps/console/src/__tests__/ObjectView.test.tsx @@ -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' }); @@ -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' }); @@ -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(); + + 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' }); }); From e6fa5a55bf8de7463a7b687c6be5c0a0daf1352b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 06:49:28 +0000 Subject: [PATCH 4/4] refactor: fix useEffect dependency array per code review feedback Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- apps/console/src/components/ViewConfigPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/console/src/components/ViewConfigPanel.tsx b/apps/console/src/components/ViewConfigPanel.tsx index bb04ca3709..8ec133e5d6 100644 --- a/apps/console/src/components/ViewConfigPanel.tsx +++ b/apps/console/src/components/ViewConfigPanel.tsx @@ -281,7 +281,7 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje useEffect(() => { setDraft({ ...effectiveActiveView }); setIsDirty(mode === 'create'); - }, [mode === 'create' ? mode : activeView.id]); + }, [mode, activeView.id]); // eslint-disable-line react-hooks/exhaustive-deps // Focus the panel when it opens for keyboard accessibility useEffect(() => {