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