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