"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<Ordering, (a: IssueSummary, b: IssueSummary) => 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<string, PickerKind> = { 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<IssueDefaults | null>(null);
const [focused, setFocused] = useState<number | null>(null);
const [openPicker, setOpenPicker] = useState<{ number: number; kind: PickerKind } | null>(null);
const [collapsed, setCollapsed] = useState<Set<string>>(() => 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 = <K extends keyof TrackerView>(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(String(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<HTMLElement>, group: Group) {
if (!drag || !group.patch) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const cards = [...e.currentTarget.querySelectorAll<HTMLElement>("[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 && (
<span className={`flex items-center gap-1 ${compact ? "flex-wrap" : "hidden lg:flex"}`}>
{issue.labels.slice(0, compact ? 4 : 3).map((l) => (
<LabelChip key={l.id} name={l.name} color={l.color} />
))}
{issue.labels.length > (compact ? 4 : 3) && (
<span className="type-meta text-ink3">+{issue.labels.length - (compact ? 4 : 3)}</span>
)}
</span>
)}
{issue.dueDate &&
(() => {
const overdue = issue.dueDate < today && !["done", "canceled"].includes(issue.status);
return (
<span className={pill} title={overdue ? "Overdue" : undefined}>
{overdue ? <Led kind="failed" size="small" /> : <CalendarIcon width={11} height={11} />}
{formatDay(issue.dueDate)}
{overdue && <span className="sr-only">(overdue)</span>}
</span>
);
})()}
{issue.estimate !== null && (
<span className={pill} title={`${issue.estimate} points`}>
<EstimateIcon size={11} /> {issue.estimate}
</span>
)}
{issue.commentCount > 0 && (
<span className={`items-center gap-1 type-meta text-ink3 tabular-nums ${compact ? "inline-flex" : "hidden sm:inline-flex"}`}>
<CommentIcon width={12} height={12} /> {issue.commentCount}
</span>
)}
</>
);
// 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) => (
<div
key={issue.number}
id={`issue-${issue.number}`}
onMouseEnter={() => 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" : ""}`}
>
<PriorityPicker
variant="icon"
value={issue.priority}
onChange={(priority) => update(issue, { priority })}
disabled={!canTriage}
{...pickerProps(issue, "priority")}
/>
<span className="hidden w-11 shrink-0 font-mono type-meta text-ink3 sm:inline">#{issue.number}</span>
<StatusPicker
variant="icon"
value={issue.status}
onChange={(status) => update(issue, { status })}
disabled={!canTriage}
{...pickerProps(issue, "status")}
/>
<Link
href={`${base}/issues/${issue.number}`}
className="min-w-0 truncate text-[13px] leading-5 font-medium text-ink outline-none after:absolute after:inset-0 after:rounded-row focus-visible:after:focus-ring-flush"
>
{issue.title}
</Link>
<span className="ml-auto flex shrink-0 items-center gap-2">
{properties(issue)}
{canTriage && openPicker?.number === issue.number && openPicker.kind === "labels" && (
<LabelsPicker
variant="icon"
value={issue.labels.map((l) => l.id)}
onChange={(labelIds) => update(issue, { labelIds })}
{...pickerProps(issue, "labels")}
/>
)}
<AssigneePicker
variant="icon"
value={issue.assigneeId}
onChange={(assigneeId) => update(issue, { assigneeId })}
disabled={!canTriage}
{...pickerProps(issue, "assignee")}
/>
</span>
</div>
);
// A board card: a raised plate in the column's sunk well; it rises a step while pointed at.
const card = (issue: IssueSummary) => (
<div
key={issue.number}
id={`issue-${issue.number}`}
data-card
data-number={issue.number}
draggable={canTriage}
onDragStart={(e) => {
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"}`}
>
<div className="flex items-center gap-2">
<span className="font-mono type-meta text-ink3">#{issue.number}</span>
<span className="ml-auto">
<AssigneePicker
variant="icon"
value={issue.assigneeId}
onChange={(assigneeId) => update(issue, { assigneeId })}
disabled={!canTriage}
{...pickerProps(issue, "assignee")}
/>
</span>
</div>
<div className="mt-1 flex items-start gap-1.5">
{view.group !== "status" && (
<span className="-ml-1">
<StatusPicker
variant="icon"
value={issue.status}
onChange={(status) => update(issue, { status })}
disabled={!canTriage}
{...pickerProps(issue, "status")}
/>
</span>
)}
<Link
href={`${base}/issues/${issue.number}`}
draggable={false}
className="line-clamp-2 pt-0.5 text-[13px] leading-5 font-medium text-ink outline-none after:absolute after:inset-0 after:rounded-plate focus-visible:after:focus-ring"
>
{issue.title}
</Link>
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
<span className="-ml-1">
<PriorityPicker
variant="icon"
value={issue.priority}
onChange={(priority) => update(issue, { priority })}
disabled={!canTriage}
{...pickerProps(issue, "priority")}
/>
</span>
{properties(issue, true)}
{canTriage && openPicker?.number === issue.number && openPicker.kind === "labels" && (
<LabelsPicker
variant="icon"
value={issue.labels.map((l) => l.id)}
onChange={(labelIds) => update(issue, { labelIds })}
{...pickerProps(issue, "labels")}
/>
)}
</div>
</div>
);
// Where a dragged card will land: a green rule (intent), between plates.
const dropLine = <div className="-my-1 h-0.5 shrink-0 rounded-full bg-green-deep" aria-hidden />;
const newInGroup = (group: Group) =>
group.patch &&
canTriage && (
<Tooltip label={`New issue in ${group.label}`}>
<IconButton
variant="ghost"
label={`New issue in ${group.label}`}
icon={<PlusIcon />}
onClick={() => startCreate(group.patch as IssueDefaults)}
className="ml-auto"
/>
</Tooltip>
);
const mine = view.assignee.length === 1 && view.assignee[0] === String(viewerId);
return (
<div>
{/* Header: scopes, search and view controls */}
<div className="mb-4 flex flex-wrap items-center gap-3">
<h1 className="mr-1 text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">Issues</h1>
<Switcher<Scope>
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"}
<span className="type-meta text-ink3 tabular-nums">{scopeCounts[scope]}</span>
</>
),
}))}
/>
<div className="ml-auto flex flex-wrap items-center gap-2">
<Field size="regular" className="w-52">
<Field.Icon>
<SearchIcon />
</Field.Icon>
<Field.Input
value={view.q}
onChange={(e) => set("q", e.target.value)}
onKeyDown={(e) => e.key === "Escape" && (e.currentTarget.blur(), set("q", ""))}
placeholder="Filter by title or #"
aria-label="Filter issues"
/>
</Field>
<Switcher<TrackerView["view"]>
aria-label="Layout"
value={view.view}
onValueChange={(layout) => set("view", layout)}
options={[
{ value: "list", label: "List", icon: <ListIcon width={14} height={14} /> },
{ value: "board", label: "Board", icon: <BoardIcon width={14} height={14} /> },
]}
/>
<Popover.Root>
<Popover.Trigger>
<MuButton cap="standard" icon={<SlidersIcon />} aria-label="Display options">
Display
</MuButton>
</Popover.Trigger>
<Popover.Content align="end">
<Popover.Title>Display</Popover.Title>
<Popover.Body className="space-y-2.5">
<DisplaySelect
label="Grouping"
value={view.group}
onChange={(v) => set("group", v as Grouping)}
options={[
["status", "Status"],
["priority", "Priority"],
["assignee", "Assignee"],
["none", "No grouping"],
]}
/>
<DisplaySelect
label="Ordering"
value={view.order}
onChange={(v) => set("order", v as Ordering)}
options={[
["manual", "Manual"],
["priority", "Priority"],
["updated", "Last updated"],
["created", "Last created"],
]}
/>
</Popover.Body>
</Popover.Content>
</Popover.Root>
{viewerId === null ? (
<ButtonLink href={`/login?next=${encodeURIComponent(`${base}/issues`)}`} variant="primary" icon={<PlusIcon />}>
New issue
</ButtonLink>
) : (
<Tooltip label="Create issue" shortcut="C">
<MuButton cap="primary" icon={<PlusIcon />} onClick={() => startCreate()}>
New issue
</MuButton>
</Tooltip>
)}
</div>
</div>
{/* Filters: latching keys whose lamp is lit while they narrow the list */}
<div className="mb-4 flex flex-wrap items-center gap-2">
<FilterPicker
label="Assignee"
icon={<PersonIcon width={14} height={14} />}
value={view.assignee}
onChange={(v) => set("assignee", v)}
options={[
{ value: "none", label: "No assignee", icon: <PersonIcon width={14} height={14} /> },
...members.map((m) => ({
value: String(m.id),
label: m.id === viewerId ? `${m.username} (you)` : m.username,
icon: <Avatar name={m.username} size={16} />,
})),
]}
/>
<FilterPicker
label="Priority"
icon={<PriorityIcon priority={2} size={13} />}
value={view.priority}
onChange={(v) => set("priority", v)}
options={PRIORITIES.map((p) => ({ value: String(p.id), label: p.label, icon: <PriorityIcon priority={p.id} /> }))}
/>
<FilterPicker
label="Label"
icon={<TagIcon width={14} height={14} />}
value={view.label}
onChange={(v) => set("label", v)}
options={labels.map((l) => ({ value: String(l.id), label: l.name, icon: <LabelDot color={l.color} /> }))}
/>
{viewerId !== null && members.some((m) => m.id === viewerId) && (
<Toggle pressed={mine} onPressedChange={() => set("assignee", mine ? [] : [String(viewerId)])}>
My issues
</Toggle>
)}
{filtered && (
<Button
type="button"
variant="invisible"
icon={<XIcon />}
onClick={() => setView((v) => ({ ...v, q: "", assignee: [], priority: [], label: [] }))}
>
Clear
</Button>
)}
<span className="ml-auto type-label engraved tabular-nums">
{visible.length} issue{visible.length === 1 ? "" : "s"}
</span>
</div>
{optimistic.length === 0 ? (
<Box>
<Blankslate
icon={<IssueOpenedIcon />}
title="Plan your work"
action={
<Button type="button" variant="primary" icon={<PlusIcon />} onClick={() => startCreate()}>
Create the first issue
</Button>
}
>
Track todos, bugs and features with statuses, priorities, assignees and labels — as a list or on a board.
</Blankslate>
</Box>
) : view.view === "list" ? (
listGroups.length === 0 ? (
<Box>
<Blankslate icon={<SearchIcon />} title="No matching issues">
Try a different scope or clear the filters.
</Blankslate>
</Box>
) : (
<Box className="p-1.5">
{listGroups.map((group, gi) => {
const isCollapsed = collapsed.has(group.key);
return (
<section key={group.key} aria-label={view.group !== "none" ? group.label : undefined}>
{view.group !== "none" && (
<div className={`flex h-9 items-center gap-2 pr-1 pl-0.5 ${gi > 0 ? "rule-t mt-1.5 h-[43px] pt-1.5" : ""}`}>
<button
type="button"
onClick={() =>
setCollapsed((c) => {
const next = new Set(c);
if (next.has(group.key)) next.delete(group.key);
else next.add(group.key);
return next;
})
}
className="mu-icon-trigger flex h-7 min-w-0 items-center gap-2 rounded-pill px-2 type-ui text-ink transition-colors hover:recipe-icon-button-ghost-hover focus-visible:focus-ring-flush"
aria-expanded={!isCollapsed}
>
<ChevronRightIcon
width={12}
height={12}
className={`text-ink3 transition-transform ${isCollapsed ? "" : "rotate-90"}`}
/>
{group.icon}
<span className="truncate font-semibold">{group.label}</span>
<span className="type-label engraved tabular-nums">{group.issues.length}</span>
</button>
{newInGroup(group)}
</div>
)}
{!isCollapsed && group.issues.map(row)}
</section>
);
})}
</Box>
)
) : (
<div className="-mx-4 overflow-x-auto px-4 pb-2 md:-mx-5 md:px-5">
<div className="flex min-h-[60vh] items-start gap-3">
{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 (
<Well
key={group.key}
variant="region"
radius="region"
over={drag?.group === group.key}
role="group"
aria-label={group.label}
onDragOver={(e) => 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"
>
<div className="flex h-11 shrink-0 items-center gap-2 pr-2 pl-3.5">
{group.icon}
<span className="truncate type-ui font-semibold text-ink">{group.label}</span>
<span className="type-label engraved tabular-nums">{group.issues.length}</span>
{newInGroup(group)}
</div>
<div className="flex min-h-16 flex-col gap-2 overflow-y-auto px-2 pt-0.5 pb-2">
{group.issues.map((issue) => (
<Fragment key={issue.number}>
{lineAt !== -1 && others[lineAt] === issue && dropLine}
{card(issue)}
</Fragment>
))}
{lineAt !== -1 && lineAt >= others.length && dropLine}
</div>
</Well>
);
})}
</div>
</div>
)}
<p className="mt-4 hidden flex-wrap items-center gap-x-4 gap-y-1 type-meta text-ink3 md:flex">
<span className="flex items-center gap-1.5">
<Kbd>C</Kbd> new issue
</span>
<span className="flex items-center gap-1.5">
<Kbd>J</Kbd>
<Kbd>K</Kbd> move
</span>
<span className="flex items-center gap-1.5">
<Kbd>↵</Kbd> open
</span>
{canTriage && (
<>
<span className="flex items-center gap-1.5">
<Kbd>S</Kbd> status
</span>
<span className="flex items-center gap-1.5">
<Kbd>P</Kbd> priority
</span>
<span className="flex items-center gap-1.5">
<Kbd>A</Kbd> assignee
</span>
<span className="flex items-center gap-1.5">
<Kbd>L</Kbd> labels
</span>
{view.view === "board" && <span>Drag cards between columns</span>}
</>
)}
</p>
<CreateIssueDialog open={creating !== null} defaults={creating ?? undefined} onClose={() => setCreating(null)} />
</div>
);
}
function groupIssues(visible: IssueSummary[], view: TrackerView, members: Member[], all: IssueSummary[]): Group[] {
const by = <K,>(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: <StatusIcon status={s} />,
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: <PriorityIcon priority={p.id} />,
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: <Avatar name={m.username} size={16} />,
patch: { assigneeId: m.id },
issues: by((i) => i.assigneeId, m.id as number | 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: <Avatar name={username} size={16} />,
patch: null,
issues: by((i) => i.assigneeId, id as number | null),
});
}
groups.push({
key: "user-none",
label: "No assignee",
icon: (
<span className="well flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-ink3">
<PersonIcon width={10} height={10} />
</span>
),
patch: { assigneeId: null },
issues: by((i) => i.assigneeId, null as number | 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 (
<div className="flex items-center justify-between gap-3">
<span className="type-ui text-ink2">{label}</span>
<Select
size="compact"
aria-label={label}
value={value}
onValueChange={onChange}
options={options.map(([v, l]) => ({ value: v, label: l }))}
className="w-36"
/>
</div>
);
}
/** A filter as a latching key: its lamp is lit, and the cap pressed in, while it narrows the list. */
function FilterPicker({
label,
icon,
value,
onChange,
options,
}: {
label: string;
icon: ReactNode;
value: string[];
onChange: (value: string[]) => void;
options: { value: string; label: string; icon?: ReactNode }[];
}) {
const active = value.length > 0;
const summary = active
? value.length === 1
? (options.find((o) => o.value === value[0])?.label ?? "1 selected")
: `${value.length} selected`
: null;
return (
<Picker
multiple
label={`Filter by ${label.toLowerCase()}`}
placeholder={`Filter by ${label.toLowerCase()}…`}
options={options}
value={value}
onSelect={onChange}
triggerClassName={buttonClass("default", `max-w-64 ${active ? "recipe-button-pressed" : ""}`)}
>
<Led kind={active ? "live" : "off"} size="small" />
{icon}
<span className="truncate">
{label}
{summary && <span className="text-ink2"> is {summary}</span>}
</span>
</Picker>
);
}