Add drag-and-drop sortable Run cards on Runs page

Uses @dnd-kit to make cards reorderable within their column (board view)
and group (list view). Prevents accidental navigation on drag release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-01 11:16:31 -05:00
parent 3912cab183
commit 7f921fb82a
3 changed files with 240 additions and 110 deletions

View file

@ -1,7 +1,24 @@
import { useState } from "react";
import { useState, useCallback, useRef } from "react";
import { Link } from "react-router";
import { ChevronDownIcon, ChevronRightIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { columns, ciConfig, statusColors } from "../data/runs";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { DragEndEvent } from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { columns as staticColumns, ciConfig, statusColors } from "../data/runs";
import type { CiStatus, RunItem, RunWithStatus } from "../data/runs";
import type { Route } from "./+types/pipelines";
@ -51,8 +68,8 @@ function CiBadge({ status }: { status: CiStatus }) {
);
}
const totalCards = columns.reduce((sum, col) => sum + col.items.length, 0);
const totalPrs = columns.reduce(
const totalCards = staticColumns.reduce((sum, col) => sum + col.items.length, 0);
const totalPrs = staticColumns.reduce(
(sum, col) => sum + col.items.filter((item) => item.number != null).length,
0,
);
@ -179,7 +196,47 @@ function PrCard({
);
}
function BoardColumn({ column }: { column: (typeof columns)[number] }) {
function SortablePrCard({
pr,
icon,
iconColor,
actions,
}: {
pr: RunItem;
icon: React.ComponentType<{ className?: string }>;
iconColor: string;
actions?: string[];
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pr.id });
const wasDragging = useRef(false);
if (isDragging) wasDragging.current = true;
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
position: "relative" as const,
zIndex: isDragging ? 10 : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClickCapture={(e) => {
if (wasDragging.current) {
e.preventDefault();
e.stopPropagation();
wasDragging.current = false;
}
}}
>
<PrCard pr={pr} icon={icon} iconColor={iconColor} actions={actions} />
</div>
);
}
function BoardColumn({ column }: { column: (typeof staticColumns)[number] }) {
const Icon = iconMap[column.iconType];
return (
<div className="flex min-w-[280px] flex-1 flex-col">
@ -193,17 +250,19 @@ function BoardColumn({ column }: { column: (typeof columns)[number] }) {
</span>
</div>
<div className="flex flex-1 flex-col gap-3">
{column.items.map((pr) => (
<PrCard
key={pr.id}
pr={pr}
icon={Icon}
iconColor={column.iconColor}
actions={column.actions}
/>
))}
</div>
<SortableContext items={column.items.map((pr) => pr.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-1 flex-col gap-3">
{column.items.map((pr) => (
<SortablePrCard
key={pr.id}
pr={pr}
icon={Icon}
iconColor={column.iconColor}
actions={column.actions}
/>
))}
</div>
</SortableContext>
</div>
);
}
@ -248,15 +307,68 @@ function RunRow({ run }: { run: RunWithStatus }) {
);
}
const allRepos = [...new Set(columns.flatMap((col) => col.items.map((item) => item.repo)))].sort();
function SortableRunRow({ run }: { run: RunWithStatus }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: run.id });
const wasDragging = useRef(false);
if (isDragging) wasDragging.current = true;
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
position: "relative" as const,
zIndex: isDragging ? 10 : undefined,
gridColumn: "1 / -1",
display: "grid",
gridTemplateColumns: "subgrid",
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClickCapture={(e) => {
if (wasDragging.current) {
e.preventDefault();
e.stopPropagation();
wasDragging.current = false;
}
}}
>
<RunRow run={run} />
</div>
);
}
const allRepos = [...new Set(staticColumns.flatMap((col) => col.items.map((item) => item.repo)))].sort();
export default function Pipelines() {
const [query, setQuery] = useState("");
const [repoFilter, setRepoFilter] = useState("all");
const [view, setView] = useState<ViewMode>("columns");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [columns, setColumns] = useState(staticColumns);
const lowerQuery = query.toLowerCase();
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;
setColumns((prev) =>
prev.map((col) => {
const oldIndex = col.items.findIndex((item) => item.id === active.id);
const newIndex = col.items.findIndex((item) => item.id === over.id);
if (oldIndex === -1 || newIndex === -1) return col;
return { ...col, items: arrayMove(col.items, oldIndex, newIndex) };
}),
);
}, []);
const filteredColumns = columns.map((col) => ({
...col,
items: col.items.filter(
@ -270,100 +382,104 @@ export default function Pipelines() {
}));
return (
<div className="space-y-4">
<div className="flex gap-3">
<div className="relative flex-1">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-navy-600" />
<input
type="text"
placeholder="Search runs..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-md border border-white/[0.06] bg-navy-800/80 py-2 pl-9 pr-3 text-sm text-ice-100 placeholder-navy-600 outline-none transition-colors focus:border-teal-500/40 focus:ring-0"
/>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div className="space-y-4">
<div className="flex gap-3">
<div className="relative flex-1">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-navy-600" />
<input
type="text"
placeholder="Search runs..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-md border border-white/[0.06] bg-navy-800/80 py-2 pl-9 pr-3 text-sm text-ice-100 placeholder-navy-600 outline-none transition-colors focus:border-teal-500/40 focus:ring-0"
/>
</div>
<div className="relative">
<select
value={repoFilter}
onChange={(e) => setRepoFilter(e.target.value)}
className="appearance-none rounded-md border border-white/[0.06] bg-navy-800/80 py-2 pl-3 pr-8 text-sm text-ice-100 outline-none transition-colors focus:border-teal-500/40 focus:ring-0"
>
<option value="all">All repos</option>
{allRepos.map((repo) => (
<option key={repo} value={repo}>{repo}</option>
))}
</select>
<ChevronDownIcon className="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-navy-600" />
</div>
<div className="flex rounded-md border border-white/[0.06] bg-navy-800/80">
<button
type="button"
onClick={() => setView("columns")}
className={`inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${view === "columns" ? "text-teal-500" : "text-navy-600 hover:text-ice-300"}`}
aria-label="Columns view"
>
<svg viewBox="0 0 20 20" fill="currentColor" className="size-4" aria-hidden="true">
<path d="M2 4.75A.75.75 0 0 1 2.75 4h2.5a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1-.75-.75V4.75ZM8.25 4a.75.75 0 0 0-.75.75v10.5c0 .414.336.75.75.75h2.5a.75.75 0 0 0 .75-.75V4.75a.75.75 0 0 0-.75-.75h-2.5ZM14 4.75a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1-.75-.75V4.75Z" />
</svg>
</button>
<button
type="button"
onClick={() => setView("list")}
className={`inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${view === "list" ? "text-teal-500" : "text-navy-600 hover:text-ice-300"}`}
aria-label="List view"
>
<svg viewBox="0 0 20 20" fill="currentColor" className="size-4" aria-hidden="true">
<path fillRule="evenodd" d="M2 4.75A.75.75 0 0 1 2.75 4h14.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 4.75Zm0 5A.75.75 0 0 1 2.75 9h14.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 9.75Zm0 5a.75.75 0 0 1 .75-.75h14.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1-.75-.75Z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
<div className="relative">
<select
value={repoFilter}
onChange={(e) => setRepoFilter(e.target.value)}
className="appearance-none rounded-md border border-white/[0.06] bg-navy-800/80 py-2 pl-3 pr-8 text-sm text-ice-100 outline-none transition-colors focus:border-teal-500/40 focus:ring-0"
>
<option value="all">All repos</option>
{allRepos.map((repo) => (
<option key={repo} value={repo}>{repo}</option>
))}
</select>
<ChevronDownIcon className="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-navy-600" />
</div>
<div className="flex rounded-md border border-white/[0.06] bg-navy-800/80">
<button
type="button"
onClick={() => setView("columns")}
className={`inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${view === "columns" ? "text-teal-500" : "text-navy-600 hover:text-ice-300"}`}
aria-label="Columns view"
>
<svg viewBox="0 0 20 20" fill="currentColor" className="size-4" aria-hidden="true">
<path d="M2 4.75A.75.75 0 0 1 2.75 4h2.5a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1-.75-.75V4.75ZM8.25 4a.75.75 0 0 0-.75.75v10.5c0 .414.336.75.75.75h2.5a.75.75 0 0 0 .75-.75V4.75a.75.75 0 0 0-.75-.75h-2.5ZM14 4.75a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1-.75-.75V4.75Z" />
</svg>
</button>
<button
type="button"
onClick={() => setView("list")}
className={`inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${view === "list" ? "text-teal-500" : "text-navy-600 hover:text-ice-300"}`}
aria-label="List view"
>
<svg viewBox="0 0 20 20" fill="currentColor" className="size-4" aria-hidden="true">
<path fillRule="evenodd" d="M2 4.75A.75.75 0 0 1 2.75 4h14.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 4.75Zm0 5A.75.75 0 0 1 2.75 9h14.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 9.75Zm0 5a.75.75 0 0 1 .75-.75h14.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1-.75-.75Z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
{view === "columns" ? (
<div className="flex gap-5 overflow-x-auto pb-4">
{filteredColumns.map((col) => (
<BoardColumn key={col.id} column={col} />
))}
</div>
) : (
<div className="space-y-4">
{filteredColumns.map((col) => {
const isCollapsed = collapsed.has(col.id);
return (
<div key={col.id}>
<button
type="button"
onClick={() => setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(col.id)) next.delete(col.id);
else next.add(col.id);
return next;
})}
className="mb-3 flex w-full items-center gap-2 text-left"
>
{isCollapsed
? <ChevronRightIcon className="size-3.5 text-navy-600" />
: <ChevronDownIcon className="size-3.5 text-navy-600" />}
<div className={`h-2.5 w-2.5 rounded-full ${col.accent}`} />
<h3 className="text-sm font-semibold tracking-wide text-ice-100">{col.name}</h3>
<span className="rounded-full bg-white/[0.06] px-2 py-0.5 font-mono text-xs text-navy-600">
{col.items.length}
</span>
</button>
{!isCollapsed && (col.items.length > 0 ? (
<div className="grid gap-2" style={{ gridTemplateColumns: "5rem 1fr 8rem auto" }}>
{col.items.map((item) => (
<RunRow key={item.id} run={{ ...item, status: col.id, statusLabel: col.name }} />
))}
</div>
) : (
<p className="py-4 text-center text-sm text-navy-600">No runs</p>
))}
</div>
);
})}
</div>
)}
</div>
{view === "columns" ? (
<div className="flex gap-5 overflow-x-auto pb-4">
{filteredColumns.map((col) => (
<BoardColumn key={col.id} column={col} />
))}
</div>
) : (
<div className="space-y-4">
{filteredColumns.map((col) => {
const isCollapsed = collapsed.has(col.id);
return (
<div key={col.id}>
<button
type="button"
onClick={() => setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(col.id)) next.delete(col.id);
else next.add(col.id);
return next;
})}
className="mb-3 flex w-full items-center gap-2 text-left"
>
{isCollapsed
? <ChevronRightIcon className="size-3.5 text-navy-600" />
: <ChevronDownIcon className="size-3.5 text-navy-600" />}
<div className={`h-2.5 w-2.5 rounded-full ${col.accent}`} />
<h3 className="text-sm font-semibold tracking-wide text-ice-100">{col.name}</h3>
<span className="rounded-full bg-white/[0.06] px-2 py-0.5 font-mono text-xs text-navy-600">
{col.items.length}
</span>
</button>
{!isCollapsed && (col.items.length > 0 ? (
<SortableContext items={col.items.map((item) => item.id)} strategy={verticalListSortingStrategy}>
<div className="grid gap-2" style={{ gridTemplateColumns: "5rem 1fr 8rem auto" }}>
{col.items.map((item) => (
<SortableRunRow key={item.id} run={{ ...item, status: col.id, statusLabel: col.name }} />
))}
</div>
</SortableContext>
) : (
<p className="py-4 text-center text-sm text-navy-600">No runs</p>
))}
</div>
);
})}
</div>
)}
</div>
</DndContext>
);
}

View file

@ -5,6 +5,9 @@
"": {
"name": "arc-web",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"@pierre/diffs": "^1.0.11",
@ -87,6 +90,14 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
"@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],

View file

@ -9,6 +9,9 @@
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"@pierre/diffs": "^1.0.11",