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