web: workspace billing, people, tokens, integrations and settings buttons each say they are working instead of all at once; forms clear after they create; statement lines no longer duplicate on reload; context rebuild shows its progress
15 files+160−1310/15 viewed
| 24 | 24 | spendRange, | |
| 25 | 25 | wholeDollars, | |
| 26 | 26 | } from "../lib/billing"; | |
| 27 | − | import { Button, ErrorText } from "./ui"; | |
| 27 | + | import { ErrorText, SubmitButton } from "./ui"; | |
| 28 | 28 | import { RadioGroup, RadioGroupItem } from "./ui/radio-group"; | |
| 29 | 29 | ||
| 30 | 30 | /** An error the action returned, for the section it belongs to. */ | |
| 181 | 181 | owner, | |
| 182 | 182 | enabled, | |
| 183 | 183 | live, | |
| 184 | − | busy, | |
| 185 | 184 | meters, | |
| 186 | 185 | error, | |
| 187 | 186 | }: { | |
| 191 | 190 | owner: boolean; | |
| 192 | 191 | enabled: boolean; | |
| 193 | 192 | live: boolean; | |
| 194 | − | busy: boolean; | |
| 195 | 193 | /** This month's usage by meter, from billing's `usage_meters`. */ | |
| 196 | 194 | meters: MeterUsage[] | null; | |
| 197 | 195 | error?: string; | |
| 271 | 269 | <Form method="post" className="mt-5 flex flex-wrap items-center gap-3"> | |
| 272 | 270 | <input type="hidden" name="feature" value={plan?.feature ?? "plan"} /> | |
| 273 | 271 | {status.kind === "free" || status.kind === "trial" ? ( | |
| 274 | − | <Button variant="accent" type="submit" name="intent" value="subscribe" disabled={busy}> | |
| 272 | + | <SubmitButton variant="accent" name="intent" value="subscribe" pending="Opening Stripe…"> | |
| 275 | 273 | <CreditCard size={14} /> | |
| 276 | 274 | Start the g1t plan | |
| 277 | − | </Button> | |
| 275 | + | </SubmitButton> | |
| 278 | 276 | ) : status.kind === "canceling" ? ( | |
| 279 | − | <Button variant="accent" type="submit" name="intent" value="resume" disabled={busy}> | |
| 277 | + | <SubmitButton variant="accent" name="intent" value="resume" pending="Saving…"> | |
| 280 | 278 | Keep the plan | |
| 281 | − | </Button> | |
| 279 | + | </SubmitButton> | |
| 282 | 280 | ) : null} | |
| 283 | 281 | {(status.kind === "paid" || status.kind === "canceling" || status.kind === "past_due") && ( | |
| 284 | − | <Button variant={status.kind === "past_due" ? "accent" : "quiet"} type="submit" name="intent" value="portal" disabled={busy}> | |
| 282 | + | <SubmitButton variant={status.kind === "past_due" ? "accent" : "quiet"} name="intent" value="portal" pending="Opening Stripe…"> | |
| 285 | 283 | {status.kind === "past_due" ? "Update payment on Stripe" : "Manage on Stripe"} | |
| 286 | 284 | <ArrowUpRight size={14} /> | |
| 287 | − | </Button> | |
| 285 | + | </SubmitButton> | |
| 288 | 286 | )} | |
| 289 | 287 | {status.kind === "paid" && subscription && ( | |
| 290 | − | <Button variant="quiet" type="submit" name="intent" value="cancel" disabled={busy}> | |
| 288 | + | <SubmitButton variant="quiet" name="intent" value="cancel" pending="Saving…"> | |
| 291 | 289 | End at the end of the period | |
| 292 | − | </Button> | |
| 290 | + | </SubmitButton> | |
| 293 | 291 | )} | |
| 294 | 292 | {!live && (status.kind === "free" || status.kind === "trial") && ( | |
| 295 | 293 | <span className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</span> | |
| 369 | 367 | trialMicros, | |
| 370 | 368 | owner, | |
| 371 | 369 | enabled, | |
| 372 | − | busy, | |
| 373 | 370 | error, | |
| 374 | 371 | }: { | |
| 375 | 372 | entitlements: Entitlements | null; | |
| 376 | 373 | trialMicros: number; | |
| 377 | 374 | owner: boolean; | |
| 378 | 375 | enabled: boolean; | |
| 379 | − | busy: boolean; | |
| 380 | 376 | error?: string; | |
| 381 | 377 | }) { | |
| 382 | 378 | const verified = entitlements?.trialVerified ?? false; | |
| 397 | 393 | {verified && left > 0 && <Meter label="Trial used" used={Math.max(0, trialMicros - left)} of={trialMicros} />} | |
| 398 | 394 | {!verified && enabled && owner && ( | |
| 399 | 395 | <Form method="post" className="mt-4 flex flex-wrap items-center gap-3"> | |
| 400 | − | <Button variant="quiet" type="submit" name="intent" value="card-check" disabled={busy}> | |
| 396 | + | <SubmitButton variant="quiet" name="intent" value="card-check" pending="Opening Stripe…"> | |
| 401 | 397 | <ShieldCheck size={14} /> | |
| 402 | 398 | Check a card | |
| 403 | − | </Button> | |
| 399 | + | </SubmitButton> | |
| 404 | 400 | <span className="text-xs text-faint">Trials come from a monthly pool; when it is given out, new ones start on the 1st.</span> | |
| 405 | 401 | </Form> | |
| 406 | 402 | )} | |
| 412 | 408 | ||
| 413 | 409 | // --- Spend limit ----------------------------------------------------------------------- | |
| 414 | 410 | ||
| 415 | − | export function SpendLimitCard({ limit, owner, busy, error }: { limit: Limit; owner: boolean; busy: boolean; error?: string }) { | |
| 411 | + | export function SpendLimitCard({ limit, owner, error }: { limit: Limit; owner: boolean; error?: string }) { | |
| 416 | 412 | const range = spendRange(limit); | |
| 417 | 413 | const spent = limit.spentMicros ?? 0; | |
| 418 | 414 | const spendLimit = limit.spendLimitMicros; | |
| 513 | 509 | </RadioGroup> | |
| 514 | 510 | </fieldset> | |
| 515 | 511 | <div className="mt-3 flex flex-wrap items-center gap-3"> | |
| 516 | − | <Button variant="quiet" type="submit" disabled={busy}> | |
| 512 | + | <SubmitButton variant="quiet" match={{ intent: "spend-limit" }} pending="Saving…"> | |
| 517 | 513 | Save limit | |
| 518 | − | </Button> | |
| 514 | + | </SubmitButton> | |
| 519 | 515 | <a href="#raise" className="text-sm text-muted hover:text-fg"> | |
| 520 | 516 | Need more? Raise my limit | |
| 521 | 517 | </a> | |
| 559 | 555 | ); | |
| 560 | 556 | } | |
| 561 | 557 | ||
| 562 | − | export function RaiseCard({ requests, owner, busy, error }: { requests: LimitRequest[]; owner: boolean; busy: boolean; error?: string }) { | |
| 558 | + | export function RaiseCard({ requests, owner, error }: { requests: LimitRequest[]; owner: boolean; error?: string }) { | |
| 563 | 559 | const mine = requests.filter((request) => request.kind === "limit"); | |
| 564 | 560 | const waiting = mine.some((request) => request.status === "open"); | |
| 565 | 561 | return ( | |
| 592 | 588 | className={`${CONTROL} w-full`} | |
| 593 | 589 | /> | |
| 594 | 590 | </label> | |
| 595 | − | <Button variant="quiet" type="submit" disabled={busy}> | |
| 591 | + | <SubmitButton variant="quiet" match={{ intent: "request", kind: "limit" }} pending="Sending…"> | |
| 596 | 592 | Send the request | |
| 597 | − | </Button> | |
| 593 | + | </SubmitButton> | |
| 598 | 594 | <ErrorText>{error}</ErrorText> | |
| 599 | 595 | </Form> | |
| 600 | 596 | )} | |
| 607 | 603 | ||
| 608 | 604 | // --- Prepay ----------------------------------------------------------------------- | |
| 609 | 605 | ||
| 610 | − | export function PrepayCard({ prepaidMicros, owner, live, busy, error }: { prepaidMicros: number; owner: boolean; live: boolean; busy: boolean; error?: string }) { | |
| 606 | + | export function PrepayCard({ prepaidMicros, owner, live, error }: { prepaidMicros: number; owner: boolean; live: boolean; error?: string }) { | |
| 611 | 607 | return ( | |
| 612 | 608 | <Card | |
| 613 | 609 | id="prepay" | |
| 626 | 622 | <input type="hidden" name="intent" value="prepay" /> | |
| 627 | 623 | <div className="flex flex-wrap items-center gap-2"> | |
| 628 | 624 | {PREPAY.presets.map((amount) => ( | |
| 629 | − | <Button key={amount} variant="quiet" type="submit" name="amount" value={amount} disabled={busy}> | |
| 625 | + | <SubmitButton key={amount} variant="quiet" name="amount" value={amount} match={{ intent: "prepay" }} pending="Opening Stripe…"> | |
| 630 | 626 | {wholeDollars(amount * 1_000_000)} | |
| 631 | − | </Button> | |
| 627 | + | </SubmitButton> | |
| 632 | 628 | ))} | |
| 633 | 629 | <span className="text-sm text-muted">or</span> | |
| 634 | 630 | <DollarInput name="custom" label="Amount to prepay in dollars" placeholder="250" /> | |
| 635 | − | <Button variant="accent" type="submit" disabled={busy}> | |
| 631 | + | {/* An empty amount, so a preset's press is not this button's: the typed amount is used. */} | |
| 632 | + | <SubmitButton variant="accent" name="amount" value="" match={{ intent: "prepay" }} pending="Opening Stripe…"> | |
| 636 | 633 | <CreditCard size={14} /> | |
| 637 | 634 | Prepay | |
| 638 | − | </Button> | |
| 635 | + | </SubmitButton> | |
| 639 | 636 | </div> | |
| 640 | 637 | <RadioGroup name="method" defaultValue="card" className="flex flex-wrap gap-x-5 gap-y-2 text-sm"> | |
| 641 | 638 | <label className="flex cursor-pointer items-center gap-2"> | |
| 659 | 656 | ||
| 660 | 657 | // --- Caps ----------------------------------------------------------------------- | |
| 661 | 658 | ||
| 662 | − | export function CapsCard({ entitlements, owner, busy, error }: { entitlements: Entitlements; owner: boolean; busy: boolean; error?: string }) { | |
| 659 | + | export function CapsCard({ entitlements, owner, error }: { entitlements: Entitlements; owner: boolean; error?: string }) { | |
| 663 | 660 | return ( | |
| 664 | 661 | <Card | |
| 665 | 662 | id="caps" | |
| 697 | 694 | </span> | |
| 698 | 695 | <DollarInput name="issue" label="Issue cap in dollars" defaultValue={String(entitlements.issueCapMicros / 1_000_000)} /> | |
| 699 | 696 | </label> | |
| 700 | − | <Button variant="quiet" type="submit" disabled={busy}> | |
| 697 | + | <SubmitButton variant="quiet" match={{ intent: "caps" }} pending="Saving…"> | |
| 701 | 698 | Save caps | |
| 702 | − | </Button> | |
| 699 | + | </SubmitButton> | |
| 703 | 700 | </Form> | |
| 704 | 701 | )} | |
| 705 | 702 | <ErrorText>{error}</ErrorText> | |
| 713 | 710 | ||
| 714 | 711 | // --- Overage ----------------------------------------------------------------------- | |
| 715 | 712 | ||
| 716 | − | export function OverageCard({ requests, owner, busy, error }: { requests: LimitRequest[]; owner: boolean; busy: boolean; error?: string }) { | |
| 713 | + | export function OverageCard({ requests, owner, error }: { requests: LimitRequest[]; owner: boolean; error?: string }) { | |
| 717 | 714 | const mine = requests.filter((request) => request.kind === "overage"); | |
| 718 | 715 | const waiting = mine.some((request) => request.status === "open"); | |
| 719 | 716 | return ( | |
| 734 | 731 | placeholder="An agent kept retrying a failing check on Tuesday night." | |
| 735 | 732 | className={`${CONTROL} w-full`} | |
| 736 | 733 | /> | |
| 737 | − | <Button variant="quiet" type="submit" disabled={busy}> | |
| 734 | + | <SubmitButton variant="quiet" match={{ intent: "request", kind: "overage" }} pending="Sending…"> | |
| 738 | 735 | Tell g1t | |
| 739 | − | </Button> | |
| 736 | + | </SubmitButton> | |
| 740 | 737 | <ErrorText>{error}</ErrorText> | |
| 741 | 738 | </Form> | |
| 742 | 739 | )} |
| 3 | 3 | ||
| 4 | 4 | import type { Invite, InvitesOverview } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { Button, CopyLine, ErrorText, Field, Input, TimeAgo } from "./ui"; | |
| 6 | + | import { CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui"; | |
| 7 | 7 | import { inviteFor, inviteLink, inviteState, moreInvitesMailto, remainingLine } from "../lib/invites"; | |
| 8 | 8 | ||
| 9 | 9 | const TONE: Record<"pending" | "done" | "dead", string> = { | |
| 23 | 23 | <Form method="post" className="ml-auto"> | |
| 24 | 24 | <input type="hidden" name="intent" value="revoke-invite" /> | |
| 25 | 25 | <input type="hidden" name="id" value={invite.id} /> | |
| 26 | − | <Button variant="quiet" type="submit"> | |
| 26 | + | <SubmitButton variant="quiet" match={{ intent: "revoke-invite", id: invite.id }} pending="Revoking…"> | |
| 27 | 27 | Revoke | |
| 28 | − | </Button> | |
| 28 | + | </SubmitButton> | |
| 29 | 29 | </Form> | |
| 30 | 30 | )} | |
| 31 | 31 | </div> | |
| 112 | 112 | </p> | |
| 113 | 113 | </div> | |
| 114 | 114 | ) : ( | |
| 115 | − | <Form method="post" className="mt-4 space-y-3"> | |
| 115 | + | // Empty again once the invite is made; kept as filled in when it failed. | |
| 116 | + | <Form method="post" key={created?.id ?? ""} className="mt-4 space-y-3"> | |
| 116 | 117 | <input type="hidden" name="intent" value="create-invite" /> | |
| 117 | 118 | <div className="flex flex-col gap-3 sm:flex-row sm:items-end"> | |
| 118 | 119 | <div className="grow"> | |
| 138 | 139 | </select> | |
| 139 | 140 | </label> | |
| 140 | 141 | )} | |
| 141 | − | <Button type="submit">Create invite</Button> | |
| 142 | + | <SubmitButton match={{ intent: "create-invite" }} pending="Creating…"> | |
| 143 | + | Create invite | |
| 144 | + | </SubmitButton> | |
| 142 | 145 | </div> | |
| 143 | 146 | <p className="text-xs text-faint"> | |
| 144 | 147 | With an email, the invite is sent there and only that address can use it. |
| 9 | 9 | ||
| 10 | 10 | import { type Connection, MODEL_TASKS, type ModelRoute, type ModelTask, type Provider, PROVIDERS } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | − | import { Button, Field, Input } from "./ui"; | |
| 12 | + | import { Field, Input, SubmitButton } from "./ui"; | |
| 13 | 13 | import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select"; | |
| 14 | 14 | ||
| 15 | 15 | type Group = "labs" | "platforms" | "any"; | |
| 361 | 361 | hostedOpen, | |
| 362 | 362 | marginPercent, | |
| 363 | 363 | owner, | |
| 364 | − | busy, | |
| 365 | 364 | saved, | |
| 366 | 365 | }: { | |
| 367 | 366 | connections: Connection[]; | |
| 369 | 368 | hostedOpen: boolean; | |
| 370 | 369 | marginPercent: number; | |
| 371 | 370 | owner: boolean; | |
| 372 | − | busy: boolean; | |
| 373 | 371 | saved: boolean; | |
| 374 | 372 | }) { | |
| 375 | 373 | const fallback: Choice = hostedOpen || connections.length === 0 ? { target: "g1t", model: "" } : { target: connections[0].id, model: "" }; | |
| 477 | 475 | </ul> | |
| 478 | 476 | {owner && ( | |
| 479 | 477 | <div className="flex items-center gap-3 border-t border-line px-4 py-3"> | |
| 480 | − | <Button type="submit" variant="quiet" disabled={busy}> | |
| 478 | + | <SubmitButton variant="quiet" match={{ intent: "routes" }} pending="Saving…"> | |
| 481 | 479 | Save routing | |
| 482 | − | </Button> | |
| 480 | + | </SubmitButton> | |
| 483 | 481 | {saved && <span className="text-sm text-accent">Saved. The next runs use it.</span>} | |
| 484 | 482 | </div> | |
| 485 | 483 | )} |
| 184 | 184 | const [open, setOpen] = useState(false); | |
| 185 | 185 | const [entries, setEntries] = useState<LedgerEntry[]>([]); | |
| 186 | 186 | const [done, setDone] = useState(false); | |
| 187 | − | const fetcher = useFetcher<LedgerEntry[]>(); | |
| 187 | + | const fetcher = useFetcher<LedgerEntry[] | { error: string }>(); | |
| 188 | 188 | const [seen, setSeen] = useState<LedgerEntry[] | undefined>(undefined); | |
| 189 | + | const failed = fetcher.data && !Array.isArray(fetcher.data) ? fetcher.data.error : null; | |
| 189 | 190 | ||
| 190 | − | // Each page that arrives is added once. | |
| 191 | − | if (fetcher.data && fetcher.data !== seen) { | |
| 192 | − | setSeen(fetcher.data); | |
| 193 | − | setEntries((before) => [...before, ...fetcher.data!]); | |
| 194 | − | if (fetcher.data.length < 50) setDone(true); | |
| 191 | + | // Each page that arrives is added once. A page can come again: the page | |
| 192 | + | // reloads what fetchers loaded after every change made on it, so an entry | |
| 193 | + | // already listed is not listed twice. | |
| 194 | + | if (Array.isArray(fetcher.data) && fetcher.data !== seen) { | |
| 195 | + | const page = fetcher.data; | |
| 196 | + | setSeen(page); | |
| 197 | + | setEntries((before) => { | |
| 198 | + | const listed = new Set(before.map((entry) => entry.id)); | |
| 199 | + | return [...before, ...page.filter((entry) => !listed.has(entry.id))]; | |
| 200 | + | }); | |
| 201 | + | if (page.length < 50) setDone(true); | |
| 195 | 202 | } | |
| 196 | 203 | ||
| 197 | 204 | const load = (before: string | null) => { | |
| 253 | 260 | </li> | |
| 254 | 261 | ))} | |
| 255 | 262 | </ul> | |
| 263 | + | {failed && fetcher.state === "idle" && <p className="py-2 pl-11 text-xs text-danger">{failed}</p>} | |
| 256 | 264 | {fetcher.state === "loading" && ( | |
| 257 | 265 | <div aria-busy="true" className="pl-8"> | |
| 258 | 266 | <SkeletonRows rows={3} rowClassName="h-12" /> |
| 1 | 1 | import { ArrowUpRight, CreditCard, FileText } from "lucide-react"; | |
| 2 | − | import { Form, Link, data, redirect, useNavigation } from "react-router"; | |
| 2 | + | import { Form, Link, data, redirect } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import { MICROS_PER_DOLLAR, type WorkspaceInvoice } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 17 | 17 | TrialCard, | |
| 18 | 18 | } from "../../components/billing"; | |
| 19 | 19 | import { StatementView } from "../../components/statement"; | |
| 20 | − | import { Button } from "../../components/ui"; | |
| 20 | + | import { SubmitButton } from "../../components/ui"; | |
| 21 | 21 | import { | |
| 22 | 22 | cardCheckResult, | |
| 23 | 23 | dollars, | |
| 209 | 209 | loaderData; | |
| 210 | 210 | const { status } = account; | |
| 211 | 211 | const owner = role === "owner"; | |
| 212 | − | const busy = useNavigation().state === "submitting"; | |
| 213 | 212 | const error = actionData as SectionError; | |
| 214 | 213 | const err = (section: string) => (error && error.section === section ? error.error : undefined); | |
| 215 | 214 | const standing = planStatus(plan, entitlements); | |
| 243 | 242 | owner={owner} | |
| 244 | 243 | enabled={status.enabled} | |
| 245 | 244 | live={status.live} | |
| 246 | − | busy={busy} | |
| 247 | 245 | meters={meters} | |
| 248 | 246 | error={err("plan")} | |
| 249 | 247 | /> | |
| 250 | 248 | ||
| 251 | 249 | {free && status.enabled && ( | |
| 252 | − | <TrialCard entitlements={entitlements} trialMicros={trialMicros} owner={owner} enabled={status.enabled} busy={busy} error={err("trial")} /> | |
| 250 | + | <TrialCard entitlements={entitlements} trialMicros={trialMicros} owner={owner} enabled={status.enabled} error={err("trial")} /> | |
| 253 | 251 | )} | |
| 254 | 252 | ||
| 255 | 253 | {paying && limit && limit.trust !== "internal" && ( | |
| 256 | 254 | <> | |
| 257 | − | <SpendLimitCard limit={limit} owner={owner} busy={busy} error={err("limit")} /> | |
| 258 | − | <RaiseCard requests={requests} owner={owner} busy={busy} error={err("raise")} /> | |
| 259 | − | <PrepayCard prepaidMicros={prepaid} owner={owner} live={status.live} busy={busy} error={err("prepay")} /> | |
| 255 | + | <SpendLimitCard limit={limit} owner={owner} error={err("limit")} /> | |
| 256 | + | <RaiseCard requests={requests} owner={owner} error={err("raise")} /> | |
| 257 | + | <PrepayCard prepaidMicros={prepaid} owner={owner} live={status.live} error={err("prepay")} /> | |
| 260 | 258 | </> | |
| 261 | 259 | )} | |
| 262 | 260 | ||
| 263 | 261 | {entitlements && (paying || standing.kind === "trial" || standing.kind === "comped" || standing.kind === "enterprise") && ( | |
| 264 | − | <CapsCard entitlements={entitlements} owner={owner} busy={busy} error={err("caps")} /> | |
| 262 | + | <CapsCard entitlements={entitlements} owner={owner} error={err("caps")} /> | |
| 265 | 263 | )} | |
| 266 | 264 | ||
| 267 | − | {paying && <OverageCard requests={requests} owner={owner} busy={busy} error={err("overage")} />} | |
| 265 | + | {paying && <OverageCard requests={requests} owner={owner} error={err("overage")} />} | |
| 268 | 266 | ||
| 269 | 267 | {status.enabled && ( | |
| 270 | 268 | <section className="mb-10 rounded-xl border border-line bg-surface p-5"> | |
| 286 | 284 | </div> | |
| 287 | 285 | {owner && ( | |
| 288 | 286 | <Form method="post"> | |
| 289 | − | <Button variant="quiet" type="submit" name="intent" value="portal" disabled={busy}> | |
| 287 | + | <SubmitButton variant="quiet" name="intent" value="portal" pending="Opening Stripe…"> | |
| 290 | 288 | <CreditCard size={14} /> | |
| 291 | 289 | Open Stripe billing | |
| 292 | − | </Button> | |
| 290 | + | </SubmitButton> | |
| 293 | 291 | </Form> | |
| 294 | 292 | )} | |
| 295 | 293 | </div> |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/context"; | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | + | import { useRefreshWhile } from "../../lib/refresh"; | |
| 9 | 10 | import { CatalogView, ReviewQueue, ScorecardsView, SearchView, fixRule, reviewAction } from "../../components/context"; | |
| 11 | + | import { SubmitButton } from "../../components/ui"; | |
| 10 | 12 | import { Tabs, TabsList, TabsTrigger } from "../../components/ui/tabs"; | |
| 11 | 13 | import { context as hub, memoryReview, work } from "../../lib/services.server"; | |
| 12 | 14 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 70 | 72 | return ( | |
| 71 | 73 | <fetcher.Form method="post" action={action} className="flex items-center gap-2"> | |
| 72 | 74 | <input type="hidden" name="intent" value="backfill" /> | |
| 73 | − | <button | |
| 74 | − | type="submit" | |
| 75 | − | disabled={running || fetcher.state !== "idle"} | |
| 75 | + | <SubmitButton | |
| 76 | + | fetcher={fetcher} | |
| 77 | + | match={{ intent: "backfill" }} | |
| 78 | + | pending="Starting…" | |
| 79 | + | disabled={running} | |
| 76 | 80 | className="inline-flex items-center gap-1.5 rounded-md border border-line px-2.5 py-1.5 text-xs text-muted hover:border-line-strong hover:text-fg disabled:opacity-50" | |
| 77 | 81 | > | |
| 78 | 82 | <RefreshCw size={12} className={running ? "animate-spin" : ""} /> | |
| 79 | 83 | {running ? "Building…" : "Rebuild"} | |
| 80 | − | </button> | |
| 84 | + | </SubmitButton> | |
| 81 | 85 | {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>} | |
| 82 | 86 | </fetcher.Form> | |
| 83 | 87 | ); | |
| 89 | 93 | const navigate = useNavigate(); | |
| 90 | 94 | const backfill = status.backfill; | |
| 91 | 95 | const running = backfill?.status === "running"; | |
| 96 | + | // While it builds, the count of projects done keeps up on its own. | |
| 97 | + | useRefreshWhile(running); | |
| 92 | 98 | const total = Object.values(status.counts).reduce((sum, n) => sum + (n ?? 0), 0); | |
| 93 | 99 | return ( | |
| 94 | 100 | <div className="space-y-6"> |
| 1 | − | import { data, useNavigation } from "react-router"; | |
| 1 | + | import { data } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import { RUN_KINDS } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/guardrails"; | |
| 6 | 6 | import { GuardrailsForm } from "../../components/guardrails"; | |
| 7 | + | import { usePending } from "../../components/ui"; | |
| 7 | 8 | import { settingsFromForm } from "../../lib/guardrails"; | |
| 8 | 9 | import { page } from "../../lib/meta"; | |
| 9 | 10 | import { guardrails } from "../../lib/services.server"; | |
| 37 | 38 | } | |
| 38 | 39 | ||
| 39 | 40 | export default function WorkspaceGuardrails({ loaderData, actionData }: Route.ComponentProps) { | |
| 40 | − | const saving = useNavigation().state === "submitting"; | |
| 41 | + | // Until the saved settings are back on the page, not just until the post is answered. | |
| 42 | + | const saving = usePending(); | |
| 41 | 43 | return ( | |
| 42 | 44 | <GuardrailsForm | |
| 43 | 45 | view={loaderData.view} |
| 9 | 9 | } from "lucide-react"; | |
| 10 | 10 | import { env } from "cloudflare:workers"; | |
| 11 | 11 | import type { ReactNode } from "react"; | |
| 12 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 12 | + | import { Form, Link } from "react-router"; | |
| 13 | 13 | ||
| 14 | 14 | import { | |
| 15 | 15 | type Connection, | |
| 26 | 26 | import { page } from "../../lib/meta"; | |
| 27 | 27 | import { trialClosed } from "../../lib/trial"; | |
| 28 | 28 | import { MODEL_CATALOG, ModelCatalog, ModelProviderFields, ProviderMark, ProviderTiles, Routing } from "../../components/model-providers"; | |
| 29 | − | import { Avatar, Button, CopyLine, ErrorText, Field, Input, TimeAgo } from "../../components/ui"; | |
| 29 | + | import { Avatar, CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 30 | 30 | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 31 | 31 | import { Combobox } from "../../components/ui/combobox"; | |
| 32 | 32 | import { billing, integrations, repos } from "../../lib/services.server"; | |
| 135 | 135 | signingSecret: text(form, "signingSecret"), | |
| 136 | 136 | secret: text(form, "secret"), | |
| 137 | 137 | }); | |
| 138 | − | return { error: updated.ok ? null : updated.error.message }; | |
| 138 | + | return updated.ok ? { updated: id } : { error: updated.error.message }; | |
| 139 | 139 | } | |
| 140 | 140 | const provider = String(form.get("provider") ?? ""); | |
| 141 | 141 | if (!isProvider(provider)) return { error: "Choose what to connect." }; | |
| 183 | 183 | const { slug, role, connections, deliveries, repos: repoNames, adding, free, marginPercent, hostedOpen, hostedPreview, trial, routes } = | |
| 184 | 184 | loaderData; | |
| 185 | 185 | const owner = role === "owner"; | |
| 186 | − | const busy = useNavigation().state === "submitting"; | |
| 187 | 186 | const modelConnections = connections.filter((connection) => connection.kind === "models"); | |
| 188 | 187 | const justConnected = actionData && "connected" in actionData ? actionData.connected : null; | |
| 189 | 188 | const tested = (actionData && "tested" in actionData ? actionData.tested : null) ?? null; | |
| 190 | 189 | const error = (actionData && "error" in actionData ? actionData.error : null) ?? null; | |
| 190 | + | const updated = (actionData && "updated" in actionData ? actionData.updated : null) ?? null; | |
| 191 | 191 | ||
| 192 | 192 | return ( | |
| 193 | 193 | <div> | |
| 212 | 212 | </p> | |
| 213 | 213 | </div> | |
| 214 | 214 | )} | |
| 215 | − | <Connections list={modelConnections} owner={owner} busy={busy} tested={tested} deliveries={deliveries} /> | |
| 215 | + | <Connections list={modelConnections} owner={owner} tested={tested} updated={updated} deliveries={deliveries} /> | |
| 216 | 216 | {modelConnections.length === 0 && hostedOpen && ( | |
| 217 | 217 | <p className="flex items-center gap-2 rounded-xl border border-line bg-surface px-4 py-3 text-sm text-muted"> | |
| 218 | 218 | <CheckCircle2 size={15} className="shrink-0 text-merged" /> | |
| 227 | 227 | )} | |
| 228 | 228 | {modelConnections.length > 0 && ( | |
| 229 | 229 | <Routing | |
| 230 | + | // Started again from what is saved whenever that changes, such as a provider disconnected. | |
| 231 | + | key={JSON.stringify([routes, modelConnections.map((c) => c.id)])} | |
| 230 | 232 | connections={modelConnections} | |
| 231 | 233 | routes={routes} | |
| 232 | 234 | hostedOpen={hostedOpen} | |
| 233 | 235 | marginPercent={marginPercent} | |
| 234 | 236 | owner={owner} | |
| 235 | − | busy={busy} | |
| 236 | 237 | saved={actionData != null && "routed" in actionData} | |
| 237 | 238 | /> | |
| 238 | 239 | )} | |
| 250 | 251 | provider={adding} | |
| 251 | 252 | slug={slug} | |
| 252 | 253 | repos={repoNames} | |
| 253 | − | busy={busy} | |
| 254 | 254 | error={actionData && "provider" in actionData ? error : null} | |
| 255 | 255 | /> | |
| 256 | 256 | )} | |
| 261 | 261 | <Connections | |
| 262 | 262 | list={connections.filter((c) => c.kind === "alerts")} | |
| 263 | 263 | owner={owner} | |
| 264 | − | busy={busy} | |
| 265 | 264 | tested={tested} | |
| 265 | + | updated={updated} | |
| 266 | 266 | deliveries={deliveries} | |
| 267 | 267 | /> | |
| 268 | 268 | {owner && <Choices kind="alerts" slug={slug} adding={adding} />} | |
| 271 | 271 | provider={adding} | |
| 272 | 272 | slug={slug} | |
| 273 | 273 | repos={repoNames} | |
| 274 | − | busy={busy} | |
| 275 | 274 | error={actionData && "provider" in actionData ? error : null} | |
| 276 | 275 | /> | |
| 277 | 276 | )} | |
| 282 | 281 | <Connections | |
| 283 | 282 | list={connections.filter((c) => c.kind === "tracker")} | |
| 284 | 283 | owner={owner} | |
| 285 | − | busy={busy} | |
| 286 | 284 | tested={tested} | |
| 285 | + | updated={updated} | |
| 287 | 286 | deliveries={deliveries} | |
| 288 | 287 | /> | |
| 289 | 288 | {owner && <Choices kind="tracker" slug={slug} adding={adding} />} | |
| 292 | 291 | provider={adding} | |
| 293 | 292 | slug={slug} | |
| 294 | 293 | repos={repoNames} | |
| 295 | − | busy={busy} | |
| 296 | 294 | error={actionData && "provider" in actionData ? error : null} | |
| 297 | 295 | /> | |
| 298 | 296 | )} | |
| 320 | 318 | function Connections({ | |
| 321 | 319 | list, | |
| 322 | 320 | owner, | |
| 323 | − | busy, | |
| 324 | 321 | tested, | |
| 322 | + | updated, | |
| 325 | 323 | deliveries, | |
| 326 | 324 | }: { | |
| 327 | 325 | list: Connection[]; | |
| 328 | 326 | owner: boolean; | |
| 329 | − | busy: boolean; | |
| 330 | 327 | tested: { id: string; ok: boolean; message: string } | null; | |
| 328 | + | /** The connection whose secret was just saved. */ | |
| 329 | + | updated: string | null; | |
| 331 | 330 | deliveries: Record<string, Delivery[]>; | |
| 332 | 331 | }) { | |
| 333 | 332 | if (list.length === 0) return null; | |
| 338 | 337 | <ConnectionRow | |
| 339 | 338 | connection={connection} | |
| 340 | 339 | owner={owner} | |
| 341 | − | busy={busy} | |
| 342 | 340 | tested={tested} | |
| 341 | + | updated={updated === connection.id} | |
| 343 | 342 | deliveries={deliveries[connection.id] ?? []} | |
| 344 | 343 | /> | |
| 345 | 344 | </li> | |
| 361 | 360 | function ConnectionRow({ | |
| 362 | 361 | connection, | |
| 363 | 362 | owner, | |
| 364 | − | busy, | |
| 365 | 363 | tested, | |
| 364 | + | updated, | |
| 366 | 365 | deliveries, | |
| 367 | 366 | }: { | |
| 368 | 367 | connection: Connection; | |
| 369 | 368 | owner: boolean; | |
| 370 | − | busy: boolean; | |
| 371 | 369 | tested: { id: string; ok: boolean; message: string } | null; | |
| 370 | + | updated: boolean; | |
| 372 | 371 | deliveries: Delivery[]; | |
| 373 | 372 | }) { | |
| 374 | 373 | const { config } = connection; | |
| 401 | 400 | {owner && ( | |
| 402 | 401 | <Form method="post" className="flex shrink-0 gap-2"> | |
| 403 | 402 | <input type="hidden" name="id" value={connection.id} /> | |
| 404 | − | <Button variant="quiet" type="submit" name="intent" value="test" disabled={busy}> | |
| 403 | + | <SubmitButton variant="quiet" name="intent" value="test" match={{ id: connection.id }} pending="Testing…"> | |
| 405 | 404 | Test | |
| 406 | − | </Button> | |
| 407 | − | <Button variant="quiet" type="submit" name="intent" value="disconnect" disabled={busy} aria-label="Disconnect"> | |
| 405 | + | </SubmitButton> | |
| 406 | + | <SubmitButton variant="quiet" name="intent" value="disconnect" match={{ id: connection.id }} pending="Disconnecting…" aria-label="Disconnect"> | |
| 408 | 407 | <X size={14} /> | |
| 409 | − | </Button> | |
| 408 | + | </SubmitButton> | |
| 410 | 409 | </Form> | |
| 411 | 410 | )} | |
| 412 | 411 | </div> | |
| 434 | 433 | <Input name="signingSecret" type="password" placeholder="Paste to replace" /> | |
| 435 | 434 | </Field> | |
| 436 | 435 | </div> | |
| 437 | − | <Button type="submit" variant="quiet" disabled={busy}> | |
| 436 | + | <SubmitButton variant="quiet" match={{ intent: "update", id: connection.id }} pending="Saving…"> | |
| 438 | 437 | Save | |
| 439 | − | </Button> | |
| 438 | + | </SubmitButton> | |
| 439 | + | {updated && <span className="pb-2 text-sm text-muted">Saved.</span>} | |
| 440 | 440 | </Form> | |
| 441 | 441 | )} | |
| 442 | 442 | {deliveries.length > 0 && ( | |
| 526 | 526 | provider, | |
| 527 | 527 | slug, | |
| 528 | 528 | repos, | |
| 529 | − | busy, | |
| 530 | 529 | error, | |
| 531 | 530 | }: { | |
| 532 | 531 | provider: Provider; | |
| 533 | 532 | slug: string; | |
| 534 | 533 | repos: string[]; | |
| 535 | − | busy: boolean; | |
| 536 | 534 | error: string | null; | |
| 537 | 535 | }) { | |
| 538 | 536 | const fields: Partial<Record<Provider, ReactNode>> = { | |
| 609 | 607 | {fields[provider] ?? <ModelProviderFields provider={provider} />} | |
| 610 | 608 | <ErrorText>{error}</ErrorText> | |
| 611 | 609 | <div> | |
| 612 | − | <Button type="submit" disabled={busy}> | |
| 613 | − | {busy ? "Connecting…" : "Connect"} | |
| 614 | − | </Button> | |
| 610 | + | <SubmitButton match={{ provider }} pending="Connecting…"> | |
| 611 | + | Connect | |
| 612 | + | </SubmitButton> | |
| 615 | 613 | </div> | |
| 616 | 614 | </Form> | |
| 617 | 615 | </section> |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/new"; | |
| 4 | 4 | import { page } from "../../lib/meta"; | |
| 5 | − | import { Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 5 | + | import { ErrorText, Field, Input, SubmitButton } from "../../components/ui"; | |
| 6 | 6 | import { identity } from "../../lib/services.server"; | |
| 7 | 7 | import { assertSameOrigin, nextPath, requireUser } from "../../lib/session.server"; | |
| 8 | 8 | ||
| 73 | 73 | <Input name="displayName" maxLength={80} /> | |
| 74 | 74 | </Field> | |
| 75 | 75 | <ErrorText>{actionData?.error}</ErrorText> | |
| 76 | − | <Button type="submit">Create workspace</Button> | |
| 76 | + | <SubmitButton pending="Creating…">Create workspace</SubmitButton> | |
| 77 | 77 | </Form> | |
| 78 | 78 | </main> | |
| 79 | 79 | ); |
| 1 | 1 | import { BookOpen, Box, Trash2 } from "lucide-react"; | |
| 2 | − | import { Form, Link, data, redirect, useNavigation } from "react-router"; | |
| 2 | + | import { Form, Link, data, redirect } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import { ECOSYSTEMS, type Ecosystem, type PackageVersion } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 7 | 7 | import { Markdown } from "../../components/markdown"; | |
| 8 | 8 | import { PackageIcon } from "../../components/package-icon"; | |
| 9 | 9 | import { ConfirmDialog } from "../../components/repo-lifecycle"; | |
| 10 | − | import { Button, CopyLine, ErrorText, TimeAgo } from "../../components/ui"; | |
| 10 | + | import { Button, CopyLine, ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 11 | 11 | import { Badge } from "../../components/ui/badge"; | |
| 12 | 12 | import { page } from "../../lib/meta"; | |
| 13 | 13 | import { ECOSYSTEM_LABEL, formatBytes, installCommands, shortDigest } from "../../lib/packages"; | |
| 293 | 293 | ||
| 294 | 294 | function Settings({ detail }: { detail: Route.ComponentProps["loaderData"]["detail"] }) { | |
| 295 | 295 | const { package: pkg } = detail; | |
| 296 | − | const busy = useNavigation().state !== "idle"; | |
| 297 | 296 | return ( | |
| 298 | 297 | <section className="space-y-4 rounded-xl border border-line bg-surface p-5"> | |
| 299 | 298 | <h2 className="text-sm font-semibold">Settings</h2> | |
| 318 | 317 | <option value="private">Private: workspace members</option> | |
| 319 | 318 | <option value="public">Public: anyone can pull</option> | |
| 320 | 319 | </select> | |
| 321 | − | <Button type="submit" variant="quiet" disabled={busy}> | |
| 320 | + | <SubmitButton variant="quiet" match={{ intent: "visibility" }} pending="Saving…"> | |
| 322 | 321 | Save | |
| 323 | − | </Button> | |
| 322 | + | </SubmitButton> | |
| 324 | 323 | </Form> | |
| 325 | 324 | )} | |
| 326 | 325 | <Form method="post" className="flex flex-wrap items-center gap-2 text-sm"> | |
| 335 | 334 | placeholder="none" | |
| 336 | 335 | className="h-8 w-56 rounded-md border border-line bg-bg px-2 font-mono text-sm placeholder:text-faint" | |
| 337 | 336 | /> | |
| 338 | − | <Button type="submit" variant="quiet" disabled={busy}> | |
| 337 | + | <SubmitButton variant="quiet" match={{ intent: "link" }} pending="Saving…"> | |
| 339 | 338 | {pkg.repo ? "Change link" : "Link"} | |
| 340 | − | </Button> | |
| 339 | + | </SubmitButton> | |
| 341 | 340 | <span className="text-xs text-faint">Empty to unlink. A linked package takes the repository's access.</span> | |
| 342 | 341 | </Form> | |
| 343 | 342 | {detail.permissions.delete && ( |
| 1 | 1 | import { ArrowUpRight, Users } from "lucide-react"; | |
| 2 | 2 | import { useState } from "react"; | |
| 3 | − | import { Form, Link, data, useNavigation, useSearchParams } from "react-router"; | |
| 3 | + | import { Form, Link, data, useSearchParams } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { | |
| 6 | 6 | BASE_PERMISSIONS, | |
| 12 | 12 | ||
| 13 | 13 | import type { Route } from "./+types/people"; | |
| 14 | 14 | import { page } from "../../lib/meta"; | |
| 15 | − | import { Avatar, Button, CopyLine, ErrorText, Field, Input, Pill, TimeAgo } from "../../components/ui"; | |
| 15 | + | import { Avatar, CopyLine, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 16 | 16 | import { Badge } from "../../components/ui/badge"; | |
| 17 | 17 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 18 | 18 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs"; | |
| 115 | 115 | <Form method="post"> | |
| 116 | 116 | <input type="hidden" name="action" value="remove" /> | |
| 117 | 117 | <input type="hidden" name="member" value={member.username} /> | |
| 118 | − | <Button variant="quiet" type="submit"> | |
| 118 | + | <SubmitButton variant="quiet" match={{ action: "remove", member: member.username }} pending="Removing…"> | |
| 119 | 119 | Remove | |
| 120 | − | </Button> | |
| 120 | + | </SubmitButton> | |
| 121 | 121 | </Form> | |
| 122 | 122 | )} | |
| 123 | 123 | </span> | |
| 126 | 126 | ))} | |
| 127 | 127 | </ul> | |
| 128 | 128 | {owner && ( | |
| 129 | − | <Form method="post" className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-start"> | |
| 129 | + | // Empty again once the person is on the list; kept as typed when it failed. | |
| 130 | + | <Form | |
| 131 | + | method="post" | |
| 132 | + | key={`${members.length}:${pending.length}`} | |
| 133 | + | className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-start" | |
| 134 | + | > | |
| 135 | + | <input type="hidden" name="action" value="add" /> | |
| 130 | 136 | <div className="grow"> | |
| 131 | 137 | <Field | |
| 132 | 138 | label="Add a member" | |
| 136 | 142 | </Field> | |
| 137 | 143 | </div> | |
| 138 | 144 | <div className="sm:pt-[1.625rem]"> | |
| 139 | − | <Button type="submit">Add</Button> | |
| 145 | + | <SubmitButton match={{ action: "add" }} pending="Adding…"> | |
| 146 | + | Add | |
| 147 | + | </SubmitButton> | |
| 140 | 148 | </div> | |
| 141 | 149 | </Form> | |
| 142 | 150 | )} | |
| 174 | 182 | <Form method="post"> | |
| 175 | 183 | <input type="hidden" name="action" value="revoke-invite" /> | |
| 176 | 184 | <input type="hidden" name="id" value={invite.id} /> | |
| 177 | − | <Button variant="quiet" type="submit"> | |
| 185 | + | <SubmitButton variant="quiet" match={{ action: "revoke-invite", id: invite.id }} pending="Revoking…"> | |
| 178 | 186 | Revoke | |
| 179 | − | </Button> | |
| 187 | + | </SubmitButton> | |
| 180 | 188 | </Form> | |
| 181 | 189 | </div> | |
| 182 | 190 | <p className="text-xs text-faint"> | |
| 190 | 198 | )} | |
| 191 | 199 | ||
| 192 | 200 | <BasePermissionSection | |
| 201 | + | // Starts from the saved choice whenever it changes. | |
| 202 | + | key={base} | |
| 193 | 203 | base={base} | |
| 194 | 204 | owner={owner} | |
| 195 | 205 | slug={params.owner} | |
| 245 | 255 | saved: boolean; | |
| 246 | 256 | }) { | |
| 247 | 257 | const [chosen, setChosen] = useState<BasePermission>(base); | |
| 248 | − | const saving = useNavigation().formData?.get("action") === "base-permission"; | |
| 249 | 258 | return ( | |
| 250 | 259 | <section id="base-permission" className="mt-10 scroll-mt-20 border-t border-line pt-8"> | |
| 251 | 260 | <h2 className="font-medium">Base permission</h2> | |
| 269 | 278 | ))} | |
| 270 | 279 | </SelectContent> | |
| 271 | 280 | </Select> | |
| 272 | − | <Button type="submit" variant="quiet" disabled={saving || chosen === base}> | |
| 273 | − | {saving ? "Saving…" : "Save"} | |
| 274 | − | </Button> | |
| 281 | + | <SubmitButton variant="quiet" match={{ action: "base-permission" }} pending="Saving…" disabled={chosen === base}> | |
| 282 | + | Save | |
| 283 | + | </SubmitButton> | |
| 275 | 284 | {saved && chosen === base && <span className="text-sm text-muted">Saved.</span>} | |
| 276 | 285 | </div> | |
| 277 | 286 | <p className="mt-3 text-sm text-muted">{BASE_MEANS[chosen]}</p> | |
| 327 | 336 | <Form method="post"> | |
| 328 | 337 | <input type="hidden" name="action" value="convert" /> | |
| 329 | 338 | <input type="hidden" name="member" value={person.username} /> | |
| 330 | − | <Button variant="quiet" type="submit"> | |
| 339 | + | <SubmitButton variant="quiet" match={{ action: "convert", member: person.username }} pending="Converting…"> | |
| 331 | 340 | Convert to member | |
| 332 | − | </Button> | |
| 341 | + | </SubmitButton> | |
| 333 | 342 | </Form> | |
| 334 | 343 | </div> | |
| 335 | 344 | <ul className="mt-2 flex flex-wrap gap-1.5 pl-10"> |
| 16 | 16 | import { useAddresses } from "../../lib/addresses"; | |
| 17 | 17 | import { AvatarField } from "../../components/avatar-field"; | |
| 18 | 18 | import { DangerAction, DangerZone } from "../../components/danger-zone"; | |
| 19 | − | import { Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 19 | + | import { Button, ErrorText, Field, Input, SubmitButton } from "../../components/ui"; | |
| 20 | 20 | import { | |
| 21 | 21 | AlertDialog, | |
| 22 | 22 | AlertDialogCancel, | |
| 151 | 151 | <section> | |
| 152 | 152 | <h2 className="font-medium">Workspace details</h2> | |
| 153 | 153 | <Form method="post" className="mt-5 space-y-4"> | |
| 154 | + | <input type="hidden" name="intent" value="details" /> | |
| 154 | 155 | <Field | |
| 155 | 156 | label="Display name" | |
| 156 | 157 | hint="How people see the workspace: in the sidebar, at the top of its page and in link previews. Up to 80 characters; spaces and capitals are fine." | |
| 161 | 162 | <Input name="description" maxLength={160} defaultValue={workspace.description ?? ""} /> | |
| 162 | 163 | </Field> | |
| 163 | 164 | <ErrorText>{actionData && "error" in actionData ? actionData.error : undefined}</ErrorText> | |
| 164 | − | <Button type="submit">Save</Button> | |
| 165 | + | <SubmitButton match={{ intent: "details" }} pending="Saving…"> | |
| 166 | + | Save | |
| 167 | + | </SubmitButton> | |
| 165 | 168 | </Form> | |
| 166 | 169 | </section> | |
| 167 | 170 | ||
| 168 | 171 | <AddressSection | |
| 172 | + | // A new address starts it again, so the dialog is closed on the renamed page. | |
| 173 | + | key={workspace.slug} | |
| 169 | 174 | workspace={workspace} | |
| 170 | 175 | error={actionData && "renameError" in actionData ? actionData.renameError : undefined} | |
| 171 | 176 | /> | |
| 172 | 177 | ||
| 173 | 178 | {(loaderData.euAvailable || loaderData.residency === "eu") && ( | |
| 174 | 179 | <ResidencySection | |
| 180 | + | // Starts from the saved choice whenever it changes. | |
| 181 | + | key={loaderData.residency} | |
| 175 | 182 | residency={loaderData.residency} | |
| 176 | 183 | euAvailable={loaderData.euAvailable} | |
| 177 | 184 | saved={Boolean(actionData && "saved" in actionData && actionData.saved === "residency")} |
| 1 | − | import { data } from "react-router"; | |
| 2 | − | ||
| 3 | 1 | import type { Route } from "./+types/statement-entries"; | |
| 4 | 2 | import { billing } from "../../lib/services.server"; | |
| 5 | 3 | import { getViewer } from "../../lib/session.server"; | |
| 14 | 12 | project: query.get("project"), | |
| 15 | 13 | before: query.get("before"), | |
| 16 | 14 | }); | |
| 17 | − | if (!entries.ok) throw data({ error: entries.error.message }, { status: 404 }); | |
| 15 | + | // Said beside the line that asked. As a 200: a fetcher shows any other | |
| 16 | + | // status as an error page in place of the whole billing page. | |
| 17 | + | if (!entries.ok) return Response.json({ error: entries.error.message }, { headers: { "cache-control": "no-store" } }); | |
| 18 | 18 | return Response.json(entries.value, { headers: { "cache-control": "no-store" } }); | |
| 19 | 19 | } |
| 6 | 6 | import { page } from "../../lib/meta"; | |
| 7 | 7 | import { useAddresses } from "../../lib/addresses"; | |
| 8 | 8 | import { | |
| 9 | − | Button, | |
| 10 | 9 | CopyLine, | |
| 11 | 10 | EmptyState, | |
| 12 | 11 | ErrorText, | |
| 13 | 12 | Field, | |
| 14 | 13 | Input, | |
| 14 | + | SubmitButton, | |
| 15 | 15 | TimeAgo, | |
| 16 | 16 | } from "../../components/ui"; | |
| 17 | 17 | import { AccessSummary, ExpiryField, ScopeChecklist } from "../../components/token-scopes"; | |
| 135 | 135 | <Form method="post"> | |
| 136 | 136 | <input type="hidden" name="action" value="delete" /> | |
| 137 | 137 | <input type="hidden" name="id" value={token.id} /> | |
| 138 | − | <Button variant="quiet" type="submit"> | |
| 138 | + | <SubmitButton variant="quiet" match={{ action: "delete", id: token.id }} pending="Deleting…"> | |
| 139 | 139 | Delete | |
| 140 | − | </Button> | |
| 140 | + | </SubmitButton> | |
| 141 | 141 | </Form> | |
| 142 | 142 | )} | |
| 143 | 143 | </li> | |
| 147 | 147 | </div> | |
| 148 | 148 | ||
| 149 | 149 | {role === "owner" ? ( | |
| 150 | − | <Form method="post" className="mt-6 space-y-5 rounded-xl border border-line p-4 sm:p-5"> | |
| 150 | + | // Empty again once the token is made; kept as filled in when it failed. | |
| 151 | + | <Form method="post" key={created?.info.id ?? ""} className="mt-6 space-y-5 rounded-xl border border-line p-4 sm:p-5"> | |
| 152 | + | <input type="hidden" name="action" value="create" /> | |
| 151 | 153 | <h2 className="font-medium">New token</h2> | |
| 152 | 154 | <div className="grid gap-4 sm:grid-cols-[1fr_11rem]"> | |
| 153 | 155 | <Field label="Name" hint="Name it after what will use it."> | |
| 156 | 158 | <ExpiryField /> | |
| 157 | 159 | </div> | |
| 158 | 160 | <ScopeChecklist initial={presetScopes("ci")} /> | |
| 159 | − | <Button type="submit">Create token</Button> | |
| 161 | + | <SubmitButton match={{ action: "create" }} pending="Creating…"> | |
| 162 | + | Create token | |
| 163 | + | </SubmitButton> | |
| 160 | 164 | </Form> | |
| 161 | 165 | ) : ( | |
| 162 | 166 | <p className="mt-4 text-sm text-muted"> |