Skip to content
391 linesCodeBlameRaw
1import { Check, ChevronDown, Milestone as MilestoneIcon, Plus, Settings2, Tag, X } from "lucide-react";
2import { type ReactNode, useEffect, useMemo, useState } from "react";
3import { Link, useFetcher, useNavigate } from "react-router";
4
5import type { Label as RepoLabel, Milestone, MilestoneRef } from "@g1t/contracts";
6
7import { chipStyle, matchLabels, percentDone, tidyColor, tidyLabelName } from "../lib/labels";
8import { cn } from "../lib/cn";
9import { ErrorText } from "./ui";
10import { Button } from "./ui/button";
11import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "./ui/command";
12import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
13
14/** A label as a colored chip. Without a known color it is neutral. */
15export function LabelChip({ name, color, className }: { name: string; color?: string | null; className?: string }) {
16 const tinted = tidyColor(color) != null;
17 return (
18 <span
19 style={chipStyle(color)}
20 className={cn(
21 "inline-flex max-w-full shrink-0 items-center truncate rounded-full border px-2 py-px text-xs font-medium",
22 !tinted && "border-line text-muted",
23 className,
24 )}
25 >
26 {name}
27 </span>
28 );
29}
30
31/** A swatch of a label's color, for lists and pickers. */
32export function Swatch({ color }: { color?: string | null }) {
33 const hex = tidyColor(color);
34 return (
35 <span
36 aria-hidden="true"
37 className="inline-block size-3 shrink-0 rounded-full border border-line-strong"
38 style={hex ? { backgroundColor: `#${hex}`, borderColor: `color-mix(in oklab, #${hex} 70%, var(--color-fg))` } : undefined}
39 />
40 );
41}
42
43/** How far along a milestone is, as a bar. */
44export function MilestoneBar({ milestone, className }: { milestone: Milestone; className?: string }) {
45 const percent = percentDone(milestone);
46 return (
47 <span
48 role="progressbar"
49 aria-valuemin={0}
50 aria-valuemax={100}
51 aria-valuenow={percent}
52 aria-label={`${percent}% complete`}
53 className={cn("block h-2 overflow-hidden rounded-full bg-line", className)}
54 >
55 <span className="block h-full rounded-full bg-success transition-[width]" style={{ width: `${percent}%` }} />
56 </span>
57 );
58}
59
60/**
61 * The sidebar's Labels: the item's chips, and for someone who may change
62 * them, a menu to tick labels on and off, find one, or make one. It saves
63 * when the menu closes, posting `action=labels` with each `label`.
64 */
65export function LabelsBox({
66 labels,
67 chosen,
68 canEdit,
69 canCreate,
70 manageUrl,
71}: {
72 labels: RepoLabel[];
73 chosen: string[];
74 canEdit: boolean;
75 canCreate: boolean;
76 /** The labels page, for someone who may manage them. */
77 manageUrl?: string;
78}) {
79 const fetcher = useFetcher<{ error?: string } | null>();
80 const [open, setOpen] = useState(false);
81 const [picked, setPicked] = useState<string[]>(chosen);
82 const [query, setQuery] = useState("");
83 // What the page says now, once a save has come back.
84 useEffect(() => {
85 if (!open) setPicked(chosen);
86 }, [chosen, open]);
87 const colors = useMemo(() => new Map(labels.map((label) => [label.name, label.color])), [labels]);
88 const typed = tidyLabelName(query);
89 const offerNew = canCreate && typed !== "" && !labels.some((label) => label.name === typed) && !picked.includes(typed);
90 const toggle = (name: string) =>
91 setPicked((now) => (now.includes(name) ? now.filter((other) => other !== name) : [...now, name]));
92 const save = (next: string[]) => {
93 const same = next.length === chosen.length && next.every((name) => chosen.includes(name));
94 if (same) return;
95 const form = new FormData();
96 form.set("action", "labels");
97 for (const name of next) form.append("label", name);
98 fetcher.submit(form, { method: "post" });
99 };
100 // While saving, show what was picked.
101 const shown = fetcher.state !== "idle" ? picked : chosen;
102 return (
103 <section>
104 <div className="flex items-center justify-between gap-2">
105 <h3 className="text-sm font-medium">Labels</h3>
106 {canEdit && (
107 <Popover
108 open={open}
109 onOpenChange={(next) => {
110 setOpen(next);
111 if (!next) {
112 setQuery("");
113 save(picked);
114 }
115 }}
116 >
117 <PopoverTrigger asChild>
118 <Button
119 type="button"
120 aria-label="Edit labels"
121 variant="ghost"
122 size="inline"
123 className="p-1 text-faint"
124 >
125 <Settings2 size={15} />
126 </Button>
127 </PopoverTrigger>
128 <PopoverContent align="end" className="w-72 p-0">
129 <Command shouldFilter={false}>
130 <CommandInput value={query} onValueChange={setQuery} placeholder="Find or make a label" />
131 <CommandList>
132 {!offerNew && <CommandEmpty>No label matches.</CommandEmpty>}
133 <CommandGroup heading="Labels">
134 {matchLabels(labels, query).map((label) => (
135 <CommandItem key={label.name} value={label.name} onSelect={() => toggle(label.name)}>
136 <span className="flex size-4 items-center justify-center">
137 {picked.includes(label.name) && <Check className="text-accent" />}
138 </span>
139 <Swatch color={label.color} />
140 <span className="min-w-0 grow">
141 <span className="block truncate">{label.name}</span>
142 {label.description && (
143 <span className="block truncate text-xs text-faint">{label.description}</span>
144 )}
145 </span>
146 </CommandItem>
147 ))}
148 {picked
149 .filter((name) => !colors.has(name) && name.includes(typed))
150 .map((name) => (
151 <CommandItem key={name} value={`new:${name}`} onSelect={() => toggle(name)}>
152 <span className="flex size-4 items-center justify-center">
153 <Check className="text-accent" />
154 </span>
155 <Swatch />
156 <span className="grow truncate">{name}</span>
157 <span className="text-xs text-faint">new</span>
158 </CommandItem>
159 ))}
160 </CommandGroup>
161 {offerNew && (
162 <CommandGroup heading="New label">
163 <CommandItem
164 value={`create:${typed}`}
165 onSelect={() => {
166 toggle(typed);
167 setQuery("");
168 }}
169 >
170 <Plus />
171 <span className="truncate">
172 Create <span className="font-medium">{typed}</span>
173 </span>
174 </CommandItem>
175 </CommandGroup>
176 )}
177 </CommandList>
178 </Command>
179 {manageUrl && (
180 <Link
181 to={manageUrl}
182 className="flex items-center gap-2 border-t border-line px-3 py-2 text-xs text-muted hover:text-fg"
183 >
184 <Tag size={13} />
185 Edit labels
186 </Link>
187 )}
188 </PopoverContent>
189 </Popover>
190 )}
191 </div>
192 <div className="mt-2 flex flex-wrap gap-1.5">
193 {shown.length === 0 ? (
194 <span className="text-xs text-faint">None yet</span>
195 ) : (
196 shown.map((name) => <LabelChip key={name} name={name} color={colors.get(name)} />)
197 )}
198 </div>
199 {fetcher.data?.error && (
200 <div className="mt-2">
201 <ErrorText>{fetcher.data.error}</ErrorText>
202 </div>
203 )}
204 </section>
205 );
206}
207
208/**
209 * The sidebar's Milestone: the one the item is in, with its progress, and
210 * for someone with the Triage role a menu to move it, posting
211 * `action=milestone` with `milestone` (0 for none).
212 */
213export function MilestoneBox({
214 milestones,
215 current,
216 canEdit,
217 base,
218}: {
219 milestones: Milestone[];
220 current: MilestoneRef | null | undefined;
221 canEdit: boolean;
222 /** The repository's address, for links to milestones. */
223 base: string;
224}) {
225 const fetcher = useFetcher<{ error?: string } | null>();
226 const [open, setOpen] = useState(false);
227 const choose = (number: number) => {
228 setOpen(false);
229 if (number === (current?.number ?? 0)) return;
230 const form = new FormData();
231 form.set("action", "milestone");
232 form.set("milestone", String(number));
233 fetcher.submit(form, { method: "post" });
234 };
235 const pending = fetcher.state !== "idle" ? Number(fetcher.formData?.get("milestone") ?? 0) : null;
236 const shownNumber = pending ?? current?.number ?? 0;
237 const shown = milestones.find((milestone) => milestone.number === shownNumber);
238 const title = shown?.title ?? (shownNumber === current?.number ? current?.title : undefined);
239 return (
240 <section>
241 <div className="flex items-center justify-between gap-2">
242 <h3 className="text-sm font-medium">Milestone</h3>
243 {canEdit && (
244 <Popover open={open} onOpenChange={setOpen}>
245 <PopoverTrigger asChild>
246 <Button
247 type="button"
248 aria-label="Choose a milestone"
249 variant="ghost"
250 size="inline"
251 className="p-1 text-faint"
252 >
253 <Settings2 size={15} />
254 </Button>
255 </PopoverTrigger>
256 <PopoverContent align="end" className="w-72 p-0">
257 <Command>
258 <CommandInput placeholder="Find a milestone" />
259 <CommandList>
260 <CommandEmpty>No milestone matches.</CommandEmpty>
261 {current && (
262 <CommandGroup>
263 <CommandItem value="none" onSelect={() => choose(0)}>
264 <X />
265 Clear milestone
266 </CommandItem>
267 </CommandGroup>
268 )}
269 <CommandGroup heading="Open">
270 {milestones
271 .filter((milestone) => milestone.state === "open")
272 .map((milestone) => (
273 <CommandItem
274 key={milestone.number}
275 value={`${milestone.title} ${milestone.number}`}
276 onSelect={() => choose(milestone.number)}
277 >
278 <span className="flex size-4 items-center justify-center">
279 {milestone.number === current?.number && <Check className="text-accent" />}
280 </span>
281 <span className="min-w-0 grow truncate">{milestone.title}</span>
282 {milestone.dueOn && <span className="text-xs text-faint">{milestone.dueOn}</span>}
283 </CommandItem>
284 ))}
285 </CommandGroup>
286 </CommandList>
287 </Command>
288 <Link
289 to={`${base}/milestones`}
290 className="flex items-center gap-2 border-t border-line px-3 py-2 text-xs text-muted hover:text-fg"
291 >
292 <MilestoneIcon size={13} />
293 All milestones
294 </Link>
295 </PopoverContent>
296 </Popover>
297 )}
298 </div>
299 <div className="mt-2">
300 {shownNumber && title ? (
301 <Link to={`${base}/milestones/${shownNumber}`} className="group block">
302 {shown && <MilestoneBar milestone={shown} className="mb-1.5" />}
303 <span className="flex items-center gap-1.5 text-sm group-hover:text-accent">
304 <MilestoneIcon size={14} className="shrink-0 text-faint" />
305 <span className="truncate">{title}</span>
306 </span>
307 </Link>
308 ) : (
309 <span className="text-xs text-faint">None</span>
310 )}
311 </div>
312 {fetcher.data?.error && (
313 <div className="mt-2">
314 <ErrorText>{fetcher.data.error}</ErrorText>
315 </div>
316 )}
317 </section>
318 );
319}
320
321/**
322 * A list's filter: a button that opens a searchable menu of choices, each
323 * a link to the list filtered that way.
324 */
325export function FilterMenu({
326 label,
327 active,
328 options,
329 clearTo,
330 searchPlaceholder,
331 emptyText,
332}: {
333 label: string;
334 /** What it is filtered by now, shown on the button. */
335 active?: ReactNode;
336 options: { key: string; to: string; label: ReactNode; keywords: string; selected?: boolean }[];
337 /** Where "Clear" goes, when something is chosen. */
338 clearTo?: string;
339 searchPlaceholder: string;
340 emptyText: string;
341}) {
342 const [open, setOpen] = useState(false);
343 const navigate = useNavigate();
344 const go = (to: string) => {
345 setOpen(false);
346 navigate(to);
347 };
348 return (
349 <Popover open={open} onOpenChange={setOpen}>
350 <PopoverTrigger asChild>
351 <button
352 type="button"
353 className={cn(
354 "inline-flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-sm transition-colors",
355 active ? "border-accent/40 bg-accent/5 text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg",
356 )}
357 >
358 {label}
359 {active && <span className="max-w-32 truncate font-medium">{active}</span>}
360 <ChevronDown size={14} className="text-faint" />
361 </button>
362 </PopoverTrigger>
363 <PopoverContent align="start" className="w-72 p-0">
364 <Command>
365 <CommandInput placeholder={searchPlaceholder} />
366 <CommandList>
367 <CommandEmpty>{emptyText}</CommandEmpty>
368 {clearTo && (
369 <CommandGroup>
370 <CommandItem value="clear-filter" onSelect={() => go(clearTo)}>
371 <X />
372 Clear
373 </CommandItem>
374 </CommandGroup>
375 )}
376 <CommandGroup>
377 {options.map((option) => (
378 <CommandItem key={option.key} value={option.keywords} onSelect={() => go(option.to)}>
379 <span className="flex size-4 items-center justify-center">
380 {option.selected && <Check className="text-accent" />}
381 </span>
382 {option.label}
383 </CommandItem>
384 ))}
385 </CommandGroup>
386 </CommandList>
387 </Command>
388 </PopoverContent>
389 </Popover>
390 );
391}