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 40177a19..5f297a91 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, backend persistence via DataSource.updateViewConfig
+ 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
diff --git a/apps/console/src/__tests__/ViewConfigPanel.test.tsx b/apps/console/src/__tests__/ViewConfigPanel.test.tsx
index c1fd75a9..642068fa 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 = {
@@ -50,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' }],
};
@@ -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 equals 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', () => {
@@ -551,4 +618,149 @@ 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);
+ });
+
+ // ── 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 ca5fc7a5..e8964164 100644
--- a/apps/console/src/components/ObjectView.tsx
+++ b/apps/console/src/components/ObjectView.tsx
@@ -149,6 +149,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);
@@ -339,13 +348,24 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
);
}, [activeView, objectDef, objectName, refreshKey]);
- // Build the ObjectViewSchema for the plugin
+ // 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,
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') => {
@@ -353,7 +373,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 (
@@ -486,7 +506,7 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
key={refreshKey}
schema={objectViewSchema}
dataSource={dataSource}
- views={views}
+ views={mergedViews}
activeViewId={activeViewId}
onViewChange={handleViewChange}
onEdit={(record: any) => onEdit?.(record)}
@@ -518,6 +538,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..72bdfc17 100644
--- a/apps/console/src/components/ViewConfigPanel.tsx
+++ b/apps/console/src/components/ViewConfigPanel.tsx
@@ -13,10 +13,159 @@
*/
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';
+// ---------------------------------------------------------------------------
+// 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',
@@ -110,11 +259,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(() => {
@@ -144,9 +296,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;
@@ -155,17 +304,66 @@ 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: normalizeFieldType(field.type),
+ options: field.options,
+ }));
+ }, [objectDef.fields]);
+
+ // Bridge: view filter (any spec format) → FilterGroup
+ const filterGroupValue = useMemo(() => {
+ const parsed = parseSpecFilter(draft.filter);
+ return { id: 'root', logic: parsed.logic, conditions: parsed.conditions };
+ }, [draft.filter]);
- // 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 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 in spec format */
+ const handleFilterChange = useCallback((group: FilterGroup) => {
+ 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 */
+ 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;
@@ -216,26 +414,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 */}
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}