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 bar | 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 | } |