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.

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