From e3fca0f822fda008c12a8a3d07b7cfe543559c53 Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Sat, 7 Feb 2026 14:49:14 -0500 Subject: [PATCH 1/2] feat(frontend): add drag-and-drop reordering to workflow list Add sortable row reordering using @dnd-kit to the workflow list table. Each row now has a grip handle on the left that allows users to drag and reorder workflows. The cursor changes to grab/grabbing for clear visual affordance. Also pin bunx eslint@9 in lint-staged to avoid breakage from ESLint 10 which was released recently and is incompatible with eslint-plugin-react. Signed-off-by: Aseem Shrey --- frontend/src/pages/WorkflowList.tsx | 193 +++++++++++++++++++--------- package.json | 6 +- 2 files changed, 136 insertions(+), 63 deletions(-) diff --git a/frontend/src/pages/WorkflowList.tsx b/frontend/src/pages/WorkflowList.tsx index 90ef12de..3795e0f2 100644 --- a/frontend/src/pages/WorkflowList.tsx +++ b/frontend/src/pages/WorkflowList.tsx @@ -1,6 +1,6 @@ import { useNavigate } from 'react-router-dom'; import { useEffect, useState, useRef, useCallback } from 'react'; -import type { MouseEvent } from 'react'; +import type { CSSProperties, MouseEvent } from 'react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { @@ -21,7 +21,7 @@ import { } from '@/components/ui/table'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Skeleton } from '@/components/ui/skeleton'; -import { Workflow, AlertCircle, Trash2, Loader2, Info } from 'lucide-react'; +import { Workflow, AlertCircle, Trash2, Loader2, Info, GripVertical } from 'lucide-react'; import { api } from '@/services/api'; import { getStatusBadgeClassFromStatus } from '@/utils/statusBadgeStyles'; import { WorkflowMetadataSchema, type WorkflowMetadataNormalized } from '@/schemas/workflow'; @@ -30,6 +30,23 @@ import { hasAdminRole } from '@/utils/auth'; import { track, Events } from '@/features/analytics/events'; import { useAuth } from '@/auth/auth-context'; import { useRunStore } from '@/store/runStore'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; export function WorkflowList() { const navigate = useNavigate(); @@ -50,6 +67,24 @@ export function WorkflowList() { const token = useAuthStore((state) => state.token); const adminUsername = useAuthStore((state) => state.adminUsername); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + setWorkflows((prev) => { + const oldIndex = prev.findIndex((w) => w.id === active.id); + const newIndex = prev.findIndex((w) => w.id === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + return arrayMove(prev, oldIndex, newIndex); + } + return prev; + }); + }, []); + const MAX_RETRY_ATTEMPTS = 30; // Try for ~60 seconds (30 attempts × 2s) const RETRY_INTERVAL_MS = 2000; // 2 seconds between retries @@ -230,6 +265,7 @@ export function WorkflowList() { + Name Nodes Status @@ -269,6 +305,9 @@ export function WorkflowList() { {Array.from({ length: 5 }).map((_, idx) => ( + + + @@ -318,62 +357,74 @@ export function WorkflowList() { ) : (
-
- - - Name - Nodes - Status - - - - - - Last Run - - - - -

Times shown in your local timezone

-
-
-
-
- - - - - - Last Updated - - - - -

Times shown in your local timezone

-
-
-
-
- {canManageWorkflows && ( - Actions - )} -
-
- - {workflows.map((workflow) => ( - navigate(`/workflows/${workflow.id}`)} - onDeleteClick={handleDeleteClick} - /> - ))} - -
+ + + + + + Name + Nodes + Status + + + + + + Last Run + + + + +

Times shown in your local timezone

+
+
+
+
+ + + + + + Last Updated + + + + +

Times shown in your local timezone

+
+
+
+
+ {canManageWorkflows && ( + Actions + )} +
+
+ + w.id)} + strategy={verticalListSortingStrategy} + > + {workflows.map((workflow) => ( + navigate(`/workflows/${workflow.id}`)} + onDeleteClick={handleDeleteClick} + /> + ))} + + +
+
)} @@ -439,6 +490,17 @@ function WorkflowRowItem({ }: WorkflowRowItemProps) { const fetchRuns = useRunStore((state) => state.fetchRuns); const latestRun = useRunStore((state) => state.getLatestRun(workflow.id)); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: workflow.id, + }); + + const style: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + position: 'relative', + zIndex: isDragging ? 50 : undefined, + opacity: isDragging ? 0.5 : 1, + }; useEffect(() => { fetchRuns({ workflowId: workflow.id }).catch(() => undefined); @@ -458,10 +520,21 @@ function WorkflowRowItem({ return ( + +
e.stopPropagation()} + > + +
+
{workflow.name} diff --git a/package.json b/package.json index 73ef79cc..e41ddf0f 100644 --- a/package.json +++ b/package.json @@ -51,9 +51,9 @@ "undici": "^7.19.0" }, "lint-staged": { - "frontend/**/*.{ts,tsx,js,jsx}": "bunx eslint --fix --config frontend/eslint.config.mjs", - "backend/**/*.{ts,js}": "bunx eslint --fix --config backend/eslint.config.mjs", - "worker/**/*.{ts,js}": "bunx eslint --fix --config worker/eslint.config.mjs", + "frontend/**/*.{ts,tsx,js,jsx}": "bunx eslint@9 --fix --config frontend/eslint.config.mjs", + "backend/**/*.{ts,js}": "bunx eslint@9 --fix --config backend/eslint.config.mjs", + "worker/**/*.{ts,js}": "bunx eslint@9 --fix --config worker/eslint.config.mjs", "*.{json,md,yml,yaml}": "bunx prettier --write" }, "dependencies": { From 36af4a0696dafa59865c8b815f1fe2aefc6e5769 Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Sat, 7 Feb 2026 14:57:59 -0500 Subject: [PATCH 2/2] feat(frontend): persist workflow list order in localStorage Save the drag-and-drop reordering to localStorage so the custom order survives page refreshes. New workflows appear at the end of the list. Signed-off-by: Aseem Shrey --- frontend/src/pages/WorkflowList.tsx | 31 +++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/WorkflowList.tsx b/frontend/src/pages/WorkflowList.tsx index 3795e0f2..d9547480 100644 --- a/frontend/src/pages/WorkflowList.tsx +++ b/frontend/src/pages/WorkflowList.tsx @@ -48,6 +48,31 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; +const WORKFLOW_ORDER_KEY = 'workflow-list-order'; + +function getSavedOrder(): string[] { + try { + const saved = localStorage.getItem(WORKFLOW_ORDER_KEY); + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } +} + +function saveOrder(ids: string[]) { + localStorage.setItem(WORKFLOW_ORDER_KEY, JSON.stringify(ids)); +} + +function applyOrder(items: T[], savedOrder: string[]): T[] { + if (savedOrder.length === 0) return items; + const orderMap = new Map(savedOrder.map((id, idx) => [id, idx])); + return [...items].sort((a, b) => { + const aIdx = orderMap.get(a.id) ?? Infinity; + const bIdx = orderMap.get(b.id) ?? Infinity; + return aIdx - bIdx; + }); +} + export function WorkflowList() { const navigate = useNavigate(); const [workflows, setWorkflows] = useState([]); @@ -79,7 +104,9 @@ export function WorkflowList() { const oldIndex = prev.findIndex((w) => w.id === active.id); const newIndex = prev.findIndex((w) => w.id === over.id); if (oldIndex !== -1 && newIndex !== -1) { - return arrayMove(prev, oldIndex, newIndex); + const reordered = arrayMove(prev, oldIndex, newIndex); + saveOrder(reordered.map((w) => w.id)); + return reordered; } return prev; }); @@ -120,7 +147,7 @@ export function WorkflowList() { try { const data = await api.workflows.list(); const normalized = data.map((workflow) => WorkflowMetadataSchema.parse(workflow)); - setWorkflows(normalized); + setWorkflows(applyOrder(normalized, getSavedOrder())); setRetryCount(0); retryCountRef.current = 0; // Reset on success setIsLoading(false);