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.

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