Skip to content

Commit d39e376

Browse files
Copilothotlong
andcommitted
feat(plugin-designer): replace column up/down buttons with @dnd-kit drag-to-reorder
- Add @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities dependencies - Create SortableColumnItem component with drag handles - Replace ArrowUp/ArrowDown buttons with drag-to-reorder via DndContext - Wire handleDragEnd to pushState for undo/redo integration - Add 5 tests for drag-to-reorder behavior (42 total) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent bde744b commit d39e376

4 files changed

Lines changed: 273 additions & 91 deletions

File tree

packages/plugin-designer/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232
"react-dom": "^18.0.0 || ^19.0.0"
3333
},
3434
"dependencies": {
35+
"@dnd-kit/core": "^6.3.1",
36+
"@dnd-kit/sortable": "^10.0.0",
37+
"@dnd-kit/utilities": "^3.2.2",
3538
"clsx": "^2.1.1",
3639
"lucide-react": "^0.574.0",
3740
"tailwind-merge": "^3.4.1"

packages/plugin-designer/src/ViewDesigner.tsx

Lines changed: 161 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ import {
1414
Trash2,
1515
Eye,
1616
EyeOff,
17-
ArrowUp,
18-
ArrowDown,
1917
Save,
2018
X,
2119
Columns3,
@@ -32,6 +30,22 @@ import {
3230
Copy,
3331
Clipboard,
3432
} from 'lucide-react';
33+
import {
34+
DndContext,
35+
closestCenter,
36+
KeyboardSensor,
37+
PointerSensor,
38+
useSensor,
39+
useSensors,
40+
type DragEndEvent,
41+
} from '@dnd-kit/core';
42+
import {
43+
SortableContext,
44+
verticalListSortingStrategy,
45+
useSortable,
46+
arrayMove,
47+
} from '@dnd-kit/sortable';
48+
import { CSS } from '@dnd-kit/utilities';
3549
import { clsx } from 'clsx';
3650
import { twMerge } from 'tailwind-merge';
3751
import { useDesignerHistory } from './hooks/useDesignerHistory';
@@ -119,10 +133,7 @@ const LABELS = {
119133
asc: 'Asc',
120134
desc: 'Desc',
121135
selectField: 'Select field...',
122-
moveUp: 'Move up',
123-
moveDown: 'Move down',
124-
moveColumnUp: 'Move column up',
125-
moveColumnDown: 'Move column down',
136+
dragToReorder: 'Drag to reorder',
126137
hideColumn: 'Hide column',
127138
showColumn: 'Show column',
128139
toggleVisibility: 'Toggle column visibility',
@@ -190,6 +201,104 @@ const DESIGNER_FIELD_TYPES = [
190201
const COLUMN_WIDTH_MIN = 50;
191202
const COLUMN_WIDTH_MAX = 1000;
192203

204+
/** Sortable column item for drag-to-reorder */
205+
function SortableColumnItem({
206+
col,
207+
index,
208+
isSelected,
209+
readOnly,
210+
onSelect,
211+
onToggleVisibility,
212+
onRemove,
213+
}: {
214+
col: ViewDesignerColumn;
215+
index: number;
216+
isSelected: boolean;
217+
readOnly?: boolean;
218+
onSelect: () => void;
219+
onToggleVisibility: () => void;
220+
onRemove: () => void;
221+
}) {
222+
const {
223+
attributes,
224+
listeners,
225+
setNodeRef,
226+
transform,
227+
transition,
228+
isDragging,
229+
} = useSortable({ id: `col-${index}`, disabled: readOnly });
230+
231+
const style: React.CSSProperties = {
232+
transform: CSS.Transform.toString(transform),
233+
transition,
234+
opacity: isDragging ? 0.5 : undefined,
235+
zIndex: isDragging ? 10 : undefined,
236+
};
237+
238+
return (
239+
<div
240+
ref={setNodeRef}
241+
style={style}
242+
{...attributes}
243+
className={cn(
244+
'flex items-center gap-2 px-3 py-2 rounded border transition-colors cursor-pointer',
245+
isSelected
246+
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
247+
: 'border-border hover:border-primary/50',
248+
!col.visible && 'opacity-50',
249+
)}
250+
onClick={onSelect}
251+
data-testid={`column-${col.field}`}
252+
>
253+
<span
254+
{...(readOnly ? {} : listeners)}
255+
className={cn(
256+
'shrink-0 touch-none',
257+
!readOnly && 'cursor-grab',
258+
)}
259+
title={readOnly ? undefined : LABELS.dragToReorder}
260+
aria-label={readOnly ? undefined : LABELS.dragToReorder}
261+
>
262+
<GripVertical className="h-3.5 w-3.5 text-muted-foreground" />
263+
</span>
264+
<span className="text-sm font-medium truncate flex-1">
265+
{col.label || col.field}
266+
</span>
267+
<span className="text-xs text-muted-foreground">{col.field}</span>
268+
{!readOnly && (
269+
<div className="flex items-center gap-0.5 shrink-0">
270+
<button
271+
onClick={(e) => {
272+
e.stopPropagation();
273+
onToggleVisibility();
274+
}}
275+
className="p-0.5 rounded hover:bg-accent"
276+
title={col.visible !== false ? LABELS.hideColumn : LABELS.showColumn}
277+
aria-label={LABELS.toggleVisibility}
278+
>
279+
{col.visible !== false ? (
280+
<Eye className="h-3 w-3" />
281+
) : (
282+
<EyeOff className="h-3 w-3" />
283+
)}
284+
</button>
285+
<button
286+
onClick={(e) => {
287+
e.stopPropagation();
288+
onRemove();
289+
}}
290+
className="p-0.5 rounded hover:bg-destructive/10"
291+
title={LABELS.removeColumn}
292+
aria-label={LABELS.removeColumn}
293+
>
294+
<Trash2 className="h-3 w-3 text-destructive" />
295+
</button>
296+
</div>
297+
)}
298+
</div>
299+
);
300+
}
301+
193302
/**
194303
* Visual designer for creating and editing list views.
195304
* Provides a 3-panel layout:
@@ -294,19 +403,38 @@ export function ViewDesigner({
294403
[readOnly, columns, pushState],
295404
);
296405

297-
const handleMoveColumn = useCallback(
298-
(index: number, direction: 'up' | 'down') => {
406+
// --- Drag-reorder sensors ---
407+
const sensors = useSensors(
408+
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
409+
useSensor(KeyboardSensor),
410+
);
411+
412+
const handleDragEnd = useCallback(
413+
(event: DragEndEvent) => {
299414
if (readOnly) return;
300-
const newIndex = direction === 'up' ? index - 1 : index + 1;
301-
if (newIndex < 0 || newIndex >= columns.length) return;
302-
const updated = [...columns];
303-
const temp = updated[index];
304-
updated[index] = updated[newIndex];
305-
updated[newIndex] = temp;
306-
pushState({ columns: updated });
307-
setSelectedColumnIndex(newIndex);
415+
const { active, over } = event;
416+
if (!over || active.id === over.id) return;
417+
418+
const oldIndex = columns.findIndex((_, i) => `col-${i}` === active.id);
419+
const newIndex = columns.findIndex((_, i) => `col-${i}` === over.id);
420+
if (oldIndex === -1 || newIndex === -1) return;
421+
422+
const reordered = arrayMove(columns, oldIndex, newIndex);
423+
pushState({ columns: reordered });
424+
425+
// Update selection to follow the dragged column
426+
if (selectedColumnIndex === oldIndex) {
427+
setSelectedColumnIndex(newIndex);
428+
} else if (selectedColumnIndex !== null) {
429+
// Adjust selection if it was affected by the move
430+
if (oldIndex < selectedColumnIndex && newIndex >= selectedColumnIndex) {
431+
setSelectedColumnIndex(selectedColumnIndex - 1);
432+
} else if (oldIndex > selectedColumnIndex && newIndex <= selectedColumnIndex) {
433+
setSelectedColumnIndex(selectedColumnIndex + 1);
434+
}
435+
}
308436
},
309-
[readOnly, columns, pushState],
437+
[readOnly, columns, pushState, selectedColumnIndex],
310438
);
311439

312440
const handleAddFilter = useCallback(() => {
@@ -624,80 +752,22 @@ export function ViewDesigner({
624752
<div className="text-xs font-medium text-muted-foreground mb-2">
625753
{LABELS.columnsCount(columns.length)}
626754
</div>
627-
{columns.map((col, index) => (
628-
<div
629-
key={`${col.field}-${index}`}
630-
className={cn(
631-
'flex items-center gap-2 px-3 py-2 rounded border transition-colors cursor-pointer',
632-
selectedColumnIndex === index
633-
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
634-
: 'border-border hover:border-primary/50',
635-
!col.visible && 'opacity-50',
636-
)}
637-
onClick={() => setSelectedColumnIndex(index)}
638-
data-testid={`column-${col.field}`}
639-
>
640-
<GripVertical className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
641-
<span className="text-sm font-medium truncate flex-1">
642-
{col.label || col.field}
643-
</span>
644-
<span className="text-xs text-muted-foreground">{col.field}</span>
645-
{!readOnly && (
646-
<div className="flex items-center gap-0.5 shrink-0">
647-
<button
648-
onClick={(e) => {
649-
e.stopPropagation();
650-
handleMoveColumn(index, 'up');
651-
}}
652-
disabled={index === 0}
653-
className="p-0.5 rounded hover:bg-accent disabled:opacity-30"
654-
title={LABELS.moveUp}
655-
aria-label={LABELS.moveColumnUp}
656-
>
657-
<ArrowUp className="h-3 w-3" />
658-
</button>
659-
<button
660-
onClick={(e) => {
661-
e.stopPropagation();
662-
handleMoveColumn(index, 'down');
663-
}}
664-
disabled={index === columns.length - 1}
665-
className="p-0.5 rounded hover:bg-accent disabled:opacity-30"
666-
title={LABELS.moveDown}
667-
aria-label={LABELS.moveColumnDown}
668-
>
669-
<ArrowDown className="h-3 w-3" />
670-
</button>
671-
<button
672-
onClick={(e) => {
673-
e.stopPropagation();
674-
handleToggleColumnVisibility(index);
675-
}}
676-
className="p-0.5 rounded hover:bg-accent"
677-
title={col.visible !== false ? LABELS.hideColumn : LABELS.showColumn}
678-
aria-label={LABELS.toggleVisibility}
679-
>
680-
{col.visible !== false ? (
681-
<Eye className="h-3 w-3" />
682-
) : (
683-
<EyeOff className="h-3 w-3" />
684-
)}
685-
</button>
686-
<button
687-
onClick={(e) => {
688-
e.stopPropagation();
689-
handleRemoveColumn(index);
690-
}}
691-
className="p-0.5 rounded hover:bg-destructive/10"
692-
title={LABELS.removeColumn}
693-
aria-label={LABELS.removeColumn}
694-
>
695-
<Trash2 className="h-3 w-3 text-destructive" />
696-
</button>
697-
</div>
698-
)}
699-
</div>
700-
))}
755+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
756+
<SortableContext items={columns.map((_, i) => `col-${i}`)} strategy={verticalListSortingStrategy}>
757+
{columns.map((col, index) => (
758+
<SortableColumnItem
759+
key={`${col.field}-${index}`}
760+
col={col}
761+
index={index}
762+
isSelected={selectedColumnIndex === index}
763+
readOnly={readOnly}
764+
onSelect={() => setSelectedColumnIndex(index)}
765+
onToggleVisibility={() => handleToggleColumnVisibility(index)}
766+
onRemove={() => handleRemoveColumn(index)}
767+
/>
768+
))}
769+
</SortableContext>
770+
</DndContext>
701771
</div>
702772
)}
703773
</div>

0 commit comments

Comments
 (0)