Skip to content

Commit b8228a1

Browse files
Copilothotlong
andcommitted
Add undo/redo to useConfigDraft, drag-and-drop column sorting via @dnd-kit, and tests
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 7912fa5 commit b8228a1

File tree

6 files changed

+382
-42
lines changed

6 files changed

+382
-42
lines changed

ROADMAP.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
1717

1818
**What Remains:** The gap to **Airtable-level UX** is primarily in:
1919
1. ~~**AppShell** — No dynamic navigation renderer from spec JSON (last P0 blocker)~~ ✅ Complete
20-
2. **Designer Interaction** — DataModelDesigner has undo/redo, field type selectors, inline editing, Ctrl+S save. ViewDesigner has been removed; its capabilities are now part of ViewConfigPanel (right-side config panel) ✅
20+
2. **Designer Interaction** — DataModelDesigner has undo/redo, field type selectors, inline editing, Ctrl+S save. ViewDesigner has been removed; its capabilities (drag-to-reorder columns via @dnd-kit, undo/redo via useConfigDraft history) are now integrated into ViewConfigPanel (right-side config panel) ✅
2121
3. **View Config Live Preview Sync** — Config panel changes sync in real-time for Grid, but `showSort`/`showSearch`/`showFilters`/`striped`/`bordered` not yet propagated to Kanban/Calendar/Timeline/Gallery/Map/Gantt (see P1.8.1)
2222
4. **Dashboard Config Panel** — Airtable-style right-side configuration panel for dashboards (data source, layout, widget properties, sub-editors, type definitions). Widget config live preview sync and scatter chart type switch ✅ fixed (P1.10 Phase 10). Dashboard save/refresh metadata sync ✅ fixed (P1.10 Phase 11). Data provider field override for live preview ✅ fixed (P1.10 Phase 12).
2323
5. **Console Advanced Polish** — Remaining upgrades for forms, import/export, automation, comments
@@ -99,6 +99,8 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
9999
- [x] Add field type selector dropdown with icons from `DESIGNER_FIELD_TYPES`
100100
- [x] Column width validation (min/max/pattern check)
101101
- [x] Removed: ViewDesigner replaced by ViewConfigPanel (right-side config panel)
102+
- [x] ViewConfigPanel upgraded: undo/redo integrated into `useConfigDraft` hook
103+
- [x] ViewConfigPanel upgraded: drag-and-drop column sorting via `@dnd-kit/sortable`
102104

103105
**DataModelDesigner:**
104106
- [x] Entity drag-to-move on canvas

apps/console/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
"test:ui": "vitest --ui"
3434
},
3535
"devDependencies": {
36+
"@dnd-kit/core": "^6.3.1",
37+
"@dnd-kit/sortable": "^10.0.0",
38+
"@dnd-kit/utilities": "^3.2.2",
3639
"@object-ui/auth": "workspace:*",
3740
"@object-ui/collaboration": "workspace:*",
3841
"@object-ui/components": "workspace:*",

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

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,24 +29,61 @@ vi.mock('@object-ui/components', () => {
2929
function useConfigDraft(source: any, options?: any) {
3030
const [draft, setDraft] = React.useState({ ...source });
3131
const [isDirty, setIsDirty] = React.useState(options?.mode === 'create');
32+
const pastRef = React.useRef<any[]>([]);
33+
const futureRef = React.useRef<any[]>([]);
34+
const [, forceRender] = React.useState(0);
3235

3336
React.useEffect(() => {
3437
setDraft({ ...source });
3538
setIsDirty(options?.mode === 'create');
39+
pastRef.current = [];
40+
futureRef.current = [];
3641
}, [source]); // eslint-disable-line react-hooks/exhaustive-deps
3742

3843
const updateField = React.useCallback((field: string, value: any) => {
39-
setDraft((prev: any) => ({ ...prev, [field]: value }));
44+
setDraft((prev: any) => {
45+
pastRef.current = [...pastRef.current.slice(-49), prev];
46+
futureRef.current = [];
47+
return { ...prev, [field]: value };
48+
});
4049
setIsDirty(true);
50+
forceRender((n: number) => n + 1);
4151
options?.onUpdate?.(field, value);
4252
}, [options?.onUpdate]);
4353

54+
const undo = React.useCallback(() => {
55+
if (pastRef.current.length === 0) return;
56+
setDraft((prev: any) => {
57+
const past = [...pastRef.current];
58+
const previous = past.pop()!;
59+
pastRef.current = past;
60+
futureRef.current = [prev, ...futureRef.current];
61+
return previous;
62+
});
63+
forceRender((n: number) => n + 1);
64+
}, []);
65+
66+
const redo = React.useCallback(() => {
67+
if (futureRef.current.length === 0) return;
68+
setDraft((prev: any) => {
69+
const future = [...futureRef.current];
70+
const next = future.shift()!;
71+
futureRef.current = future;
72+
pastRef.current = [...pastRef.current, prev];
73+
return next;
74+
});
75+
forceRender((n: number) => n + 1);
76+
}, []);
77+
4478
const discard = React.useCallback(() => {
4579
setDraft({ ...source });
4680
setIsDirty(false);
81+
pastRef.current = [];
82+
futureRef.current = [];
83+
forceRender((n: number) => n + 1);
4784
}, [source]);
4885

49-
return { draft, isDirty, updateField, discard, setDraft };
86+
return { draft, isDirty, updateField, discard, setDraft, undo, redo, canUndo: pastRef.current.length > 0, canRedo: futureRef.current.length > 0 };
5087
}
5188

5289
// ConfigPanelRenderer mock — renders schema sections with proper collapse/visibility

apps/console/src/utils/view-config-schema.tsx

Lines changed: 149 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,22 @@ import { Input, Switch, Checkbox, FilterBuilder, SortBuilder, ConfigRow } from '
1616
import type { ConfigPanelSchema, ConfigField } from '@object-ui/components';
1717
import type { FilterGroup, SortItem } from '@object-ui/components';
1818
import { Eye, EyeOff, GripVertical } from 'lucide-react';
19+
import {
20+
DndContext,
21+
closestCenter,
22+
KeyboardSensor,
23+
PointerSensor,
24+
useSensor,
25+
useSensors,
26+
type DragEndEvent,
27+
} from '@dnd-kit/core';
28+
import {
29+
SortableContext,
30+
verticalListSortingStrategy,
31+
useSortable,
32+
arrayMove,
33+
} from '@dnd-kit/sortable';
34+
import { CSS } from '@dnd-kit/utilities';
1935
import {
2036
VIEW_TYPE_LABELS,
2137
VIEW_TYPE_OPTIONS,
@@ -43,6 +59,131 @@ function ExpandableWidget({ renderSummary, children }: {
4359
);
4460
}
4561

62+
// ---------------------------------------------------------------------------
63+
// Sortable column item — drag-to-reorder within the field selector
64+
// ---------------------------------------------------------------------------
65+
66+
/** Minimum drag distance in pixels to activate column reorder */
67+
const COLUMN_DRAG_ACTIVATION_DISTANCE = 5;
68+
69+
function SortableColumnItem({ colName, label, idx, total, onToggle, onMove }: {
70+
colName: string;
71+
label: string;
72+
idx: number;
73+
total: number;
74+
onToggle: (name: string, checked: boolean) => void;
75+
onMove: (name: string, direction: 'up' | 'down') => void;
76+
}) {
77+
const {
78+
attributes,
79+
listeners,
80+
setNodeRef,
81+
transform,
82+
transition,
83+
isDragging,
84+
} = useSortable({ id: colName });
85+
86+
const style: React.CSSProperties = {
87+
transform: CSS.Transform.toString(transform),
88+
transition,
89+
zIndex: isDragging ? 10 : undefined,
90+
opacity: isDragging ? 0.5 : undefined,
91+
};
92+
93+
return (
94+
<div
95+
ref={setNodeRef}
96+
style={style}
97+
data-field-name={colName}
98+
data-field-label={label}
99+
className="flex items-center gap-1 text-xs hover:bg-accent/50 rounded-sm py-0.5 px-1 -mx-1 group"
100+
>
101+
<span
102+
{...attributes}
103+
{...listeners}
104+
className="shrink-0 cursor-grab touch-none"
105+
data-testid={`col-drag-handle-${colName}`}
106+
>
107+
<GripVertical className="h-3 w-3 text-muted-foreground/50" />
108+
</span>
109+
<button
110+
type="button"
111+
data-testid={`col-eye-${colName}`}
112+
className="h-5 w-5 flex items-center justify-center rounded hover:bg-accent shrink-0"
113+
onClick={() => onToggle(colName, false)}
114+
aria-label={`Hide ${label}`}
115+
>
116+
<Eye className="h-3.5 w-3.5 text-primary" />
117+
</button>
118+
<span className="truncate flex-1">{label}</span>
119+
<button
120+
type="button"
121+
data-testid={`col-move-up-${colName}`}
122+
className="h-5 w-5 flex items-center justify-center rounded hover:bg-accent disabled:opacity-30 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
123+
disabled={idx === 0}
124+
onClick={() => onMove(colName, 'up')}
125+
aria-label={`Move ${label} up`}
126+
>
127+
<GripVertical className="h-3 w-3 rotate-90" />
128+
</button>
129+
<button
130+
type="button"
131+
data-testid={`col-move-down-${colName}`}
132+
className="h-5 w-5 flex items-center justify-center rounded hover:bg-accent disabled:opacity-30 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
133+
disabled={idx === total - 1}
134+
onClick={() => onMove(colName, 'down')}
135+
aria-label={`Move ${label} down`}
136+
>
137+
<GripVertical className="h-3 w-3 -rotate-90" />
138+
</button>
139+
</div>
140+
);
141+
}
142+
143+
/** Wrapper component for drag-and-drop column selector (uses hooks for sensors) */
144+
function DraggableColumnList({ columns, fieldOptions, onToggle, onMove, onReorder }: {
145+
columns: string[];
146+
fieldOptions: FieldOption[];
147+
onToggle: (name: string, checked: boolean) => void;
148+
onMove: (name: string, direction: 'up' | 'down') => void;
149+
onReorder: (newColumns: string[]) => void;
150+
}) {
151+
const sensors = useSensors(
152+
useSensor(PointerSensor, { activationConstraint: { distance: COLUMN_DRAG_ACTIVATION_DISTANCE } }),
153+
useSensor(KeyboardSensor),
154+
);
155+
156+
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
157+
const { active, over } = event;
158+
if (!over || active.id === over.id) return;
159+
const oldIndex = columns.indexOf(String(active.id));
160+
const newIndex = columns.indexOf(String(over.id));
161+
if (oldIndex === -1 || newIndex === -1) return;
162+
onReorder(arrayMove(columns, oldIndex, newIndex));
163+
}, [columns, onReorder]);
164+
165+
return (
166+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
167+
<SortableContext items={columns} strategy={verticalListSortingStrategy}>
168+
{columns.map((colName, idx) => {
169+
const field = fieldOptions.find(f => f.value === colName);
170+
return (
171+
<SortableColumnItem
172+
key={colName}
173+
colName={colName}
174+
label={field?.label || colName}
175+
idx={idx}
176+
total={columns.length}
177+
onToggle={onToggle}
178+
onMove={onMove}
179+
/>
180+
);
181+
})}
182+
</SortableContext>
183+
</DndContext>
184+
);
185+
}
186+
46187
// ---------------------------------------------------------------------------
47188
// Types
48189
// ---------------------------------------------------------------------------
@@ -639,47 +780,16 @@ function buildDataSection(
639780
</button>
640781
</div>
641782
<div className="space-y-0.5 max-h-48 overflow-auto">
642-
{/* Visible (selected) columns with drag handle + eye toggle */}
783+
{/* Visible (selected) columns with drag-and-drop reorder */}
643784
{Array.isArray(draft.columns) && draft.columns.length > 0 && (
644785
<div data-testid="selected-columns" className="space-y-0.5 pb-1 mb-1 border-b border-border/50">
645-
{draft.columns.map((colName: string, idx: number) => {
646-
const field = fieldOptions.find(f => f.value === colName);
647-
return (
648-
<div key={colName} data-field-name={colName} data-field-label={field?.label || colName} className="flex items-center gap-1 text-xs hover:bg-accent/50 rounded-sm py-0.5 px-1 -mx-1 group">
649-
<GripVertical className="h-3 w-3 text-muted-foreground/50 shrink-0 cursor-grab" />
650-
<button
651-
type="button"
652-
data-testid={`col-eye-${colName}`}
653-
className="h-5 w-5 flex items-center justify-center rounded hover:bg-accent shrink-0"
654-
onClick={() => handleColumnToggle(colName, false)}
655-
aria-label={`Hide ${field?.label || colName}`}
656-
>
657-
<Eye className="h-3.5 w-3.5 text-primary" />
658-
</button>
659-
<span className="truncate flex-1">{field?.label || colName}</span>
660-
<button
661-
type="button"
662-
data-testid={`col-move-up-${colName}`}
663-
className="h-5 w-5 flex items-center justify-center rounded hover:bg-accent disabled:opacity-30 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
664-
disabled={idx === 0}
665-
onClick={() => handleColumnMove(colName, 'up')}
666-
aria-label={`Move ${field?.label || colName} up`}
667-
>
668-
<GripVertical className="h-3 w-3 rotate-90" />
669-
</button>
670-
<button
671-
type="button"
672-
data-testid={`col-move-down-${colName}`}
673-
className="h-5 w-5 flex items-center justify-center rounded hover:bg-accent disabled:opacity-30 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
674-
disabled={idx === draft.columns.length - 1}
675-
onClick={() => handleColumnMove(colName, 'down')}
676-
aria-label={`Move ${field?.label || colName} down`}
677-
>
678-
<GripVertical className="h-3 w-3 -rotate-90" />
679-
</button>
680-
</div>
681-
);
682-
})}
786+
<DraggableColumnList
787+
columns={draft.columns}
788+
fieldOptions={fieldOptions}
789+
onToggle={handleColumnToggle}
790+
onMove={handleColumnMove}
791+
onReorder={(newCols) => updateField('columns', newCols)}
792+
/>
683793
</div>
684794
)}
685795
{/* Hidden (unselected) fields with eye-off toggle */}

0 commit comments

Comments
 (0)