| 1 | import { useState } from "react"; |
| 2 | import { Form, data, redirect } from "react-router"; |
| 3 | |
| 4 | import { |
| 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 | |
| 13 | import type { Route } from "./+types/settings"; |
| 14 | import { ConfirmDialog } from "../../../components/repo-lifecycle"; |
| 15 | import { useTeam, useTeamAgents } from "../../../components/teams"; |
| 16 | import { Coming } from "../../../components/people"; |
| 17 | import { SelectField } from "../../../components/ui/select"; |
| 18 | import { budgetFromText } from "../../../lib/people"; |
| 19 | import { Button, ErrorText, Field, Input, SubmitButton } from "../../../components/ui"; |
| 20 | import { Input as NumberInput } from "../../../components/ui/input"; |
| 21 | import { Textarea } from "../../../components/ui/textarea"; |
| 22 | import { CheckboxOption } from "../../../components/ui/checkbox"; |
| 23 | import { RadioGroup, RadioOption } from "../../../components/ui/radio-group"; |
| 24 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select"; |
| 25 | import { SwitchCard } from "../../../components/ui/switch"; |
| 26 | import { parentChoices, reviewAssignmentFromForm, teamChangesFromForm, teamPath } from "../../../lib/teams"; |
| 27 | import { chat, identity, workspaceAgents } from "../../../lib/services.server"; |
| 28 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server"; |
| 29 | |
| 30 | /** The parent select's value for "no parent". */ |
| 31 | const NO_PARENT = "-"; |
| 32 | /** The lead select's value for no lead. */ |
| 33 | const NO_LEAD = "none"; |
| 34 | |
| 35 | /** |
| 36 | * Its lead, channel and budget. An agent made lead that is on the team |
| 37 | * only through its home team is added to it first, as identity needs; a |
| 38 | * channel is found by its name in Chat, as the viewer sees it. |
| 39 | */ |
| 40 | async function saveLeadChannelBudget(form: FormData, workspace: string, slug: string, user: Parameters<typeof identity.updateTeam>[0]) { |
| 41 | const intent = "people"; |
| 42 | const lead = String(form.get("lead") ?? NO_LEAD); |
| 43 | const channel = String(form.get("channel") ?? "").trim().replace(/^#/, "").toLowerCase(); |
| 44 | const budget = budgetFromText(String(form.get("budget") ?? "")); |
| 45 | if (budget === null) return { intent, error: "Give the budget in dollars, such as 150.", saved: false }; |
| 46 | if (lead.startsWith("agent:")) { |
| 47 | const listed = await workspaceAgents.list(workspace, user).catch(() => null); |
| 48 | const agent = listed?.ok ? listed.value.find((a) => `agent:${a.id}` === lead) : null; |
| 49 | if (!agent) return { intent, error: "Choose one of the team's agents.", saved: false }; |
| 50 | const added = await identity.setTeamAgent(user, workspace, slug, agent.id); |
| 51 | if (!added.ok) return { intent, error: added.error.message, saved: false }; |
| 52 | } |
| 53 | let channelId = ""; |
| 54 | let channelName = ""; |
| 55 | if (channel) { |
| 56 | const found = await chat.channelByName(workspace, channel, user).catch(() => null); |
| 57 | if (!found?.ok || found.value.channel.kind !== "channel") return { intent, error: `There is no channel #${channel} you can see.`, saved: false }; |
| 58 | channelId = found.value.channel.id; |
| 59 | channelName = found.value.channel.name ?? channel; |
| 60 | } |
| 61 | const saved = await identity.updateTeam(user, workspace, slug, { |
| 62 | lead: lead === NO_LEAD ? "" : lead, |
| 63 | channel_id: channelId, |
| 64 | channel_name: channelName, |
| 65 | budget_micros: budget, |
| 66 | }); |
| 67 | return saved.ok ? { intent, error: null, saved: true } : { intent, error: saved.error.message, saved: false }; |
| 68 | } |
| 69 | |
| 70 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 71 | const viewer = getViewer(context); |
| 72 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); |
| 73 | const team = unwrap(await identity.getTeam(viewer, params.owner, params.team)); |
| 74 | if (!team.can_manage) throw data(null, { status: 404 }); |
| 75 | const [all, members] = await Promise.all([ |
| 76 | identity.listTeams(viewer, params.owner).then((found) => (found.ok ? found.value : [])), |
| 77 | // Who can lead it: its own people (and its agents, from the layout). |
| 78 | identity.teamMembers(viewer, params.owner, params.team, false).then((found) => (found.ok ? found.value : [])), |
| 79 | ]); |
| 80 | const parents = parentChoices(all, team.slug).filter((other) => other.can_manage || other.slug === team.parent?.slug); |
| 81 | return { parents, people: members.map((member) => ({ username: member.username, name: member.name })) }; |
| 82 | } |
| 83 | |
| 84 | export async function action({ request, params, context }: Route.ActionArgs) { |
| 85 | assertSameOrigin(request); |
| 86 | const user = requireUser(context, request); |
| 87 | const form = await request.formData(); |
| 88 | const intent = String(form.get("intent") ?? ""); |
| 89 | if (intent === "delete") { |
| 90 | const deleted = await identity.deleteTeam(user, params.owner, params.team); |
| 91 | if (!deleted.ok) return { intent, error: deleted.error.message, saved: false }; |
| 92 | throw redirect(`/${params.owner}/-/teams`); |
| 93 | } |
| 94 | if (intent === "review") { |
| 95 | const current = unwrap(await identity.getTeam(user, params.owner, params.team)); |
| 96 | const review = reviewAssignmentFromForm(form, current.review_assignment); |
| 97 | const saved = await identity.updateTeam(user, params.owner, params.team, { review_assignment: review }); |
| 98 | return saved.ok ? { intent, error: null, saved: true } : { intent, error: saved.error.message, saved: false }; |
| 99 | } |
| 100 | if (intent === "people") return saveLeadChannelBudget(form, params.owner, params.team, user); |
| 101 | const changes = teamChangesFromForm(form); |
| 102 | if (changes.parent === NO_PARENT) changes.parent = ""; |
| 103 | const saved = await identity.updateTeam(user, params.owner, params.team, changes); |
| 104 | if (!saved.ok) return { intent, error: saved.error.message, saved: false }; |
| 105 | // A new slug is a new address. |
| 106 | if (saved.value.slug !== params.team) throw redirect(teamPath(saved.value.workspace, saved.value.slug, "settings")); |
| 107 | return { intent, error: null, saved: true }; |
| 108 | } |
| 109 | |
| 110 | export default function TeamSettings({ loaderData, actionData }: Route.ComponentProps) { |
| 111 | const team = useTeam(); |
| 112 | const { agents } = useTeamAgents(); |
| 113 | const { parents, people } = loaderData; |
| 114 | const leadValue = team.lead ? (team.lead.kind === "user" ? `@${team.lead.username}` : `agent:${team.lead.agent_id}`) : NO_LEAD; |
| 115 | const said = (intent: string) => (actionData?.intent === intent ? actionData : null); |
| 116 | const [visibility, setVisibility] = useState<TeamVisibility>(team.visibility); |
| 117 | const review = team.review_assignment; |
| 118 | const [assigning, setAssigning] = useState(review.enabled); |
| 119 | const [algorithm, setAlgorithm] = useState<ReviewAlgorithm>(review.algorithm); |
| 120 | const [skipBusy, setSkipBusy] = useState(review.skip_busy); |
| 121 | |
| 122 | return ( |
| 123 | <div className="max-w-2xl space-y-10"> |
| 124 | <section aria-labelledby="profile"> |
| 125 | <h2 id="profile" className="font-medium"> |
| 126 | Profile |
| 127 | </h2> |
| 128 | <Form method="post" className="mt-4 space-y-5" key={team.updated_at}> |
| 129 | <input type="hidden" name="intent" value="profile" /> |
| 130 | <Field label="Name"> |
| 131 | <Input name="name" required maxLength={80} defaultValue={team.name} /> |
| 132 | </Field> |
| 133 | <Field label="Slug" hint={`Mentioned as @${team.workspace}/<slug>. Changing it changes the team's address and how it is mentioned.`}> |
| 134 | <Input name="slug" required maxLength={60} defaultValue={team.slug} pattern="[a-z0-9]+(-[a-z0-9]+)*" /> |
| 135 | </Field> |
| 136 | <Field label="Description"> |
| 137 | <Textarea name="description" rows={2} maxLength={280} defaultValue={team.description ?? ""} /> |
| 138 | </Field> |
| 139 | <fieldset> |
| 140 | <legend className="mb-2 text-sm font-medium text-muted">Visibility</legend> |
| 141 | <RadioGroup name="visibility" value={visibility} onValueChange={(value) => setVisibility(value as TeamVisibility)} className="space-y-3"> |
| 142 | <RadioOption value="visible" label="Visible" description={TEAM_VISIBILITY_SUMMARIES.visible} /> |
| 143 | <RadioOption value="secret" label="Secret" description={TEAM_VISIBILITY_SUMMARIES.secret} /> |
| 144 | </RadioGroup> |
| 145 | </fieldset> |
| 146 | {visibility === "visible" && ( |
| 147 | <div> |
| 148 | <span className="mb-1.5 block text-sm font-medium text-muted">Parent team</span> |
| 149 | <Select name="parent" defaultValue={team.parent?.slug ?? NO_PARENT}> |
| 150 | <SelectTrigger aria-label="Parent team" className="sm:max-w-72"> |
| 151 | <SelectValue /> |
| 152 | </SelectTrigger> |
| 153 | <SelectContent> |
| 154 | <SelectItem value={NO_PARENT}>No parent</SelectItem> |
| 155 | {parents.map((parent) => ( |
| 156 | <SelectItem key={parent.slug} value={parent.slug}> |
| 157 | {parent.name} |
| 158 | </SelectItem> |
| 159 | ))} |
| 160 | </SelectContent> |
| 161 | </Select> |
| 162 | <span className="mt-1.5 block text-xs text-faint"> |
| 163 | It has its parent's roles on repositories, and hears what is asked of its parent. Moving it needs you to |
| 164 | maintain both teams, or own the workspace. |
| 165 | </span> |
| 166 | </div> |
| 167 | )} |
| 168 | <input type="hidden" name="notify-shown" value="1" /> |
| 169 | <SwitchCard name="notify" defaultChecked={team.notify} title="Notify the team when it is mentioned"> |
| 170 | Everyone in it, and in its child teams, hears of @{team.workspace}/{team.slug} in their notifications. |
| 171 | </SwitchCard> |
| 172 | <ErrorText>{said("profile")?.error ?? null}</ErrorText> |
| 173 | <div className="flex items-center gap-3"> |
| 174 | <SubmitButton match={{ intent: "profile" }} pending="Saving…"> |
| 175 | Save |
| 176 | </SubmitButton> |
| 177 | {said("profile")?.saved && <span className="text-sm text-muted">Saved.</span>} |
| 178 | </div> |
| 179 | </Form> |
| 180 | </section> |
| 181 | |
| 182 | <section aria-labelledby="lead" className="border-t border-line pt-8"> |
| 183 | <h2 id="lead" className="font-medium"> |
| 184 | Lead, channel and budget |
| 185 | </h2> |
| 186 | <p className="mt-1 text-sm text-muted"> |
| 187 | Everyone on {team.name}, agents included, is told who leads it and where it talks. Its budget caps what its agents spend |
| 188 | together in a month; each agent keeps its own budget too. |
| 189 | </p> |
| 190 | <Form method="post" className="mt-4 space-y-5" key={`people-${team.updated_at}`}> |
| 191 | <input type="hidden" name="intent" value="people" /> |
| 192 | <label className="block"> |
| 193 | <span className="mb-1.5 block text-sm font-medium text-muted">Lead</span> |
| 194 | <SelectField |
| 195 | name="lead" |
| 196 | defaultValue={leadValue} |
| 197 | className="w-full sm:max-w-72" |
| 198 | options={[ |
| 199 | { value: NO_LEAD, label: "No lead" }, |
| 200 | ...people.map((person) => ({ value: `@${person.username}`, label: person.name ? `${person.name} (@${person.username})` : `@${person.username}` })), |
| 201 | ...agents.map((agent) => ({ value: `agent:${agent.id}`, label: `${agent.display_name}, an agent` })), |
| 202 | ]} |
| 203 | /> |
| 204 | <span className="mt-1.5 block text-xs text-faint">Someone on the team. Agents page the lead first when a person is needed.</span> |
| 205 | </label> |
| 206 | <Field label="Channel" hint="The team's channel in Chat, by name. Leave it empty for none."> |
| 207 | <span className="block sm:max-w-72"> |
| 208 | <Input name="channel" defaultValue={team.channel?.name ?? ""} placeholder="sales" /> |
| 209 | </span> |
| 210 | </Field> |
| 211 | <Field label="Budget for its agents, a month" hint="In dollars. Empty or 0 for no team budget."> |
| 212 | <span className="block sm:max-w-40"> |
| 213 | <Input name="budget" inputMode="decimal" defaultValue={team.budget_micros ? String(team.budget_micros / 1_000_000) : ""} placeholder="150" /> |
| 214 | </span> |
| 215 | </Field> |
| 216 | <div className="flex items-center gap-2 text-sm text-muted"> |
| 217 | Storage level <Coming /> |
| 218 | </div> |
| 219 | <ErrorText>{said("people")?.error ?? null}</ErrorText> |
| 220 | <div className="flex items-center gap-3"> |
| 221 | <SubmitButton match={{ intent: "people" }} pending="Saving…"> |
| 222 | Save |
| 223 | </SubmitButton> |
| 224 | {said("people")?.saved && <span className="text-sm text-muted">Saved.</span>} |
| 225 | </div> |
| 226 | </Form> |
| 227 | </section> |
| 228 | |
| 229 | <section aria-labelledby="review" className="border-t border-line pt-8"> |
| 230 | <h2 id="review" className="font-medium"> |
| 231 | Code review assignment |
| 232 | </h2> |
| 233 | <p className="mt-1 text-sm text-muted"> |
| 234 | When {team.name} is asked to review a pull request, by a person or by a CODEOWNERS file. Off, everyone in the |
| 235 | team is asked. On, g1t picks people from it; the team stays shown as asked. |
| 236 | </p> |
| 237 | <Form method="post" className="mt-4 space-y-5" key={`review-${team.updated_at}`}> |
| 238 | <input type="hidden" name="intent" value="review" /> |
| 239 | <SwitchCard name="enabled" checked={assigning} onCheckedChange={setAssigning} title="Assign reviewers from the team"> |
| 240 | Never the pull request's author, and people from the team already asked count. |
| 241 | </SwitchCard> |
| 242 | {assigning && ( |
| 243 | <div className="space-y-5 rounded-xl border border-line p-4"> |
| 244 | <Field label="How many people" hint={`1 to ${MAX_ASSIGNED}.`}> |
| 245 | <NumberInput name="count" type="number" min={1} max={MAX_ASSIGNED} defaultValue={review.count} className="max-w-24" /> |
| 246 | </Field> |
| 247 | <fieldset> |
| 248 | <legend className="mb-2 text-sm font-medium text-muted">Who goes first</legend> |
| 249 | <RadioGroup name="algorithm" value={algorithm} onValueChange={(value) => setAlgorithm(value as ReviewAlgorithm)} className="space-y-3"> |
| 250 | {(["round_robin", "load_balance"] as const).map((value) => ( |
| 251 | <RadioOption key={value} value={value} label={REVIEW_ALGORITHM_LABELS[value]} description={REVIEW_ALGORITHM_SUMMARIES[value]} /> |
| 252 | ))} |
| 253 | </RadioGroup> |
| 254 | </fieldset> |
| 255 | <div className="space-y-2"> |
| 256 | <CheckboxOption |
| 257 | name="skip_busy" |
| 258 | checked={skipBusy} |
| 259 | onCheckedChange={(on) => setSkipBusy(on === true)} |
| 260 | label="Skip people who are busy" |
| 261 | description="Busy: this many or more open pull requests are waiting on their review." |
| 262 | /> |
| 263 | {skipBusy && ( |
| 264 | <div className="pl-6"> |
| 265 | <NumberInput name="busy_at" type="number" min={1} max={100} defaultValue={review.busy_at} aria-label="Busy at" className="max-w-24" /> |
| 266 | </div> |
| 267 | )} |
| 268 | </div> |
| 269 | <CheckboxOption |
| 270 | name="include_child_teams" |
| 271 | defaultChecked={review.include_child_teams} |
| 272 | label="Pick from child teams too" |
| 273 | description="Otherwise only the team's own people are picked." |
| 274 | /> |
| 275 | <CheckboxOption |
| 276 | name="notify_team" |
| 277 | defaultChecked={review.notify_team} |
| 278 | label="Also tell the rest of the team" |
| 279 | description="Everyone in the team hears of the request, not only the people picked." |
| 280 | /> |
| 281 | <Field label="Never pick" hint="Usernames, separated by spaces or commas."> |
| 282 | <Input name="excluded" defaultValue={review.excluded.join(" ")} placeholder="ana bo" /> |
| 283 | </Field> |
| 284 | </div> |
| 285 | )} |
| 286 | {!assigning && ( |
| 287 | <> |
| 288 | {/* Kept as they are while assignment is off. */} |
| 289 | <input type="hidden" name="algorithm" value={review.algorithm} /> |
| 290 | <input type="hidden" name="count" value={review.count} /> |
| 291 | <input type="hidden" name="busy_at" value={review.busy_at} /> |
| 292 | <input type="hidden" name="excluded" value={review.excluded.join(" ")} /> |
| 293 | {review.skip_busy && <input type="hidden" name="skip_busy" value="on" />} |
| 294 | {review.include_child_teams && <input type="hidden" name="include_child_teams" value="on" />} |
| 295 | {review.notify_team && <input type="hidden" name="notify_team" value="on" />} |
| 296 | </> |
| 297 | )} |
| 298 | <ErrorText>{said("review")?.error ?? null}</ErrorText> |
| 299 | <div className="flex items-center gap-3"> |
| 300 | <SubmitButton match={{ intent: "review" }} pending="Saving…"> |
| 301 | Save |
| 302 | </SubmitButton> |
| 303 | {said("review")?.saved && <span className="text-sm text-muted">Saved.</span>} |
| 304 | </div> |
| 305 | </Form> |
| 306 | </section> |
| 307 | |
| 308 | <section aria-labelledby="danger" className="border-t border-line pt-8"> |
| 309 | <h2 id="danger" className="font-medium"> |
| 310 | Delete this team |
| 311 | </h2> |
| 312 | <p className="mt-1 text-sm text-muted"> |
| 313 | Its people stay in {team.workspace}, and lose the roles the team gave them. Its child teams move up to{" "} |
| 314 | {team.parent ? team.parent.name : "the top"}. |
| 315 | </p> |
| 316 | <div className="mt-4"> |
| 317 | <ConfirmDialog |
| 318 | intent="delete" |
| 319 | title={`Delete ${team.name}?`} |
| 320 | description="This cannot be undone." |
| 321 | confirm={`${team.workspace}/${team.slug}`} |
| 322 | submit="Delete team" |
| 323 | busy="Deleting…" |
| 324 | error={said("delete")?.error ?? null} |
| 325 | trigger={(open) => ( |
| 326 | <Button type="button" variant="danger" onClick={open}> |
| 327 | Delete team |
| 328 | </Button> |
| 329 | )} |
| 330 | > |
| 331 | <li>Mentions of @{team.workspace}/{team.slug} stop telling anyone.</li> |
| 332 | <li>Pull requests that asked it to review keep the people it picked.</li> |
| 333 | </ConfirmDialog> |
| 334 | </div> |
| 335 | </section> |
| 336 | </div> |
| 337 | ); |
| 338 | } |