Skip to content

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

159 lines6,505 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1import { CalendarClock, LoaderCircle, Trash2 } from "lucide-react";
2import { useEffect, useRef } from "react";
3import { Form, useActionData } from "react-router";
4
5import type { Milestone } from "@g1t/contracts";
6
7import { dueInWords, isOverdue, percentDone } from "../lib/labels";
8import { cn } from "../lib/cn";
9import { Button, ErrorText, SubmitButton, Textarea, usePending } from "./ui";
10import {
11 AlertDialog,
12 AlertDialogAction,
13 AlertDialogCancel,
14 AlertDialogContent,
15 AlertDialogDescription,
16 AlertDialogFooter,
17 AlertDialogHeader,
18 AlertDialogTitle,
19 AlertDialogTrigger,
20} from "./ui/alert-dialog";
21
22/** What the milestone forms post back. */
23export type MilestoneResult = { intent: string; number?: number; error?: string };
24
25/** When it is due, or that it is late, in words, with an icon. */
26export function DueLine({ milestone, today }: { milestone: Milestone; today: Date }) {
27 const due = dueInWords(milestone, today);
28 if (!due) return <span className="text-faint">No due date</span>;
29 return (
30 <span className={cn("inline-flex items-center gap-1", isOverdue(milestone, today) ? "text-danger" : "text-muted")}>
31 <CalendarClock size={13} className="shrink-0" />
32 {due}
33 </span>
34 );
35}
36
37/** "40% complete · 3 open · 2 closed". */
38export function ProgressLine({ milestone }: { milestone: Milestone }) {
39 return (
40 <span className="text-xs text-muted">
41 <span className="font-medium text-fg">{percentDone(milestone)}%</span> complete
42 <span className="text-faint"> · </span>
43 {milestone.openItems} open
44 <span className="text-faint"> · </span>
45 {milestone.closedItems} closed
46 </span>
47 );
48}
49
50/** Creating a milestone, or editing one: title, due date, description. */
51export function MilestoneForm({ milestone, onDone }: { milestone?: Milestone; onDone: () => void }) {
52 const intent = milestone ? "edit" : "create";
53 const result = useActionData<MilestoneResult>();
54 const pending = usePending({ intent });
55 const sent = useRef(false);
56 const mine = result?.intent === intent;
57 useEffect(() => {
58 if (sent.current && !pending && mine && !result?.error) onDone();
59 sent.current = pending;
60 }, [mine, result, pending, onDone]);
61 return (
62 <Form method="post" className="grid gap-3 rounded-xl border border-line bg-surface p-4">
63 <input type="hidden" name="intent" value={intent} />
64 {milestone && <input type="hidden" name="number" value={milestone.number} />}
65 <div className="grid gap-3 sm:grid-cols-[1fr_12rem]">
66 <label className="block">
67 <span className="mb-1.5 block text-xs font-medium text-muted">Title</span>
68 <input
69 name="title"
70 defaultValue={milestone?.title ?? ""}
71 required
72 maxLength={100}
73 autoFocus
74 autoComplete="off"
75 data-1p-ignore
76 placeholder="Launch"
77 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"
78 />
79 </label>
80 <label className="block">
81 <span className="mb-1.5 block text-xs font-medium text-muted">Due date (optional)</span>
82 <input
83 type="date"
84 name="dueOn"
85 defaultValue={milestone?.dueOn ?? ""}
86 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"
87 />
88 </label>
89 </div>
90 <label className="block">
91 <span className="mb-1.5 block text-xs font-medium text-muted">Description (optional)</span>
92 <Textarea name="description" rows={3} defaultValue={milestone?.description ?? ""} maxLength={4000} />
93 </label>
94 {mine && result?.error && <ErrorText>{result.error}</ErrorText>}
95 <div className="flex flex-wrap justify-end gap-2">
96 <Button type="button" variant="quiet" onClick={onDone}>
97 Cancel
98 </Button>
99 <SubmitButton match={{ intent }} pending="Saving…">
100 {milestone ? "Save milestone" : "Create milestone"}
101 </SubmitButton>
102 </div>
103 </Form>
104 );
105}
106
107/** Closing or reopening a milestone. */
108export function MilestoneStateButton({ milestone }: { milestone: Milestone }) {
109 const next = milestone.state === "open" ? "closed" : "open";
110 return (
111 <Form method="post">
112 <input type="hidden" name="intent" value="state" />
113 <input type="hidden" name="number" value={milestone.number} />
114 <input type="hidden" name="state" value={next} />
115 <SubmitButton variant="quiet" match={{ intent: "state", number: String(milestone.number) }} pending="Saving…">
116 {next === "closed" ? "Close" : "Reopen"}
117 </SubmitButton>
118 </Form>
119 );
120}
121
122export function DeleteMilestone({ milestone }: { milestone: Milestone }) {
123 const deleting = usePending({ intent: "delete", number: String(milestone.number) });
124 const items = milestone.openItems + milestone.closedItems;
125 return (
126 <AlertDialog>
127 <AlertDialogTrigger asChild>
128 <Button type="button" variant="quiet" disabled={deleting} aria-label={`Delete ${milestone.title}`}>
129 {deleting ? <LoaderCircle size={14} className="animate-spin" aria-hidden="true" /> : <Trash2 size={14} />}
130 <span className="hidden sm:inline">{deleting ? "Deleting…" : "Delete"}</span>
131 </Button>
132 </AlertDialogTrigger>
133 <AlertDialogContent>
134 <Form method="post" className="grid gap-4">
135 <input type="hidden" name="intent" value="delete" />
136 <input type="hidden" name="number" value={milestone.number} />
137 <AlertDialogHeader>
138 <AlertDialogTitle>Delete {milestone.title}?</AlertDialogTitle>
139 <AlertDialogDescription>
140 {items === 0
141 ? "Nothing is in it. "
142 : `The ${items} ${items === 1 ? "issue or pull request" : "issues and pull requests"} in it stay as they are, in no milestone. `}
143 This cannot be undone.
144 </AlertDialogDescription>
145 </AlertDialogHeader>
146 <AlertDialogFooter>
147 <AlertDialogCancel>Cancel</AlertDialogCancel>
148 <AlertDialogAction asChild>
149 <button type="submit">
150 <Trash2 size={14} />
151 Delete milestone
152 </button>
153 </AlertDialogAction>
154 </AlertDialogFooter>
155 </Form>
156 </AlertDialogContent>
157 </AlertDialog>
158 );
159}