| 1 | import { Network, Plus } from "lucide-react"; |
| 2 | import { Form, data } from "react-router"; |
| 3 | |
| 4 | import type { Route } from "./+types/teams"; |
| 5 | import { TeamRow, useTeam } from "../../../components/teams"; |
| 6 | import { ButtonLink, ErrorText, SubmitButton } from "../../../components/ui"; |
| 7 | import { Card } from "../../../components/ui/card"; |
| 8 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select"; |
| 9 | import { identity } from "../../../lib/services.server"; |
| 10 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server"; |
| 11 | |
| 12 | export 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 | |
| 22 | export 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 | |
| 34 | export 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 | } |