Skip to content

Commit 64c532b

Browse files
Copilothotlong
andcommitted
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>
1 parent 491f4b2 commit 64c532b

2 files changed

Lines changed: 212 additions & 65 deletions

File tree

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

Lines changed: 98 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,42 @@ vi.mock('@object-ui/components', () => ({
4343
{...props}
4444
/>
4545
),
46+
Checkbox: ({ checked, onCheckedChange, ...props }: any) => (
47+
<input
48+
type="checkbox"
49+
checked={!!checked}
50+
onChange={() => onCheckedChange?.(!checked)}
51+
{...props}
52+
/>
53+
),
54+
FilterBuilder: ({ fields, value, onChange, ...props }: any) => {
55+
let counter = 0;
56+
return (
57+
<div data-testid="mock-filter-builder" data-field-count={fields?.length || 0} data-condition-count={value?.conditions?.length || 0}>
58+
<button data-testid="filter-builder-add" onClick={() => {
59+
const newConditions = [...(value?.conditions || []), { id: `mock-filter-${Date.now()}-${++counter}`, field: fields?.[0]?.value || '', operator: 'equals', value: '' }];
60+
onChange?.({ ...value, conditions: newConditions });
61+
}}>Add filter</button>
62+
{value?.conditions?.map((c: any, i: number) => (
63+
<span key={c.id || i} data-testid={`filter-condition-${i}`}>{c.field} {c.operator} {String(c.value)}</span>
64+
))}
65+
</div>
66+
);
67+
},
68+
SortBuilder: ({ fields, value, onChange, ...props }: any) => {
69+
let counter = 0;
70+
return (
71+
<div data-testid="mock-sort-builder" data-field-count={fields?.length || 0} data-sort-count={value?.length || 0}>
72+
<button data-testid="sort-builder-add" onClick={() => {
73+
const newItems = [...(value || []), { id: `mock-sort-${Date.now()}-${++counter}`, field: fields?.[0]?.value || '', order: 'asc' }];
74+
onChange?.(newItems);
75+
}}>Add sort</button>
76+
{value?.map((s: any, i: number) => (
77+
<span key={s.id || i} data-testid={`sort-item-${i}`}>{s.field} {s.order}</span>
78+
))}
79+
</div>
80+
);
81+
},
4682
}));
4783

4884
const mockActiveView = {
@@ -140,7 +176,7 @@ describe('ViewConfigPanel', () => {
140176
expect(screen.getByText('console.objectView.noDescription')).toBeInTheDocument();
141177
});
142178

143-
it('displays column count', () => {
179+
it('displays column checkboxes for each field', () => {
144180
render(
145181
<ViewConfigPanel
146182
open={true}
@@ -150,8 +186,15 @@ describe('ViewConfigPanel', () => {
150186
/>
151187
);
152188

153-
// 3 columns configured
154-
expect(screen.getByText('console.objectView.columnsConfigured'.replace('{{count}}', '3'))).toBeInTheDocument();
189+
// 3 fields → 3 checkboxes
190+
expect(screen.getByTestId('column-selector')).toBeInTheDocument();
191+
expect(screen.getByTestId('col-checkbox-name')).toBeInTheDocument();
192+
expect(screen.getByTestId('col-checkbox-stage')).toBeInTheDocument();
193+
expect(screen.getByTestId('col-checkbox-amount')).toBeInTheDocument();
194+
// Columns in activeView should be checked
195+
expect(screen.getByTestId('col-checkbox-name')).toBeChecked();
196+
expect(screen.getByTestId('col-checkbox-stage')).toBeChecked();
197+
expect(screen.getByTestId('col-checkbox-amount')).toBeChecked();
155198
});
156199

157200
it('displays object source name', () => {
@@ -225,7 +268,7 @@ describe('ViewConfigPanel', () => {
225268
expect(screen.getByTestId('view-type-select')).toHaveValue('kanban');
226269
});
227270

228-
it('shows "None" for empty filters and columns', () => {
271+
it('shows inline builders with zero items for empty view', () => {
229272
render(
230273
<ViewConfigPanel
231274
open={true}
@@ -235,9 +278,10 @@ describe('ViewConfigPanel', () => {
235278
/>
236279
);
237280

238-
// Should show "None" for columns, filters
239-
const noneTexts = screen.getAllByText('console.objectView.none');
240-
expect(noneTexts.length).toBeGreaterThanOrEqual(2);
281+
// FilterBuilder should have 0 conditions
282+
expect(screen.getByTestId('mock-filter-builder')).toHaveAttribute('data-condition-count', '0');
283+
// SortBuilder should have 0 items
284+
expect(screen.getByTestId('mock-sort-builder')).toHaveAttribute('data-sort-count', '0');
241285
});
242286

243287
it('has correct ARIA attributes when open', () => {
@@ -403,62 +447,85 @@ describe('ViewConfigPanel', () => {
403447
expect(onViewUpdate).toHaveBeenCalledWith('type', 'kanban');
404448
});
405449

406-
it('calls onOpenEditor when clicking columns row', () => {
407-
const onOpenEditor = vi.fn();
450+
it('renders inline FilterBuilder with correct conditions from activeView', () => {
408451
render(
409452
<ViewConfigPanel
410453
open={true}
411454
onClose={vi.fn()}
412455
activeView={mockActiveView}
413456
objectDef={mockObjectDef}
414-
onOpenEditor={onOpenEditor}
415457
/>
416458
);
417459

418-
// Click the columns row — it's a button with the columns label
419-
const columnsRow = screen.getByText('console.objectView.columns').closest('button');
420-
expect(columnsRow).toBeTruthy();
421-
fireEvent.click(columnsRow!);
422-
423-
expect(onOpenEditor).toHaveBeenCalledWith('columns');
460+
const fb = screen.getByTestId('mock-filter-builder');
461+
expect(fb).toHaveAttribute('data-condition-count', '1');
462+
expect(fb).toHaveAttribute('data-field-count', '3');
463+
expect(screen.getByTestId('filter-condition-0')).toHaveTextContent('stage = active');
424464
});
425465

426-
it('calls onOpenEditor when clicking filters row', () => {
427-
const onOpenEditor = vi.fn();
466+
it('renders inline SortBuilder with correct items from activeView', () => {
428467
render(
429468
<ViewConfigPanel
430469
open={true}
431470
onClose={vi.fn()}
432471
activeView={mockActiveView}
433472
objectDef={mockObjectDef}
434-
onOpenEditor={onOpenEditor}
435473
/>
436474
);
437475

438-
const filterRow = screen.getByText('console.objectView.filterBy').closest('button');
439-
expect(filterRow).toBeTruthy();
440-
fireEvent.click(filterRow!);
476+
const sb = screen.getByTestId('mock-sort-builder');
477+
expect(sb).toHaveAttribute('data-sort-count', '1');
478+
expect(sb).toHaveAttribute('data-field-count', '3');
479+
expect(screen.getByTestId('sort-item-0')).toHaveTextContent('name asc');
480+
});
441481

442-
expect(onOpenEditor).toHaveBeenCalledWith('filters');
482+
it('updates draft when adding a filter via FilterBuilder', () => {
483+
const onViewUpdate = vi.fn();
484+
render(
485+
<ViewConfigPanel
486+
open={true}
487+
onClose={vi.fn()}
488+
activeView={{ id: 'empty', label: 'Empty', type: 'grid' }}
489+
objectDef={mockObjectDef}
490+
onViewUpdate={onViewUpdate}
491+
/>
492+
);
493+
494+
fireEvent.click(screen.getByTestId('filter-builder-add'));
495+
expect(onViewUpdate).toHaveBeenCalledWith('filter', expect.any(Array));
443496
});
444497

445-
it('calls onOpenEditor when clicking sort row', () => {
446-
const onOpenEditor = vi.fn();
498+
it('updates draft when adding a sort via SortBuilder', () => {
499+
const onViewUpdate = vi.fn();
447500
render(
448501
<ViewConfigPanel
449502
open={true}
450503
onClose={vi.fn()}
451-
activeView={mockActiveView}
504+
activeView={{ id: 'empty', label: 'Empty', type: 'grid' }}
452505
objectDef={mockObjectDef}
453-
onOpenEditor={onOpenEditor}
506+
onViewUpdate={onViewUpdate}
454507
/>
455508
);
456509

457-
const sortRow = screen.getByText('console.objectView.sortBy').closest('button');
458-
expect(sortRow).toBeTruthy();
459-
fireEvent.click(sortRow!);
510+
fireEvent.click(screen.getByTestId('sort-builder-add'));
511+
expect(onViewUpdate).toHaveBeenCalledWith('sort', expect.any(Array));
512+
});
513+
514+
it('toggles column checkbox and calls onViewUpdate with updated columns', () => {
515+
const onViewUpdate = vi.fn();
516+
render(
517+
<ViewConfigPanel
518+
open={true}
519+
onClose={vi.fn()}
520+
activeView={mockActiveView}
521+
objectDef={mockObjectDef}
522+
onViewUpdate={onViewUpdate}
523+
/>
524+
);
460525

461-
expect(onOpenEditor).toHaveBeenCalledWith('sort');
526+
// Uncheck the 'stage' column
527+
fireEvent.click(screen.getByTestId('col-checkbox-stage'));
528+
expect(onViewUpdate).toHaveBeenCalledWith('columns', ['name', 'amount']);
462529
});
463530

464531
it('saves draft via onSave when Save button is clicked', () => {

apps/console/src/components/ViewConfigPanel.tsx

Lines changed: 114 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
*/
1414

1515
import { useMemo, useEffect, useRef, useState, useCallback } from 'react';
16-
import { Button, Switch, Input } from '@object-ui/components';
17-
import { X, ChevronRight, Save, RotateCcw } from 'lucide-react';
16+
import { Button, Switch, Input, Checkbox, FilterBuilder, SortBuilder } from '@object-ui/components';
17+
import type { FilterGroup, SortItem } from '@object-ui/components';
18+
import { X, Save, RotateCcw } from 'lucide-react';
1819
import { useObjectTranslation } from '@object-ui/i18n';
1920

2021
/** View type labels for display */
@@ -147,9 +148,6 @@ export function ViewConfigPanel({ open, onClose, activeView, objectDef, onViewUp
147148

148149
const viewLabel = draft.label || draft.id || activeView.id;
149150
const viewType = draft.type || 'grid';
150-
const columnCount = draft.columns?.length || 0;
151-
const filterCount = Array.isArray(draft.filter) ? draft.filter.length : 0;
152-
const sortCount = Array.isArray(draft.sort) ? draft.sort.length : 0;
153151

154152
const hasSearch = draft.showSearch !== false;
155153
const hasFilter = draft.showFilters !== false;
@@ -158,17 +156,69 @@ export function ViewConfigPanel({ open, onClose, activeView, objectDef, onViewUp
158156
const hasAddForm = draft.addRecordViaForm === true;
159157
const hasShowDescription = draft.showDescription !== false;
160158

161-
// Format filter summary
162-
const filterSummary = useMemo(() => {
163-
if (filterCount === 0) return t('console.objectView.none');
164-
return `${filterCount} ${t('console.objectView.filterBy').toLowerCase()}`;
165-
}, [filterCount, t]);
159+
// Derive field options from objectDef for FilterBuilder/SortBuilder
160+
const fieldOptions = useMemo(() => {
161+
if (!objectDef.fields) return [];
162+
return Object.entries(objectDef.fields).map(([key, field]: [string, any]) => ({
163+
value: key,
164+
label: field.label || key,
165+
type: field.type || 'text',
166+
options: field.options,
167+
}));
168+
}, [objectDef.fields]);
166169

167-
// Format sort summary
168-
const sortSummary = useMemo(() => {
169-
if (sortCount === 0) return t('console.objectView.none');
170-
return draft.sort?.map((s: any) => `${s.field} ${s.order || s.direction || 'asc'}`).join(', ') || t('console.objectView.none');
171-
}, [draft.sort, sortCount, t]);
170+
// Bridge: view filter array → FilterGroup
171+
const filterGroupValue = useMemo<FilterGroup>(() => {
172+
const conditions = (Array.isArray(draft.filter) ? draft.filter : []).map((f: any) => ({
173+
id: f.id || crypto.randomUUID(),
174+
field: f.field || '',
175+
operator: f.operator || 'equals',
176+
value: f.value ?? '',
177+
}));
178+
return { id: 'root', logic: 'and' as const, conditions };
179+
}, [draft.filter]);
180+
181+
// Bridge: view sort array → SortItem[]
182+
const sortItemsValue = useMemo<SortItem[]>(() => {
183+
return (Array.isArray(draft.sort) ? draft.sort : []).map((s: any) => ({
184+
id: s.id || crypto.randomUUID(),
185+
field: s.field || '',
186+
order: (s.order || s.direction || 'asc') as 'asc' | 'desc',
187+
}));
188+
}, [draft.sort]);
189+
190+
/** Handle FilterBuilder changes → update draft.filter */
191+
const handleFilterChange = useCallback((group: FilterGroup) => {
192+
const filters = group.conditions.map(c => ({
193+
id: c.id,
194+
field: c.field,
195+
operator: c.operator,
196+
value: c.value,
197+
}));
198+
updateDraft('filter', filters);
199+
}, [updateDraft]);
200+
201+
/** Handle SortBuilder changes → update draft.sort */
202+
const handleSortChange = useCallback((items: SortItem[]) => {
203+
const sortArr = items.map(s => ({
204+
id: s.id,
205+
field: s.field,
206+
order: s.order,
207+
}));
208+
updateDraft('sort', sortArr);
209+
}, [updateDraft]);
210+
211+
/** Handle column checkbox toggle */
212+
const handleColumnToggle = useCallback((fieldName: string, checked: boolean) => {
213+
const currentCols: string[] = Array.isArray(draft.columns) ? [...draft.columns] : [];
214+
if (checked && !currentCols.includes(fieldName)) {
215+
currentCols.push(fieldName);
216+
} else if (!checked) {
217+
const idx = currentCols.indexOf(fieldName);
218+
if (idx >= 0) currentCols.splice(idx, 1);
219+
}
220+
updateDraft('columns', currentCols);
221+
}, [draft.columns, updateDraft]);
172222

173223
if (!open) return null;
174224

@@ -219,26 +269,56 @@ export function ViewConfigPanel({ open, onClose, activeView, objectDef, onViewUp
219269

220270
{/* Data Section */}
221271
<SectionHeader title={t('console.objectView.data')} />
222-
<div className="space-y-0.5">
272+
<div className="space-y-2">
223273
<ConfigRow label={t('console.objectView.source')} value={objectDef.label || objectDef.name} />
224-
<ConfigRow label={t('console.objectView.columns')} onClick={() => onOpenEditor?.('columns')}>
225-
<span className="text-xs text-foreground flex items-center gap-1">
226-
{columnCount > 0 ? t('console.objectView.columnsConfigured', { count: columnCount }) : t('console.objectView.none')}
227-
<ChevronRight className="h-3 w-3 text-muted-foreground" />
228-
</span>
229-
</ConfigRow>
230-
<ConfigRow label={t('console.objectView.filterBy')} onClick={() => onOpenEditor?.('filters')}>
231-
<span className="text-xs text-foreground flex items-center gap-1">
232-
{filterSummary}
233-
<ChevronRight className="h-3 w-3 text-muted-foreground" />
234-
</span>
235-
</ConfigRow>
236-
<ConfigRow label={t('console.objectView.sortBy')} onClick={() => onOpenEditor?.('sort')}>
237-
<span className="text-xs text-foreground flex items-center gap-1 truncate max-w-[140px]">
238-
{sortSummary}
239-
<ChevronRight className="h-3 w-3 text-muted-foreground shrink-0" />
240-
</span>
241-
</ConfigRow>
274+
275+
{/* Columns — inline checkbox list */}
276+
<div className="pt-1">
277+
<span className="text-xs text-muted-foreground">{t('console.objectView.columns')}</span>
278+
<div data-testid="column-selector" className="mt-1 space-y-1 max-h-36 overflow-auto">
279+
{fieldOptions.map((f) => {
280+
const checked = Array.isArray(draft.columns) ? draft.columns.includes(f.value) : false;
281+
return (
282+
<label key={f.value} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/50 rounded-sm py-0.5 px-1 -mx-1">
283+
<Checkbox
284+
data-testid={`col-checkbox-${f.value}`}
285+
checked={checked}
286+
onCheckedChange={(c: boolean) => handleColumnToggle(f.value, c)}
287+
className="h-3.5 w-3.5"
288+
/>
289+
<span className="truncate">{f.label}</span>
290+
</label>
291+
);
292+
})}
293+
</div>
294+
</div>
295+
296+
{/* Filters — inline FilterBuilder */}
297+
<div className="pt-1">
298+
<span className="text-xs text-muted-foreground">{t('console.objectView.filterBy')}</span>
299+
<div data-testid="inline-filter-builder" className="mt-1">
300+
<FilterBuilder
301+
fields={fieldOptions}
302+
value={filterGroupValue}
303+
onChange={handleFilterChange}
304+
className="[&_button]:h-7 [&_button]:text-xs [&_input]:h-7 [&_input]:text-xs"
305+
showClearAll
306+
/>
307+
</div>
308+
</div>
309+
310+
{/* Sort — inline SortBuilder */}
311+
<div className="pt-1">
312+
<span className="text-xs text-muted-foreground">{t('console.objectView.sortBy')}</span>
313+
<div data-testid="inline-sort-builder" className="mt-1">
314+
<SortBuilder
315+
fields={fieldOptions.map(f => ({ value: f.value, label: f.label }))}
316+
value={sortItemsValue}
317+
onChange={handleSortChange}
318+
className="[&_button]:h-7 [&_button]:text-xs"
319+
/>
320+
</div>
321+
</div>
242322
</div>
243323

244324
{/* Appearance Section */}

0 commit comments

Comments
 (0)