Skip to content

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

117 lines5,371 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1import { Network, Plus } from "lucide-react";
2import { Form, data } from "react-router";
3
4import type { Route } from "./+types/teams";
5import { TeamRow, useTeam } from "../../../components/teams";
6import { ButtonLink, ErrorText, SubmitButton } from "../../../components/ui";
7import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
8import { identity } from "../../../lib/services.server";
9import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server";
10
11export async function loader({ params, context }: Route.LoaderArgs) {
12 const viewer = getViewer(context);
13 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
14 const [children, all] = await Promise.all([
15 identity.childTeams(viewer, params.owner, params.team).then(unwrap),
16 identity.listTeams(viewer, params.owner).then((found) => (found.ok ? found.value : [])),
17 ]);
18 return { children, all };
19}
20
21export async function action({ request, params, context }: Route.ActionArgs) {
22 assertSameOrigin(request);
23 const user = requireUser(context, request);
24 const form = await request.formData();
25 const intent = String(form.get("intent") ?? "");
26 const child = String(form.get("child") ?? "").trim();
27 if (!child) return { intent, child, error: "Choose a team." };
28 // Moving a team under this one, or out from under it, is a change to that team.
29 const moved = await identity.updateTeam(user, params.owner, child, { parent: intent === "remove" ? "" : params.team });
30 return moved.ok ? { intent, child, error: null } : { intent, child, error: moved.error.message };
31}
32
33export default function ChildTeams({ loaderData, actionData }: Route.ComponentProps) {
34 const team = useTeam();
35 const { children, all } = loaderData;
36 const manage = team.can_manage;
37 // What could move under it: visible teams you manage, not already its
38 // children, and not the team itself or above it.
39 const above = new Set<string>();
40 for (let parent = team.parent; parent; parent = all.find((other) => other.slug === parent?.slug)?.parent ?? null) {
41 if (above.has(parent.slug)) break;
42 above.add(parent.slug);
43 }
44 const movable = all.filter(
45 (other) =>
46 other.can_manage &&
47 other.visibility === "visible" &&
48 other.slug !== team.slug &&
49 !above.has(other.slug) &&
50 !children.some((child) => child.slug === other.slug),
51 );
52
53 return (
54 <div className="max-w-3xl space-y-6">
55 <p className="text-sm text-muted">
56 Child teams have {team.name}'s roles on repositories as well as their own, and hear when {team.name} is mentioned
57 or asked to review.
58 </p>
59 {children.length === 0 ? (
60 <div className="rounded-xl border border-dashed border-line px-6 py-10 text-center">
61 <Network size={18} className="mx-auto text-faint" />
62 <p className="mt-2 text-sm font-medium">No child teams</p>
63 {team.visibility === "secret" && <p className="mt-1 text-sm text-muted">Secret teams cannot be nested.</p>}
64 </div>
65 ) : (
66 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
67 {children.map((child) => (
68 <TeamRow key={child.id} team={child}>
69 {(manage || child.can_manage) && (
70 <Form method="post" className="shrink-0">
71 <input type="hidden" name="intent" value="remove" />
72 <input type="hidden" name="child" value={child.slug} />
73 <SubmitButton variant="quiet" match={{ intent: "remove", child: child.slug }} pending="Moving…">
74 Remove
75 </SubmitButton>
76 </Form>
77 )}
78 </TeamRow>
79 ))}
80 </ul>
81 )}
82 {actionData?.intent === "remove" && <ErrorText>{actionData.error}</ErrorText>}
83
84 {manage && team.visibility === "visible" && (
85 <div className="flex flex-col gap-3 rounded-xl border border-line bg-surface p-4 sm:flex-row sm:items-center">
86 {movable.length > 0 ? (
87 <Form method="post" className="flex grow flex-col gap-3 sm:flex-row sm:items-center">
88 <input type="hidden" name="intent" value="add" />
89 <Select name="child">
90 <SelectTrigger aria-label="Team to add" className="sm:max-w-64">
91 <SelectValue placeholder="Choose a team" />
92 </SelectTrigger>
93 <SelectContent>
94 {movable.map((other) => (
95 <SelectItem key={other.slug} value={other.slug}>
96 {other.name}
97 </SelectItem>
98 ))}
99 </SelectContent>
100 </Select>
101 <SubmitButton variant="quiet" match={{ intent: "add" }} pending="Adding…">
102 Add as a child team
103 </SubmitButton>
104 </Form>
105 ) : (
106 <p className="grow text-sm text-muted">No other team you manage can go under {team.name}.</p>
107 )}
108 <ButtonLink variant="quiet" to={`/${team.workspace}/-/teams/new?parent=${team.slug}`}>
109 <Plus size={15} />
110 New child team
111 </ButtonLink>
112 </div>
113 )}
114 {actionData?.intent === "add" && <ErrorText>{actionData.error}</ErrorText>}
115 </div>
116 );
117}