feat(ui): add kanban board view toggle to Issues page

Adds a new board/kanban view alongside the existing list view on the
Issues page. Users can toggle between views via a segmented control
in the toolbar. The preference persists in localStorage.

Board view features:
- Columns for all 7 status types (backlog through cancelled)
- Drag-and-drop cards between columns to update issue status
- Cards display identifier, title, priority, assignee, and live indicator
- Uses existing @dnd-kit library already in the project

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-23 19:47:34 -06:00
parent 544beaaae7
commit 94f4e43161
2 changed files with 448 additions and 136 deletions

View File

@@ -1,6 +1,10 @@
import { useMemo, useState, useCallback } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { groupBy } from "../lib/groupBy";
import { formatDate } from "../lib/utils";
import { StatusIcon } from "./StatusIcon";
@@ -11,7 +15,8 @@ import { Button } from "@/components/ui/button";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight } from "lucide-react";
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3 } from "lucide-react";
import { KanbanBoard } from "./KanbanBoard";
import type { Issue } from "@paperclip/shared";
/* ── Helpers ── */
@@ -32,6 +37,7 @@ export type IssueViewState = {
sortField: "status" | "priority" | "title" | "created" | "updated";
sortDir: "asc" | "desc";
groupBy: "status" | "priority" | "assignee" | "none";
viewMode: "list" | "board";
};
const defaultViewState: IssueViewState = {
@@ -41,6 +47,7 @@ const defaultViewState: IssueViewState = {
sortField: "status",
sortDir: "asc",
groupBy: "status",
viewMode: "list",
};
const quickFilterPresets = [
@@ -215,6 +222,24 @@ export function IssuesList({
</Button>
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
{/* View mode toggle */}
<div className="flex items-center border border-border rounded-md overflow-hidden mr-1">
<button
className={`p-1.5 transition-colors ${viewState.viewMode === "list" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => updateView({ viewMode: "list" })}
title="List view"
>
<List className="h-3.5 w-3.5" />
</button>
<button
className={`p-1.5 transition-colors ${viewState.viewMode === "board" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => updateView({ viewMode: "board" })}
title="Board view"
>
<Columns3 className="h-3.5 w-3.5" />
</button>
</div>
{/* Filter */}
<Popover>
<PopoverTrigger asChild>
@@ -335,85 +360,89 @@ export function IssuesList({
</PopoverContent>
</Popover>
{/* Sort */}
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs">
<ArrowUpDown className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span className="hidden sm:inline">Sort</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-48 p-0">
<div className="p-2 space-y-0.5">
{([
["status", "Status"],
["priority", "Priority"],
["title", "Title"],
["created", "Created"],
["updated", "Updated"],
] as const).map(([field, label]) => (
<button
key={field}
className={`flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-sm ${
viewState.sortField === field ? "bg-accent/50 text-foreground" : "hover:bg-accent/50 text-muted-foreground"
}`}
onClick={() => {
if (viewState.sortField === field) {
updateView({ sortDir: viewState.sortDir === "asc" ? "desc" : "asc" });
} else {
updateView({ sortField: field, sortDir: "asc" });
}
}}
>
<span>{label}</span>
{viewState.sortField === field && (
<span className="text-xs text-muted-foreground">
{viewState.sortDir === "asc" ? "\u2191" : "\u2193"}
</span>
)}
</button>
))}
</div>
</PopoverContent>
</Popover>
{/* Sort (list view only) */}
{viewState.viewMode === "list" && (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs">
<ArrowUpDown className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span className="hidden sm:inline">Sort</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-48 p-0">
<div className="p-2 space-y-0.5">
{([
["status", "Status"],
["priority", "Priority"],
["title", "Title"],
["created", "Created"],
["updated", "Updated"],
] as const).map(([field, label]) => (
<button
key={field}
className={`flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-sm ${
viewState.sortField === field ? "bg-accent/50 text-foreground" : "hover:bg-accent/50 text-muted-foreground"
}`}
onClick={() => {
if (viewState.sortField === field) {
updateView({ sortDir: viewState.sortDir === "asc" ? "desc" : "asc" });
} else {
updateView({ sortField: field, sortDir: "asc" });
}
}}
>
<span>{label}</span>
{viewState.sortField === field && (
<span className="text-xs text-muted-foreground">
{viewState.sortDir === "asc" ? "\u2191" : "\u2193"}
</span>
)}
</button>
))}
</div>
</PopoverContent>
</Popover>
)}
{/* Group */}
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs">
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span className="hidden sm:inline">Group</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{([
["status", "Status"],
["priority", "Priority"],
["assignee", "Assignee"],
["none", "None"],
] as const).map(([value, label]) => (
<button
key={value}
className={`flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-sm ${
viewState.groupBy === value ? "bg-accent/50 text-foreground" : "hover:bg-accent/50 text-muted-foreground"
}`}
onClick={() => updateView({ groupBy: value })}
>
<span>{label}</span>
{viewState.groupBy === value && <Check className="h-3.5 w-3.5" />}
</button>
))}
</div>
</PopoverContent>
</Popover>
{/* Group (list view only) */}
{viewState.viewMode === "list" && (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs">
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span className="hidden sm:inline">Group</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{([
["status", "Status"],
["priority", "Priority"],
["assignee", "Assignee"],
["none", "None"],
] as const).map(([value, label]) => (
<button
key={value}
className={`flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-sm ${
viewState.groupBy === value ? "bg-accent/50 text-foreground" : "hover:bg-accent/50 text-muted-foreground"
}`}
onClick={() => updateView({ groupBy: value })}
>
<span>{label}</span>
{viewState.groupBy === value && <Check className="h-3.5 w-3.5" />}
</button>
))}
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{!isLoading && filtered.length === 0 && (
{!isLoading && filtered.length === 0 && viewState.viewMode === "list" && (
<EmptyState
icon={CircleDot}
message="No issues match the current filters."
@@ -422,70 +451,79 @@ export function IssuesList({
/>
)}
{groupedContent.map((group) => (
<Collapsible key={group.key} defaultOpen>
{group.label && (
<div className="flex items-center py-1.5 pl-1">
<CollapsibleTrigger className="flex items-center gap-1.5">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
<span className="text-sm font-semibold uppercase tracking-wide">
{group.label}
</span>
</CollapsibleTrigger>
<Button
variant="ghost"
size="icon-xs"
className="ml-auto text-muted-foreground"
onClick={() => openNewIssue(newIssueDefaults(group.key))}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
<CollapsibleContent>
{group.items.map((issue) => (
<Link
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
className="flex items-center gap-2 py-2 pl-1 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit"
>
{/* Spacer matching caret width so status icon aligns with group title */}
<div className="w-3.5 shrink-0" />
<div className="shrink-0" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</div>
<span className="text-xs text-muted-foreground font-mono shrink-0">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="truncate flex-1 min-w-0">{issue.title}</span>
<div className="flex items-center gap-2 sm:gap-3 shrink-0 ml-auto">
{liveIssueIds?.has(issue.id) && (
<span className="inline-flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2 py-0.5 rounded-full bg-blue-500/10">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-400 hidden sm:inline">Live</span>
</span>
)}
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <Identity name={name} size="sm" />
: <span className="text-xs text-muted-foreground font-mono">{issue.assigneeAgentId.slice(0, 8)}</span>;
})()}
<span className="text-xs text-muted-foreground hidden sm:inline">
{formatDate(issue.createdAt)}
{viewState.viewMode === "board" ? (
<KanbanBoard
issues={filtered}
agents={agents}
liveIssueIds={liveIssueIds}
onUpdateIssue={onUpdateIssue}
/>
) : (
groupedContent.map((group) => (
<Collapsible key={group.key} defaultOpen>
{group.label && (
<div className="flex items-center py-1.5 pl-1">
<CollapsibleTrigger className="flex items-center gap-1.5">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
<span className="text-sm font-semibold uppercase tracking-wide">
{group.label}
</span>
</div>
</Link>
))}
</CollapsibleContent>
</Collapsible>
))}
</CollapsibleTrigger>
<Button
variant="ghost"
size="icon-xs"
className="ml-auto text-muted-foreground"
onClick={() => openNewIssue(newIssueDefaults(group.key))}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
<CollapsibleContent>
{group.items.map((issue) => (
<Link
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
className="flex items-center gap-2 py-2 pl-1 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit"
>
{/* Spacer matching caret width so status icon aligns with group title */}
<div className="w-3.5 shrink-0" />
<div className="shrink-0" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</div>
<span className="text-xs text-muted-foreground font-mono shrink-0">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="truncate flex-1 min-w-0">{issue.title}</span>
<div className="flex items-center gap-2 sm:gap-3 shrink-0 ml-auto">
{liveIssueIds?.has(issue.id) && (
<span className="inline-flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2 py-0.5 rounded-full bg-blue-500/10">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-400 hidden sm:inline">Live</span>
</span>
)}
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <Identity name={name} size="sm" />
: <span className="text-xs text-muted-foreground font-mono">{issue.assigneeAgentId.slice(0, 8)}</span>;
})()}
<span className="text-xs text-muted-foreground hidden sm:inline">
{formatDate(issue.createdAt)}
</span>
</div>
</Link>
))}
</CollapsibleContent>
</Collapsible>
))
)}
</div>
);
}

View File

@@ -0,0 +1,274 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
type DragStartEvent,
type DragEndEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import { useDroppable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity";
import type { Issue } from "@paperclip/shared";
const boardStatuses = [
"backlog",
"todo",
"in_progress",
"in_review",
"blocked",
"done",
"cancelled",
];
function statusLabel(status: string): string {
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
interface Agent {
id: string;
name: string;
}
interface KanbanBoardProps {
issues: Issue[];
agents?: Agent[];
liveIssueIds?: Set<string>;
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
}
/* ── Droppable Column ── */
function KanbanColumn({
status,
issues,
agents,
liveIssueIds,
}: {
status: string;
issues: Issue[];
agents?: Agent[];
liveIssueIds?: Set<string>;
}) {
const { setNodeRef, isOver } = useDroppable({ id: status });
return (
<div className="flex flex-col min-w-[260px] w-[260px] shrink-0">
<div className="flex items-center gap-2 px-2 py-2 mb-1">
<StatusIcon status={status} />
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{statusLabel(status)}
</span>
<span className="text-xs text-muted-foreground/60 ml-auto tabular-nums">
{issues.length}
</span>
</div>
<div
ref={setNodeRef}
className={`flex-1 min-h-[120px] rounded-md p-1 space-y-1 transition-colors ${
isOver ? "bg-accent/40" : "bg-muted/20"
}`}
>
<SortableContext
items={issues.map((i) => i.id)}
strategy={verticalListSortingStrategy}
>
{issues.map((issue) => (
<KanbanCard
key={issue.id}
issue={issue}
agents={agents}
isLive={liveIssueIds?.has(issue.id)}
/>
))}
</SortableContext>
</div>
</div>
);
}
/* ── Draggable Card ── */
function KanbanCard({
issue,
agents,
isLive,
isOverlay,
}: {
issue: Issue;
agents?: Agent[];
isLive?: boolean;
isOverlay?: boolean;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: issue.id, data: { issue } });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const agentName = (id: string | null) => {
if (!id || !agents) return null;
return agents.find((a) => a.id === id)?.name ?? null;
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={`rounded-md border bg-card p-2.5 cursor-grab active:cursor-grabbing transition-shadow ${
isDragging && !isOverlay ? "opacity-30" : ""
} ${isOverlay ? "shadow-lg ring-1 ring-primary/20" : "hover:shadow-sm"}`}
>
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
className="block no-underline text-inherit"
onClick={(e) => {
// Prevent navigation during drag
if (isDragging) e.preventDefault();
}}
>
<div className="flex items-start gap-1.5 mb-1.5">
<span className="text-xs text-muted-foreground font-mono shrink-0">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{isLive && (
<span className="relative flex h-2 w-2 shrink-0 mt-0.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
)}
</div>
<p className="text-sm leading-snug line-clamp-2 mb-2">{issue.title}</p>
<div className="flex items-center gap-2">
<PriorityIcon priority={issue.priority} />
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name ? (
<Identity name={name} size="xs" />
) : (
<span className="text-xs text-muted-foreground font-mono">
{issue.assigneeAgentId.slice(0, 8)}
</span>
);
})()}
</div>
</Link>
</div>
);
}
/* ── Main Board ── */
export function KanbanBoard({
issues,
agents,
liveIssueIds,
onUpdateIssue,
}: KanbanBoardProps) {
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
);
const columnIssues = useMemo(() => {
const grouped: Record<string, Issue[]> = {};
for (const status of boardStatuses) {
grouped[status] = [];
}
for (const issue of issues) {
if (grouped[issue.status]) {
grouped[issue.status].push(issue);
}
}
return grouped;
}, [issues]);
const activeIssue = useMemo(
() => (activeId ? issues.find((i) => i.id === activeId) : null),
[activeId, issues]
);
function handleDragStart(event: DragStartEvent) {
setActiveId(event.active.id as string);
}
function handleDragEnd(event: DragEndEvent) {
setActiveId(null);
const { active, over } = event;
if (!over) return;
const issueId = active.id as string;
const issue = issues.find((i) => i.id === issueId);
if (!issue) return;
// Determine target status: the "over" could be a column id (status string)
// or another card's id. Find which column the "over" belongs to.
let targetStatus: string | null = null;
if (boardStatuses.includes(over.id as string)) {
targetStatus = over.id as string;
} else {
// It's a card - find which column it's in
const targetIssue = issues.find((i) => i.id === over.id);
if (targetIssue) {
targetStatus = targetIssue.status;
}
}
if (targetStatus && targetStatus !== issue.status) {
onUpdateIssue(issueId, { status: targetStatus });
}
}
function handleDragOver(_event: DragOverEvent) {
// Could be used for visual feedback; keeping simple for now
}
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex gap-3 overflow-x-auto pb-4 -mx-2 px-2">
{boardStatuses.map((status) => (
<KanbanColumn
key={status}
status={status}
issues={columnIssues[status] ?? []}
agents={agents}
liveIssueIds={liveIssueIds}
/>
))}
</div>
<DragOverlay>
{activeIssue ? (
<KanbanCard issue={activeIssue} agents={agents} isOverlay />
) : null}
</DragOverlay>
</DndContext>
);
}