Skip to content

g1t/apps/web/app/routes/workspace/team/settings.tsx

244 lines12,547 bytesCodeBlame

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 bar1import { useState } from "react";
2import { Form, data, redirect } from "react-router";
3
4import {
5 MAX_ASSIGNED,
6 REVIEW_ALGORITHM_LABELS,
7 REVIEW_ALGORITHM_SUMMARIES,
8 TEAM_VISIBILITY_SUMMARIES,
9 type ReviewAlgorithm,
10 type TeamVisibility,
11} from "@g1t/contracts";
12
13import type { Route } from "./+types/settings";
14import { ConfirmDialog } from "../../../components/repo-lifecycle";
15import { useTeam } from "../../../components/teams";
16import { Button, ErrorText, Field, Input, SubmitButton } from "../../../components/ui";
17import { Input as NumberInput } from "../../../components/ui/input";
18import { Textarea } from "../../../components/ui/textarea";
19import { CheckboxOption } from "../../../components/ui/checkbox";
20import { RadioGroup, RadioOption } from "../../../components/ui/radio-group";
21import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
22import { SwitchCard } from "../../../components/ui/switch";
23import { parentChoices, reviewAssignmentFromForm, teamChangesFromForm, teamPath } from "../../../lib/teams";
24import { identity } from "../../../lib/services.server";
25import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server";
26
27/** The parent select's value for "no parent". */
28const NO_PARENT = "-";
29
30export async function loader({ params, context }: Route.LoaderArgs) {
31 const viewer = getViewer(context);
32 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
33 const team = unwrap(await identity.getTeam(viewer, params.owner, params.team));
34 if (!team.can_manage) throw data(null, { status: 404 });
35 const all = await identity.listTeams(viewer, params.owner).then((found) => (found.ok ? found.value : []));
36 const parents = parentChoices(all, team.slug).filter((other) => other.can_manage || other.slug === team.parent?.slug);
37 return { parents };
38}
39
40export async function action({ request, params, context }: Route.ActionArgs) {
41 assertSameOrigin(request);
42 const user = requireUser(context, request);
43 const form = await request.formData();
44 const intent = String(form.get("intent") ?? "");
45 if (intent === "delete") {
46 const deleted = await identity.deleteTeam(user, params.owner, params.team);
47 if (!deleted.ok) return { intent, error: deleted.error.message, saved: false };
48 throw redirect(`/${params.owner}/-/teams`);
49 }
50 if (intent === "review") {
51 const current = unwrap(await identity.getTeam(user, params.owner, params.team));
52 const review = reviewAssignmentFromForm(form, current.review_assignment);
53 const saved = await identity.updateTeam(user, params.owner, params.team, { review_assignment: review });
54 return saved.ok ? { intent, error: null, saved: true } : { intent, error: saved.error.message, saved: false };
55 }
56 const changes = teamChangesFromForm(form);
57 if (changes.parent === NO_PARENT) changes.parent = "";
58 const saved = await identity.updateTeam(user, params.owner, params.team, changes);
59 if (!saved.ok) return { intent, error: saved.error.message, saved: false };
60 // A new slug is a new address.
61 if (saved.value.slug !== params.team) throw redirect(teamPath(saved.value.workspace, saved.value.slug, "settings"));
62 return { intent, error: null, saved: true };
63}
64
65export default function TeamSettings({ loaderData, actionData }: Route.ComponentProps) {
66 const team = useTeam();
67 const { parents } = loaderData;
68 const said = (intent: string) => (actionData?.intent === intent ? actionData : null);
69 const [visibility, setVisibility] = useState<TeamVisibility>(team.visibility);
70 const review = team.review_assignment;
71 const [assigning, setAssigning] = useState(review.enabled);
72 const [algorithm, setAlgorithm] = useState<ReviewAlgorithm>(review.algorithm);
73 const [skipBusy, setSkipBusy] = useState(review.skip_busy);
74
75 return (
76 <div className="max-w-2xl space-y-10">
77 <section aria-labelledby="profile">
78 <h2 id="profile" className="font-medium">
79 Profile
80 </h2>
81 <Form method="post" className="mt-4 space-y-5" key={team.updated_at}>
82 <input type="hidden" name="intent" value="profile" />
83 <Field label="Name">
84 <Input name="name" required maxLength={80} defaultValue={team.name} />
85 </Field>
86 <Field label="Slug" hint={`Mentioned as @${team.workspace}/<slug>. Changing it changes the team's address and how it is mentioned.`}>
87 <Input name="slug" required maxLength={60} defaultValue={team.slug} pattern="[a-z0-9]+(-[a-z0-9]+)*" />
88 </Field>
89 <Field label="Description">
90 <Textarea name="description" rows={2} maxLength={280} defaultValue={team.description ?? ""} />
91 </Field>
92 <fieldset>
93 <legend className="mb-2 text-sm font-medium text-muted">Visibility</legend>
94 <RadioGroup name="visibility" value={visibility} onValueChange={(value) => setVisibility(value as TeamVisibility)} className="space-y-3">
95 <RadioOption value="visible" label="Visible" description={TEAM_VISIBILITY_SUMMARIES.visible} />
96 <RadioOption value="secret" label="Secret" description={TEAM_VISIBILITY_SUMMARIES.secret} />
97 </RadioGroup>
98 </fieldset>
99 {visibility === "visible" && (
100 <div>
101 <span className="mb-1.5 block text-sm font-medium text-muted">Parent team</span>
102 <Select name="parent" defaultValue={team.parent?.slug ?? NO_PARENT}>
103 <SelectTrigger aria-label="Parent team" className="sm:max-w-72">
104 <SelectValue />
105 </SelectTrigger>
106 <SelectContent>
107 <SelectItem value={NO_PARENT}>No parent</SelectItem>
108 {parents.map((parent) => (
109 <SelectItem key={parent.slug} value={parent.slug}>
110 {parent.name}
111 </SelectItem>
112 ))}
113 </SelectContent>
114 </Select>
115 <span className="mt-1.5 block text-xs text-faint">
116 It has its parent's roles on repositories, and hears what is asked of its parent. Moving it needs you to
117 maintain both teams, or own the workspace.
118 </span>
119 </div>
120 )}
121 <input type="hidden" name="notify-shown" value="1" />
122 <SwitchCard name="notify" defaultChecked={team.notify} title="Notify the team when it is mentioned">
123 Everyone in it, and in its child teams, hears of @{team.workspace}/{team.slug} in their inbox.
124 </SwitchCard>
125 <ErrorText>{said("profile")?.error ?? null}</ErrorText>
126 <div className="flex items-center gap-3">
127 <SubmitButton match={{ intent: "profile" }} pending="Saving…">
128 Save
129 </SubmitButton>
130 {said("profile")?.saved && <span className="text-sm text-muted">Saved.</span>}
131 </div>
132 </Form>
133 </section>
134
135 <section aria-labelledby="review" className="border-t border-line pt-8">
136 <h2 id="review" className="font-medium">
137 Code review assignment
138 </h2>
139 <p className="mt-1 text-sm text-muted">
140 When {team.name} is asked to review a pull request, by a person or by a CODEOWNERS file. Off, everyone in the
141 team is asked. On, g1t picks people from it; the team stays shown as asked.
142 </p>
143 <Form method="post" className="mt-4 space-y-5" key={`review-${team.updated_at}`}>
144 <input type="hidden" name="intent" value="review" />
145 <SwitchCard name="enabled" checked={assigning} onCheckedChange={setAssigning} title="Assign reviewers from the team">
146 Never the pull request's author, and people from the team already asked count.
147 </SwitchCard>
148 {assigning && (
149 <div className="space-y-5 rounded-xl border border-line p-4">
150 <Field label="How many people" hint={`1 to ${MAX_ASSIGNED}.`}>
151 <NumberInput name="count" type="number" min={1} max={MAX_ASSIGNED} defaultValue={review.count} className="max-w-24" />
152 </Field>
153 <fieldset>
154 <legend className="mb-2 text-sm font-medium text-muted">Who goes first</legend>
155 <RadioGroup name="algorithm" value={algorithm} onValueChange={(value) => setAlgorithm(value as ReviewAlgorithm)} className="space-y-3">
156 {(["round_robin", "load_balance"] as const).map((value) => (
157 <RadioOption key={value} value={value} label={REVIEW_ALGORITHM_LABELS[value]} description={REVIEW_ALGORITHM_SUMMARIES[value]} />
158 ))}
159 </RadioGroup>
160 </fieldset>
161 <div className="space-y-2">
162 <CheckboxOption
163 name="skip_busy"
164 checked={skipBusy}
165 onCheckedChange={(on) => setSkipBusy(on === true)}
166 label="Skip people who are busy"
167 description="Busy: this many or more open pull requests are waiting on their review."
168 />
169 {skipBusy && (
170 <div className="pl-6">
171 <NumberInput name="busy_at" type="number" min={1} max={100} defaultValue={review.busy_at} aria-label="Busy at" className="max-w-24" />
172 </div>
173 )}
174 </div>
175 <CheckboxOption
176 name="include_child_teams"
177 defaultChecked={review.include_child_teams}
178 label="Pick from child teams too"
179 description="Otherwise only the team's own people are picked."
180 />
181 <CheckboxOption
182 name="notify_team"
183 defaultChecked={review.notify_team}
184 label="Also tell the rest of the team"
185 description="Everyone in the team hears of the request, not only the people picked."
186 />
187 <Field label="Never pick" hint="Usernames, separated by spaces or commas.">
188 <Input name="excluded" defaultValue={review.excluded.join(" ")} placeholder="ana bo" />
189 </Field>
190 </div>
191 )}
192 {!assigning && (
193 <>
194 {/* Kept as they are while assignment is off. */}
195 <input type="hidden" name="algorithm" value={review.algorithm} />
196 <input type="hidden" name="count" value={review.count} />
197 <input type="hidden" name="busy_at" value={review.busy_at} />
198 <input type="hidden" name="excluded" value={review.excluded.join(" ")} />
199 {review.skip_busy && <input type="hidden" name="skip_busy" value="on" />}
200 {review.include_child_teams && <input type="hidden" name="include_child_teams" value="on" />}
201 {review.notify_team && <input type="hidden" name="notify_team" value="on" />}
202 </>
203 )}
204 <ErrorText>{said("review")?.error ?? null}</ErrorText>
205 <div className="flex items-center gap-3">
206 <SubmitButton match={{ intent: "review" }} pending="Saving…">
207 Save
208 </SubmitButton>
209 {said("review")?.saved && <span className="text-sm text-muted">Saved.</span>}
210 </div>
211 </Form>
212 </section>
213
214 <section aria-labelledby="danger" className="border-t border-line pt-8">
215 <h2 id="danger" className="font-medium">
216 Delete this team
217 </h2>
218 <p className="mt-1 text-sm text-muted">
219 Its people stay in {team.workspace}, and lose the roles the team gave them. Its child teams move up to{" "}
220 {team.parent ? team.parent.name : "the top"}.
221 </p>
222 <div className="mt-4">
223 <ConfirmDialog
224 intent="delete"
225 title={`Delete ${team.name}?`}
226 description="This cannot be undone."
227 confirm={`${team.workspace}/${team.slug}`}
228 submit="Delete team"
229 busy="Deleting…"
230 error={said("delete")?.error ?? null}
231 trigger={(open) => (
232 <Button type="button" variant="danger" onClick={open}>
233 Delete team
234 </Button>
235 )}
236 >
237 <li>Mentions of @{team.workspace}/{team.slug} stop telling anyone.</li>
238 <li>Pull requests that asked it to review keep the people it picked.</li>
239 </ConfirmDialog>
240 </div>
241 </section>
242 </div>
243 );
244}