Skip to content

g1t/apps/web/app/routes/workspace/team/repositories.tsx

159 lines7,244 bytesCodeBlame
1import { Box } from "lucide-react";
2import { useState } from "react";
3import { Form, Link, data, useFetcher } from "react-router";
4
5import { REPO_ROLES, REPO_ROLE_LABELS, REPO_ROLE_SUMMARIES, type RepoRole, type TeamRepo } from "@g1t/contracts";
6
7import type { Route } from "./+types/repositories";
8import { useTeam } from "../../../components/teams";
9import { ErrorText, SubmitButton } from "../../../components/ui";
10import { Badge } from "../../../components/ui/badge";
11import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
12import { teamPath } from "../../../lib/teams";
13import { identity, repos } from "../../../lib/services.server";
14import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server";
15
16const isRole = (value: string): value is RepoRole => (REPO_ROLES as readonly string[]).includes(value);
17
18export async function loader({ params, context }: Route.LoaderArgs) {
19 const viewer = getViewer(context);
20 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
21 const [granted, list] = await Promise.all([
22 identity.teamRepos(viewer, params.owner, params.team).then(unwrap),
23 repos.list(viewer, { namespace: params.owner, memberOnly: true }).catch(() => []),
24 ]);
25 return { granted, names: list.filter((repo) => !repo.archivedAt).map((repo) => repo.name) };
26}
27
28export async function action({ request, params, context }: Route.ActionArgs) {
29 assertSameOrigin(request);
30 const user = requireUser(context, request);
31 const form = await request.formData();
32 const intent = String(form.get("intent") ?? "");
33 const name = String(form.get("repo") ?? "").trim().replace(/^.*\//, "");
34 if (!name) return { intent, repo: name, error: "Choose a repository." };
35 if (intent === "remove") {
36 const removed = await identity.removeTeamRepo(user, params.owner, params.team, params.owner, name);
37 return removed.ok ? { intent, repo: name, error: null } : { intent, repo: name, error: removed.error.message };
38 }
39 const role = String(form.get("role") ?? "");
40 if (!isRole(role)) return { intent, repo: name, error: "Choose a role." };
41 const set = await identity.setTeamRepo(user, params.owner, params.team, params.owner, name, role);
42 return set.ok ? { intent, repo: name, error: null } : { intent, repo: name, error: set.error.message };
43}
44
45function RoleSelect({ value, onValueChange, name }: { value: RepoRole; onValueChange?: (role: RepoRole) => void; name?: string }) {
46 return (
47 <Select name={name} value={value} onValueChange={(role) => onValueChange?.(role as RepoRole)}>
48 <SelectTrigger aria-label="Role" className="w-32">
49 <SelectValue />
50 </SelectTrigger>
51 <SelectContent>
52 {REPO_ROLES.map((role) => (
53 <SelectItem key={role} value={role} description={REPO_ROLE_SUMMARIES[role]}>
54 {REPO_ROLE_LABELS[role]}
55 </SelectItem>
56 ))}
57 </SelectContent>
58 </Select>
59 );
60}
61
62export default function TeamRepositories({ loaderData, actionData }: Route.ComponentProps) {
63 const team = useTeam();
64 const { granted, names } = loaderData;
65 const own = new Set(granted.filter((repo) => repo.inherited_from == null).map((repo) => repo.repo.split("/")[1]));
66 const addable = names.filter((name) => !own.has(name));
67 const [role, setRole] = useState<RepoRole>("write");
68
69 return (
70 <div className="max-w-3xl space-y-6">
71 <p className="text-sm text-muted">
72 Everyone in {team.name}, and in its child teams, has these roles. Where someone has a role another way too, the
73 highest one counts. Giving a team a role on a repository needs the Admin role there.
74 </p>
75 {granted.length === 0 ? (
76 <div className="rounded-xl border border-dashed border-line px-6 py-10 text-center">
77 <Box size={18} className="mx-auto text-faint" />
78 <p className="mt-2 text-sm font-medium">{team.name} has no roles on repositories yet</p>
79 </div>
80 ) : (
81 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
82 {granted.map((repo) => (
83 <RepoRow key={repo.repo_id} repo={repo} manage={team.can_manage} error={actionData?.repo === repo.repo.split("/")[1] && actionData.intent !== "add" ? actionData.error : null} />
84 ))}
85 </ul>
86 )}
87
88 {addable.length > 0 && (
89 <Form method="post" key={granted.length} className="flex flex-col gap-3 rounded-xl border border-line bg-surface p-4 sm:flex-row sm:items-center">
90 <input type="hidden" name="intent" value="add" />
91 <Select name="repo">
92 <SelectTrigger aria-label="Repository" className="sm:max-w-64">
93 <SelectValue placeholder="Choose a repository" />
94 </SelectTrigger>
95 <SelectContent>
96 {addable.map((name) => (
97 <SelectItem key={name} value={name}>
98 {name}
99 </SelectItem>
100 ))}
101 </SelectContent>
102 </Select>
103 <RoleSelect name="role" value={role} onValueChange={setRole} />
104 <SubmitButton variant="quiet" match={{ intent: "add" }} pending="Adding…">
105 Add repository
106 </SubmitButton>
107 </Form>
108 )}
109 {actionData?.intent === "add" && <ErrorText>{actionData.error}</ErrorText>}
110 </div>
111 );
112}
113
114function RepoRow({ repo, manage, error }: { repo: TeamRepo; manage: boolean; error: string | null }) {
115 const fetcher = useFetcher<typeof action>();
116 const team = useTeam();
117 const name = repo.repo.split("/")[1] ?? repo.repo;
118 const inherited = repo.inherited_from != null;
119 return (
120 <li className="px-4 py-3">
121 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
122 <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
123 <Box size={13} />
124 </span>
125 <div className="min-w-0 grow basis-40">
126 <Link to={`/${repo.repo}`} className="font-mono text-sm font-medium hover:underline">
127 {name}
128 </Link>
129 {inherited && (
130 <p className="text-xs text-faint">
131 From{" "}
132 <Link to={teamPath(team.workspace, repo.inherited_from!)} className="hover:text-fg hover:underline">
133 {repo.inherited_from}
134 </Link>
135 </p>
136 )}
137 </div>
138 {inherited || !manage ? (
139 <Badge>{REPO_ROLE_LABELS[repo.role]}</Badge>
140 ) : (
141 <span className="flex items-center gap-1">
142 <RoleSelect
143 value={repo.role}
144 onValueChange={(role) => fetcher.submit({ intent: "role", repo: name, role }, { method: "post" })}
145 />
146 <Form method="post">
147 <input type="hidden" name="intent" value="remove" />
148 <input type="hidden" name="repo" value={name} />
149 <SubmitButton variant="quiet" match={{ intent: "remove", repo: name }} pending="Removing…">
150 Remove
151 </SubmitButton>
152 </Form>
153 </span>
154 )}
155 </div>
156 <ErrorText>{fetcher.data?.error ?? error}</ErrorText>
157 </li>
158 );
159}