From 3150df6f428c50a1a7f5f29d7031b658fb093e62 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 04:01:47 +0000 Subject: [PATCH 1/6] Initial plan From e6376780d012a3c042d0e919d96648dbc2818dda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 04:07:06 +0000 Subject: [PATCH 2/6] fix: make ViewConfigPanel draft changes reactive in ObjectView main view - Fix objectViewSchema to read showSearch/showFilters from activeView instead of hardcoded true - Add handleViewUpdate callback for real-time field-by-field draft propagation - Wire onViewUpdate prop to ViewConfigPanel for live preview - Pass draft-aware views to PluginObjectView - Fix ViewConfigPanel useEffect to depend on activeView.id (prevents draft reset loop) - Add tests for draft persistence and real-time propagation Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/__tests__/ViewConfigPanel.test.tsx | 83 +++++++++++++++++++ apps/console/src/components/ObjectView.tsx | 24 ++++-- .../src/components/ViewConfigPanel.tsx | 7 +- 3 files changed, 107 insertions(+), 7 deletions(-) diff --git a/apps/console/src/__tests__/ViewConfigPanel.test.tsx b/apps/console/src/__tests__/ViewConfigPanel.test.tsx index c1fd75a9..b4a0736f 100644 --- a/apps/console/src/__tests__/ViewConfigPanel.test.tsx +++ b/apps/console/src/__tests__/ViewConfigPanel.test.tsx @@ -551,4 +551,87 @@ describe('ViewConfigPanel', () => { expect(screen.getByTestId('toggle-allowExport')).toHaveAttribute('aria-checked', 'false'); expect(screen.getByTestId('toggle-addRecordViaForm')).toHaveAttribute('aria-checked', 'true'); }); + + // ── Real-time draft propagation tests (issue fix) ── + + it('keeps dirty state when re-rendered with same view ID but updated activeView', () => { + const onViewUpdate = vi.fn(); + const { rerender } = render( + + ); + + // Toggle showSearch — panel becomes dirty + fireEvent.click(screen.getByTestId('toggle-showSearch')); + expect(screen.getByTestId('view-config-footer')).toBeInTheDocument(); + + // Simulate parent re-rendering with the same view ID but merged draft + // (this happens when onViewUpdate propagates to parent viewDraft → activeView) + rerender( + + ); + + // Draft footer should still be visible (isDirty should NOT reset for same view ID) + expect(screen.getByTestId('view-config-footer')).toBeInTheDocument(); + }); + + it('resets dirty state when activeView changes to a different view ID', () => { + const { rerender } = render( + + ); + + // Make the panel dirty + fireEvent.click(screen.getByTestId('toggle-showSearch')); + expect(screen.getByTestId('view-config-footer')).toBeInTheDocument(); + + // Switch to a completely different view + rerender( + + ); + + // Draft should reset — footer should be gone + expect(screen.queryByTestId('view-config-footer')).not.toBeInTheDocument(); + }); + + it('calls onViewUpdate for each real-time field change to enable live preview', () => { + const onViewUpdate = vi.fn(); + render( + + ); + + // Toggle multiple switches + fireEvent.click(screen.getByTestId('toggle-showSearch')); + fireEvent.click(screen.getByTestId('toggle-showFilters')); + + expect(onViewUpdate).toHaveBeenCalledTimes(2); + expect(onViewUpdate).toHaveBeenCalledWith('showSearch', false); + expect(onViewUpdate).toHaveBeenCalledWith('showFilters', false); + }); }); diff --git a/apps/console/src/components/ObjectView.tsx b/apps/console/src/components/ObjectView.tsx index 441455e2..9c6c43c4 100644 --- a/apps/console/src/components/ObjectView.tsx +++ b/apps/console/src/components/ObjectView.tsx @@ -134,6 +134,15 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) { ? { ...baseView, ...viewDraft } : baseView; + /** Real-time draft field update — propagates each toggle/input change immediately */ + const handleViewUpdate = useCallback((field: string, value: any) => { + setViewDraft(prev => ({ + ...(prev || {}), + id: baseView?.id, + [field]: value, + })); + }, [baseView?.id]); + const handleViewChange = (newViewId: string) => { // The plugin ObjectView returns the view ID directly via onViewChange const matchedView = views.find((v: any) => v.id === newViewId); @@ -324,13 +333,13 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) { ); }, [activeView, objectDef, objectName, refreshKey]); - // Build the ObjectViewSchema for the plugin + // Build the ObjectViewSchema for the plugin — reads from activeView (which merges draft) const objectViewSchema = useMemo(() => ({ type: 'object-view' as const, objectName: objectDef.name, layout: 'page' as const, - showSearch: true, - showFilters: true, + showSearch: activeView?.showSearch !== false, + showFilters: activeView?.showFilters !== false, showCreate: false, // We render our own create button in the header showRefresh: true, onNavigate: (recordId: string | number, mode: 'view' | 'edit') => { @@ -338,7 +347,7 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) { onEdit?.({ _id: recordId, id: recordId }); } }, - }), [objectDef.name, onEdit]); + }), [objectDef.name, onEdit, activeView?.showSearch, activeView?.showFilters]); return (
@@ -471,7 +480,11 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) { key={refreshKey} schema={objectViewSchema} dataSource={dataSource} - views={views} + views={views.map((v: any) => + v.id === activeViewId && viewDraft && viewDraft.id === v.id + ? { ...v, ...viewDraft } + : v + )} activeViewId={activeViewId} onViewChange={handleViewChange} onEdit={(record: any) => onEdit?.(record)} @@ -503,6 +516,7 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) { recordCount={recordCount} onOpenEditor={handleOpenEditor} onSave={handleViewConfigSave} + onViewUpdate={handleViewUpdate} />
diff --git a/apps/console/src/components/ViewConfigPanel.tsx b/apps/console/src/components/ViewConfigPanel.tsx index cf92f460..16f720e9 100644 --- a/apps/console/src/components/ViewConfigPanel.tsx +++ b/apps/console/src/components/ViewConfigPanel.tsx @@ -110,11 +110,14 @@ export function ViewConfigPanel({ open, onClose, activeView, objectDef, onViewUp const [draft, setDraft] = useState>({}); const [isDirty, setIsDirty] = useState(false); - // Reset draft when activeView changes (e.g. switching views) + // Reset draft when switching to a different view (by ID change only). + // We intentionally depend on activeView.id rather than the full activeView + // 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]); + }, [activeView.id]); // Focus the panel when it opens for keyboard accessibility useEffect(() => { From 491f4b215d29fb347f3158c768ed86ac251e05b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 04:09:26 +0000 Subject: [PATCH 3/6] docs: update roadmap to reflect real-time draft preview capability Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 2 +- ROADMAP_CONSOLE.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index da911774..035491ed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,7 +49,7 @@ Everything below has been built, tested, and verified. These items are stable an - **Animation:** 7 presets, reduced-motion aware, page transitions (9 types with View Transitions API). - **Notifications:** Toast/banner/snackbar with full CRUD integration. - **View Enhancements:** Gallery, column summary, grouping, row color, density modes, view sharing, ViewTabBar (reorder, pin, context menu, type-switch, personal/shared grouping). -- **Inline View Config Panel:** Airtable-style right sidebar for view configuration (Page, Data, Appearance, User Filters, Actions, Advanced), breadcrumb header, record count footer, responsive mobile overlay, ARIA accessibility, auto-close on view switch — no page navigation required. Full interactive editing support: inline title editing, Switch toggles for all boolean settings, ViewType select, clickable rows to open sub-editors (columns/filters/sort), local draft state with Save/Discard workflow, i18n in all 10 locales. +- **Inline View Config Panel:** Airtable-style right sidebar for view configuration (Page, Data, Appearance, User Filters, Actions, Advanced), breadcrumb header, record count footer, responsive mobile overlay, ARIA accessibility, auto-close on view switch — no page navigation required. Full interactive editing support: inline title editing, Switch toggles for all boolean settings, ViewType select, clickable rows to open sub-editors (columns/filters/sort), local draft state with Save/Discard workflow, real-time draft preview in main view, i18n in all 10 locales. ### Enterprise Features ✅ diff --git a/ROADMAP_CONSOLE.md b/ROADMAP_CONSOLE.md index 186bc577..2c65e879 100644 --- a/ROADMAP_CONSOLE.md +++ b/ROADMAP_CONSOLE.md @@ -1021,7 +1021,7 @@ These were the initial tasks to bring the console prototype to production-qualit | **Global Undo/Redo (Ctrl+Z)** | ✅ Done (global UndoManager + batch ops + persistent stack) | Post v1.0 | Phase 16 (L1+L2) | | Notification center | ✅ Partial (ActivityFeed with filter preferences) | Post v1.0 | Phase 17 (L2) | | Activity feed | ✅ Done | Post v1.0 | Phase 17 (L1) | -| **Inline View Config Panel** | ✅ Done (Airtable-style right sidebar, breadcrumb header, record count footer, responsive mobile overlay, ARIA accessibility, auto-close on view switch, full interactive editing: Switch toggles, inline title edit, ViewType select, clickable sub-editor rows, draft state with Save/Discard) | Post v1.0 | Phase 20 | +| **Inline View Config Panel** | ✅ Done (Airtable-style right sidebar, breadcrumb header, record count footer, responsive mobile overlay, ARIA accessibility, auto-close on view switch, full interactive editing: Switch toggles, inline title edit, ViewType select, clickable sub-editor rows, draft state with Save/Discard, real-time draft preview in main view) | Post v1.0 | Phase 20 | ### 5.5 Kanban & Visual Views @@ -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, real-time draft preview), ARIA accessibility ``` ### Milestone Summary From 64c532b2c8977384f121855701ccd2b58221ba76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 04:39:54 +0000 Subject: [PATCH 4/6] feat: replace clickable rows with inline FilterBuilder, SortBuilder, and column checkboxes in ViewConfigPanel - Integrate FilterBuilder for inline filter editing (with data bridge to flat filter array) - Integrate SortBuilder for inline sort editing (with data bridge to SortItem[]) - Add column selector with Checkbox for each objectDef field - Use crypto.randomUUID() for stable fallback IDs - Update tests: mock FilterBuilder/SortBuilder/Checkbox, add tests for inline editing Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/__tests__/ViewConfigPanel.test.tsx | 129 +++++++++++---- .../src/components/ViewConfigPanel.tsx | 148 ++++++++++++++---- 2 files changed, 212 insertions(+), 65 deletions(-) diff --git a/apps/console/src/__tests__/ViewConfigPanel.test.tsx b/apps/console/src/__tests__/ViewConfigPanel.test.tsx index b4a0736f..3e69eabf 100644 --- a/apps/console/src/__tests__/ViewConfigPanel.test.tsx +++ b/apps/console/src/__tests__/ViewConfigPanel.test.tsx @@ -43,6 +43,42 @@ vi.mock('@object-ui/components', () => ({ {...props} /> ), + Checkbox: ({ checked, onCheckedChange, ...props }: any) => ( + onCheckedChange?.(!checked)} + {...props} + /> + ), + FilterBuilder: ({ fields, value, onChange, ...props }: any) => { + let counter = 0; + return ( +
+ + {value?.conditions?.map((c: any, i: number) => ( + {c.field} {c.operator} {String(c.value)} + ))} +
+ ); + }, + SortBuilder: ({ fields, value, onChange, ...props }: any) => { + let counter = 0; + return ( +
+ + {value?.map((s: any, i: number) => ( + {s.field} {s.order} + ))} +
+ ); + }, })); const mockActiveView = { @@ -140,7 +176,7 @@ describe('ViewConfigPanel', () => { expect(screen.getByText('console.objectView.noDescription')).toBeInTheDocument(); }); - it('displays column count', () => { + it('displays column checkboxes for each field', () => { render( { /> ); - // 3 columns configured - expect(screen.getByText('console.objectView.columnsConfigured'.replace('{{count}}', '3'))).toBeInTheDocument(); + // 3 fields → 3 checkboxes + expect(screen.getByTestId('column-selector')).toBeInTheDocument(); + expect(screen.getByTestId('col-checkbox-name')).toBeInTheDocument(); + expect(screen.getByTestId('col-checkbox-stage')).toBeInTheDocument(); + expect(screen.getByTestId('col-checkbox-amount')).toBeInTheDocument(); + // Columns in activeView should be checked + expect(screen.getByTestId('col-checkbox-name')).toBeChecked(); + expect(screen.getByTestId('col-checkbox-stage')).toBeChecked(); + expect(screen.getByTestId('col-checkbox-amount')).toBeChecked(); }); it('displays object source name', () => { @@ -225,7 +268,7 @@ describe('ViewConfigPanel', () => { expect(screen.getByTestId('view-type-select')).toHaveValue('kanban'); }); - it('shows "None" for empty filters and columns', () => { + it('shows inline builders with zero items for empty view', () => { render( { /> ); - // Should show "None" for columns, filters - const noneTexts = screen.getAllByText('console.objectView.none'); - expect(noneTexts.length).toBeGreaterThanOrEqual(2); + // FilterBuilder should have 0 conditions + expect(screen.getByTestId('mock-filter-builder')).toHaveAttribute('data-condition-count', '0'); + // SortBuilder should have 0 items + expect(screen.getByTestId('mock-sort-builder')).toHaveAttribute('data-sort-count', '0'); }); it('has correct ARIA attributes when open', () => { @@ -403,62 +447,85 @@ describe('ViewConfigPanel', () => { expect(onViewUpdate).toHaveBeenCalledWith('type', 'kanban'); }); - it('calls onOpenEditor when clicking columns row', () => { - const onOpenEditor = vi.fn(); + it('renders inline FilterBuilder with correct conditions from activeView', () => { render( ); - // Click the columns row — it's a button with the columns label - const columnsRow = screen.getByText('console.objectView.columns').closest('button'); - expect(columnsRow).toBeTruthy(); - fireEvent.click(columnsRow!); - - expect(onOpenEditor).toHaveBeenCalledWith('columns'); + const fb = screen.getByTestId('mock-filter-builder'); + expect(fb).toHaveAttribute('data-condition-count', '1'); + expect(fb).toHaveAttribute('data-field-count', '3'); + expect(screen.getByTestId('filter-condition-0')).toHaveTextContent('stage = active'); }); - it('calls onOpenEditor when clicking filters row', () => { - const onOpenEditor = vi.fn(); + it('renders inline SortBuilder with correct items from activeView', () => { render( ); - const filterRow = screen.getByText('console.objectView.filterBy').closest('button'); - expect(filterRow).toBeTruthy(); - fireEvent.click(filterRow!); + const sb = screen.getByTestId('mock-sort-builder'); + expect(sb).toHaveAttribute('data-sort-count', '1'); + expect(sb).toHaveAttribute('data-field-count', '3'); + expect(screen.getByTestId('sort-item-0')).toHaveTextContent('name asc'); + }); - expect(onOpenEditor).toHaveBeenCalledWith('filters'); + it('updates draft when adding a filter via FilterBuilder', () => { + const onViewUpdate = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByTestId('filter-builder-add')); + expect(onViewUpdate).toHaveBeenCalledWith('filter', expect.any(Array)); }); - it('calls onOpenEditor when clicking sort row', () => { - const onOpenEditor = vi.fn(); + it('updates draft when adding a sort via SortBuilder', () => { + const onViewUpdate = vi.fn(); render( ); - const sortRow = screen.getByText('console.objectView.sortBy').closest('button'); - expect(sortRow).toBeTruthy(); - fireEvent.click(sortRow!); + fireEvent.click(screen.getByTestId('sort-builder-add')); + expect(onViewUpdate).toHaveBeenCalledWith('sort', expect.any(Array)); + }); + + it('toggles column checkbox and calls onViewUpdate with updated columns', () => { + const onViewUpdate = vi.fn(); + render( + + ); - expect(onOpenEditor).toHaveBeenCalledWith('sort'); + // Uncheck the 'stage' column + fireEvent.click(screen.getByTestId('col-checkbox-stage')); + expect(onViewUpdate).toHaveBeenCalledWith('columns', ['name', 'amount']); }); it('saves draft via onSave when Save button is clicked', () => { diff --git a/apps/console/src/components/ViewConfigPanel.tsx b/apps/console/src/components/ViewConfigPanel.tsx index 16f720e9..96e1c73e 100644 --- a/apps/console/src/components/ViewConfigPanel.tsx +++ b/apps/console/src/components/ViewConfigPanel.tsx @@ -13,8 +13,9 @@ */ import { useMemo, useEffect, useRef, useState, useCallback } from 'react'; -import { Button, Switch, Input } from '@object-ui/components'; -import { X, ChevronRight, Save, RotateCcw } from 'lucide-react'; +import { Button, Switch, Input, Checkbox, FilterBuilder, SortBuilder } from '@object-ui/components'; +import type { FilterGroup, SortItem } from '@object-ui/components'; +import { X, Save, RotateCcw } from 'lucide-react'; import { useObjectTranslation } from '@object-ui/i18n'; /** View type labels for display */ @@ -147,9 +148,6 @@ export function ViewConfigPanel({ open, onClose, activeView, objectDef, onViewUp const viewLabel = draft.label || draft.id || activeView.id; const viewType = draft.type || 'grid'; - const columnCount = draft.columns?.length || 0; - const filterCount = Array.isArray(draft.filter) ? draft.filter.length : 0; - const sortCount = Array.isArray(draft.sort) ? draft.sort.length : 0; const hasSearch = draft.showSearch !== false; const hasFilter = draft.showFilters !== false; @@ -158,17 +156,69 @@ export function ViewConfigPanel({ open, onClose, activeView, objectDef, onViewUp const hasAddForm = draft.addRecordViaForm === true; const hasShowDescription = draft.showDescription !== false; - // Format filter summary - const filterSummary = useMemo(() => { - if (filterCount === 0) return t('console.objectView.none'); - return `${filterCount} ${t('console.objectView.filterBy').toLowerCase()}`; - }, [filterCount, t]); + // Derive field options from objectDef for FilterBuilder/SortBuilder + const fieldOptions = useMemo(() => { + if (!objectDef.fields) return []; + return Object.entries(objectDef.fields).map(([key, field]: [string, any]) => ({ + value: key, + label: field.label || key, + type: field.type || 'text', + options: field.options, + })); + }, [objectDef.fields]); - // Format sort summary - const sortSummary = useMemo(() => { - if (sortCount === 0) return t('console.objectView.none'); - return draft.sort?.map((s: any) => `${s.field} ${s.order || s.direction || 'asc'}`).join(', ') || t('console.objectView.none'); - }, [draft.sort, sortCount, t]); + // Bridge: view filter array → FilterGroup + const filterGroupValue = useMemo(() => { + const conditions = (Array.isArray(draft.filter) ? draft.filter : []).map((f: any) => ({ + id: f.id || crypto.randomUUID(), + field: f.field || '', + operator: f.operator || 'equals', + value: f.value ?? '', + })); + return { id: 'root', logic: 'and' as const, conditions }; + }, [draft.filter]); + + // Bridge: view sort array → SortItem[] + const sortItemsValue = useMemo(() => { + return (Array.isArray(draft.sort) ? draft.sort : []).map((s: any) => ({ + id: s.id || crypto.randomUUID(), + field: s.field || '', + order: (s.order || s.direction || 'asc') as 'asc' | 'desc', + })); + }, [draft.sort]); + + /** Handle FilterBuilder changes → update draft.filter */ + const handleFilterChange = useCallback((group: FilterGroup) => { + const filters = group.conditions.map(c => ({ + id: c.id, + field: c.field, + operator: c.operator, + value: c.value, + })); + updateDraft('filter', filters); + }, [updateDraft]); + + /** Handle SortBuilder changes → update draft.sort */ + const handleSortChange = useCallback((items: SortItem[]) => { + const sortArr = items.map(s => ({ + id: s.id, + field: s.field, + order: s.order, + })); + updateDraft('sort', sortArr); + }, [updateDraft]); + + /** Handle column checkbox toggle */ + const handleColumnToggle = useCallback((fieldName: string, checked: boolean) => { + const currentCols: string[] = Array.isArray(draft.columns) ? [...draft.columns] : []; + if (checked && !currentCols.includes(fieldName)) { + currentCols.push(fieldName); + } else if (!checked) { + const idx = currentCols.indexOf(fieldName); + if (idx >= 0) currentCols.splice(idx, 1); + } + updateDraft('columns', currentCols); + }, [draft.columns, updateDraft]); if (!open) return null; @@ -219,26 +269,56 @@ export function ViewConfigPanel({ open, onClose, activeView, objectDef, onViewUp {/* Data Section */} -
+
- onOpenEditor?.('columns')}> - - {columnCount > 0 ? t('console.objectView.columnsConfigured', { count: columnCount }) : t('console.objectView.none')} - - - - onOpenEditor?.('filters')}> - - {filterSummary} - - - - onOpenEditor?.('sort')}> - - {sortSummary} - - - + + {/* Columns — inline checkbox list */} +
+ {t('console.objectView.columns')} +
+ {fieldOptions.map((f) => { + const checked = Array.isArray(draft.columns) ? draft.columns.includes(f.value) : false; + return ( + + ); + })} +
+
+ + {/* Filters — inline FilterBuilder */} +
+ {t('console.objectView.filterBy')} +
+ +
+
+ + {/* Sort — inline SortBuilder */} +
+ {t('console.objectView.sortBy')} +
+ ({ value: f.value, label: f.label }))} + value={sortItemsValue} + onChange={handleSortChange} + className="[&_button]:h-7 [&_button]:text-xs" + /> +
+
{/* Appearance Section */} From bcf39e17a1733a4023f5141e9e2a352edcb7766f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 04:57:45 +0000 Subject: [PATCH 5/6] fix: resolve TypeScript build errors in ViewTabBar.tsx - Fix DraggableAttributes cast by using double assertion (as unknown as Record) - Remove redundant visibility !== 'private' check (already handled by prior if-branch) - Add explicit event handler types for Input onChange/onKeyDown/onClick Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/plugin-view/src/ViewTabBar.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/plugin-view/src/ViewTabBar.tsx b/packages/plugin-view/src/ViewTabBar.tsx index c4444ac7..24f37ff7 100644 --- a/packages/plugin-view/src/ViewTabBar.tsx +++ b/packages/plugin-view/src/ViewTabBar.tsx @@ -185,7 +185,7 @@ const SortableTab: React.FC<{ opacity: isDragging ? 0.5 : undefined, }; - return <>{children({ setNodeRef, style, listeners, attributes: attributes as Record, isDragging })}; + return <>{children({ setNodeRef, style, listeners, attributes: attributes as unknown as Record, isDragging })}; }; /** @@ -322,7 +322,7 @@ export const ViewTabBar: React.FC = ({ if (view.visibility === 'private') { return ; } - if (view.visibility && view.visibility !== 'private') { + if (view.visibility) { return ; } return null; @@ -365,14 +365,14 @@ export const ViewTabBar: React.FC = ({ ref={renameInputRef} data-testid={`view-tab-rename-input-${view.id}`} value={renameValue} - onChange={(e) => setRenameValue(e.target.value)} + onChange={(e: React.ChangeEvent) => setRenameValue(e.target.value)} onBlur={commitRename} - onKeyDown={(e) => { + onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') cancelRename(); }} className="h-5 w-24 px-1 py-0 text-sm border-none focus-visible:ring-1" - onClick={(e) => e.stopPropagation()} + onClick={(e: React.MouseEvent) => e.stopPropagation()} /> ) : ( {view.label} From b00dc6157c61025108fde83ef412874c24342fe0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 05:04:44 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20address=20all=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20spec=20filter=20bridge,=20type=20normalization,=20m?= =?UTF-8?q?emoized=20views?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement robust spec-style filter bridge: parse ['field','=',val], [['f','=',v],...], and ['and'|'or',...] formats to FilterGroup, and convert back to spec format on save - Map operators between spec ('=','!=','>',etc.) and FilterBuilder IDs (equals, notEquals, etc.) - Normalize ObjectUI field types to FilterBuilder types (currency→number, datetime→date, etc.) - Fix Checkbox onCheckedChange to handle CheckedState properly (c === true) - Memoize merged views array in ObjectView.tsx to prevent unnecessary re-renders - Add tests for nested filter arrays, and/or logic, and field type normalization Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/__tests__/ViewConfigPanel.test.tsx | 66 ++++++- apps/console/src/components/ObjectView.tsx | 17 +- .../src/components/ViewConfigPanel.tsx | 181 ++++++++++++++++-- 3 files changed, 239 insertions(+), 25 deletions(-) diff --git a/apps/console/src/__tests__/ViewConfigPanel.test.tsx b/apps/console/src/__tests__/ViewConfigPanel.test.tsx index 3e69eabf..642068fa 100644 --- a/apps/console/src/__tests__/ViewConfigPanel.test.tsx +++ b/apps/console/src/__tests__/ViewConfigPanel.test.tsx @@ -86,7 +86,7 @@ const mockActiveView = { label: 'All Records', type: 'grid', columns: ['name', 'stage', 'amount'], - filter: [{ field: 'stage', operator: '=', value: 'active' }], + filter: ['stage', '=', 'active'], // spec-style single triplet sort: [{ field: 'name', order: 'asc' }], }; @@ -460,7 +460,7 @@ describe('ViewConfigPanel', () => { const fb = screen.getByTestId('mock-filter-builder'); expect(fb).toHaveAttribute('data-condition-count', '1'); expect(fb).toHaveAttribute('data-field-count', '3'); - expect(screen.getByTestId('filter-condition-0')).toHaveTextContent('stage = active'); + expect(screen.getByTestId('filter-condition-0')).toHaveTextContent('stage equals active'); }); it('renders inline SortBuilder with correct items from activeView', () => { @@ -701,4 +701,66 @@ describe('ViewConfigPanel', () => { expect(onViewUpdate).toHaveBeenCalledWith('showSearch', false); expect(onViewUpdate).toHaveBeenCalledWith('showFilters', false); }); + + // ── Spec-style filter bridge tests ── + + it('parses nested spec-style filter array [[field,op,val],[field,op,val]]', () => { + render( + + ); + + const fb = screen.getByTestId('mock-filter-builder'); + expect(fb).toHaveAttribute('data-condition-count', '2'); + expect(screen.getByTestId('filter-condition-0')).toHaveTextContent('stage equals active'); + expect(screen.getByTestId('filter-condition-1')).toHaveTextContent('name notEquals Test'); + }); + + it('parses and/or logic prefix: ["or", [...], [...]]', () => { + render( + + ); + + const fb = screen.getByTestId('mock-filter-builder'); + expect(fb).toHaveAttribute('data-condition-count', '2'); + }); + + it('normalizes field types for FilterBuilder (currency→number)', () => { + render( + + ); + + // The mock FilterBuilder receives normalized fields via data-field-count + const fb = screen.getByTestId('mock-filter-builder'); + expect(fb).toHaveAttribute('data-field-count', '5'); + }); }); diff --git a/apps/console/src/components/ObjectView.tsx b/apps/console/src/components/ObjectView.tsx index 9c6c43c4..000be178 100644 --- a/apps/console/src/components/ObjectView.tsx +++ b/apps/console/src/components/ObjectView.tsx @@ -333,6 +333,17 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) { ); }, [activeView, objectDef, objectName, refreshKey]); + // Memoize the merged views array so PluginObjectView doesn't get a new + // reference on every render (which would trigger unnecessary data refetches). + const mergedViews = useMemo(() => + views.map((v: any) => + v.id === activeViewId && viewDraft && viewDraft.id === v.id + ? { ...v, ...viewDraft } + : v + ), + [views, activeViewId, viewDraft] + ); + // Build the ObjectViewSchema for the plugin — reads from activeView (which merges draft) const objectViewSchema = useMemo(() => ({ type: 'object-view' as const, @@ -480,11 +491,7 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) { key={refreshKey} schema={objectViewSchema} dataSource={dataSource} - views={views.map((v: any) => - v.id === activeViewId && viewDraft && viewDraft.id === v.id - ? { ...v, ...viewDraft } - : v - )} + views={mergedViews} activeViewId={activeViewId} onViewChange={handleViewChange} onEdit={(record: any) => onEdit?.(record)} diff --git a/apps/console/src/components/ViewConfigPanel.tsx b/apps/console/src/components/ViewConfigPanel.tsx index 96e1c73e..72bdfc17 100644 --- a/apps/console/src/components/ViewConfigPanel.tsx +++ b/apps/console/src/components/ViewConfigPanel.tsx @@ -18,6 +18,154 @@ import type { FilterGroup, SortItem } from '@object-ui/components'; import { X, Save, RotateCcw } from 'lucide-react'; import { useObjectTranslation } from '@object-ui/i18n'; +// --------------------------------------------------------------------------- +// Operator mapping: @objectstack/spec ↔ FilterBuilder +// --------------------------------------------------------------------------- +const SPEC_TO_BUILDER_OP: Record = { + '=': 'equals', + '==': 'equals', + '!=': 'notEquals', + '<>': 'notEquals', + '>': 'greaterThan', + '<': 'lessThan', + '>=': 'greaterOrEqual', + '<=': 'lessOrEqual', + 'contains': 'contains', + 'not_contains': 'notContains', + 'is_empty': 'isEmpty', + 'is_not_empty': 'isNotEmpty', + 'in': 'in', + 'not_in': 'notIn', + 'not in': 'notIn', + 'before': 'before', + 'after': 'after', + 'between': 'between', + // Pass-through for already-normalized IDs + 'equals': 'equals', + 'notEquals': 'notEquals', + 'greaterThan': 'greaterThan', + 'lessThan': 'lessThan', + 'greaterOrEqual': 'greaterOrEqual', + 'lessOrEqual': 'lessOrEqual', + 'notContains': 'notContains', + 'isEmpty': 'isEmpty', + 'isNotEmpty': 'isNotEmpty', + 'notIn': 'notIn', +}; + +const BUILDER_TO_SPEC_OP: Record = { + 'equals': '=', + 'notEquals': '!=', + 'greaterThan': '>', + 'lessThan': '<', + 'greaterOrEqual': '>=', + 'lessOrEqual': '<=', + 'contains': 'contains', + 'notContains': 'not_contains', + 'isEmpty': 'is_empty', + 'isNotEmpty': 'is_not_empty', + 'in': 'in', + 'notIn': 'not in', + 'before': 'before', + 'after': 'after', + 'between': 'between', +}; + +// --------------------------------------------------------------------------- +// Field type normalization: ObjectUI → FilterBuilder +// --------------------------------------------------------------------------- +function normalizeFieldType(rawType?: string): 'text' | 'number' | 'boolean' | 'date' | 'select' { + const t = (rawType || '').toLowerCase(); + if (['integer', 'int', 'float', 'double', 'number', 'currency', 'money', 'percent', 'rating'].includes(t)) return 'number'; + if (['date', 'datetime', 'datetime_tz', 'timestamp'].includes(t)) return 'date'; + if (['boolean', 'bool', 'checkbox', 'switch'].includes(t)) return 'boolean'; + if (['select', 'picklist', 'single_select', 'multi_select', 'enum'].includes(t)) return 'select'; + return 'text'; +} + +// --------------------------------------------------------------------------- +// Spec-style filter bridge: parse any supported format → FilterGroup conditions +// Formats: ['field','=',val], [['f','=',v],['f2','!=',v2]], ['and'|'or', ...] +// Also supports object-style: { field, operator, value } +// --------------------------------------------------------------------------- +function parseSpecFilter(raw: any): { logic: 'and' | 'or'; conditions: Array<{ id: string; field: string; operator: string; value: any }> } { + if (!Array.isArray(raw) || raw.length === 0) { + return { logic: 'and', conditions: [] }; + } + + // Detect ['and', ...conditions] or ['or', ...conditions] + if (typeof raw[0] === 'string' && (raw[0] === 'and' || raw[0] === 'or')) { + const logic = raw[0] as 'and' | 'or'; + const rest = raw.slice(1); + const conditions = rest.flatMap((item: any) => parseSingleOrNested(item)); + return { logic, conditions }; + } + + // Detect single triplet: ['field', '=', value] (all primitives at top level) + if (raw.length >= 2 && raw.length <= 3 && typeof raw[0] === 'string' && typeof raw[1] === 'string' && !Array.isArray(raw[0])) { + // Check it's not an array of arrays + if (!Array.isArray(raw[2])) { + const cond = parseTriplet(raw); + return { logic: 'and', conditions: cond ? [cond] : [] }; + } + } + + // Detect array of conditions: [[...], [...]] or [{...}, {...}] + if (Array.isArray(raw[0]) || (typeof raw[0] === 'object' && raw[0] !== null && !Array.isArray(raw[0]))) { + const conditions = raw.flatMap((item: any) => parseSingleOrNested(item)); + return { logic: 'and', conditions }; + } + + // Fallback: try as single triplet + const cond = parseTriplet(raw); + return { logic: 'and', conditions: cond ? [cond] : [] }; +} + +function parseTriplet(arr: any[]): { id: string; field: string; operator: string; value: any } | null { + if (!Array.isArray(arr) || arr.length < 2) return null; + const [field, op, value] = arr; + if (typeof field !== 'string' || typeof op !== 'string') return null; + return { + id: crypto.randomUUID(), + field, + operator: SPEC_TO_BUILDER_OP[op] || op, + value: value ?? '', + }; +} + +function parseSingleOrNested(item: any): Array<{ id: string; field: string; operator: string; value: any }> { + if (Array.isArray(item)) { + const triplet = parseTriplet(item); + return triplet ? [triplet] : []; + } + if (typeof item === 'object' && item !== null && item.field) { + return [{ + id: item.id || crypto.randomUUID(), + field: item.field, + operator: SPEC_TO_BUILDER_OP[item.operator] || item.operator || 'equals', + value: item.value ?? '', + }]; + } + return []; +} + +/** + * Convert FilterGroup conditions back to spec-style filter array. + * Produces [['field','op',value], ...] for multiple conditions, + * or ['field','op',value] for single condition, + * or ['and'|'or', ...] when logic is 'or'. + */ +function toSpecFilter(logic: 'and' | 'or', conditions: Array<{ field: string; operator: string; value: any }>): any[] { + const triplets = conditions + .filter(c => c.field) // skip empty + .map(c => [c.field, BUILDER_TO_SPEC_OP[c.operator] || c.operator, c.value]); + + if (triplets.length === 0) return []; + if (triplets.length === 1 && logic === 'and') return triplets[0]; + if (logic === 'or') return ['or', ...triplets]; + return triplets; +} + /** View type labels for display */ const VIEW_TYPE_LABELS: Record = { grid: 'Grid', @@ -162,20 +310,15 @@ export function ViewConfigPanel({ open, onClose, activeView, objectDef, onViewUp return Object.entries(objectDef.fields).map(([key, field]: [string, any]) => ({ value: key, label: field.label || key, - type: field.type || 'text', + type: normalizeFieldType(field.type), options: field.options, })); }, [objectDef.fields]); - // Bridge: view filter array → FilterGroup + // Bridge: view filter (any spec format) → FilterGroup const filterGroupValue = useMemo(() => { - const conditions = (Array.isArray(draft.filter) ? draft.filter : []).map((f: any) => ({ - id: f.id || crypto.randomUUID(), - field: f.field || '', - operator: f.operator || 'equals', - value: f.value ?? '', - })); - return { id: 'root', logic: 'and' as const, conditions }; + const parsed = parseSpecFilter(draft.filter); + return { id: 'root', logic: parsed.logic, conditions: parsed.conditions }; }, [draft.filter]); // Bridge: view sort array → SortItem[] @@ -187,15 +330,17 @@ export function ViewConfigPanel({ open, onClose, activeView, objectDef, onViewUp })); }, [draft.sort]); - /** Handle FilterBuilder changes → update draft.filter */ + /** Handle FilterBuilder changes → update draft.filter in spec format */ const handleFilterChange = useCallback((group: FilterGroup) => { - const filters = group.conditions.map(c => ({ - id: c.id, - field: c.field, - operator: c.operator, - value: c.value, - })); - updateDraft('filter', filters); + const specFilter = toSpecFilter( + group.logic, + group.conditions.map(c => ({ + field: c.field, + operator: c.operator, + value: c.value, + })) + ); + updateDraft('filter', specFilter); }, [updateDraft]); /** Handle SortBuilder changes → update draft.sort */ @@ -283,7 +428,7 @@ export function ViewConfigPanel({ open, onClose, activeView, objectDef, onViewUp handleColumnToggle(f.value, c)} + onCheckedChange={(c) => handleColumnToggle(f.value, c === true)} className="h-3.5 w-3.5" /> {f.label}