Skip to content

Commit 5c08c0b

Browse files
authored
Merge pull request #694 from objectstack-ai/copilot/add-columns-selector-subpanel
2 parents 08d025a + 46856a7 commit 5c08c0b

File tree

3 files changed

+240
-9
lines changed

3 files changed

+240
-9
lines changed

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
118118

119119
- [x] Inline ViewConfigPanel for all view types (Airtable-style right sidebar)
120120
- [x] Column visibility toggle from config panel
121+
- [x] Column reorder (move up/down) from config panel with real-time preview
121122
- [x] Sort/filter/group config from right sidebar
122123
- [x] Type-specific options in config panel (kanban/calendar/map/gallery/timeline/gantt)
123124
- [x] Unified create/edit mode (`mode="create"|"edit"`) — single panel entry point

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

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1284,4 +1284,180 @@ describe('ViewConfigPanel', () => {
12841284
fireEvent.click(screen.getByText('console.objectView.sortBy'));
12851285
expect(screen.queryByTestId('inline-sort-builder')).not.toBeInTheDocument();
12861286
});
1287+
1288+
// ── Column selector sub-panel tests ──
1289+
1290+
it('shows selected columns in order with move up/down buttons', () => {
1291+
render(
1292+
<ViewConfigPanel
1293+
open={true}
1294+
onClose={vi.fn()}
1295+
activeView={mockActiveView}
1296+
objectDef={mockObjectDef}
1297+
/>
1298+
);
1299+
1300+
// Expand the Fields sub-section
1301+
fireEvent.click(screen.getByText('console.objectView.fields'));
1302+
1303+
// Selected columns section should appear
1304+
expect(screen.getByTestId('selected-columns')).toBeInTheDocument();
1305+
1306+
// Move buttons should exist for selected columns
1307+
expect(screen.getByTestId('col-move-up-name')).toBeInTheDocument();
1308+
expect(screen.getByTestId('col-move-down-name')).toBeInTheDocument();
1309+
expect(screen.getByTestId('col-move-up-stage')).toBeInTheDocument();
1310+
expect(screen.getByTestId('col-move-down-stage')).toBeInTheDocument();
1311+
expect(screen.getByTestId('col-move-up-amount')).toBeInTheDocument();
1312+
expect(screen.getByTestId('col-move-down-amount')).toBeInTheDocument();
1313+
});
1314+
1315+
it('disables move-up for first column and move-down for last column', () => {
1316+
render(
1317+
<ViewConfigPanel
1318+
open={true}
1319+
onClose={vi.fn()}
1320+
activeView={mockActiveView}
1321+
objectDef={mockObjectDef}
1322+
/>
1323+
);
1324+
1325+
fireEvent.click(screen.getByText('console.objectView.fields'));
1326+
1327+
// First column (name) — move up disabled
1328+
expect(screen.getByTestId('col-move-up-name')).toBeDisabled();
1329+
expect(screen.getByTestId('col-move-down-name')).not.toBeDisabled();
1330+
1331+
// Last column (amount) — move down disabled
1332+
expect(screen.getByTestId('col-move-up-amount')).not.toBeDisabled();
1333+
expect(screen.getByTestId('col-move-down-amount')).toBeDisabled();
1334+
});
1335+
1336+
it('moves column down and updates draft.columns order', () => {
1337+
const onViewUpdate = vi.fn();
1338+
render(
1339+
<ViewConfigPanel
1340+
open={true}
1341+
onClose={vi.fn()}
1342+
activeView={mockActiveView}
1343+
objectDef={mockObjectDef}
1344+
onViewUpdate={onViewUpdate}
1345+
/>
1346+
);
1347+
1348+
fireEvent.click(screen.getByText('console.objectView.fields'));
1349+
1350+
// Move "name" down — should swap with "stage"
1351+
fireEvent.click(screen.getByTestId('col-move-down-name'));
1352+
expect(onViewUpdate).toHaveBeenCalledWith('columns', ['stage', 'name', 'amount']);
1353+
});
1354+
1355+
it('moves column up and updates draft.columns order', () => {
1356+
const onViewUpdate = vi.fn();
1357+
render(
1358+
<ViewConfigPanel
1359+
open={true}
1360+
onClose={vi.fn()}
1361+
activeView={mockActiveView}
1362+
objectDef={mockObjectDef}
1363+
onViewUpdate={onViewUpdate}
1364+
/>
1365+
);
1366+
1367+
fireEvent.click(screen.getByText('console.objectView.fields'));
1368+
1369+
// Move "amount" up — should swap with "stage"
1370+
fireEvent.click(screen.getByTestId('col-move-up-amount'));
1371+
expect(onViewUpdate).toHaveBeenCalledWith('columns', ['name', 'amount', 'stage']);
1372+
});
1373+
1374+
it('saves reordered columns via onSave', () => {
1375+
const onSave = vi.fn();
1376+
render(
1377+
<ViewConfigPanel
1378+
open={true}
1379+
onClose={vi.fn()}
1380+
activeView={mockActiveView}
1381+
objectDef={mockObjectDef}
1382+
onSave={onSave}
1383+
/>
1384+
);
1385+
1386+
fireEvent.click(screen.getByText('console.objectView.fields'));
1387+
1388+
// Move "name" down
1389+
fireEvent.click(screen.getByTestId('col-move-down-name'));
1390+
1391+
// Footer should appear
1392+
expect(screen.getByTestId('view-config-footer')).toBeInTheDocument();
1393+
1394+
// Save
1395+
fireEvent.click(screen.getByTestId('view-config-save'));
1396+
expect(onSave).toHaveBeenCalledOnce();
1397+
expect(onSave.mock.calls[0][0].columns).toEqual(['stage', 'name', 'amount']);
1398+
});
1399+
1400+
it('shows unselected fields below selected columns', () => {
1401+
// Only 'name' and 'stage' are selected, 'amount' is not
1402+
render(
1403+
<ViewConfigPanel
1404+
open={true}
1405+
onClose={vi.fn()}
1406+
activeView={{ ...mockActiveView, columns: ['name', 'stage'] }}
1407+
objectDef={mockObjectDef}
1408+
/>
1409+
);
1410+
1411+
fireEvent.click(screen.getByText('console.objectView.fields'));
1412+
1413+
// 'amount' should have a checkbox but no move buttons
1414+
expect(screen.getByTestId('col-checkbox-amount')).toBeInTheDocument();
1415+
expect(screen.getByTestId('col-checkbox-amount')).not.toBeChecked();
1416+
expect(screen.queryByTestId('col-move-up-amount')).not.toBeInTheDocument();
1417+
expect(screen.queryByTestId('col-move-down-amount')).not.toBeInTheDocument();
1418+
});
1419+
1420+
it('adding unselected field appends to columns and shows move buttons', () => {
1421+
const onViewUpdate = vi.fn();
1422+
render(
1423+
<ViewConfigPanel
1424+
open={true}
1425+
onClose={vi.fn()}
1426+
activeView={{ ...mockActiveView, columns: ['name', 'stage'] }}
1427+
objectDef={mockObjectDef}
1428+
onViewUpdate={onViewUpdate}
1429+
/>
1430+
);
1431+
1432+
fireEvent.click(screen.getByText('console.objectView.fields'));
1433+
1434+
// Click checkbox for 'amount' to add it
1435+
fireEvent.click(screen.getByTestId('col-checkbox-amount'));
1436+
expect(onViewUpdate).toHaveBeenCalledWith('columns', ['name', 'stage', 'amount']);
1437+
});
1438+
1439+
it('reorder triggers onViewUpdate for real-time preview', () => {
1440+
const onViewUpdate = vi.fn();
1441+
render(
1442+
<ViewConfigPanel
1443+
open={true}
1444+
onClose={vi.fn()}
1445+
activeView={mockActiveView}
1446+
objectDef={mockObjectDef}
1447+
onViewUpdate={onViewUpdate}
1448+
/>
1449+
);
1450+
1451+
fireEvent.click(screen.getByText('console.objectView.fields'));
1452+
1453+
// Move "stage" up
1454+
fireEvent.click(screen.getByTestId('col-move-up-stage'));
1455+
expect(onViewUpdate).toHaveBeenCalledWith('columns', ['stage', 'name', 'amount']);
1456+
1457+
// Move "stage" down (now at index 0)
1458+
fireEvent.click(screen.getByTestId('col-move-down-stage'));
1459+
// After the first move, state has stage at index 0
1460+
// The second move should operate on the updated state
1461+
expect(onViewUpdate).toHaveBeenCalledTimes(2);
1462+
});
12871463
});

apps/console/src/components/ViewConfigPanel.tsx

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import { useMemo, useEffect, useRef, useState, useCallback } from 'react';
1616
import { Button, Switch, Input, Checkbox, FilterBuilder, SortBuilder } from '@object-ui/components';
1717
import type { FilterGroup, SortItem } from '@object-ui/components';
18-
import { X, Save, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
18+
import { X, Save, RotateCcw, ChevronDown, ChevronRight, ArrowUp, ArrowDown } from 'lucide-react';
1919
import { useObjectTranslation } from '@object-ui/i18n';
2020

2121
// ---------------------------------------------------------------------------
@@ -190,6 +190,8 @@ const ROW_HEIGHT_OPTIONS = [
190190
];
191191

192192
/** Editor panel types that can be opened from clickable rows */
193+
export type EditorPanelType = 'columns' | 'filter' | 'sort';
194+
193195
export interface ViewConfigPanelProps {
194196
/** Whether the panel is open */
195197
open: boolean;
@@ -427,6 +429,18 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje
427429
updateDraft('columns', currentCols);
428430
}, [draft.columns, updateDraft]);
429431

432+
/** Move a column up or down in the columns array */
433+
const handleColumnMove = useCallback((fieldName: string, direction: 'up' | 'down') => {
434+
const currentCols: string[] = Array.isArray(draft.columns) ? [...draft.columns] : [];
435+
const idx = currentCols.indexOf(fieldName);
436+
if (idx < 0) return;
437+
const targetIdx = direction === 'up' ? idx - 1 : idx + 1;
438+
if (targetIdx < 0 || targetIdx >= currentCols.length) return;
439+
// Swap
440+
[currentCols[idx], currentCols[targetIdx]] = [currentCols[targetIdx], currentCols[idx]];
441+
updateDraft('columns', currentCols);
442+
}, [draft.columns, updateDraft]);
443+
430444
/** Handle type-specific option change (e.g., kanban.groupByField, calendar.startDateField) */
431445
const handleTypeOptionChange = useCallback((typeKey: string, optionKey: string, value: any) => {
432446
const current = draft[typeKey] || {};
@@ -584,21 +598,61 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje
584598
onClick={() => toggleDataSub('fields')}
585599
/>
586600
{expandedDataSubs.fields && (
587-
<div data-testid="column-selector" className="pb-2 space-y-1 max-h-36 overflow-auto">
588-
{fieldOptions.map((f) => {
589-
const checked = Array.isArray(draft.columns) ? draft.columns.includes(f.value) : false;
590-
return (
601+
<div data-testid="column-selector" className="pb-2 space-y-0.5 max-h-48 overflow-auto">
602+
{/* Selected columns — shown in draft order with reorder buttons */}
603+
{Array.isArray(draft.columns) && draft.columns.length > 0 && (
604+
<div data-testid="selected-columns" className="space-y-0.5 pb-1 mb-1 border-b border-border/50">
605+
{draft.columns.map((colName: string, idx: number) => {
606+
const field = fieldOptions.find(f => f.value === colName);
607+
return (
608+
<div key={colName} className="flex items-center gap-1 text-xs hover:bg-accent/50 rounded-sm py-0.5 px-1 -mx-1">
609+
<Checkbox
610+
data-testid={`col-checkbox-${colName}`}
611+
checked={true}
612+
onCheckedChange={() => handleColumnToggle(colName, false)}
613+
className="h-3.5 w-3.5 shrink-0"
614+
/>
615+
<span className="truncate flex-1">{field?.label || colName}</span>
616+
<button
617+
type="button"
618+
data-testid={`col-move-up-${colName}`}
619+
className="h-5 w-5 flex items-center justify-center rounded hover:bg-accent disabled:opacity-30 shrink-0"
620+
disabled={idx === 0}
621+
onClick={() => handleColumnMove(colName, 'up')}
622+
aria-label={`Move ${field?.label || colName} up`}
623+
>
624+
<ArrowUp className="h-3 w-3" />
625+
</button>
626+
<button
627+
type="button"
628+
data-testid={`col-move-down-${colName}`}
629+
className="h-5 w-5 flex items-center justify-center rounded hover:bg-accent disabled:opacity-30 shrink-0"
630+
disabled={idx === draft.columns.length - 1}
631+
onClick={() => handleColumnMove(colName, 'down')}
632+
aria-label={`Move ${field?.label || colName} down`}
633+
>
634+
<ArrowDown className="h-3 w-3" />
635+
</button>
636+
</div>
637+
);
638+
})}
639+
</div>
640+
)}
641+
{/* Unselected fields — available to add */}
642+
{fieldOptions
643+
.filter(f => !Array.isArray(draft.columns) || !draft.columns.includes(f.value))
644+
.map((f) => (
591645
<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">
592646
<Checkbox
593647
data-testid={`col-checkbox-${f.value}`}
594-
checked={checked}
595-
onCheckedChange={(c) => handleColumnToggle(f.value, c === true)}
648+
checked={false}
649+
onCheckedChange={() => handleColumnToggle(f.value, true)}
596650
className="h-3.5 w-3.5"
597651
/>
598652
<span className="truncate">{f.label}</span>
599653
</label>
600-
);
601-
})}
654+
))
655+
}
602656
</div>
603657
)}
604658

0 commit comments

Comments
 (0)