"use client"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Fragment, useEffect, useOptimistic, useState, useTransition, type ReactNode } from "react"; import { Field, IconButton, Led, Button as MuButton, Popover, Select, Switcher, Toggle, Tooltip, Well, } from "@unlocalhosted/metalui"; import { updateIssueAction } from "@/app/actions/issues"; import { BoardIcon, CalendarIcon, ChevronRightIcon, CommentIcon, IssueOpenedIcon, ListIcon, PersonIcon, PlusIcon, SearchIcon, SlidersIcon, TagIcon, XIcon, } from "@/components/icons"; import { Avatar, Blankslate, Box, Button, ButtonLink, Kbd, buttonClass } from "@/components/ui"; import { LIST_STATUS_ORDER, PRIORITIES, STATUSES, applyPatch, formatDay, priorityRank, type IssuePatch, type IssueSummary, type Member, } from "@/lib/issue-meta"; import { CreateIssueDialog, type IssueDefaults } from "./issue-composer"; import { SCOPE_STATUSES, viewToQuery, type Grouping, type Ordering, type Scope, type TrackerView } from "./view"; import { LabelChip, LabelDot, PriorityIcon, StatusIcon } from "./issue-icons"; import { AssigneePicker, EstimateIcon, LabelsPicker, PriorityPicker, StatusPicker } from "./properties"; import { Picker } from "./picker"; import { useTracker } from "./tracker-context"; type Group = { key: string; label: string; icon: ReactNode; /** What dropping an issue into (or creating one in) this group sets; null if it can't be set. */ patch: IssuePatch | null; issues: IssueSummary[]; }; const COMPARATORS: Record number> = { manual: (a, b) => a.sortOrder - b.sortOrder || b.number - a.number, priority: (a, b) => priorityRank(a.priority) - priorityRank(b.priority) || a.sortOrder - b.sortOrder, updated: (a, b) => b.updatedAt - a.updatedAt, created: (a, b) => b.createdAt - a.createdAt, }; type PickerKind = "status" | "priority" | "assignee" | "labels"; const SHORTCUT_PICKERS: Record = { s: "status", p: "priority", a: "assignee", l: "labels" }; export function IssueTracker({ issues, initialView }: { issues: IssueSummary[]; initialView: TrackerView }) { const tracker = useTracker(); const { owner, name, base, members, labels, viewerId, canTriage, today, notify } = tracker; const router = useRouter(); const [view, setView] = useState(initialView); const [optimistic, applyOptimistic] = useOptimistic(issues, (list, { number, patch }: { number: number; patch: IssuePatch }) => list.map((i) => (i.number === number ? applyPatch(i, patch, { members, labels }) : i)), ); const [, startTransition] = useTransition(); const [creating, setCreating] = useState(null); const [focused, setFocused] = useState(null); const [openPicker, setOpenPicker] = useState<{ number: number; kind: PickerKind } | null>(null); const [collapsed, setCollapsed] = useState>(() => new Set(["done", "canceled"])); const [drag, setDrag] = useState<{ number: number; group: string | null; index: number } | null>(null); const update = (issue: IssueSummary, patch: IssuePatch) => startTransition(async () => { applyOptimistic({ number: issue.number, patch }); const result = await updateIssueAction(owner, name, issue.number, patch); if (result.error) notify(result.error, { tone: "error" }); }); // Keep the URL in sync so views can be bookmarked and shared. useEffect(() => { window.history.replaceState(null, "", `${window.location.pathname}${viewToQuery(view)}`); }, [view]); const set = (key: K, value: TrackerView[K]) => setView((v) => ({ ...v, [key]: value })); // ---- Filtering, ordering, grouping -------------------------------------- const scoped = SCOPE_STATUSES[view.scope]; const q = view.q.trim().toLowerCase(); const visible = optimistic .filter( (i) => (!scoped || scoped.includes(i.status)) && (!q || i.title.toLowerCase().includes(q) || `#${i.number}` === q || String(i.number) === q) && (view.assignee.length === 0 || view.assignee.includes(i.assigneeId === null ? "none" : String(i.assigneeId))) && (view.priority.length === 0 || view.priority.includes(String(i.priority))) && (view.label.length === 0 || i.labels.some((l) => view.label.includes(l.id))), ) .sort(COMPARATORS[view.order]); const groups = groupIssues(visible, view, members, optimistic); const listGroups = groups.filter((g) => g.issues.length > 0); const navigable = (view.view === "board" ? groups : listGroups.filter((g) => !collapsed.has(g.key))).flatMap((g) => g.issues); const scopeCounts = { all: optimistic.length, active: optimistic.filter((i) => SCOPE_STATUSES.active!.includes(i.status)).length, backlog: optimistic.filter((i) => i.status === "backlog").length, }; const filtered = view.assignee.length + view.priority.length + view.label.length > 0 || !!q; function startCreate(defaults: IssueDefaults = {}) { if (viewerId === null) { router.push(`/login?next=${encodeURIComponent(`${base}/issues`)}`); return; } setCreating(view.scope === "backlog" && !defaults.status ? { ...defaults, status: "backlog" } : defaults); } // ---- Keyboard shortcuts --------------------------------------------------- useEffect(() => { const onKey = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if (e.metaKey || e.ctrlKey || e.altKey || target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return; // A MetalUI control already handled it (a switcher's arrows), or a dialog or popover is open. if (e.defaultPrevented || document.querySelector('dialog[open], [role="dialog"]')) return; const key = e.key.toLowerCase(); if (key.startsWith("arrow") && target.closest('[role="radiogroup"]')) return; const index = navigable.findIndex((i) => i.number === focused); const current = navigable[index]; if (key === "c") { e.preventDefault(); startCreate(); } else if (key === "j" || key === "arrowdown" || key === "k" || key === "arrowup") { e.preventDefault(); const down = key === "j" || key === "arrowdown"; const next = navigable[ index === -1 ? (down ? 0 : navigable.length - 1) : Math.min(Math.max(index + (down ? 1 : -1), 0), navigable.length - 1) ]; if (!next) return; setFocused(next.number); document.getElementById(`issue-${next.number}`)?.scrollIntoView({ block: "nearest" }); } else if ((key === "enter" || key === "o") && current) { e.preventDefault(); router.push(`${base}/issues/${current.number}`); } else if (key === "escape") { setFocused(null); } else if (current && SHORTCUT_PICKERS[key]) { const kind = SHORTCUT_PICKERS[key]; if (!canTriage) return; e.preventDefault(); setOpenPicker({ number: current.number, kind }); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }); const pickerProps = (issue: IssueSummary, kind: PickerKind) => ({ open: openPicker?.number === issue.number && openPicker.kind === kind ? true : undefined, onOpenChange: (open: boolean) => !open && setOpenPicker(null), }); // ---- Board drag and drop ---------------------------------------------------- function dragOver(e: React.DragEvent, group: Group) { if (!drag || !group.patch) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; const cards = [...e.currentTarget.querySelectorAll("[data-card]")].filter( (el) => el.dataset.number !== String(drag.number), ); let index = cards.findIndex((el) => { const r = el.getBoundingClientRect(); return e.clientY < r.top + r.height / 2; }); if (index === -1) index = cards.length; if (drag.group !== group.key || drag.index !== index) setDrag({ ...drag, group: group.key, index }); } function drop(e: React.DragEvent, group: Group) { e.preventDefault(); const issue = optimistic.find((i) => i.number === drag?.number); setDrag(null); if (!issue || !drag || !group.patch) return; const patch: IssuePatch = {}; const [field, value] = Object.entries(group.patch)[0] ?? []; if (field && issue[field as keyof IssueSummary] !== value) Object.assign(patch, group.patch); if (view.order === "manual") { const others = group.issues.filter((i) => i.number !== issue.number); const before = others[drag.index - 1]?.sortOrder; const after = others[drag.index]?.sortOrder; const sortOrder = before !== undefined && after !== undefined ? (before + after) / 2 : before !== undefined ? before + 1 : after !== undefined ? after - 1 : issue.sortOrder; if (sortOrder !== issue.sortOrder) patch.sortOrder = sortOrder; } if (Object.keys(patch).length > 0) update(issue, patch); } // ---- Rendering ---------------------------------------------------------------- const pill = "inline-flex h-5 items-center gap-1 rounded-pill px-2 type-meta font-medium text-ink2 recipe-chip-tag"; const properties = (issue: IssueSummary, compact = false) => ( <> {issue.labels.length > 0 && ( {issue.labels.slice(0, compact ? 4 : 3).map((l) => ( ))} {issue.labels.length > (compact ? 4 : 3) && ( +{issue.labels.length - (compact ? 4 : 3)} )} )} {issue.dueDate && (() => { const overdue = issue.dueDate < today && !["done", "canceled"].includes(issue.status); return ( {overdue ? : } {formatDay(issue.dueDate)} {overdue && (overdue)} ); })()} {issue.estimate !== null && ( {issue.estimate} )} {issue.commentCount > 0 && ( {issue.commentCount} )} ); // A list row on the plate: it lifts (MetalUI's list-row hover) while pointed at or reached with J/K. const row = (issue: IssueSummary) => (
setFocused(issue.number)} className={`group relative flex h-10 items-center gap-2 rounded-row pr-2.5 pl-1.5 transition-[background,box-shadow] ${focused === issue.number ? "recipe-row-list-hover" : ""}`} > update(issue, { priority })} disabled={!canTriage} {...pickerProps(issue, "priority")} /> #{issue.number} update(issue, { status })} disabled={!canTriage} {...pickerProps(issue, "status")} /> {issue.title} {properties(issue)} {canTriage && openPicker?.number === issue.number && openPicker.kind === "labels" && ( l.id)} onChange={(labelIds) => update(issue, { labelIds })} {...pickerProps(issue, "labels")} /> )} update(issue, { assigneeId })} disabled={!canTriage} {...pickerProps(issue, "assignee")} />
); // A board card: a raised plate in the column's sunk well; it rises a step while pointed at. const card = (issue: IssueSummary) => (
{ e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(issue.number)); setDrag({ number: issue.number, group: null, index: -1 }); }} onDragEnd={() => setDrag(null)} onMouseEnter={() => setFocused(issue.number)} className={`group relative rounded-plate p-3 transition-[box-shadow,opacity] ${canTriage ? "cursor-grab active:cursor-grabbing" : ""} ${drag?.number === issue.number ? "opacity-40" : ""} ${focused === issue.number ? "recipe-surface-raise" : "recipe-surface-raise-lite"}`} >
#{issue.number} update(issue, { assigneeId })} disabled={!canTriage} {...pickerProps(issue, "assignee")} />
{view.group !== "status" && ( update(issue, { status })} disabled={!canTriage} {...pickerProps(issue, "status")} /> )} {issue.title}
update(issue, { priority })} disabled={!canTriage} {...pickerProps(issue, "priority")} /> {properties(issue, true)} {canTriage && openPicker?.number === issue.number && openPicker.kind === "labels" && ( l.id)} onChange={(labelIds) => update(issue, { labelIds })} {...pickerProps(issue, "labels")} /> )}
); // Where a dragged card will land: a green rule (intent), between plates. const dropLine =
; const newInGroup = (group: Group) => group.patch && canTriage && ( } onClick={() => startCreate(group.patch as IssueDefaults)} className="ml-auto" /> ); const mine = view.assignee.length === 1 && view.assignee[0] === viewerId; return (
{/* Header: scopes, search and view controls */}

Issues

aria-label="Scope" value={view.scope} onValueChange={(scope) => set("scope", scope)} options={(["all", "active", "backlog"] as Scope[]).map((scope) => ({ value: scope, label: ( <> {scope === "all" ? "All issues" : scope === "active" ? "Active" : "Backlog"} {scopeCounts[scope]} ), }))} />
set("q", e.target.value)} onKeyDown={(e) => e.key === "Escape" && (e.currentTarget.blur(), set("q", ""))} placeholder="Filter by title or #" aria-label="Filter issues" /> aria-label="Layout" value={view.view} onValueChange={(layout) => set("view", layout)} options={[ { value: "list", label: "List", icon: }, { value: "board", label: "Board", icon: }, ]} /> } aria-label="Display options"> Display Display set("group", v as Grouping)} options={[ ["status", "Status"], ["priority", "Priority"], ["assignee", "Assignee"], ["none", "No grouping"], ]} /> set("order", v as Ordering)} options={[ ["manual", "Manual"], ["priority", "Priority"], ["updated", "Last updated"], ["created", "Last created"], ]} /> {viewerId === null ? ( }> New issue ) : ( } onClick={() => startCreate()}> New issue )}
{/* Filters: latching keys whose lamp is lit while they narrow the list */}
} value={view.assignee} onChange={(v) => set("assignee", v)} options={[ { value: "none", label: "No assignee", icon: }, ...members.map((m) => ({ value: m.id, label: m.id === viewerId ? `${m.username} (you)` : m.username, icon: , })), ]} /> } value={view.priority} onChange={(v) => set("priority", v)} options={PRIORITIES.map((p) => ({ value: String(p.id), label: p.label, icon: }))} /> } value={view.label} onChange={(v) => set("label", v)} options={labels.map((l) => ({ value: l.id, label: l.name, icon: }))} /> {viewerId !== null && members.some((m) => m.id === viewerId) && ( set("assignee", mine ? [] : [viewerId])}> My issues )} {filtered && ( )} {visible.length} issue{visible.length === 1 ? "" : "s"}
{optimistic.length === 0 ? ( } title="Plan your work" action={ } > Track todos, bugs and features with statuses, priorities, assignees and labels — as a list or on a board. ) : view.view === "list" ? ( listGroups.length === 0 ? ( } title="No matching issues"> Try a different scope or clear the filters. ) : ( {listGroups.map((group, gi) => { const isCollapsed = collapsed.has(group.key); return (
{view.group !== "none" && (
0 ? "rule-t mt-1.5 h-[43px] pt-1.5" : ""}`}> {newInGroup(group)}
)} {!isCollapsed && group.issues.map(row)}
); })}
) ) : (
{groups.map((group) => { // Where the drop line goes: before others[lineAt], or at the end. const others = group.issues.filter((i) => i.number !== drag?.number); const lineAt = drag?.group === group.key && view.order === "manual" ? drag.index : -1; return ( dragOver(e, group)} onDragLeave={(e) => { if (drag && !e.currentTarget.contains(e.relatedTarget as Node)) setDrag({ ...drag, group: null, index: -1 }); }} onDrop={(e) => drop(e, group)} className="flex max-h-[calc(100vh-220px)] w-[300px] shrink-0 flex-col" >
{group.icon} {group.label} {group.issues.length} {newInGroup(group)}
{group.issues.map((issue) => ( {lineAt !== -1 && others[lineAt] === issue && dropLine} {card(issue)} ))} {lineAt !== -1 && lineAt >= others.length && dropLine}
); })}
)}

C new issue J K move ↵ open {canTriage && ( <> S status P priority A assignee L labels {view.view === "board" && Drag cards between columns} )}

setCreating(null)} />
); } function groupIssues(visible: IssueSummary[], view: TrackerView, members: Member[], all: IssueSummary[]): Group[] { const by = (key: (i: IssueSummary) => K, value: K) => visible.filter((i) => key(i) === value); if (view.group === "status") { const scoped = SCOPE_STATUSES[view.scope]; const order = view.view === "board" ? STATUSES.map((s) => s.id) : LIST_STATUS_ORDER; return order .filter((s) => !scoped || scoped.includes(s)) .map((s) => ({ key: s, label: STATUSES.find((x) => x.id === s)!.label, icon: , patch: { status: s }, issues: by((i) => i.status, s), })); } if (view.group === "priority") { return PRIORITIES.map((p) => ({ key: `priority-${p.id}`, label: p.label, icon: , patch: { priority: p.id }, issues: by((i) => i.priority, p.id as number), })); } if (view.group === "assignee") { const groups: Group[] = [ ...members.map((m) => ({ key: `user-${m.id}`, label: m.username, icon: , patch: { assigneeId: m.id }, issues: by((i) => i.assigneeId, m.id as string | null), })), ]; // People who were assigned before losing access still get a (read-only) column. const former = new Map( all .filter((i) => i.assigneeId !== null && !members.some((m) => m.id === i.assigneeId)) .map((i) => [i.assigneeId!, i.assignee ?? "?"]), ); for (const [id, username] of former) { groups.push({ key: `user-${id}`, label: username, icon: , patch: null, issues: by((i) => i.assigneeId, id as string | null), }); } groups.push({ key: "user-none", label: "No assignee", icon: ( ), patch: { assigneeId: null }, issues: by((i) => i.assigneeId, null as string | null), }); return groups; } return [{ key: "all", label: "All issues", icon: null, patch: {}, issues: visible }]; } /** One display setting: its name, and MetalUI's compact select. */ function DisplaySelect({ label, value, onChange, options, }: { label: string; value: string; onChange: (value: string) => void; options: [string, string][]; }) { return (
{label}