From 0a05285973cb5a8ecaf8d35512deceb0d9a1ab1e Mon Sep 17 00:00:00 2001 From: Zanuar Tri Romadon Date: Thu, 14 May 2026 09:54:52 +0700 Subject: [PATCH] feat: add drag-and-drop reordering for combo models (#1056) (#1108) --- package.json | 4 + src/app/(dashboard)/dashboard/combos/page.js | 100 ++++++++++++++----- 2 files changed, 80 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index 138d2e8..159a390 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,10 @@ "start:bun": "NODE_ENV=production bun ./.next/standalone/server.js" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", "@xyflow/react": "^12.10.1", "bcryptjs": "^3.0.3", diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index a31713c..629432c 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -1,6 +1,10 @@ "use client"; import { useState, useEffect, useCallback } from "react"; +import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; +import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers"; import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, Toggle, ConfirmModal } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; @@ -283,15 +287,20 @@ function ComboCard({ combo, copied, onCopy, onEdit, onDelete, roundRobinEnabled, ); } -// Inline editable model item -function ModelItem({ index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown, onRemove }) { +function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown, onRemove }) { + const { attributes, listeners, setNodeRef, transform, isDragging } = useSortable({ id }); + const style = { + transform: CSS.Transform.toString(transform), + // no transition — prevents the CSS settle animation fighting React's re-render on drop + opacity: isDragging ? 0.4 : 1, + zIndex: isDragging ? 999 : undefined, + }; const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(model); - const commit = () => { const trimmed = draft.trim(); if (trimmed && trimmed !== model) onEdit(trimmed); - else setDraft(model); // revert if empty or unchanged + else setDraft(model); setEditing(false); }; @@ -301,7 +310,26 @@ function ModelItem({ index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown }; return ( -
+
+ {/* Drag handle */} + + {/* Index badge */} {index + 1} @@ -366,6 +394,25 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF const [nameError, setNameError] = useState(""); const [modelAliases, setModelAliases] = useState({}); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + + // Use stable index-based IDs so duplicates and similar names are handled correctly + const modelItems = models.map((model, i) => ({ uid: `item-${i}`, model })); + + const handleDragEnd = (event) => { + const { active, over } = event; + if (over && active.id !== over.id) { + const oldIndex = modelItems.findIndex((m) => m.uid === active.id); + const newIndex = modelItems.findIndex((m) => m.uid === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + setModels((prev) => arrayMove(prev, oldIndex, newIndex)); + } + } + }; + const fetchModalData = async () => { try { const aliasesRes = await fetch("/api/models/alias"); @@ -470,25 +517,30 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF

No models added yet

) : ( -
- {models.map((model, index) => ( - { - const updated = [...models]; - updated[index] = newVal; - setModels(updated); - }} - onMoveUp={() => handleMoveUp(index)} - onMoveDown={() => handleMoveDown(index)} - onRemove={() => handleRemoveModel(index)} - /> - ))} -
+ + m.uid)} strategy={verticalListSortingStrategy}> +
+ {modelItems.map(({ uid, model }, index) => ( + { + const updated = [...models]; + updated[index] = newVal; + setModels(updated); + }} + onMoveUp={() => handleMoveUp(index)} + onMoveDown={() => handleMoveDown(index)} + onRemove={() => handleRemoveModel(index)} + /> + ))} +
+
+
)} {/* Add Model button */}