Skip to content

g1t/apps/web/app/routes/repo/milestones.tsx

128 lines5,705 bytesCodeBlame
1import { Milestone as MilestoneIcon, Plus } from "lucide-react";
2import { useState } from "react";
3import { Link } from "react-router";
4
5import type { Route } from "./+types/milestones";
6import { MilestoneBar } from "../../components/labels";
7import {
8 DeleteMilestone,
9 DueLine,
10 MilestoneForm,
11 MilestoneStateButton,
12 ProgressLine,
13} from "../../components/milestones";
14import { Button, EmptyState, ErrorText } from "../../components/ui";
15import { plainText } from "../../components/work";
16import { requireRepo } from "../../lib/access.server";
17import { page } from "../../lib/meta";
18import { milestoneAction } from "../../lib/milestones.server";
19import { work } from "../../lib/services.server";
20import { unwrap } from "../../lib/session.server";
21
22export function meta({ params, ...args }: Route.MetaArgs) {
23 return page(args, { title: `Milestones · ${params.owner}/${params.repo} · g1t` });
24}
25
26export async function loader({ params, context, request }: Route.LoaderArgs) {
27 const { viewer, access } = await requireRepo(context, params, "read");
28 const state = new URL(request.url).searchParams.get("state") === "closed" ? "closed" : "open";
29 // Both, for the tabs' counts; the list shows one.
30 const milestones = unwrap(await work.listMilestones({ namespace: params.owner, name: params.repo }, viewer));
31 return { milestones, state, canEdit: access.can.triage, today: new Date().toISOString() } as const;
32}
33
34export async function action({ request, params, context }: Route.ActionArgs) {
35 return milestoneAction(request, params, context);
36}
37
38export default function Milestones({ loaderData, actionData, params }: Route.ComponentProps) {
39 const { milestones, state, canEdit } = loaderData;
40 const base = `/${params.owner}/${params.repo}`;
41 const today = new Date(loaderData.today);
42 const [creating, setCreating] = useState(false);
43 const open = milestones.filter((milestone) => milestone.state === "open");
44 const closed = milestones.filter((milestone) => milestone.state === "closed");
45 const shown = state === "open" ? open : closed;
46 const tab = (value: "open" | "closed", label: string, count: number) => (
47 <Link
48 to={value === "open" ? `${base}/milestones` : `${base}/milestones?state=closed`}
49 className={
50 "rounded-md px-3 py-1.5 text-sm transition-colors " +
51 (state === value ? "bg-raised font-medium text-fg" : "text-muted hover:text-fg")
52 }
53 >
54 {label} <span className="tabular-nums text-faint">{count}</span>
55 </Link>
56 );
57 return (
58 <div className="space-y-4">
59 <div>
60 <h2 className="text-xl font-semibold tracking-tight">Milestones</h2>
61 <p className="mt-1 text-sm text-muted">
62 A milestone gathers issues and pull requests under one goal and a due date, and shows how much of it is done.
63 </p>
64 </div>
65 <div className="flex flex-wrap items-center justify-between gap-3">
66 <div className="flex gap-1 rounded-lg border border-line p-1">
67 {tab("open", "Open", open.length)}
68 {tab("closed", "Closed", closed.length)}
69 </div>
70 {canEdit && !creating && (
71 <Button type="button" onClick={() => setCreating(true)}>
72 <Plus size={15} />
73 New milestone
74 </Button>
75 )}
76 </div>
77 {creating && <MilestoneForm onDone={() => setCreating(false)} />}
78 {actionData && actionData.intent !== "create" && actionData.error && <ErrorText>{actionData.error}</ErrorText>}
79 {shown.length === 0 ? (
80 <EmptyState title={state === "open" ? "No open milestones" : "No closed milestones"}>
81 {state === "open"
82 ? canEdit
83 ? "Make one for the next release or goal, then put issues and pull requests in it from their sidebars."
84 : "Nobody has made a milestone here yet."
85 : "Milestones that are done show here once they are closed."}
86 </EmptyState>
87 ) : (
88 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
89 {shown.map((milestone) => (
90 <li key={milestone.number} className="grid gap-3 px-4 py-4 sm:grid-cols-[1fr_16rem]">
91 <div className="min-w-0">
92 <Link
93 to={`${base}/milestones/${milestone.number}`}
94 className="inline-flex max-w-full items-center gap-2 text-base font-medium hover:text-accent"
95 >
96 <MilestoneIcon size={16} className="shrink-0 text-faint" />
97 <span className="truncate">{milestone.title}</span>
98 </Link>
99 <p className="mt-1 text-xs">
100 {milestone.state === "closed" && milestone.closedAt ? (
101 <span className="text-muted">Closed {milestone.closedAt.slice(0, 10)}</span>
102 ) : (
103 <DueLine milestone={milestone} today={today} />
104 )}
105 </p>
106 {milestone.description && (
107 <p className="mt-2 line-clamp-2 text-sm text-muted">{plainText(milestone.description)}</p>
108 )}
109 </div>
110 <div className="min-w-0">
111 <MilestoneBar milestone={milestone} />
112 <p className="mt-1.5">
113 <ProgressLine milestone={milestone} />
114 </p>
115 {canEdit && (
116 <div className="mt-3 flex flex-wrap gap-2">
117 <MilestoneStateButton milestone={milestone} />
118 <DeleteMilestone milestone={milestone} />
119 </div>
120 )}
121 </div>
122 </li>
123 ))}
124 </ul>
125 )}
126 </div>
127 );
128}