web: repository settings buttons say they are working per row and clear or re-key their forms from what was saved; confirm dialogs close when done; Follow the workspace on runners follows instead of saving; Enter on project settings saves
16 files+209−1830/16 viewed
| 44 | 44 | type User as Actor, | |
| 45 | 45 | } from "@g1t/contracts"; | |
| 46 | 46 | ||
| 47 | − | import { TimeAgo } from "./ui"; | |
| 47 | + | import { SubmitButton, TimeAgo } from "./ui"; | |
| 48 | 48 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 49 | 49 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 50 | 50 | ||
| 157 | 157 | <textarea name="text" required rows={4} maxLength={1000} defaultValue={memory.text} className={TEXTAREA} /> | |
| 158 | 158 | <div className="flex items-center justify-between gap-3"> | |
| 159 | 159 | <KindSelect value={memory.kind} /> | |
| 160 | − | <button type="submit" disabled={fetcher.state !== "idle"} className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50"> | |
| 160 | + | <SubmitButton fetcher={fetcher} pending="Keeping…"> | |
| 161 | 161 | Keep | |
| 162 | − | </button> | |
| 162 | + | </SubmitButton> | |
| 163 | 163 | </div> | |
| 164 | 164 | {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>} | |
| 165 | 165 | </fetcher.Form> | |
| 497 | 497 | <input type="hidden" name="intent" value="fix" /> | |
| 498 | 498 | <input type="hidden" name="project" value={project} /> | |
| 499 | 499 | <input type="hidden" name="rule" value={rule.rule} /> | |
| 500 | − | <button | |
| 501 | − | type="submit" | |
| 502 | − | disabled={fetcher.state !== "idle"} | |
| 500 | + | <SubmitButton | |
| 501 | + | fetcher={fetcher} | |
| 502 | + | pending="Opening…" | |
| 503 | 503 | title={`Opens “${rule.fix.title}” and puts an agent on it`} | |
| 504 | 504 | className="inline-flex shrink-0 items-center gap-1 rounded-md border border-line px-2 py-1 text-xs text-muted hover:border-accent-dim hover:text-fg disabled:opacity-50" | |
| 505 | 505 | > | |
| 506 | 506 | <Bot size={12} /> | |
| 507 | − | {fetcher.state !== "idle" ? "Opening…" : "Fix with an agent"} | |
| 508 | − | </button> | |
| 507 | + | Fix with an agent | |
| 508 | + | </SubmitButton> | |
| 509 | 509 | </fetcher.Form> | |
| 510 | 510 | )} | |
| 511 | 511 | </li> |
| 18 | 18 | ||
| 19 | 19 | import { formatCap, tri, workflowDomainLine } from "../lib/guardrails"; | |
| 20 | 20 | import { formatCost } from "./agents"; | |
| 21 | − | import { Button, ErrorText, Input, TimeAgo } from "./ui"; | |
| 21 | + | import { ErrorText, Input, SubmitButton, TimeAgo } from "./ui"; | |
| 22 | 22 | import { CheckboxOption } from "./ui/checkbox"; | |
| 23 | 23 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 24 | 24 | import { Textarea } from "./ui/textarea"; | |
| 97 | 97 | view: GuardrailsView; | |
| 98 | 98 | level: "workspace" | "project"; | |
| 99 | 99 | editable: boolean; | |
| 100 | − | saving: boolean; | |
| 100 | + | /** Working for a reason the Save button cannot see; it follows its own submission by itself. */ | |
| 101 | + | saving?: boolean; | |
| 101 | 102 | saved: boolean; | |
| 102 | 103 | error: string | null | undefined; | |
| 103 | 104 | }) { | |
| 111 | 112 | const inheritedDeny = level === "project" ? view.inherited.deny : []; | |
| 112 | 113 | const inheritedWorkflowDomains = level === "project" ? (view.inherited.workflowDomains ?? []) : []; | |
| 113 | 114 | return ( | |
| 114 | − | <Form method="post" className="max-w-4xl space-y-8"> | |
| 115 | + | // Keyed to the last change, so after a save the fields show what was kept, as it was tidied. | |
| 116 | + | <Form key={own.updatedAt ?? "unset"} method="post" className="max-w-4xl space-y-8"> | |
| 115 | 117 | <fieldset disabled={!editable} className="min-w-0 space-y-8"> | |
| 116 | 118 | <Section | |
| 117 | 119 | title="Network" | |
| 310 | 312 | ||
| 311 | 313 | {editable ? ( | |
| 312 | 314 | <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur"> | |
| 313 | − | <Button type="submit" disabled={saving}> | |
| 314 | − | {saving ? "Saving…" : "Save guardrails"} | |
| 315 | − | </Button> | |
| 315 | + | <SubmitButton pending="Saving…" busy={saving}> | |
| 316 | + | Save guardrails | |
| 317 | + | </SubmitButton> | |
| 316 | 318 | {saved && <span className="text-sm text-muted">Saved. Runs that start from now on get these.</span>} | |
| 317 | 319 | <ErrorText>{error}</ErrorText> | |
| 318 | 320 | {own.updatedBy && own.updatedAt && !saved && !error && ( |
| 9 | 9 | ||
| 10 | 10 | import { MEMORY_KINDS, type Memory, type MemoryKind, type MemoryScope, type Result, type User as Actor } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | − | import { TimeAgo } from "./ui"; | |
| 12 | + | import { SubmitButton, TimeAgo } from "./ui"; | |
| 13 | 13 | import { | |
| 14 | 14 | AlertDialog, | |
| 15 | 15 | AlertDialogAction, | |
| 127 | 127 | <textarea name="text" required rows={4} maxLength={1000} defaultValue={memory.text} className={TEXTAREA} /> | |
| 128 | 128 | <div className="flex items-center justify-between gap-3"> | |
| 129 | 129 | <KindSelect value={memory.kind} /> | |
| 130 | − | <button | |
| 131 | − | type="submit" | |
| 132 | − | disabled={fetcher.state !== "idle"} | |
| 133 | − | className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50" | |
| 134 | − | > | |
| 130 | + | <SubmitButton fetcher={fetcher} pending="Saving…"> | |
| 135 | 131 | Save | |
| 136 | − | </button> | |
| 132 | + | </SubmitButton> | |
| 137 | 133 | </div> | |
| 138 | 134 | {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>} | |
| 139 | 135 | </fetcher.Form> | |
| 176 | 172 | aria-label={pinned ? "Unpin" : "Pin"} | |
| 177 | 173 | title={pinned ? "Unpin" : "Pin: given to every agent first"} | |
| 178 | 174 | onClick={() => fetcher.submit({ intent: "update", id: memory.id, pinned: String(!pinned) }, { method: "post", action })} | |
| 179 | − | className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg" | |
| 175 | + | disabled={fetcher.state !== "idle"} | |
| 176 | + | className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg disabled:opacity-50" | |
| 180 | 177 | > | |
| 181 | 178 | {pinned ? <PinOff size={13} /> : <Pin size={13} />} | |
| 182 | 179 | </button> | |
| 252 | 249 | Pin it | |
| 253 | 250 | </label> | |
| 254 | 251 | <span className="grow" /> | |
| 255 | − | <button | |
| 256 | − | type="submit" | |
| 257 | − | disabled={fetcher.state !== "idle"} | |
| 258 | − | className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50" | |
| 259 | − | > | |
| 260 | − | {fetcher.state !== "idle" ? "Adding…" : "Add"} | |
| 261 | − | </button> | |
| 252 | + | <SubmitButton fetcher={fetcher} pending="Adding…"> | |
| 253 | + | Add | |
| 254 | + | </SubmitButton> | |
| 262 | 255 | </div> | |
| 263 | 256 | {fetcher.data?.error && <p className="mt-2 text-sm text-danger">{fetcher.data.error}</p>} | |
| 264 | 257 | <p className="mt-2 text-xs text-faint"> |
| 1 | 1 | import { Archive } from "lucide-react"; | |
| 2 | − | import { type ReactNode, useId, useState } from "react"; | |
| 3 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 2 | + | import { type ReactNode, useEffect, useId, useRef, useState } from "react"; | |
| 3 | + | import { Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | − | import { Button, ErrorText } from "./ui"; | |
| 5 | + | import { ErrorText, SubmitButton, usePending } from "./ui"; | |
| 6 | 6 | import { | |
| 7 | 7 | AlertDialog, | |
| 8 | 8 | AlertDialogCancel, | |
| 56 | 56 | }) { | |
| 57 | 57 | const [open, setOpen] = useState(Boolean(error)); | |
| 58 | 58 | const [typed, setTyped] = useState(""); | |
| 59 | − | const navigation = useNavigation(); | |
| 60 | 59 | const id = useId(); | |
| 61 | − | const posting = | |
| 62 | − | navigation.state !== "idle" && | |
| 63 | − | navigation.formData?.get("intent") === intent && | |
| 64 | − | Object.entries(fields ?? {}).every(([k, v]) => navigation.formData?.get(k) === v); | |
| 60 | + | const posting = usePending({ intent, ...fields }); | |
| 61 | + | // Done without an error, it closes: the page behind it now shows the change. | |
| 62 | + | const wasPosting = useRef(false); | |
| 63 | + | useEffect(() => { | |
| 64 | + | if (wasPosting.current && !posting && !error) { | |
| 65 | + | setOpen(false); | |
| 66 | + | setTyped(""); | |
| 67 | + | } | |
| 68 | + | wasPosting.current = posting; | |
| 69 | + | }, [posting, error]); | |
| 65 | 70 | const ready = confirm == null || confirmsName(typed, confirm); | |
| 66 | 71 | return ( | |
| 67 | 72 | <> | |
| 104 | 109 | <ErrorText>{error}</ErrorText> | |
| 105 | 110 | <AlertDialogFooter> | |
| 106 | 111 | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> | |
| 107 | − | <Button type="submit" variant={danger ? "danger" : "primary"} disabled={!ready || posting}> | |
| 108 | − | {posting ? busy : submit} | |
| 109 | − | </Button> | |
| 112 | + | <SubmitButton variant={danger ? "danger" : "primary"} disabled={!ready} match={{ intent, ...fields }} pending={busy}> | |
| 113 | + | {submit} | |
| 114 | + | </SubmitButton> | |
| 110 | 115 | </AlertDialogFooter> | |
| 111 | 116 | </Form> | |
| 112 | 117 | </AlertDialogContent> |
| 1 | 1 | import { Bot, Boxes, Cpu, Plus, ServerCog, ShieldAlert, Trash2 } from "lucide-react"; | |
| 2 | − | import { useState } from "react"; | |
| 3 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 2 | + | import { useEffect, useState } from "react"; | |
| 3 | + | import { Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { type RegistrationToken, RUNNER_DOWNLOADS, RUNNER_FILES, RUNNER_IMAGE, type Runner, type RunnerGroup } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { RunnersAction, RunnersData } from "../lib/runners.server"; | |
| 8 | − | import { Button, CopyLine, EmptyState, ErrorText, Field, Input, Pill, TimeAgo } from "./ui"; | |
| 8 | + | import { Button, CopyLine, EmptyState, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "./ui"; | |
| 9 | 9 | import { Switch } from "./ui/switch"; | |
| 10 | 10 | import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; | |
| 11 | 11 | ||
| 34 | 34 | ||
| 35 | 35 | function RunnerRow({ runner, manage, base }: { runner: Runner; manage: boolean; base: (repo: string) => string }) { | |
| 36 | 36 | const status = STATUS[runner.status]; | |
| 37 | − | const busy = useNavigation().state === "submitting"; | |
| 38 | 37 | return ( | |
| 39 | 38 | <li className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3"> | |
| 40 | 39 | <span className={`mt-1.5 size-2 shrink-0 rounded-full ${status.dot}`} aria-hidden /> | |
| 84 | 83 | <input type="hidden" name="intent" value="remove" /> | |
| 85 | 84 | <input type="hidden" name="id" value={runner.id} /> | |
| 86 | 85 | <input type="hidden" name="name" value={runner.name} /> | |
| 87 | − | <button | |
| 88 | − | type="submit" | |
| 89 | − | disabled={busy} | |
| 86 | + | <SubmitButton | |
| 87 | + | icon | |
| 88 | + | match={{ intent: "remove", id: runner.id }} | |
| 90 | 89 | aria-label={`Remove ${runner.name}`} | |
| 91 | − | className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger" | |
| 90 | + | className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger disabled:opacity-50" | |
| 92 | 91 | > | |
| 93 | 92 | <Trash2 size={15} /> | |
| 94 | − | </button> | |
| 93 | + | </SubmitButton> | |
| 95 | 94 | </Form> | |
| 96 | 95 | )} | |
| 97 | 96 | </li> | |
| 146 | 145 | function NewRunner({ token, groups, repoScoped }: { token: RegistrationToken | null; groups: RunnerGroup[]; repoScoped: boolean }) { | |
| 147 | 146 | const [platform, setPlatform] = useState<Platform>("linux"); | |
| 148 | 147 | const [arch, setArch] = useState<"x64" | "arm64">("x64"); | |
| 149 | − | const busy = useNavigation().state === "submitting"; | |
| 150 | 148 | return ( | |
| 151 | 149 | <div className="space-y-4 rounded-xl border border-line bg-surface p-4"> | |
| 152 | 150 | {!token ? ( | |
| 163 | 161 | </select> | |
| 164 | 162 | </Field> | |
| 165 | 163 | )} | |
| 166 | − | <Button type="submit" disabled={busy}> | |
| 164 | + | <SubmitButton match={{ intent: "token" }} pending="Making a token…"> | |
| 167 | 165 | <Plus size={15} /> | |
| 168 | 166 | New runner | |
| 169 | − | </Button> | |
| 167 | + | </SubmitButton> | |
| 170 | 168 | <p className="basis-full text-xs text-faint">Makes a registration token that lasts an hour and registers any number of runners. It is shown once.</p> | |
| 171 | 169 | </Form> | |
| 172 | 170 | ) : ( | |
| 239 | 237 | ); | |
| 240 | 238 | } | |
| 241 | 239 | ||
| 242 | − | function Groups({ groups, repositories, manage }: { groups: RunnerGroup[]; repositories: string[]; manage: boolean }) { | |
| 240 | + | function Groups({ | |
| 241 | + | groups, | |
| 242 | + | repositories, | |
| 243 | + | manage, | |
| 244 | + | action, | |
| 245 | + | }: { | |
| 246 | + | groups: RunnerGroup[]; | |
| 247 | + | repositories: string[]; | |
| 248 | + | manage: boolean; | |
| 249 | + | action: RunnersAction | undefined; | |
| 250 | + | }) { | |
| 243 | 251 | const [editing, setEditing] = useState<string | null>(null); | |
| 252 | + | // A group saved closes its form: the list above now shows it as saved. | |
| 253 | + | useEffect(() => { | |
| 254 | + | if (action?.notice) setEditing(null); | |
| 255 | + | }, [action]); | |
| 244 | 256 | return ( | |
| 245 | 257 | <div className="space-y-3"> | |
| 246 | 258 | <ul className="divide-y divide-line rounded-xl border border-line bg-surface"> | |
| 262 | 274 | <Form method="post" onSubmit={(event) => { if (!confirm(`Delete ${group.name}? Its runners join the default group.`)) event.preventDefault(); }}> | |
| 263 | 275 | <input type="hidden" name="intent" value="delete-group" /> | |
| 264 | 276 | <input type="hidden" name="id" value={group.id} /> | |
| 265 | − | <Button type="submit" variant="quiet"> | |
| 277 | + | <SubmitButton variant="quiet" match={{ intent: "delete-group", id: group.id }} pending="Deleting…"> | |
| 266 | 278 | Delete | |
| 267 | − | </Button> | |
| 279 | + | </SubmitButton> | |
| 268 | 280 | </Form> | |
| 269 | 281 | )} | |
| 270 | 282 | </span> | |
| 286 | 298 | ||
| 287 | 299 | function GroupForm({ group, repositories }: { group: RunnerGroup | null; repositories: string[] }) { | |
| 288 | 300 | const [reach, setReach] = useState(group && group.repositories.length > 0 ? "some" : "all"); | |
| 289 | − | const busy = useNavigation().state === "submitting"; | |
| 290 | 301 | return ( | |
| 291 | 302 | <Form method="post" className="mt-3 space-y-3 rounded-lg border border-line bg-bg p-3"> | |
| 292 | 303 | <input type="hidden" name="intent" value="group" /> | |
| 315 | 326 | </div> | |
| 316 | 327 | )} | |
| 317 | 328 | </fieldset> | |
| 318 | − | <Button type="submit" disabled={busy}> | |
| 329 | + | <SubmitButton match={{ intent: "group", id: group?.id }} pending="Saving…"> | |
| 319 | 330 | Save group | |
| 320 | − | </Button> | |
| 331 | + | </SubmitButton> | |
| 321 | 332 | </Form> | |
| 322 | 333 | ); | |
| 323 | 334 | } | |
| 324 | 335 | ||
| 325 | 336 | function Settings({ data, manage, scope }: { data: RunnersData; manage: boolean; scope: "workspace" | "project" }) { | |
| 326 | 337 | const settings = data.settings; | |
| 327 | − | const busy = useNavigation().state === "submitting"; | |
| 328 | 338 | const [agents, setAgents] = useState(settings?.agentsOnSelfHosted ?? false); | |
| 329 | 339 | const [forks, setForks] = useState(settings?.forkPullRequests ?? false); | |
| 330 | 340 | if (!settings) return null; | |
| 341 | + | // The intent comes from the button pressed: a hidden one before it would | |
| 342 | + | // win the form's first "intent", and Follow the workspace would only save. | |
| 331 | 343 | return ( | |
| 332 | 344 | <Form method="post" className="space-y-4"> | |
| 333 | − | <input type="hidden" name="intent" value="settings" /> | |
| 334 | 345 | {scope === "project" && settings.inherited && ( | |
| 335 | 346 | <p className="text-sm text-muted">These are the workspace's settings. Saving here gives this project its own.</p> | |
| 336 | 347 | )} | |
| 369 | 380 | </p> | |
| 370 | 381 | {manage && ( | |
| 371 | 382 | <div className="flex flex-wrap gap-3"> | |
| 372 | − | <Button type="submit" disabled={busy}> | |
| 383 | + | <SubmitButton name="intent" value="settings" pending="Saving…"> | |
| 373 | 384 | Save | |
| 374 | − | </Button> | |
| 385 | + | </SubmitButton> | |
| 375 | 386 | {scope === "project" && !settings.inherited && ( | |
| 376 | − | <Button type="submit" name="intent" value="inherit" variant="quiet" disabled={busy}> | |
| 387 | + | <SubmitButton name="intent" value="inherit" variant="quiet" pending="Following…"> | |
| 377 | 388 | Follow the workspace | |
| 378 | − | </Button> | |
| 389 | + | </SubmitButton> | |
| 379 | 390 | )} | |
| 380 | 391 | </div> | |
| 381 | 392 | )} | |
| 444 | 455 | title="Groups" | |
| 445 | 456 | about="Which repositories may use which runners. A runner joins the default group, every repository, unless its token names another." | |
| 446 | 457 | > | |
| 447 | − | <Groups groups={data.groups} repositories={data.repositories} manage={manage} /> | |
| 458 | + | <Groups groups={data.groups} repositories={data.repositories} manage={manage} action={action} /> | |
| 448 | 459 | </Section> | |
| 449 | 460 | )} | |
| 450 | 461 | <Section | |
| 452 | 463 | title="Where work runs" | |
| 453 | 464 | about={scope === "workspace" ? "For every project in the workspace, unless a project says otherwise." : "For this project."} | |
| 454 | 465 | > | |
| 455 | − | <Settings data={data} manage={manage} scope={scope} /> | |
| 466 | + | {/* Keyed to what is saved, so following the workspace again shows its settings, not the switches as they were. */} | |
| 467 | + | <Settings | |
| 468 | + | key={data.settings ? `${data.settings.inherited}:${data.settings.agentsOnSelfHosted}:${data.settings.forkPullRequests}:${data.settings.agentLabels.join(",")}` : "none"} | |
| 469 | + | data={data} | |
| 470 | + | manage={manage} | |
| 471 | + | scope={scope} | |
| 472 | + | /> | |
| 456 | 473 | </Section> | |
| 457 | 474 | </div> | |
| 458 | 475 | ); |
| 7 | 7 | */ | |
| 8 | 8 | import { Lock, Pencil, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react"; | |
| 9 | 9 | import { useMemo, useState } from "react"; | |
| 10 | − | import { Form, Link, useLocation, useNavigation } from "react-router"; | |
| 10 | + | import { Form, Link, useLocation } from "react-router"; | |
| 11 | 11 | ||
| 12 | 12 | import type { Setting } from "@g1t/contracts"; | |
| 13 | 13 | ||
| 14 | 14 | import type { SecretsAction, SecretsData } from "../lib/secrets.server"; | |
| 15 | − | import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "./ui"; | |
| 15 | + | import { ButtonLink, EmptyState, ErrorText, SubmitButton, TimeAgo } from "./ui"; | |
| 16 | 16 | import { CheckboxOption } from "./ui/checkbox"; | |
| 17 | 17 | import { RadioCard, RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 18 | 18 | import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select"; | |
| 150 | 150 | </div> | |
| 151 | 151 | ||
| 152 | 152 | {manage && (adding || row) && ( | |
| 153 | − | <Drawer row={row} scope={scope} projects={data.projects} error={action?.error} /> | |
| 153 | + | // Keyed to the row, so going from one row's edit to another's starts from that row. | |
| 154 | + | <Drawer key={row?.id ?? "add"} row={row} scope={scope} projects={data.projects} error={action?.error} /> | |
| 154 | 155 | )} | |
| 155 | 156 | </div> | |
| 156 | 157 | ); | |
| 157 | 158 | } | |
| 158 | 159 | ||
| 159 | 160 | function Row({ row, inherited, manage }: { row: Setting; inherited: boolean; manage: boolean }) { | |
| 160 | − | const busy = useNavigation().state === "submitting"; | |
| 161 | 161 | const secret = row.kind === "secret"; | |
| 162 | 162 | return ( | |
| 163 | 163 | <li className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_auto] items-center gap-x-4 gap-y-1 border-t border-line px-4 py-3 text-sm first:border-t-0 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,1fr)_6rem_6rem_auto]"> | |
| 200 | 200 | <input type="hidden" name="intent" value="delete" /> | |
| 201 | 201 | <input type="hidden" name="id" value={row.id} /> | |
| 202 | 202 | <input type="hidden" name="name" value={row.name} /> | |
| 203 | − | <button | |
| 204 | − | type="submit" | |
| 205 | − | disabled={busy} | |
| 203 | + | <SubmitButton | |
| 204 | + | icon | |
| 205 | + | match={{ intent: "delete", id: row.id }} | |
| 206 | 206 | aria-label={`Remove ${row.name}`} | |
| 207 | − | className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger" | |
| 207 | + | className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger disabled:opacity-50" | |
| 208 | 208 | > | |
| 209 | 209 | <Trash2 size={14} /> | |
| 210 | − | </button> | |
| 210 | + | </SubmitButton> | |
| 211 | 211 | </Form> | |
| 212 | 212 | </> | |
| 213 | 213 | )} | |
| 229 | 229 | projects: string[]; | |
| 230 | 230 | error: string | undefined; | |
| 231 | 231 | }) { | |
| 232 | − | const busy = useNavigation().state === "submitting"; | |
| 233 | 232 | const editing = !!row; | |
| 234 | 233 | const [type, setType] = useState<"secret" | "config">(row?.kind === "variable" ? "config" : "secret"); | |
| 235 | 234 | const [some, setSome] = useState(!!row && row.environments.length > 0); | |
| 403 | 402 | </div> | |
| 404 | 403 | <div className="flex items-center justify-between gap-4 border-t border-line px-6 py-4"> | |
| 405 | 404 | <p className="text-xs text-faint">{editing ? "" : "Paste .env contents into Key to add many."}</p> | |
| 406 | − | <Button type="submit" disabled={busy}> | |
| 405 | + | <SubmitButton match={{ intent: "save" }} pending="Saving…"> | |
| 407 | 406 | Save | |
| 408 | − | </Button> | |
| 407 | + | </SubmitButton> | |
| 409 | 408 | </div> | |
| 410 | 409 | </Form> | |
| 411 | 410 | </div> |
| 4 | 4 | */ | |
| 5 | 5 | import { ChevronRight, Pause, Play, RotateCw, Send, Trash2 } from "lucide-react"; | |
| 6 | 6 | import { useState } from "react"; | |
| 7 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 7 | + | import { Form, Link } from "react-router"; | |
| 8 | 8 | ||
| 9 | 9 | import { EVENT_TYPES, type Hook, type HookDelivery } from "@g1t/contracts"; | |
| 10 | 10 | ||
| 11 | 11 | import type { WebhooksAction, WebhooksData } from "../lib/webhooks.server"; | |
| 12 | − | import { Button, CopyLine, EmptyState, ErrorText, Field, Input, TimeAgo } from "./ui"; | |
| 12 | + | import { CopyLine, EmptyState, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui"; | |
| 13 | 13 | import { CheckboxOption } from "./ui/checkbox"; | |
| 14 | 14 | import { RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 15 | 15 | ||
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | 53 | function DeliveryRow({ delivery, manage }: { delivery: HookDelivery; manage: boolean }) { | |
| 54 | − | const busy = useNavigation().state === "submitting"; | |
| 55 | 54 | return ( | |
| 56 | 55 | <details className="group border-t border-line first:border-t-0"> | |
| 57 | 56 | <summary className="flex cursor-pointer list-none items-center gap-3 px-4 py-2.5 text-sm hover:bg-raised/40"> | |
| 90 | 89 | <Form method="post" className="mt-3"> | |
| 91 | 90 | <input type="hidden" name="intent" value="redeliver" /> | |
| 92 | 91 | <input type="hidden" name="delivery" value={delivery.id} /> | |
| 93 | − | <Button type="submit" variant="quiet" disabled={busy}> | |
| 92 | + | <SubmitButton | |
| 93 | + | variant="quiet" | |
| 94 | + | match={{ intent: "redeliver", delivery: delivery.id }} | |
| 95 | + | pending="Redelivering…" | |
| 96 | + | > | |
| 94 | 97 | <RotateCw size={14} /> | |
| 95 | 98 | Redeliver | |
| 96 | − | </Button> | |
| 99 | + | </SubmitButton> | |
| 97 | 100 | </Form> | |
| 98 | 101 | )} | |
| 99 | 102 | </div> | |
| 103 | 106 | } | |
| 104 | 107 | ||
| 105 | 108 | function HookRow({ hook, open, deliveries, manage }: { hook: Hook; open: boolean; deliveries: HookDelivery[]; manage: boolean }) { | |
| 106 | − | const busy = useNavigation().state === "submitting"; | |
| 107 | 109 | return ( | |
| 108 | 110 | <li className="border-t border-line first:border-t-0"> | |
| 109 | 111 | <div className="flex items-center gap-3 px-4 py-3"> | |
| 132 | 134 | {manage && ( | |
| 133 | 135 | <Form method="post" className="flex shrink-0 gap-1"> | |
| 134 | 136 | <input type="hidden" name="id" value={hook.id} /> | |
| 135 | − | <IconButton intent="ping" label="Send a ping" disabled={busy}> | |
| 137 | + | <IconButton intent="ping" id={hook.id} label="Send a ping"> | |
| 136 | 138 | <Send size={14} /> | |
| 137 | 139 | </IconButton> | |
| 138 | 140 | <input type="hidden" name="active" value={hook.active ? "false" : "true"} /> | |
| 139 | − | <IconButton intent="toggle" label={hook.active ? "Pause" : "Resume"} disabled={busy}> | |
| 141 | + | <IconButton intent="toggle" id={hook.id} label={hook.active ? "Pause" : "Resume"}> | |
| 140 | 142 | {hook.active ? <Pause size={14} /> : <Play size={14} />} | |
| 141 | 143 | </IconButton> | |
| 142 | − | <IconButton intent="delete" label="Delete" disabled={busy}> | |
| 144 | + | <IconButton intent="delete" id={hook.id} label="Delete"> | |
| 143 | 145 | <Trash2 size={14} /> | |
| 144 | 146 | </IconButton> | |
| 145 | 147 | </Form> | |
| 158 | 160 | ); | |
| 159 | 161 | } | |
| 160 | 162 | ||
| 161 | − | function IconButton({ intent, label, disabled, children }: { intent: string; label: string; disabled: boolean; children: React.ReactNode }) { | |
| 163 | + | /** One webhook's action, as an icon: a spinner in its place while that action goes. */ | |
| 164 | + | function IconButton({ intent, id, label, children }: { intent: string; id: string; label: string; children: React.ReactNode }) { | |
| 162 | 165 | return ( | |
| 163 | − | <button | |
| 164 | − | type="submit" | |
| 166 | + | <SubmitButton | |
| 167 | + | icon | |
| 165 | 168 | name="intent" | |
| 166 | 169 | value={intent} | |
| 170 | + | match={{ id }} | |
| 167 | 171 | title={label} | |
| 168 | 172 | aria-label={label} | |
| 169 | − | disabled={disabled} | |
| 170 | 173 | className="rounded-md p-2 text-muted transition-colors hover:bg-raised hover:text-fg disabled:opacity-50" | |
| 171 | 174 | > | |
| 172 | 175 | {children} | |
| 173 | − | </button> | |
| 176 | + | </SubmitButton> | |
| 174 | 177 | ); | |
| 175 | 178 | } | |
| 176 | 179 | ||
| 177 | 180 | function AddWebhook() { | |
| 178 | − | const busy = useNavigation().state === "submitting"; | |
| 179 | 181 | const [which, setWhich] = useState<"all" | "some">("all"); | |
| 180 | 182 | return ( | |
| 181 | 183 | <Form method="post" className="space-y-4 rounded-xl border border-line bg-surface p-5"> | |
| 223 | 225 | <Field label="Secret" hint="Optional. Deliveries are signed with it. Leave it empty and g1t makes one, shown once."> | |
| 224 | 226 | <Input name="secret" type="password" autoComplete="off" /> | |
| 225 | 227 | </Field> | |
| 226 | − | <Button type="submit" disabled={busy}> | |
| 227 | − | {busy ? "Adding…" : "Add webhook"} | |
| 228 | − | </Button> | |
| 228 | + | <SubmitButton match={{ intent: "create" }} pending="Adding…"> | |
| 229 | + | Add webhook | |
| 230 | + | </SubmitButton> | |
| 229 | 231 | </Form> | |
| 230 | 232 | ); | |
| 231 | 233 | } | |
| 291 | 293 | </ul> | |
| 292 | 294 | )} | |
| 293 | 295 | ||
| 294 | − | {manage && <AddWebhook />} | |
| 296 | + | {/* Keyed to the webhook just added, so the form starts empty for the next. */} | |
| 297 | + | {manage && <AddWebhook key={created?.hook.id ?? "new"} />} | |
| 295 | 298 | <p className="text-xs text-faint"> | |
| 296 | 299 | Each delivery is an HTTPS POST of JSON with <code>X-G1t-Event</code>, <code>X-G1t-Delivery</code> and{" "} | |
| 297 | 300 | <code>X-G1t-Signature-256</code>. One that is not answered with a 2xx is tried again after 1 minute, 5 minutes, |
| 1 | 1 | import { Mail, UserPlus } from "lucide-react"; | |
| 2 | 2 | import { useEffect, useState } from "react"; | |
| 3 | − | import { Form, Link, useFetcher, useNavigation } from "react-router"; | |
| 3 | + | import { Form, Link, useFetcher } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { | |
| 6 | 6 | BASE_PERMISSION_LABELS, | |
| 16 | 16 | import { RoleSelect, RolesTable } from "../../components/access"; | |
| 17 | 17 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 18 | 18 | import { SettingsSection as Section } from "../../components/settings-section"; | |
| 19 | − | import { Avatar, Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 19 | + | import { Avatar, Button, ErrorText, Field, Input, SubmitButton } from "../../components/ui"; | |
| 20 | 20 | import { Badge } from "../../components/ui/badge"; | |
| 21 | 21 | import { | |
| 22 | 22 | AlertDialog, | |
| 214 | 214 | function AddForm({ result }: { result: Outcome | undefined }) { | |
| 215 | 215 | const [role, setRole] = useState<RepoRole>("write"); | |
| 216 | 216 | const [key, setKey] = useState(0); | |
| 217 | − | const navigation = useNavigation(); | |
| 218 | − | const adding = navigation.state !== "idle" && navigation.formData?.get("intent") === "add"; | |
| 219 | 217 | // Cleared once someone was added, ready for the next. | |
| 220 | 218 | useEffect(() => { | |
| 221 | 219 | if (result?.ok) setKey((k) => k + 1); | |
| 231 | 229 | <span className="mb-1.5 block text-sm font-medium text-muted">Role</span> | |
| 232 | 230 | <RoleSelect name="role" label="Role" value={role} onValueChange={(value) => setRole(value as RepoRole)} /> | |
| 233 | 231 | </div> | |
| 234 | − | <Button type="submit" disabled={adding}> | |
| 232 | + | <SubmitButton match={{ intent: "add" }} pending="Adding…"> | |
| 235 | 233 | <UserPlus size={15} /> | |
| 236 | − | {adding ? "Adding…" : "Add"} | |
| 237 | − | </Button> | |
| 234 | + | Add | |
| 235 | + | </SubmitButton> | |
| 238 | 236 | </div> | |
| 239 | 237 | <p className="text-xs text-faint">{REPO_ROLE_LABELS[role]}: {roleLine(role)}</p> | |
| 240 | 238 | {result && | |
| 356 | 354 | {fetcher.data && !fetcher.data.ok && <ErrorText>{fetcher.data.error}</ErrorText>} | |
| 357 | 355 | <AlertDialogFooter> | |
| 358 | 356 | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> | |
| 359 | − | <Button type="submit" variant="danger" disabled={fetcher.state !== "idle"}> | |
| 360 | − | {fetcher.state !== "idle" ? "Removing…" : "Remove"} | |
| 361 | − | </Button> | |
| 357 | + | <SubmitButton variant="danger" fetcher={fetcher} pending="Removing…"> | |
| 358 | + | Remove | |
| 359 | + | </SubmitButton> | |
| 362 | 360 | </AlertDialogFooter> | |
| 363 | 361 | </fetcher.Form> | |
| 364 | 362 | </AlertDialogContent> | |
| 396 | 394 | <fetcher.Form method="post" className="shrink-0"> | |
| 397 | 395 | <input type="hidden" name="intent" value="revoke" /> | |
| 398 | 396 | <input type="hidden" name="id" value={invitation.id} /> | |
| 399 | − | <Button type="submit" variant="quiet" disabled={fetcher.state !== "idle"}> | |
| 400 | − | {fetcher.state !== "idle" ? "Revoking…" : "Revoke"} | |
| 401 | − | </Button> | |
| 397 | + | <SubmitButton variant="quiet" fetcher={fetcher} pending="Revoking…"> | |
| 398 | + | Revoke | |
| 399 | + | </SubmitButton> | |
| 402 | 400 | </fetcher.Form> | |
| 403 | 401 | </li> | |
| 404 | 402 | ); |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | import { AtSign, Tag } from "lucide-react"; | |
| 3 | − | import { Form, useNavigation } from "react-router"; | |
| 3 | + | import { Form } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { AGENT_HANDLE, mentionsClient } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/settings-agents"; | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | 9 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 10 | − | import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui"; | |
| 10 | + | import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 11 | 11 | import { work } from "../../lib/services.server"; | |
| 12 | 12 | import { instrumented } from "../../lib/perf.server"; | |
| 13 | 13 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 47 | 47 | ||
| 48 | 48 | export default function AgentSettings({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 49 | 49 | const { rules, labels } = loaderData; | |
| 50 | − | const busy = useNavigation().state === "submitting"; | |
| 51 | 50 | const base = `/${params.owner}/${params.repo}`; | |
| 52 | 51 | return ( | |
| 53 | 52 | <div className="max-w-4xl"> | |
| 70 | 69 | </div> | |
| 71 | 70 | <Form method="post" className="space-y-3 rounded-xl border border-line bg-surface p-4"> | |
| 72 | 71 | <Field label="Label" hint="An issue that already has it is not affected; adding it again is."> | |
| 73 | − | <Input name="label" list="known-labels" defaultValue={rules.label ?? ""} placeholder="agent" maxLength={40} /> | |
| 72 | + | {/* Keyed to the saved rule, so turning it off empties the box. */} | |
| 73 | + | <Input key={rules.label ?? ""} name="label" list="known-labels" defaultValue={rules.label ?? ""} placeholder="agent" maxLength={40} /> | |
| 74 | 74 | </Field> | |
| 75 | 75 | <datalist id="known-labels"> | |
| 76 | 76 | {labels.map((label) => ( | |
| 78 | 78 | ))} | |
| 79 | 79 | </datalist> | |
| 80 | 80 | <div className="flex flex-wrap items-center gap-3"> | |
| 81 | − | <Button type="submit" name="intent" value="save" disabled={busy}> | |
| 81 | + | <SubmitButton name="intent" value="save" pending="Saving…"> | |
| 82 | 82 | Save | |
| 83 | − | </Button> | |
| 83 | + | </SubmitButton> | |
| 84 | 84 | {rules.label && ( | |
| 85 | − | <Button type="submit" variant="quiet" name="intent" value="off" disabled={busy}> | |
| 85 | + | <SubmitButton variant="quiet" name="intent" value="off" pending="Turning off…"> | |
| 86 | 86 | Turn off | |
| 87 | − | </Button> | |
| 87 | + | </SubmitButton> | |
| 88 | 88 | )} | |
| 89 | 89 | {rules.updatedBy && rules.updatedAt && ( | |
| 90 | 90 | <span className="text-xs text-faint"> |
| 1 | 1 | import { ChevronRight, ShieldCheck } from "lucide-react"; | |
| 2 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 2 | + | import { Form, Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/settings-branches"; | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| 7 | 7 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 8 | 8 | import { RequiredChecksPicker } from "../../components/required-checks"; | |
| 9 | 9 | import { SettingChoice as Choice, SettingsSection as Section, SettingToggle as Toggle } from "../../components/settings-section"; | |
| 10 | − | import { Button, ErrorText, TimeAgo } from "../../components/ui"; | |
| 10 | + | import { ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 11 | 11 | import { actions, repos, work } from "../../lib/services.server"; | |
| 12 | 12 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 13 | 13 | import { requireCapability, requireInsider } from "../../lib/access.server"; | |
| 72 | 72 | ||
| 73 | 73 | export default function BranchSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 74 | 74 | const { repo, settings, seen, noChecks, canPush } = loaderData; | |
| 75 | − | const saving = useNavigation().state === "submitting"; | |
| 76 | 75 | const branch = repo.defaultBranch; | |
| 77 | 76 | const base = `/${repo.namespace}/${repo.name}`; | |
| 78 | 77 | const archived = Boolean(repo.archivedAt); | |
| 190 | 189 | </Section> | |
| 191 | 190 | ||
| 192 | 191 | <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur"> | |
| 193 | − | <Button type="submit" disabled={saving || archived}> | |
| 194 | − | {saving ? "Saving…" : "Save settings"} | |
| 195 | − | </Button> | |
| 192 | + | <SubmitButton pending="Saving…" disabled={archived}> | |
| 193 | + | Save settings | |
| 194 | + | </SubmitButton> | |
| 196 | 195 | {actionData?.saved && <span className="text-sm text-muted">Saved.</span>} | |
| 197 | 196 | <ErrorText>{actionData?.error}</ErrorText> | |
| 198 | 197 | {settings.updatedBy && settings.updatedAt && !actionData && ( |
| 1 | 1 | import { ArrowDownLeft, ArrowUpRight, FileCode2, Network, Trash2 } from "lucide-react"; | |
| 2 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 2 | + | import { Form, Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { DependencyLink } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/settings-dependencies"; | |
| 7 | 7 | import { page } from "../../lib/meta"; | |
| 8 | 8 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 9 | − | import { Avatar, Button, EmptyState, ErrorText, Field, Input } from "../../components/ui"; | |
| 9 | + | import { Avatar, EmptyState, ErrorText, Field, Input, SubmitButton } from "../../components/ui"; | |
| 10 | 10 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 11 | 11 | import { projects } from "../../lib/services.server"; | |
| 12 | 12 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 41 | 41 | return added.ok ? { notice: `Depends on ${on}.` } : { error: added.error.message }; | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | − | function Row({ link, base, removable, busy }: { link: DependencyLink; base: string; removable: boolean; busy: boolean }) { | |
| 44 | + | function Row({ link, base, removable }: { link: DependencyLink; base: string; removable: boolean }) { | |
| 45 | 45 | return ( | |
| 46 | 46 | <li className="flex items-center gap-3 px-4 py-3 text-sm"> | |
| 47 | 47 | <Link to={`/${base.split("/")[1]}/${link.slug}`} className="font-medium hover:underline"> | |
| 62 | 62 | <Form method="post" className="ml-auto"> | |
| 63 | 63 | <input type="hidden" name="intent" value="remove" /> | |
| 64 | 64 | <input type="hidden" name="on" value={link.slug} /> | |
| 65 | − | <button | |
| 66 | − | type="submit" | |
| 67 | − | disabled={busy} | |
| 65 | + | <SubmitButton | |
| 66 | + | icon | |
| 67 | + | match={{ intent: "remove", on: link.slug }} | |
| 68 | 68 | aria-label={`Stop depending on ${link.name}`} | |
| 69 | − | className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger" | |
| 69 | + | className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger disabled:opacity-50" | |
| 70 | 70 | > | |
| 71 | 71 | <Trash2 size={14} /> | |
| 72 | − | </button> | |
| 72 | + | </SubmitButton> | |
| 73 | 73 | </Form> | |
| 74 | 74 | )} | |
| 75 | 75 | </li> | |
| 78 | 78 | ||
| 79 | 79 | export default function DependencySettings({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 80 | 80 | const { dependencies, projects: all } = loaderData; | |
| 81 | − | const busy = useNavigation().state === "submitting"; | |
| 82 | 81 | const base = `/${params.owner}/${params.repo}`; | |
| 83 | 82 | const taken = new Set([params.repo.toLowerCase(), ...dependencies.dependsOn.map((d) => d.slug)]); | |
| 84 | 83 | const choices = all.filter((project) => !taken.has(project.slug)); | |
| 116 | 115 | ) : ( | |
| 117 | 116 | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 118 | 117 | {dependencies.dependsOn.map((link) => ( | |
| 119 | − | <Row key={link.slug} link={link} base={base} removable busy={busy} /> | |
| 118 | + | <Row key={link.slug} link={link} base={base} removable /> | |
| 120 | 119 | ))} | |
| 121 | 120 | </ul> | |
| 122 | 121 | )} | |
| 123 | 122 | </div> | |
| 124 | 123 | {choices.length > 0 && ( | |
| 125 | − | <Form method="post" className="mt-4 grid items-end gap-3 rounded-xl border border-line bg-surface p-4 sm:grid-cols-[1fr_1fr_auto]"> | |
| 124 | + | // Keyed to what it depends on, so once one is added the form starts over on what is left. | |
| 125 | + | <Form | |
| 126 | + | key={dependencies.dependsOn.map((link) => link.slug).join(" ")} | |
| 127 | + | method="post" | |
| 128 | + | className="mt-4 grid items-end gap-3 rounded-xl border border-line bg-surface p-4 sm:grid-cols-[1fr_1fr_auto]" | |
| 129 | + | > | |
| 126 | 130 | <input type="hidden" name="intent" value="add" /> | |
| 127 | 131 | <Field label="Project"> | |
| 128 | 132 | <Select name="on" required defaultValue={choices[0].slug}> | |
| 146 | 150 | <Field label="Its address as"> | |
| 147 | 151 | <Input name="as" placeholder="API_URL" className="font-mono" /> | |
| 148 | 152 | </Field> | |
| 149 | − | <Button type="submit" disabled={busy}> | |
| 153 | + | <SubmitButton match={{ intent: "add" }} pending="Adding…"> | |
| 150 | 154 | Add dependency | |
| 151 | − | </Button> | |
| 155 | + | </SubmitButton> | |
| 152 | 156 | </Form> | |
| 153 | 157 | )} | |
| 154 | 158 | </section> | |
| 164 | 168 | ) : ( | |
| 165 | 169 | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 166 | 170 | {dependencies.usedBy.map((link) => ( | |
| 167 | − | <Row key={link.slug} link={link} base={base} removable={false} busy={busy} /> | |
| 171 | + | <Row key={link.slug} link={link} base={base} removable={false} /> | |
| 168 | 172 | ))} | |
| 169 | 173 | </ul> | |
| 170 | 174 | )} |
| 1 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 1 | + | import { Form, Link } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import type { DetectedKind } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/settings-deployments"; | |
| 6 | 6 | import { page } from "../../lib/meta"; | |
| 7 | 7 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 8 | − | import { Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 8 | + | import { ErrorText, Field, Input, SubmitButton } from "../../components/ui"; | |
| 9 | 9 | import { SwitchCard } from "../../components/ui/switch"; | |
| 10 | 10 | import { deployments, projects } from "../../lib/services.server"; | |
| 11 | 11 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 96 | 96 | ||
| 97 | 97 | export default function DeploymentSettings({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 98 | 98 | const { settings, notDeploying } = loaderData; | |
| 99 | − | const busy = useNavigation().state === "submitting"; | |
| 100 | 99 | const base = `/${params.owner}/${params.repo}`; | |
| 101 | 100 | return ( | |
| 102 | 101 | <div className="max-w-4xl"> | |
| 128 | 127 | </p> | |
| 129 | 128 | ) : ( | |
| 130 | 129 | <Form method="post" className="mt-4"> | |
| 131 | − | <Button variant="accent" type="submit" name="intent" value="enable" disabled={busy}> | |
| 130 | + | <SubmitButton variant="accent" name="intent" value="enable" pending="Turning on…"> | |
| 132 | 131 | Turn on deployments | |
| 133 | − | </Button> | |
| 132 | + | </SubmitButton> | |
| 134 | 133 | </Form> | |
| 135 | 134 | )} | |
| 136 | 135 | </section> | |
| 163 | 162 | </Link>{" "} | |
| 164 | 163 | available to Deployments: each row for Production or Preview, or for all environments. | |
| 165 | 164 | </p> | |
| 166 | − | <Button type="submit" disabled={busy}> | |
| 165 | + | <SubmitButton name="intent" value="save" pending="Saving…"> | |
| 167 | 166 | Save | |
| 168 | − | </Button> | |
| 167 | + | </SubmitButton> | |
| 169 | 168 | </Form> | |
| 170 | 169 | ||
| 171 | 170 | <section className="mt-10 rounded-xl border border-danger/30 p-5"> | |
| 175 | 174 | running or costing anything. The workspace's plan stays on; turn it off under Billing. | |
| 176 | 175 | </p> | |
| 177 | 176 | <Form method="post" className="mt-3"> | |
| 178 | − | <Button variant="quiet" type="submit" name="intent" value="disable" disabled={busy}> | |
| 177 | + | <SubmitButton variant="quiet" name="intent" value="disable" pending="Turning off…"> | |
| 179 | 178 | Turn off deployments | |
| 180 | − | </Button> | |
| 179 | + | </SubmitButton> | |
| 181 | 180 | </Form> | |
| 182 | 181 | </section> | |
| 183 | 182 | </> |
| 1 | − | import { CheckCircle2, Globe, Info, RotateCw, Trash2 } from "lucide-react"; | |
| 1 | + | import { CheckCircle2, Globe, Info, LoaderCircle, RotateCw, Trash2 } from "lucide-react"; | |
| 2 | 2 | import { useEffect, useState } from "react"; | |
| 3 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 3 | + | import { Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Domain, DomainRecord, DomainStatus } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/settings-domains"; | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | 9 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 10 | − | import { Button, CopyLine, EmptyState, ErrorText, Field, Input } from "../../components/ui"; | |
| 10 | + | import { Button, CopyLine, EmptyState, ErrorText, Field, Input, SubmitButton, usePending } from "../../components/ui"; | |
| 11 | 11 | import { | |
| 12 | 12 | AlertDialog, | |
| 13 | 13 | AlertDialogAction, | |
| 97 | 97 | ||
| 98 | 98 | export default function DomainSettings({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 99 | 99 | const { settings, domains, target, available, notice, monthlyMicros, used } = loaderData; | |
| 100 | − | const busy = useNavigation().state === "submitting"; | |
| 101 | 100 | const base = `/${params.owner}/${params.repo}`; | |
| 102 | 101 | const [hostname, setHostname] = useState(""); | |
| 103 | 102 | const twin = twinOf(hostname); | |
| 171 | 170 | </Field> | |
| 172 | 171 | </div> | |
| 173 | 172 | <div className="sm:mb-6"> | |
| 174 | − | <Button type="submit" variant="accent" disabled={busy || !hostname.trim()}> | |
| 173 | + | <SubmitButton variant="accent" match={{ intent: "add" }} pending="Adding…" disabled={!hostname.trim()}> | |
| 175 | 174 | Add domain | |
| 176 | − | </Button> | |
| 175 | + | </SubmitButton> | |
| 177 | 176 | </div> | |
| 178 | 177 | </div> | |
| 179 | 178 | <CheckboxOption | |
| 199 | 198 | key={domain.id} | |
| 200 | 199 | domain={domain} | |
| 201 | 200 | redirects={domains.filter((d) => d.redirectTo === domain.hostname)} | |
| 202 | − | busy={busy} | |
| 203 | 201 | /> | |
| 204 | 202 | )) | |
| 205 | 203 | )} | |
| 208 | 206 | ); | |
| 209 | 207 | } | |
| 210 | 208 | ||
| 211 | − | function DomainCard({ domain, redirects, busy }: { domain: Domain; redirects: Domain[]; busy: boolean }) { | |
| 209 | + | function DomainCard({ domain, redirects }: { domain: Domain; redirects: Domain[] }) { | |
| 212 | 210 | return ( | |
| 213 | 211 | <section className="overflow-hidden rounded-xl border border-line bg-surface"> | |
| 214 | − | <DomainRow domain={domain} busy={busy} /> | |
| 212 | + | <DomainRow domain={domain} /> | |
| 215 | 213 | {redirects.map((other) => ( | |
| 216 | 214 | <div key={other.id} className="border-t border-line"> | |
| 217 | − | <DomainRow domain={other} busy={busy} /> | |
| 215 | + | <DomainRow domain={other} /> | |
| 218 | 216 | </div> | |
| 219 | 217 | ))} | |
| 220 | 218 | </section> | |
| 221 | 219 | ); | |
| 222 | 220 | } | |
| 223 | 221 | ||
| 224 | − | function DomainRow({ domain, busy }: { domain: Domain; busy: boolean }) { | |
| 222 | + | function DomainRow({ domain }: { domain: Domain }) { | |
| 225 | 223 | const active = domain.status === "active"; | |
| 226 | 224 | return ( | |
| 227 | 225 | <div className="p-5"> | |
| 240 | 238 | <Form method="post"> | |
| 241 | 239 | <input type="hidden" name="intent" value="refresh" /> | |
| 242 | 240 | <input type="hidden" name="id" value={domain.id} /> | |
| 243 | − | <Button type="submit" variant="quiet" disabled={busy} title="Ask Cloudflare to check the records again now"> | |
| 241 | + | <SubmitButton | |
| 242 | + | variant="quiet" | |
| 243 | + | match={{ intent: "refresh", id: domain.id }} | |
| 244 | + | pending="Checking…" | |
| 245 | + | title="Ask Cloudflare to check the records again now" | |
| 246 | + | > | |
| 244 | 247 | <RotateCw size={14} /> | |
| 245 | 248 | Check now | |
| 246 | − | </Button> | |
| 249 | + | </SubmitButton> | |
| 247 | 250 | </Form> | |
| 248 | 251 | )} | |
| 249 | − | <RemoveDomain domain={domain} busy={busy} /> | |
| 252 | + | <RemoveDomain domain={domain} /> | |
| 250 | 253 | </span> | |
| 251 | 254 | </div> | |
| 252 | 255 | {domain.error && !active && <p className="mt-2 text-sm text-muted">{domain.error}</p>} | |
| 307 | 310 | ); | |
| 308 | 311 | } | |
| 309 | 312 | ||
| 310 | − | function RemoveDomain({ domain, busy }: { domain: Domain; busy: boolean }) { | |
| 313 | + | function RemoveDomain({ domain }: { domain: Domain }) { | |
| 314 | + | // The dialog closes as it posts, so the row's own button says it is going. | |
| 315 | + | const removing = usePending({ intent: "remove", id: domain.id }); | |
| 311 | 316 | return ( | |
| 312 | 317 | <AlertDialog> | |
| 313 | 318 | <AlertDialogTrigger asChild> | |
| 314 | − | <Button type="button" variant="quiet" disabled={busy} aria-label={`Remove ${domain.hostname}`}> | |
| 315 | − | <Trash2 size={14} /> | |
| 316 | − | Remove | |
| 319 | + | <Button type="button" variant="quiet" disabled={removing} aria-label={`Remove ${domain.hostname}`}> | |
| 320 | + | {removing ? <LoaderCircle size={14} className="animate-spin" aria-hidden="true" /> : <Trash2 size={14} />} | |
| 321 | + | {removing ? "Removing…" : "Remove"} | |
| 317 | 322 | </Button> | |
| 318 | 323 | </AlertDialogTrigger> | |
| 319 | 324 | <AlertDialogContent> |
| 1 | − | import { Link, useNavigation } from "react-router"; | |
| 1 | + | import { Link } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import { RUN_KINDS } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | 42 | export default function RepoGuardrails({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 43 | − | const saving = useNavigation().state === "submitting"; | |
| 44 | 43 | const base = `/${params.owner}/${params.repo}`; | |
| 45 | 44 | return ( | |
| 46 | 45 | <> | |
| 57 | 56 | view={loaderData.view} | |
| 58 | 57 | level="project" | |
| 59 | 58 | editable | |
| 60 | − | saving={saving} | |
| 61 | 59 | saved={actionData?.saved ?? false} | |
| 62 | 60 | error={actionData?.error} | |
| 63 | 61 | /> |
| 11 | 11 | import type { Route } from "./+types/settings-repository"; | |
| 12 | 12 | import { page } from "../../lib/meta"; | |
| 13 | 13 | import { confirmsName, tidyName } from "../../lib/repo-lifecycle"; | |
| 14 | − | import { Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 14 | + | import { Button, ErrorText, Field, Input, SubmitButton } from "../../components/ui"; | |
| 15 | 15 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 16 | 16 | import { | |
| 17 | 17 | AlertDialog, | |
| 157 | 157 | </Section> | |
| 158 | 158 | ||
| 159 | 159 | <Section title="Details" about="What the repository is, for its page, search and Explore."> | |
| 160 | − | <Form method="post"> | |
| 160 | + | {/* Keyed to what is saved, so the fields show the topics as the service tidied them. */} | |
| 161 | + | <Form method="post" key={`${repo.description ?? ""}|${repo.website ?? ""}|${(repo.topics ?? []).join(",")}`}> | |
| 161 | 162 | <fieldset disabled={archived} className="min-w-0 space-y-3 disabled:cursor-not-allowed disabled:opacity-60"> | |
| 162 | 163 | <input type="hidden" name="intent" value="details" /> | |
| 163 | 164 | <Field label="Description"> | |
| 173 | 174 | <Input name="topics" maxLength={800} defaultValue={(repo.topics ?? []).join(", ")} placeholder="cli, rust" /> | |
| 174 | 175 | </Field> | |
| 175 | 176 | <div className="flex flex-wrap items-center gap-4 pt-1"> | |
| 176 | − | <Button type="submit" disabled={posting("details")}> | |
| 177 | − | {posting("details") ? "Saving…" : "Save"} | |
| 178 | − | </Button> | |
| 177 | + | <SubmitButton match={{ intent: "details" }} pending="Saving…"> | |
| 178 | + | Save | |
| 179 | + | </SubmitButton> | |
| 179 | 180 | <Status intent="details" data={actionData} /> | |
| 180 | 181 | </div> | |
| 181 | 182 | </fieldset> | |
| 187 | 188 | about="The branch everything lands on, and renaming branches." | |
| 188 | 189 | > | |
| 189 | 190 | <DefaultBranchForm repo={repo} branches={branches} archived={archived || !owner} busy={posting("default-branch")} result={actionData} /> | |
| 191 | + | {/* Keyed to the branches, so after a rename it starts over on the branches there are now. */} | |
| 190 | 192 | <RenameBranchForm | |
| 193 | + | key={branches.join(" ")} | |
| 191 | 194 | repo={repo} | |
| 192 | 195 | branches={branches} | |
| 193 | 196 | owner={owner} |
| 1 | 1 | import { Box, Code2, GitBranch, Rocket } from "lucide-react"; | |
| 2 | 2 | import { useState } from "react"; | |
| 3 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 3 | + | import { Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { DeploysSetting } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/settings"; | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | 9 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 10 | − | import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui"; | |
| 10 | + | import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 11 | 11 | import { RadioGroup, RadioOption } from "../../components/ui/radio-group"; | |
| 12 | 12 | import { DEPLOYS_CHOICES } from "../../lib/project-kind"; | |
| 13 | 13 | import { deployments, identity, projects } from "../../lib/services.server"; | |
| 58 | 58 | const [deploys, setDeploys] = useState<DeploysSetting>(project.deploys); | |
| 59 | 59 | // Not deploying while Deployments are on is refused: they are turned off first. | |
| 60 | 60 | const blocked = deploys === "no" && project.deploys !== "no" && deploymentsOn === true; | |
| 61 | − | const saving = useNavigation().state === "submitting"; | |
| 62 | 61 | const base = `/${params.owner}/${params.repo}`; | |
| 63 | 62 | const source = project.source.kind === "hosted" ? project.source : null; | |
| 64 | 63 | return ( | |
| 65 | 64 | <div className="max-w-4xl"> | |
| 66 | 65 | <RepoSettingsHeading base={base} /> | |
| 67 | 66 | <Form method="post" className="space-y-8"> | |
| 67 | + | {/* Enter in a field saves: the form's first submit button is the one | |
| 68 | + | Enter presses, and otherwise it would be the description reset. */} | |
| 69 | + | <button type="submit" name="intent" value="save" tabIndex={-1} aria-hidden="true" className="absolute size-0 overflow-hidden opacity-0" /> | |
| 68 | 70 | <section className="space-y-4"> | |
| 69 | 71 | <h2 className="flex items-center gap-2 text-sm font-medium"> | |
| 70 | 72 | <Box size={15} className="text-accent" /> | |
| 92 | 94 | /> | |
| 93 | 95 | </Field> | |
| 94 | 96 | {!project.descriptionInherited && ( | |
| 95 | − | <button | |
| 96 | − | type="submit" | |
| 97 | + | <SubmitButton | |
| 97 | 98 | name="inherit" | |
| 98 | 99 | value="description" | |
| 99 | − | disabled={saving} | |
| 100 | − | className="mt-1.5 text-xs text-muted hover:text-fg disabled:opacity-50" | |
| 100 | + | pending="Resetting…" | |
| 101 | + | className="mt-1.5 inline-flex items-center gap-1.5 text-xs text-muted hover:text-fg disabled:opacity-50" | |
| 101 | 102 | > | |
| 102 | 103 | Use the repository's description | |
| 103 | − | </button> | |
| 104 | + | </SubmitButton> | |
| 104 | 105 | )} | |
| 105 | 106 | </div> | |
| 106 | 107 | </div> | |
| 185 | 186 | </section> | |
| 186 | 187 | ||
| 187 | 188 | <div className="flex items-center gap-3"> | |
| 188 | − | <Button type="submit" disabled={saving || blocked}> | |
| 189 | + | <SubmitButton name="intent" value="save" pending="Saving…" disabled={blocked}> | |
| 189 | 190 | Save | |
| 190 | − | </Button> | |
| 191 | + | </SubmitButton> | |
| 191 | 192 | {actionData && "saved" in actionData && <span className="text-sm text-accent">Saved.</span>} | |
| 192 | 193 | <span className="ml-auto text-xs text-faint"> | |
| 193 | 194 | Created{" "} |