Skip to content

g1t/apps/web/app/routes/repo/labels.tsx

308 lines13,399 bytesCodeBlame
1import { LoaderCircle, Plus, Search, Tag, Trash2 } from "lucide-react";
2import { useEffect, useRef, useState } from "react";
3import { Form, Link, useActionData } from "react-router";
4
5import type { Label } from "@g1t/contracts";
6
7import type { Route } from "./+types/labels";
8import { LabelChip } from "../../components/labels";
9import { Button, EmptyState, ErrorText, SubmitButton, usePending } from "../../components/ui";
10import {
11 AlertDialog,
12 AlertDialogAction,
13 AlertDialogCancel,
14 AlertDialogContent,
15 AlertDialogDescription,
16 AlertDialogFooter,
17 AlertDialogHeader,
18 AlertDialogTitle,
19 AlertDialogTrigger,
20} from "../../components/ui/alert-dialog";
21import { refusal, requireRepo } from "../../lib/access.server";
22import { matchLabels, tidyColor } from "../../lib/labels";
23import { page } from "../../lib/meta";
24import { work } from "../../lib/services.server";
25import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
26
27export function meta({ params, ...args }: Route.MetaArgs) {
28 return page(args, { title: `Labels · ${params.owner}/${params.repo} · g1t` });
29}
30
31export async function loader({ params, context }: Route.LoaderArgs) {
32 const { viewer, access } = await requireRepo(context, params, "read");
33 const labels = await work.listLabels({ namespace: params.owner, name: params.repo }, viewer);
34 return { labels: unwrap(labels), canEdit: access.can.manage_labels };
35}
36
37type ActionData = { intent: string; name?: string; error?: string; done?: string };
38
39export async function action({ request, params, context }: Route.ActionArgs): Promise<ActionData> {
40 assertSameOrigin(request);
41 const user = requireUser(context, request);
42 const form = await request.formData();
43 const intent = String(form.get("intent") ?? "");
44 const name = String(form.get("name") ?? "");
45 // Labels are made, changed and deleted with the Write role; Triage applies them.
46 const refused = await refusal(context, params, "manage_labels");
47 if (refused) return { intent, name, error: refused };
48 const path = { namespace: params.owner, name: params.repo };
49 const text = (key: string) => String(form.get(key) ?? "");
50 switch (intent) {
51 case "defaults": {
52 const added = await work.addDefaultLabels(user, path);
53 return added.ok ? { intent, done: "The default labels are here." } : { intent, error: added.error.message };
54 }
55 case "delete": {
56 const deleted = await work.deleteLabel(user, path, name);
57 return deleted.ok ? { intent, name } : { intent, name, error: deleted.error.message };
58 }
59 case "create":
60 case "edit": {
61 const saved = await work.saveLabel(user, path, {
62 name: intent === "edit" ? name : undefined,
63 newName: text("newName"),
64 color: text("color"),
65 description: text("description"),
66 });
67 return saved.ok ? { intent, name: saved.value.name } : { intent, name, error: saved.error.message };
68 }
69 default:
70 return { intent, error: "Nothing to do." };
71 }
72}
73
74/** A color's input: a swatch picker and its hex digits, kept together. */
75function ColorField({ defaultValue }: { defaultValue: string }) {
76 const [color, setColor] = useState(tidyColor(defaultValue) ?? "bfdadc");
77 return (
78 <label className="flex items-center gap-2">
79 <span className="sr-only">Color</span>
80 <input
81 type="color"
82 value={`#${color}`}
83 onChange={(event) => setColor(event.target.value.slice(1))}
84 className="size-9 shrink-0 cursor-pointer rounded-md border border-line bg-bg p-1"
85 aria-label="Pick a color"
86 />
87 <input
88 name="color"
89 value={color}
90 onChange={(event) => setColor(event.target.value.replace(/^#/, "").slice(0, 6))}
91 pattern="[0-9a-fA-F]{6}"
92 required
93 aria-label="Color, six hex digits"
94 autoComplete="off"
95 className="h-9 w-24 rounded-md border border-line bg-bg px-2 font-mono text-sm outline-none hover:border-line-strong focus:border-accent-dim"
96 />
97 </label>
98 );
99}
100
101/** Creating a label, or editing one: name, description and color. */
102function LabelForm({ label, onDone }: { label?: Label; onDone: () => void }) {
103 const intent = label ? "edit" : "create";
104 const result = useActionData<ActionData>();
105 const pending = usePending({ intent, name: label?.name ?? "" });
106 const mine = result?.intent === intent && (label ? result.name === label.name : true);
107 // Saved: close the form, once this form's own save has come back.
108 const sent = useRef(false);
109 useEffect(() => {
110 if (sent.current && !pending && mine && !result?.error) onDone();
111 sent.current = pending;
112 }, [mine, result, pending, onDone]);
113 return (
114 <Form method="post" className="grid gap-3 rounded-xl border border-line bg-surface p-4 sm:grid-cols-[1fr_1.4fr_auto] sm:items-end">
115 <input type="hidden" name="intent" value={intent} />
116 <input type="hidden" name="name" value={label?.name ?? ""} />
117 <label className="block">
118 <span className="mb-1.5 block text-xs font-medium text-muted">Name</span>
119 <input
120 name="newName"
121 defaultValue={label?.name ?? ""}
122 required
123 maxLength={50}
124 autoFocus
125 autoComplete="off"
126 data-1p-ignore
127 placeholder="area: cli"
128 className="h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none hover:border-line-strong focus:border-accent-dim"
129 />
130 </label>
131 <label className="block">
132 <span className="mb-1.5 block text-xs font-medium text-muted">Description</span>
133 <input
134 name="description"
135 defaultValue={label?.description ?? ""}
136 maxLength={100}
137 autoComplete="off"
138 data-1p-ignore
139 placeholder="What it means (optional)"
140 className="h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none hover:border-line-strong focus:border-accent-dim"
141 />
142 </label>
143 <div className="flex flex-wrap items-center gap-2">
144 <ColorField defaultValue={label?.color ?? ""} />
145 <Button type="button" variant="quiet" onClick={onDone}>
146 Cancel
147 </Button>
148 <SubmitButton match={{ intent, name: label?.name ?? "" }} pending="Saving…">
149 {label ? "Save" : "Create label"}
150 </SubmitButton>
151 </div>
152 {mine && result?.error && (
153 <div className="sm:col-span-3">
154 <ErrorText>{result.error}</ErrorText>
155 </div>
156 )}
157 </Form>
158 );
159}
160
161function DeleteLabel({ label }: { label: Label }) {
162 const deleting = usePending({ intent: "delete", name: label.name });
163 const carried = label.issues + label.pulls;
164 return (
165 <AlertDialog>
166 <AlertDialogTrigger asChild>
167 <Button type="button" variant="quiet" disabled={deleting} aria-label={`Delete ${label.name}`}>
168 {deleting ? <LoaderCircle size={14} className="animate-spin" aria-hidden="true" /> : <Trash2 size={14} />}
169 <span className="hidden sm:inline">{deleting ? "Deleting…" : "Delete"}</span>
170 </Button>
171 </AlertDialogTrigger>
172 <AlertDialogContent>
173 <Form method="post" className="grid gap-4">
174 <input type="hidden" name="intent" value="delete" />
175 <input type="hidden" name="name" value={label.name} />
176 <AlertDialogHeader>
177 <AlertDialogTitle>Delete the {label.name} label?</AlertDialogTitle>
178 <AlertDialogDescription>
179 {carried === 0
180 ? "Nothing carries it. "
181 : `It comes off the ${label.issues} ${label.issues === 1 ? "issue" : "issues"} and ${label.pulls} pull ${label.pulls === 1 ? "request" : "requests"} that carry it. `}
182 This cannot be undone.
183 </AlertDialogDescription>
184 </AlertDialogHeader>
185 <AlertDialogFooter>
186 <AlertDialogCancel>Cancel</AlertDialogCancel>
187 <AlertDialogAction asChild>
188 <button type="submit">
189 <Trash2 size={14} />
190 Delete label
191 </button>
192 </AlertDialogAction>
193 </AlertDialogFooter>
194 </Form>
195 </AlertDialogContent>
196 </AlertDialog>
197 );
198}
199
200export default function Labels({ loaderData, actionData, params }: Route.ComponentProps) {
201 const { labels, canEdit } = loaderData;
202 const base = `/${params.owner}/${params.repo}`;
203 const [query, setQuery] = useState("");
204 const [creating, setCreating] = useState(false);
205 const [editing, setEditing] = useState<string | null>(null);
206 const shown = matchLabels(labels, query);
207 const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
208 return (
209 <div className="space-y-4">
210 <div className="flex flex-wrap items-center justify-between gap-3">
211 <div>
212 <h2 className="text-xl font-semibold tracking-tight">Labels</h2>
213 <p className="mt-1 text-sm text-muted">
214 Issues and pull requests carry labels to say what they are. Filter either list by one.
215 </p>
216 </div>
217 {canEdit && !creating && (
218 <div className="flex flex-wrap items-center gap-2">
219 <Form method="post">
220 <input type="hidden" name="intent" value="defaults" />
221 <SubmitButton variant="quiet" match={{ intent: "defaults" }} pending="Adding…">
222 Add the default labels
223 </SubmitButton>
224 </Form>
225 <Button type="button" onClick={() => setCreating(true)}>
226 <Plus size={15} />
227 New label
228 </Button>
229 </div>
230 )}
231 </div>
232 {actionData?.intent === "defaults" &&
233 (actionData.error ? <ErrorText>{actionData.error}</ErrorText> : <p className="text-sm text-muted">{actionData.done}</p>)}
234 {actionData?.intent === "delete" && actionData.error && <ErrorText>{actionData.error}</ErrorText>}
235 {creating && <LabelForm onDone={() => setCreating(false)} />}
236
237 <div className="overflow-hidden rounded-xl border border-line">
238 <div className="flex flex-wrap items-center gap-3 border-b border-line bg-surface px-4 py-2.5">
239 <span className="text-sm font-medium">{count(labels.length, "label", "labels")}</span>
240 <label className="ml-auto flex h-8 w-full items-center gap-2 rounded-md border border-line bg-bg px-2.5 sm:w-64">
241 <Search size={14} className="shrink-0 text-faint" />
242 <input
243 type="search"
244 value={query}
245 onChange={(event) => setQuery(event.target.value)}
246 placeholder="Search labels"
247 aria-label="Search labels"
248 autoComplete="off"
249 data-1p-ignore
250 className="w-full bg-transparent text-sm outline-none placeholder:text-faint"
251 />
252 </label>
253 </div>
254 {labels.length === 0 ? (
255 <div className="p-4">
256 <EmptyState title="No labels yet">
257 {canEdit ? "Add the default labels, or make your own." : "Nobody has made a label here yet."}
258 </EmptyState>
259 </div>
260 ) : shown.length === 0 ? (
261 <p className="px-4 py-8 text-center text-sm text-faint">No label matches “{query}”.</p>
262 ) : (
263 <ul className="divide-y divide-line">
264 {shown.map((label) =>
265 editing === label.name ? (
266 <li key={label.name} className="p-3">
267 <LabelForm label={label} onDone={() => setEditing(null)} />
268 </li>
269 ) : (
270 <li key={label.name} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3">
271 <span className="w-full min-w-0 sm:w-56">
272 <LabelChip name={label.name} color={label.color} />
273 </span>
274 <span className="min-w-0 grow text-sm text-muted">{label.description}</span>
275 <span className="flex shrink-0 items-center gap-3 text-xs text-faint">
276 {label.issues > 0 ? (
277 <Link to={`${base}/issues?label=${encodeURIComponent(label.name)}&state=open`} className="hover:text-fg">
278 {count(label.issues, "issue", "issues")}
279 </Link>
280 ) : (
281 <span>no issues</span>
282 )}
283 {label.pulls > 0 ? (
284 <Link to={`${base}/pulls?label=${encodeURIComponent(label.name)}`} className="hover:text-fg">
285 {count(label.pulls, "pull request", "pull requests")}
286 </Link>
287 ) : (
288 <span>no pull requests</span>
289 )}
290 </span>
291 {canEdit && (
292 <span className="flex shrink-0 items-center gap-2">
293 <Button type="button" variant="quiet" onClick={() => setEditing(label.name)} aria-label={`Edit ${label.name}`}>
294 <Tag size={14} />
295 <span className="hidden sm:inline">Edit</span>
296 </Button>
297 <DeleteLabel label={label} />
298 </span>
299 )}
300 </li>
301 ),
302 )}
303 </ul>
304 )}
305 </div>
306 </div>
307 );
308}