| 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select"; |
| 8 | import { identity } from "../../../lib/services.server"; |
| 9 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server"; |
| 10 | |
| 11 | export 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 | |
| 21 | export 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 | |
| 33 | export 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 | } |