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.

119 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 or asked to review.
58 </p>
59 {children.length === 0 ? (
60 <Card tone="plain" className="border-dashed 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 </Card>
65 ) : (
66 <Card asChild tone="plain" divided className="overflow-hidden">
67 <ul>
68 {children.map((child) => (
69 <TeamRow key={child.id} team={child}>
70 {(manage || child.can_manage) && (
71 <Form method="post" className="shrink-0">
72 <input type="hidden" name="intent" value="remove" />
73 <input type="hidden" name="child" value={child.slug} />
74 <SubmitButton variant="outline" match={{ intent: "remove", child: child.slug }} pending="Moving…">
75 Remove
76 </SubmitButton>
77 </Form>
78 )}
79 </TeamRow>
80 ))}
81 </ul>
82 </Card>
83 )}
84 {actionData?.intent === "remove" && <ErrorText>{actionData.error}</ErrorText>}
85
86 {manage && team.visibility === "visible" && (
87 <Card className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
88 {movable.length > 0 ? (
89 <Form method="post" className="flex grow flex-col gap-3 sm:flex-row sm:items-center">
90 <input type="hidden" name="intent" value="add" />
91 <Select name="child">
92 <SelectTrigger aria-label="Team to add" className="sm:max-w-64">
93 <SelectValue placeholder="Choose a team" />
94 </SelectTrigger>
95 <SelectContent>
96 {movable.map((other) => (
97 <SelectItem key={other.slug} value={other.slug}>
98 {other.name}
99 </SelectItem>
100 ))}
101 </SelectContent>
102 </Select>
103 <SubmitButton variant="outline" match={{ intent: "add" }} pending="Adding…">
104 Add as a child team
105 </SubmitButton>
106 </Form>
107 ) : (
108 <p className="grow text-sm text-muted">No other team you manage can go under {team.name}.</p>
109 )}
110 <ButtonLink variant="outline" to={`/${team.workspace}/-/teams/new?parent=${team.slug}`}>
111 <Plus size={15} />
112 New child team
113 </ButtonLink>
114 </Card>
115 )}
116 {actionData?.intent === "add" && <ErrorText>{actionData.error}</ErrorText>}
117 </div>
118 );
119}