Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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