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.

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