Skip to content

g1t/apps/web/app/components/labels.tsx

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