A workspace's pages fail one part at a time, and never say something false because a read failed. The frame: a visitor's sidebar projects do without instead of taking every page down. Settings: data residency (a failure could save "anywhere" over "eu"), two-factor and Delete (what deleting would take) show Unavailable instead of their forms or button. Billing: spend limit, allowance and alerts, credit, limit requests and Billing on Stripe (its stream no longer rejects) each in place. AI: Ask's setting shows Unavailable instead of a form that could save over the real choice. Security: a failed overview no longer shows the activate-the-plan prompt. Members: outside collaborators, each member's teams; personal access tokens: members' tokens; Actions: managed workflows; packages: deleted packages; repositories: recently deleted; projects: production links; integrations: repositories to add and each alert source's recent deliveries; the visitors' overview: counts and open pull requests; code access, team repositories to add, members on the overview, installed apps, artifact spaces' teams (a space's form is withheld rather than lose its team kind). Artifact spaces and repository files no longer turn a failure into a 404. Home's streamed sections are each in a Boundary; usage has an errorElement. Kept quiet on purpose: the price book and this month's usage on billing (defaults and "—" stand in), EU availability, the delete list's app count, the workspace name (its slug stands in), descriptions, pins, the bypass badge. Checked: apps/web tsc -b; npm test (987).
- G1t-Session:
- ses_01m4zzpc96ect8d41mffgr1a45
25 files+456−1660/25 viewed
| 24 | 24 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../../components/ui/dialog"; | |
| 25 | 25 | import { RadioGroup, RadioOption } from "../../components/ui/radio-group"; | |
| 26 | 26 | import { SelectField } from "../../components/ui/select"; | |
| 27 | + | import { Unavailable } from "../../components/boundary"; | |
| 27 | 28 | import { page } from "../../lib/meta"; | |
| 29 | + | import { isFailed, part, type Failed } from "../../lib/partial"; | |
| 28 | 30 | import { actions } from "../../lib/services.server"; | |
| 29 | 31 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 30 | 32 | ||
| ⋯ | |||
| 40 | 42 | const workspace = params.owner.toLowerCase(); | |
| 41 | 43 | const settings = unwrap(await actions.workspaceActionsSettings(workspace, viewer)); | |
| 42 | 44 | // The workflows it manages, and their sources, are its owners' to see. | |
| 43 | − | const managed = role === "owner" ? unwrap(await actions.workspaceManagedWorkflows(workspace, viewer)) : []; | |
| 45 | + | // They are a section beside the policy: when Actions does not answer for | |
| 46 | + | // them, that section says so and the policy's form stays. | |
| 47 | + | const listed = role === "owner" ? await part(actions.workspaceManagedWorkflows(workspace, viewer), "Managed workflows") : null; | |
| 48 | + | const managed = listed === null ? [] : isFailed(listed) ? listed : unwrap(listed); | |
| 44 | 49 | return { settings, managed, owner: role === "owner" }; | |
| 45 | 50 | } | |
| 46 | 51 | ||
| ⋯ | |||
| 271 | 276 | ); | |
| 272 | 277 | } | |
| 273 | 278 | ||
| 274 | − | function ManagedWorkflows({ workspace, managed }: { workspace: string; managed: WorkspaceManagedWorkflow[] }) { | |
| 279 | + | function ManagedWorkflows({ workspace, managed }: { workspace: string; managed: WorkspaceManagedWorkflow[] | Failed }) { | |
| 275 | 280 | return ( | |
| 276 | 281 | <section className="max-w-4xl space-y-4 border-t border-line pt-8"> | |
| 277 | 282 | <div className="flex flex-wrap items-start justify-between gap-4"> | |
| ⋯ | |||
| 288 | 293 | </div> | |
| 289 | 294 | <ManagedWorkflowDialog workspace={workspace} /> | |
| 290 | 295 | </div> | |
| 291 | − | {managed.length === 0 ? ( | |
| 296 | + | {isFailed(managed) ? ( | |
| 297 | + | <Unavailable what={managed.what} /> | |
| 298 | + | ) : managed.length === 0 ? ( | |
| 292 | 299 | <EmptyState title="No managed workflows"> | |
| 293 | 300 | Add a workflow file from one of the workspace's repositories to run it in all of them, or in those you choose. | |
| 294 | 301 | </EmptyState> | |
| 17 | 17 | import { autoOffered, defaultModel as startingModel, modelLabel, modelOptions } from "../../lib/ask-model"; | |
| 18 | 18 | import { ai } from "../../lib/services.server"; | |
| 19 | 19 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 20 | + | import { Unavailable } from "../../components/boundary"; | |
| 21 | + | import { isFailed, part } from "../../lib/partial"; | |
| 20 | 22 | ||
| 21 | 23 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 22 | 24 | return page(args, { title: `AI · ${params.owner} · g1t` }); | |
| ⋯ | |||
| 33 | 35 | const role = roleIn(viewer, params.owner); | |
| 34 | 36 | if (!role) throw new Response(null, { status: 404 }); | |
| 35 | 37 | const slug = params.owner.toLowerCase(); | |
| 36 | − | const [connections, ask] = await Promise.all([ai.list(slug, viewer), ai.askSettings(slug, viewer)]); | |
| 38 | + | // The providers are what the page is about. Ask's setting is a section | |
| 39 | + | // beside them: when it does not answer, that section says so instead of | |
| 40 | + | // offering a form that would save over a choice it could not read. | |
| 41 | + | const [connections, ask] = await Promise.all([ai.list(slug, viewer), part(ai.askSettings(slug, viewer), "Ask g1t's provider")]); | |
| 37 | 42 | const adding = new URL(request.url).searchParams.get("add"); | |
| 38 | 43 | return { | |
| 39 | 44 | slug, | |
| 40 | 45 | owner: role === "owner", | |
| 41 | 46 | connections: unwrap(connections), | |
| 42 | 47 | adding: adding && isAiProvider(adding) ? adding : null, | |
| 43 | − | ask: ask.ok ? ask.value : null, | |
| 48 | + | ask: isFailed(ask) ? ask : ask.ok ? ask.value : null, | |
| 44 | 49 | }; | |
| 45 | 50 | } | |
| 46 | 51 | ||
| ⋯ | |||
| 133 | 138 | {justAdded && <Added connection={justAdded} />} | |
| 134 | 139 | <ErrorText>{askError || addError ? null : error}</ErrorText> | |
| 135 | 140 | ||
| 136 | − | <AskSetting connections={connections} setup={ask} owner={owner} saved={asked} error={askError} /> | |
| 141 | + | {isFailed(ask) ? ( | |
| 142 | + | <section aria-labelledby="ask-heading" className="mb-8"> | |
| 143 | + | <h2 id="ask-heading" className="mb-3 text-sm font-medium"> | |
| 144 | + | Ask g1t | |
| 145 | + | </h2> | |
| 146 | + | <Unavailable what={ask.what} /> | |
| 147 | + | </section> | |
| 148 | + | ) : ( | |
| 149 | + | <AskSetting connections={connections} setup={ask} owner={owner} saved={asked} error={askError} /> | |
| 150 | + | )} | |
| 137 | 151 | ||
| 138 | 152 | <section aria-labelledby="providers-heading"> | |
| 139 | 153 | <h2 id="providers-heading" className="mb-3 text-sm font-medium"> | |
| 13 | 13 | import type { Route } from "./+types/apps"; | |
| 14 | 14 | import { AppTile, NoApps, useAppPins } from "../../components/apps"; | |
| 15 | 15 | import { ButtonLink } from "../../components/ui"; | |
| 16 | − | import { DOCK_COOKIE, type InstalledAppData, appData, appPinFromForm, dockCookie, installedApps, pinsToShow, withPin, writeDock } from "../../lib/apps"; | |
| 16 | + | import { DOCK_COOKIE, appData, appPinFromForm, dockCookie, installedApps, pinsToShow, withPin, writeDock } from "../../lib/apps"; | |
| 17 | 17 | import { connectedStates } from "../../lib/connected.server"; | |
| 18 | 18 | import { savePins, savedPins } from "../../lib/dock.server"; | |
| 19 | 19 | import { page } from "../../lib/meta"; | |
| 20 | 20 | import { readCookie } from "../../lib/mission"; | |
| 21 | 21 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 22 | 22 | import { Card } from "../../components/ui/card"; | |
| 23 | + | import { Unavailable } from "../../components/boundary"; | |
| 24 | + | import { isFailed, part } from "../../lib/partial"; | |
| 23 | 25 | ||
| 24 | 26 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 25 | 27 | return page(args, { title: `Apps · ${params.owner} · g1t` }); | |
| ⋯ | |||
| 30 | 32 | const slug = params.owner.toLowerCase(); | |
| 31 | 33 | const membership = viewer?.workspaces?.find((m) => m.slug === slug); | |
| 32 | 34 | if (!viewer || !membership) throw data(null, { status: 404 }); | |
| 33 | − | const [connected, saved] = await Promise.all([connectedStates(slug, viewer).catch(() => null), savedPins(viewer, slug)]); | |
| 35 | + | const [connected, saved] = await Promise.all([part(connectedStates(slug, viewer), "What is installed"), savedPins(viewer, slug)]); | |
| 34 | 36 | return { | |
| 35 | 37 | slug, | |
| 36 | 38 | name: membership.name?.trim() || slug, | |
| 37 | − | /** Null when what is installed could not be read. */ | |
| 38 | − | apps: connected ? installedApps(connected).map((app) => appData(app, slug)) : (null as InstalledAppData[] | null), | |
| 39 | + | /** Failed when what is installed could not be read. */ | |
| 40 | + | apps: isFailed(connected) ? connected : installedApps(connected).map((app) => appData(app, slug)), | |
| 39 | 41 | pins: pinsToShow(saved, readCookie(request.headers.get("cookie"), DOCK_COOKIE), slug), | |
| 40 | 42 | }; | |
| 41 | 43 | } | |
| ⋯ | |||
| 61 | 63 | export default function Apps({ loaderData }: Route.ComponentProps) { | |
| 62 | 64 | const { slug, name, apps } = loaderData; | |
| 63 | 65 | const { pins, toggle } = useAppPins(slug, loaderData.pins); | |
| 64 | − | const usable = (apps ?? []).filter((app) => app.usable); | |
| 66 | + | const usable = (isFailed(apps) ? [] : apps).filter((app) => app.usable); | |
| 65 | 67 | const pinned = pins.map((key) => usable.find((app) => app.key === key)).filter((app) => app != null); | |
| 66 | 68 | return ( | |
| 67 | 69 | <main className="mx-auto max-w-5xl px-4 py-8 sm:px-8 sm:py-10"> | |
| ⋯ | |||
| 78 | 80 | </ButtonLink> | |
| 79 | 81 | </div> | |
| 80 | 82 | ||
| 81 | − | {apps == null ? ( | |
| 82 | − | <Card asChild className="mt-8 px-4 py-6 text-center text-sm text-muted"> | |
| 83 | − | <p>What is installed can't be read right now. Reload in a minute.</p> | |
| 84 | − | </Card> | |
| 83 | + | {isFailed(apps) ? ( | |
| 84 | + | <div className="mt-8"> | |
| 85 | + | <Unavailable what={apps.what} /> | |
| 86 | + | </div> | |
| 85 | 87 | ) : apps.length === 0 ? ( | |
| 86 | 88 | <div className="mt-8"> | |
| 87 | 89 | <NoApps slug={slug} /> | |
| 34 | 34 | import { assertSameOrigin, getViewer, managesBilling, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 35 | 35 | import { Card } from "../../components/ui/card"; | |
| 36 | 36 | import { SubmitButton } from "../../components/ui"; | |
| 37 | + | import { Unavailable } from "../../components/boundary"; | |
| 38 | + | import { isFailed, orElse, part } from "../../lib/partial"; | |
| 37 | 39 | ||
| 38 | 40 | /** The trial and pools as published, when the price book cannot be read. */ | |
| 39 | 41 | const DEFAULT_TRIAL_MICROS = 5_000_000; | |
| ⋯ | |||
| 100 | 102 | const manages = managesBilling(viewer, params.owner); | |
| 101 | 103 | // Stripe's own (the saved card, the upcoming invoice, whether Stripe Tax | |
| 102 | 104 | // needs an address) is streamed: the page does not wait on Stripe, and | |
| 103 | − | // its sections hold their place. | |
| 104 | − | const details = billing | |
| 105 | − | .billingDetails(slug, viewer) | |
| 106 | − | .then((result) => (result.ok ? result.value : null)) | |
| 107 | − | .catch(() => null); | |
| 108 | − | const [account, statement, features, limit, entitlements, requests, book, credits, report] = await Promise.all([ | |
| 105 | + | // its sections hold their place. When Stripe's side does not answer, the | |
| 106 | + | // card that shows it says so; the plan's summary does without it. | |
| 107 | + | const details = part( | |
| 108 | + | billing.billingDetails(slug, viewer).then((result) => (result.ok ? result.value : null)), | |
| 109 | + | "Billing on Stripe", | |
| 110 | + | ); | |
| 111 | + | const [account, statement, features, limit, entitled, requests, book, credits, report] = await Promise.all([ | |
| 109 | 112 | billing.account(slug, viewer), | |
| 110 | 113 | billing.statement(slug, viewer, url.searchParams.get("month"), group), | |
| 111 | 114 | billing.features(slug, viewer), | |
| 112 | − | billing.limit(slug, viewer).catch(() => null), | |
| 113 | − | billing.entitlements(slug).catch(() => null), | |
| 114 | − | billing.limitRequests(slug, viewer).catch(() => null), | |
| 115 | + | // The account, its statement and the plan are what the page is about. | |
| 116 | + | // Each of these is a card beside them, which says so in its place when | |
| 117 | + | // billing does not answer for it. | |
| 118 | + | part(billing.limit(slug, viewer), "Your spend limit"), | |
| 119 | + | part(billing.entitlements(slug), "Your allowance and alerts"), | |
| 120 | + | part(billing.limitRequests(slug, viewer), "Your limit requests"), | |
| 121 | + | // The price book only fills in published defaults, which stand in for it. | |
| 115 | 122 | billing.prices().catch(() => null), | |
| 116 | − | billing.credits(slug, viewer).catch(() => null), | |
| 123 | + | part(billing.credits(slug, viewer), "Credit from g1t"), | |
| 124 | + | // This month's usage so far: the plan's summary shows "—" without it. | |
| 117 | 125 | billing.usageReport(slug, viewer, { from: monthStart, until: today }).catch(() => null), | |
| 118 | 126 | ]); | |
| 127 | + | const entitlements = orElse(entitled, null); | |
| 119 | 128 | const featureStates = unwrap(features); | |
| 120 | 129 | const trialMicros = book?.free?.trialWorkspaceMicros ?? DEFAULT_TRIAL_MICROS; | |
| 121 | 130 | const done = url.searchParams.get("done"); | |
| 122 | 131 | const plan = featureStates.find((state) => state.plan.feature === "plan") ?? null; | |
| 123 | 132 | const securityPlan = featureStates.find((state) => state.plan.feature === "security") ?? null; | |
| 124 | 133 | // Credit from g1t for everything; grants scoped to models belong to usage no longer offered. | |
| 125 | − | const general = credits?.ok ? credits.value.grants.filter((grant) => grant.scope !== "models") : []; | |
| 134 | + | const general = !isFailed(credits) && credits.ok ? credits.value.grants.filter((grant) => grant.scope !== "models") : []; | |
| 126 | 135 | return { | |
| 127 | 136 | slug, | |
| 128 | 137 | role, | |
| ⋯ | |||
| 134 | 143 | group, | |
| 135 | 144 | plan, | |
| 136 | 145 | securityPlan, | |
| 137 | − | limit: limit?.ok ? limit.value : null, | |
| 146 | + | limit: isFailed(limit) ? limit : limit.ok ? limit.value : null, | |
| 138 | 147 | entitlements, | |
| 139 | − | requests: requests?.ok ? requests.value : [], | |
| 140 | − | credits: general.length > 0 && credits?.ok ? { ...credits.value, grants: general } : null, | |
| 148 | + | /** Set when the allowance, alerts and trial could not be read: said once, at the top. */ | |
| 149 | + | entitlementsFailed: isFailed(entitled) ? entitled : null, | |
| 150 | + | requests: isFailed(requests) ? requests : requests.ok ? requests.value : [], | |
| 151 | + | credits: isFailed(credits) ? credits : general.length > 0 && credits.ok ? { ...credits.value, grants: general } : null, | |
| 141 | 152 | report: report?.ok ? report.value : null, | |
| 142 | 153 | details, | |
| 143 | 154 | trialMicros, | |
| ⋯ | |||
| 251 | 262 | } | |
| 252 | 263 | ||
| 253 | 264 | export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) { | |
| 254 | − | const { slug, role, staff, account, statement, group, plan, securityPlan, limit, entitlements, requests, credits, report, details, trialMicros, firstMonthMicros, notice, problem } = | |
| 265 | + | const { slug, role, staff, account, statement, group, plan, securityPlan, entitlements, entitlementsFailed, credits, report, details, trialMicros, firstMonthMicros, notice, problem } = | |
| 255 | 266 | loaderData; | |
| 267 | + | const limit = orElse(loaderData.limit, null); | |
| 268 | + | const requests = orElse(loaderData.requests, []); | |
| 256 | 269 | const { status } = account; | |
| 257 | 270 | const owner = role === "owner" || loaderData.managesBilling; | |
| 258 | 271 | const error = actionData as SectionError; | |
| ⋯ | |||
| 271 | 284 | slug={slug} | |
| 272 | 285 | state={plan} | |
| 273 | 286 | status={standing} | |
| 274 | − | details={loaded} | |
| 287 | + | details={orElse(loaded, null)} | |
| 275 | 288 | report={report} | |
| 276 | 289 | owner={owner} | |
| 277 | 290 | enabled={status.enabled} | |
| ⋯ | |||
| 300 | 313 | <Suspense fallback={null}> | |
| 301 | 314 | <Await resolve={details}> | |
| 302 | 315 | {(loaded) => | |
| 303 | − | loaded?.taxAddressNeededAt ? ( | |
| 316 | + | !isFailed(loaded) && loaded?.taxAddressNeededAt ? ( | |
| 304 | 317 | <div role="alert" className="mb-6 flex flex-wrap items-start gap-3 rounded-xl border border-warn/40 bg-warn/5 p-4 text-sm"> | |
| 305 | 318 | <div className="min-w-0 flex-1 basis-64"> | |
| 306 | 319 | <p className="font-medium">Add your billing address in Stripe</p> | |
| ⋯ | |||
| 322 | 335 | } | |
| 323 | 336 | </Await> | |
| 324 | 337 | </Suspense> | |
| 338 | + | {entitlementsFailed && ( | |
| 339 | + | <div className="mb-6"> | |
| 340 | + | <Unavailable what={entitlementsFailed.what} /> | |
| 341 | + | </div> | |
| 342 | + | )} | |
| 325 | 343 | {entitlements && <SpikeBanner slug={slug} entitlements={entitlements} owner={owner} />} | |
| 326 | 344 | {err("spike") && <p className="mb-6 text-sm text-danger">{err("spike")}</p>} | |
| 327 | 345 | <Alerts alerts={entitlements?.alerts ?? []} /> | |
| ⋯ | |||
| 338 | 356 | <TrialCard entitlements={entitlements} trialMicros={trialMicros} owner={owner} enabled={status.enabled} error={err("trial")} /> | |
| 339 | 357 | )} | |
| 340 | 358 | ||
| 359 | + | {paying && isFailed(loaderData.limit) && ( | |
| 360 | + | <div className="mb-6"> | |
| 361 | + | <Unavailable what={loaderData.limit.what} /> | |
| 362 | + | </div> | |
| 363 | + | )} | |
| 341 | 364 | {paying && limit && selfServe && ( | |
| 342 | 365 | <> | |
| 343 | 366 | <SpendLimitCard limit={limit} owner={owner} error={err("limit")} /> | |
| ⋯ | |||
| 347 | 370 | </> | |
| 348 | 371 | )} | |
| 349 | 372 | ||
| 350 | − | {credits && <CreditsCard credits={credits} />} | |
| 373 | + | {isFailed(credits) ? ( | |
| 374 | + | <div className="mb-6"> | |
| 375 | + | <Unavailable what={credits.what} /> | |
| 376 | + | </div> | |
| 377 | + | ) : ( | |
| 378 | + | credits && <CreditsCard credits={credits} /> | |
| 379 | + | )} | |
| 351 | 380 | ||
| 381 | + | {/* Past requests are listed on the limit's cards: without them, say so rather than show none. */} | |
| 382 | + | {isFailed(loaderData.requests) && (paying || (limit && selfServe)) && ( | |
| 383 | + | <div className="mb-6"> | |
| 384 | + | <Unavailable what={loaderData.requests.what} compact /> | |
| 385 | + | </div> | |
| 386 | + | )} | |
| 352 | 387 | {paying && <OverageCard requests={requests} owner={owner} error={err("overage")} />} | |
| 353 | 388 | ||
| 354 | 389 | <AddOns plan={plan} security={securityPlan} error={err("security")} /> | |
| ⋯ | |||
| 356 | 391 | {status.enabled && owner && ( | |
| 357 | 392 | <Suspense fallback={<StripeSkeleton />}> | |
| 358 | 393 | <Await resolve={details}> | |
| 359 | − | {(loaded) => <BillingOnStripeCard details={loaded} enabled={status.enabled} live={status.live} staff={staff} error={err("stripe")} />} | |
| 394 | + | {(loaded) => | |
| 395 | + | isFailed(loaded) ? ( | |
| 396 | + | <div className="mb-6"> | |
| 397 | + | <Unavailable what={loaded.what} /> | |
| 398 | + | </div> | |
| 399 | + | ) : ( | |
| 400 | + | <BillingOnStripeCard details={loaded} enabled={status.enabled} live={status.live} staff={staff} error={err("stripe")} /> | |
| 401 | + | ) | |
| 402 | + | } | |
| 360 | 403 | </Await> | |
| 361 | 404 | </Suspense> | |
| 362 | 405 | )} | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| 6 | 6 | import { identity } from "../../lib/services.server"; | |
| 7 | 7 | import { requireUser, roleIn } from "../../lib/session.server"; | |
| 8 | + | import { Unavailable } from "../../components/boundary"; | |
| 9 | + | import { isFailed, part } from "../../lib/partial"; | |
| 8 | 10 | ||
| 9 | 11 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 10 | 12 | return page(args, { title: `Code access · ${params.owner} · g1t` }); | |
| ⋯ | |||
| 17 | 19 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 18 | 20 | const viewer = requireUser(context, request); | |
| 19 | 21 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 20 | − | const members = await identity.listMembers(params.owner, viewer).catch(() => null); | |
| 21 | − | const owners = members?.ok ? members.value.filter((m) => m.role === "owner").slice(0, 4).map((m) => m.username) : []; | |
| 22 | + | // Who to ask is beside what the page says: without it, the page says that much. | |
| 23 | + | const members = await part(identity.listMembers(params.owner, viewer), "The workspace's owners"); | |
| 24 | + | const owners = isFailed(members) ? members : members.ok ? members.value.filter((m) => m.role === "owner").slice(0, 4).map((m) => m.username) : []; | |
| 22 | 25 | const from = new URL(request.url).searchParams.get("from"); | |
| 23 | 26 | // Only a path on this site, shown as text. | |
| 24 | 27 | return { owners, from: from && /^\/[^/]/.test(from) ? from.split("?")[0] : null }; | |
| ⋯ | |||
| 43 | 46 | )}{" "} | |
| 44 | 47 | Your membership includes Chat, Artifacts and Notifications. An owner can turn on Code access for you. | |
| 45 | 48 | </p> | |
| 46 | − | {owners.length > 0 && ( | |
| 49 | + | {isFailed(owners) ? ( | |
| 50 | + | <div className="mt-4 flex justify-center"> | |
| 51 | + | <Unavailable what={owners.what} compact /> | |
| 52 | + | </div> | |
| 53 | + | ) : owners.length > 0 && ( | |
| 47 | 54 | <p className="mt-4 text-sm text-muted"> | |
| 48 | 55 | Owners:{" "} | |
| 49 | 56 | {owners.map((owner, index) => ( | |
| 23 | 23 | const viewer = requireUser(context, request); | |
| 24 | 24 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 25 | 25 | const repo = `${params.repoOwner}/${params.repoName}`; | |
| 26 | − | const found = await docs.repoPage(params.owner.toLowerCase(), viewer, repo, params["*"] ?? "").catch(() => null); | |
| 27 | − | if (!found?.ok) throw data(null, { status: 404 }); | |
| 26 | + | // The file is what the page is about: when it cannot be read, the page | |
| 27 | + | // says so rather than that there is no such file. | |
| 28 | + | const found = await docs.repoPage(params.owner.toLowerCase(), viewer, repo, params["*"] ?? ""); | |
| 29 | + | if (!found.ok) throw data(null, { status: 404 }); | |
| 28 | 30 | return { found: found.value }; | |
| 29 | 31 | } | |
| 30 | 32 |
| 7 | 7 | import { page } from "../../../lib/meta"; | |
| 8 | 8 | import { identity } from "../../../lib/services.server"; | |
| 9 | 9 | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 10 | + | import { Unavailable } from "../../../components/boundary"; | |
| 11 | + | import { isFailed, orElse, part } from "../../../lib/partial"; | |
| 10 | 12 | ||
| 11 | 13 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 12 | 14 | return page(args, { title: `New space · Artifacts · ${params.owner} · g1t` }); | |
| ⋯ | |||
| 15 | 17 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 16 | 18 | const viewer = requireUser(context, request); | |
| 17 | 19 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 18 | − | const teams = await identity.listTeams(viewer, params.owner.toLowerCase()).catch(() => null); | |
| 19 | − | return { teams: teams?.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] }; | |
| 20 | + | // The teams a space can be for: without them, a space for everyone or | |
| 21 | + | // for its members can still be made, and the page says why no team is offered. | |
| 22 | + | const teams = await part(identity.listTeams(viewer, params.owner.toLowerCase()), "Teams"); | |
| 23 | + | return { teams: isFailed(teams) ? teams : teams.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] }; | |
| 20 | 24 | } | |
| 21 | 25 | ||
| 22 | 26 | /** A new space: for everyone, a team, or only its members. Whoever makes it has full access. */ | |
| ⋯ | |||
| 28 | 32 | <div className="mx-auto max-w-2xl"> | |
| 29 | 33 | <h1 className="text-xl font-semibold tracking-tight">New space</h1> | |
| 30 | 34 | <p className="mt-1 mb-8 text-sm text-muted">A space holds artifacts for a team, a project or a topic. What goes in it follows its access.</p> | |
| 35 | + | {isFailed(loaderData.teams) && ( | |
| 36 | + | <div className="mb-4"> | |
| 37 | + | <Unavailable what={loaderData.teams.what} compact /> | |
| 38 | + | </div> | |
| 39 | + | )} | |
| 31 | 40 | <SpaceForm | |
| 32 | 41 | initial={initialSpace()} | |
| 33 | − | teams={loaderData.teams} | |
| 42 | + | teams={orElse(loaderData.teams, [])} | |
| 34 | 43 | submitLabel="Create space" | |
| 35 | 44 | busy={busy} | |
| 36 | 45 | error={error} | |
| 18 | 18 | import { page } from "../../../lib/meta"; | |
| 19 | 19 | import { docs, identity } from "../../../lib/services.server"; | |
| 20 | 20 | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 21 | + | import { Unavailable } from "../../../components/boundary"; | |
| 22 | + | import { isFailed, orElse, part, type Failed } from "../../../lib/partial"; | |
| 21 | 23 | ||
| 22 | 24 | export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) { | |
| 23 | 25 | return page(args, { title: `Settings · ${loaded?.space.name ?? "Space"} · Artifacts · ${params.owner} · g1t` }); | |
| 24 | 26 | } | |
| 25 | 27 | ||
| 26 | − | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ space: DocSpace; members: DocSpaceMember[]; teams: { slug: string; name: string }[] }> { | |
| 28 | + | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ space: DocSpace; members: DocSpaceMember[]; teams: { slug: string; name: string }[] | Failed }> { | |
| 27 | 29 | const viewer = requireUser(context, request); | |
| 28 | 30 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 29 | 31 | const slug = params.owner.toLowerCase(); | |
| 30 | − | const [found, teams] = await Promise.all([docs.space(slug, params.space, viewer), identity.listTeams(viewer, slug).catch(() => null)]); | |
| 32 | + | // The teams it can be for are beside the space: without them its form is | |
| 33 | + | // not offered (a space for a team would read as one for nobody), while | |
| 34 | + | // sharing and members still are. | |
| 35 | + | const [found, teams] = await Promise.all([docs.space(slug, params.space, viewer), part(identity.listTeams(viewer, slug), "This space's settings")]); | |
| 31 | 36 | if (!found.ok) throw data(null, { status: 404 }); | |
| 32 | 37 | if (found.value.space.viewer_role !== "manage") throw data(null, { status: 403 }); | |
| 33 | − | return { space: found.value.space, members: found.value.members, teams: teams?.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] }; | |
| 38 | + | return { space: found.value.space, members: found.value.members, teams: isFailed(teams) ? teams : teams.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] }; | |
| 34 | 39 | } | |
| 35 | 40 | ||
| 36 | 41 | const ROLE_OPTIONS = DOC_ROLES.map((r) => ({ value: r, label: DOC_ROLE_LABELS[r], description: DOC_ROLE_SUMMARIES[r] })); | |
| ⋯ | |||
| 53 | 58 | const listed = new Set(members.map((m) => m.key)); | |
| 54 | 59 | const candidates = [ | |
| 55 | 60 | ...(layout?.mentionables ?? []).map((m) => ({ value: `username:${m.name}`, label: `${m.display_name} (@${m.name})` })), | |
| 56 | − | ...teams.map((t) => ({ value: `team:${t.slug}`, label: `${t.name} (team)` })), | |
| 61 | + | ...orElse(teams, []).map((t) => ({ value: `team:${t.slug}`, label: `${t.name} (team)` })), | |
| 57 | 62 | ].filter((c) => !listed.has(c.value)); | |
| 58 | 63 | return ( | |
| 59 | 64 | <div className="mx-auto max-w-3xl"> | |
| ⋯ | |||
| 65 | 70 | </p> | |
| 66 | 71 | <h1 className="mt-1 text-xl font-semibold tracking-tight">Space settings</h1> | |
| 67 | 72 | <div className="mt-8"> | |
| 68 | − | <SpaceForm | |
| 69 | − | initial={initialSpace(space)} | |
| 70 | − | teams={teams} | |
| 71 | − | isDefault={space.is_default} | |
| 72 | − | submitLabel="Save" | |
| 73 | − | busy={settings.busy} | |
| 74 | − | error={settings.error} | |
| 75 | − | onSubmit={async (value) => { | |
| 76 | − | const saved = await settings.send<DocSpace>("update_space", { space_id: space.id, space_change: spaceInput(value) }); | |
| 77 | − | if (saved.ok && saved.value.slug !== space.slug) navigate(`${spacePath(slug, saved.value.slug)}/settings`, { replace: true }); | |
| 78 | − | }} | |
| 79 | − | /> | |
| 73 | + | {isFailed(teams) ? ( | |
| 74 | + | <Unavailable what={teams.what} /> | |
| 75 | + | ) : ( | |
| 76 | + | <SpaceForm | |
| 77 | + | initial={initialSpace(space)} | |
| 78 | + | teams={teams} | |
| 79 | + | isDefault={space.is_default} | |
| 80 | + | submitLabel="Save" | |
| 81 | + | busy={settings.busy} | |
| 82 | + | error={settings.error} | |
| 83 | + | onSubmit={async (value) => { | |
| 84 | + | const saved = await settings.send<DocSpace>("update_space", { space_id: space.id, space_change: spaceInput(value) }); | |
| 85 | + | if (saved.ok && saved.value.slug !== space.slug) navigate(`${spacePath(slug, saved.value.slug)}/settings`, { replace: true }); | |
| 86 | + | }} | |
| 87 | + | /> | |
| 88 | + | )} | |
| 80 | 89 | </div> | |
| 81 | 90 | ||
| 82 | 91 | <section className="mt-12"> | |
| 23 | 23 | const viewer = requireUser(context, request); | |
| 24 | 24 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 25 | 25 | const slug = params.owner.toLowerCase(); | |
| 26 | − | const found = await docs.space(slug, params.space, viewer).catch(() => null); | |
| 27 | − | if (!found?.ok || found.value.space.archived_at) throw data(null, { status: 404 }); | |
| 26 | + | // The space is what the page is about: when Artifacts does not answer, | |
| 27 | + | // the page says so rather than that there is no such space. | |
| 28 | + | const found = await docs.space(slug, params.space, viewer); | |
| 29 | + | if (!found.ok || found.value.space.archived_at) throw data(null, { status: 404 }); | |
| 28 | 30 | const list = await folios | |
| 29 | 31 | .list(slug, viewer, { tab: "all", space_id: found.value.space.id, limit: 100 }) | |
| 30 | 32 | .then((r) => (r.ok ? r.value : null)) |
| 73 | 73 | import { inbox } from "../../lib/services.server"; | |
| 74 | 74 | import { requireUser, roleIn } from "../../lib/session.server"; | |
| 75 | 75 | import { homePagePath } from "../../lib/workspace-nav"; | |
| 76 | + | import { Boundary } from "../../components/boundary"; | |
| 76 | 77 | ||
| 77 | 78 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 78 | 79 | return page(args, { title: `Home · ${params.owner} · g1t` }); | |
| ⋯ | |||
| 312 | 313 | link={{ label: "Notifications", to: "/notifications" }} | |
| 313 | 314 | > | |
| 314 | 315 | <Suspense fallback={<RowsSkeleton rows={4} />}> | |
| 315 | − | <Await resolve={waiting}>{(att) => <NeedsList attention={att} now={now} />}</Await> | |
| 316 | + | {/* Each section's data says what did not answer; a section that fails to draw keeps that to itself. */} | |
| 317 | + | <Await resolve={waiting}> | |
| 318 | + | {(att) => ( | |
| 319 | + | <Boundary what="Where you're needed"> | |
| 320 | + | <NeedsList attention={att} now={now} /> | |
| 321 | + | </Boundary> | |
| 322 | + | )} | |
| 323 | + | </Await> | |
| 316 | 324 | </Suspense> | |
| 317 | 325 | </Card> | |
| 318 | 326 | </div> | |
| ⋯ | |||
| 321 | 329 | <aside aria-label="Now" className="contents lg:sticky lg:top-4 lg:col-start-2 lg:row-span-2 lg:row-start-1 lg:block lg:space-y-5"> | |
| 322 | 330 | <Card title="Running now" className="order-2"> | |
| 323 | 331 | <Suspense fallback={<RowsSkeleton rows={2} />}> | |
| 324 | − | <Await resolve={runningNow}>{(value) => <RunningList rows={value.rows} missing={value.missing} now={now} />}</Await> | |
| 332 | + | <Await resolve={runningNow}> | |
| 333 | + | {(value) => ( | |
| 334 | + | <Boundary what="Running now"> | |
| 335 | + | <RunningList rows={value.rows} missing={value.missing} now={now} /> | |
| 336 | + | </Boundary> | |
| 337 | + | )} | |
| 338 | + | </Await> | |
| 325 | 339 | </Suspense> | |
| 326 | 340 | </Card> | |
| 327 | 341 | <div className="order-4"> | |
| ⋯ | |||
| 332 | 346 | </Card> | |
| 333 | 347 | } | |
| 334 | 348 | > | |
| 335 | − | <Await resolve={spend}>{(value) => <SpendCard spend={value} slug={slug} span={span} />}</Await> | |
| 349 | + | <Await resolve={spend}> | |
| 350 | + | {(value) => ( | |
| 351 | + | <Boundary what="Spent"> | |
| 352 | + | <SpendCard spend={value} slug={slug} span={span} /> | |
| 353 | + | </Boundary> | |
| 354 | + | )} | |
| 355 | + | </Await> | |
| 336 | 356 | </Suspense> | |
| 337 | 357 | </div> | |
| 338 | 358 | <div className="order-5"> | |
| ⋯ | |||
| 343 | 363 | </Card> | |
| 344 | 364 | } | |
| 345 | 365 | > | |
| 346 | − | <Await resolve={away}>{(value) => <SettledCard away={value} now={now} />}</Await> | |
| 366 | + | <Await resolve={away}> | |
| 367 | + | {(value) => ( | |
| 368 | + | <Boundary what="Settled for you"> | |
| 369 | + | <SettledCard away={value} now={now} /> | |
| 370 | + | </Boundary> | |
| 371 | + | )} | |
| 372 | + | </Await> | |
| 347 | 373 | </Suspense> | |
| 348 | 374 | </div> | |
| 349 | 375 | </aside> | |
| ⋯ | |||
| 367 | 393 | </div> | |
| 368 | 394 | <div className="border-t border-line"> | |
| 369 | 395 | <Suspense fallback={<SpanSkeleton />}> | |
| 370 | − | <Await resolve={digest}>{(value) => <PeopleSummary digest={value} span={span} code={loaderData.code} />}</Await> | |
| 396 | + | <Await resolve={digest}> | |
| 397 | + | {(value) => ( | |
| 398 | + | <Boundary what="Who did what"> | |
| 399 | + | <PeopleSummary digest={value} span={span} code={loaderData.code} /> | |
| 400 | + | </Boundary> | |
| 401 | + | )} | |
| 402 | + | </Await> | |
| 371 | 403 | </Suspense> | |
| 372 | 404 | </div> | |
| 373 | 405 | <Suspense | |
| ⋯ | |||
| 379 | 411 | > | |
| 380 | 412 | <Await resolve={happened}> | |
| 381 | 413 | {(value) => ( | |
| 382 | − | <LandedFeed | |
| 383 | − | landed={value?.landed ?? null} | |
| 384 | − | missing={value?.landedMissing ?? []} | |
| 385 | − | code={loaderData.code} | |
| 386 | − | now={now} | |
| 387 | − | tz={tz} | |
| 388 | − | byDay={span.key === "7d"} | |
| 389 | − | scope={span.key} | |
| 390 | − | /> | |
| 414 | + | <Boundary what="What landed"> | |
| 415 | + | <LandedFeed | |
| 416 | + | landed={value?.landed ?? null} | |
| 417 | + | missing={value?.landedMissing ?? []} | |
| 418 | + | code={loaderData.code} | |
| 419 | + | now={now} | |
| 420 | + | tz={tz} | |
| 421 | + | byDay={span.key === "7d"} | |
| 422 | + | scope={span.key} | |
| 423 | + | /> | |
| 424 | + | </Boundary> | |
| 391 | 425 | )} | |
| 392 | 426 | </Await> | |
| 393 | 427 | </Suspense> | |
| 21 | 21 | import { Combobox } from "../../components/ui/combobox"; | |
| 22 | 22 | import { integrations, repos } from "../../lib/services.server"; | |
| 23 | 23 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 24 | + | import { Unavailable } from "../../components/boundary"; | |
| 25 | + | import { isFailed, orElse, part, type Failed } from "../../lib/partial"; | |
| 24 | 26 | ||
| 25 | 27 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 26 | 28 | const kind = sectionKind(params.section); | |
| ⋯ | |||
| 39 | 41 | // One kind of connection's page: the directory at `-/integrations` finds them. | |
| 40 | 42 | const kind = sectionKind(params.section); | |
| 41 | 43 | if (!kind) throw new Response(null, { status: 404 }); | |
| 42 | − | const [connections, listed] = await Promise.all([integrations.list(slug, viewer), repos.list(viewer, { namespace: slug })]); | |
| 44 | + | // The connections are what the page is about. The repositories a new | |
| 45 | + | // one can file issues in, and what each alert source sent lately, are | |
| 46 | + | // beside them: each says so in its place when it cannot be read. | |
| 47 | + | const [connections, listed] = await Promise.all([integrations.list(slug, viewer), part(repos.list(viewer, { namespace: slug }), "Repositories")]); | |
| 43 | 48 | const all = unwrap(connections); | |
| 44 | 49 | // What each alert source has sent lately, to see it is wired up. | |
| 45 | − | const deliveries: Record<string, Delivery[]> = {}; | |
| 50 | + | const deliveries: Record<string, Delivery[] | Failed> = {}; | |
| 46 | 51 | await Promise.all( | |
| 47 | 52 | all | |
| 48 | 53 | .filter((connection) => connection.kind === "alerts") | |
| 49 | 54 | .map(async (connection) => { | |
| 50 | − | const found = await integrations.deliveries(slug, viewer, connection.id); | |
| 51 | − | deliveries[connection.id] = found.ok ? found.value.slice(0, 5) : []; | |
| 55 | + | const found = await part(integrations.deliveries(slug, viewer, connection.id), "What it sent lately"); | |
| 56 | + | deliveries[connection.id] = isFailed(found) ? found : found.ok ? found.value.slice(0, 5) : []; | |
| 52 | 57 | }), | |
| 53 | 58 | ); | |
| 54 | 59 | const adding = new URL(request.url).searchParams.get("add"); | |
| ⋯ | |||
| 58 | 63 | kind, | |
| 59 | 64 | connections: all, | |
| 60 | 65 | deliveries, | |
| 61 | − | repos: listed.map((repo) => `${repo.namespace}/${repo.name}`), | |
| 66 | + | repos: isFailed(listed) ? listed : listed.map((repo) => `${repo.namespace}/${repo.name}`), | |
| 62 | 67 | adding: isProvider(adding) && PROVIDERS[adding].kind === kind ? adding : null, | |
| 63 | 68 | }; | |
| 64 | 69 | } | |
| ⋯ | |||
| 139 | 144 | }; | |
| 140 | 145 | ||
| 141 | 146 | export default function WorkspaceIntegrations({ loaderData, actionData }: Route.ComponentProps) { | |
| 142 | − | const { slug, role, kind, connections, deliveries, repos: repoNames, adding } = loaderData; | |
| 147 | + | const { slug, role, kind, connections, deliveries, adding } = loaderData; | |
| 148 | + | const repoNames = orElse(loaderData.repos, []); | |
| 149 | + | // A new connection can still be added without the list to choose a repository from: say why it is empty. | |
| 143 | 150 | const owner = role === "owner"; | |
| 151 | + | const reposMissing = isFailed(loaderData.repos) && adding && owner ? <Unavailable what={loaderData.repos.what} compact /> : null; | |
| 144 | 152 | const justConnected = actionData && "connected" in actionData ? actionData.connected : null; | |
| 145 | 153 | const tested = (actionData && "tested" in actionData ? actionData.tested : null) ?? null; | |
| 146 | 154 | const error = (actionData && "error" in actionData ? actionData.error : null) ?? null; | |
| ⋯ | |||
| 167 | 175 | deliveries={deliveries} | |
| 168 | 176 | /> | |
| 169 | 177 | {owner && <Choices kind="alerts" slug={slug} adding={adding} />} | |
| 178 | + | {adding && PROVIDERS[adding].kind === "alerts" && reposMissing} | |
| 170 | 179 | {adding && PROVIDERS[adding].kind === "alerts" && owner && ( | |
| 171 | 180 | <AddForm | |
| 172 | 181 | provider={adding} | |
| ⋯ | |||
| 189 | 198 | deliveries={deliveries} | |
| 190 | 199 | /> | |
| 191 | 200 | {owner && <Choices kind="tracker" slug={slug} adding={adding} />} | |
| 201 | + | {adding && PROVIDERS[adding].kind === "tracker" && reposMissing} | |
| 192 | 202 | {adding && PROVIDERS[adding].kind === "tracker" && owner && ( | |
| 193 | 203 | <AddForm | |
| 194 | 204 | provider={adding} | |
| ⋯ | |||
| 232 | 242 | tested: { id: string; ok: boolean; message: string } | null; | |
| 233 | 243 | /** The connection whose secret was just saved. */ | |
| 234 | 244 | updated: string | null; | |
| 235 | − | deliveries: Record<string, Delivery[]>; | |
| 245 | + | deliveries: Record<string, Delivery[] | Failed>; | |
| 236 | 246 | }) { | |
| 237 | 247 | if (list.length === 0) return null; | |
| 238 | 248 | return ( | |
| ⋯ | |||
| 275 | 285 | owner: boolean; | |
| 276 | 286 | tested: { id: string; ok: boolean; message: string } | null; | |
| 277 | 287 | updated: boolean; | |
| 278 | − | deliveries: Delivery[]; | |
| 288 | + | deliveries: Delivery[] | Failed; | |
| 279 | 289 | }) { | |
| 280 | 290 | const { config } = connection; | |
| 281 | 291 | const facts = [ | |
| ⋯ | |||
| 286 | 296 | config.keys?.length ? config.keys.join(", ") : null, | |
| 287 | 297 | connection.secretHint && `key ${connection.secretHint}`, | |
| 288 | 298 | ].filter(Boolean); | |
| 289 | − | const waitingForSecret = connection.provider === "sentry" && !deliveries.length && !connection.lastUsedAt; | |
| 299 | + | // Unread deliveries say nothing about whether its secret was set. | |
| 300 | + | const waitingForSecret = connection.provider === "sentry" && !isFailed(deliveries) && !deliveries.length && !connection.lastUsedAt; | |
| 290 | 301 | return ( | |
| 291 | 302 | <div> | |
| 292 | 303 | <div className="flex items-center gap-3"> | |
| ⋯ | |||
| 342 | 353 | {updated && <span className="pb-2 text-sm text-muted">Saved.</span>} | |
| 343 | 354 | </Form> | |
| 344 | 355 | )} | |
| 345 | − | {deliveries.length > 0 && ( | |
| 356 | + | {isFailed(deliveries) ? ( | |
| 357 | + | <div className="mt-3"> | |
| 358 | + | <Unavailable what={deliveries.what} compact /> | |
| 359 | + | </div> | |
| 360 | + | ) : deliveries.length > 0 && ( | |
| 346 | 361 | <ul className="mt-3 space-y-1 text-xs"> | |
| 347 | 362 | {deliveries.map((delivery) => ( | |
| 348 | 363 | <li key={delivery.id} className="flex items-center gap-2 text-muted"> | |
| 16 | 16 | import { WorkspacePageSkeleton } from "../../components/workspace-skeletons"; | |
| 17 | 17 | import { identity } from "../../lib/services.server"; | |
| 18 | 18 | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 19 | + | import { isFailed, part } from "../../lib/partial"; | |
| 19 | 20 | ||
| 20 | 21 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| 21 | 22 | return page(args, { title: `${loaderData?.workspace.name ?? params.owner} · g1t` }); | |
| ⋯ | |||
| 37 | 38 | const role = roleIn(viewer, workspace.slug); | |
| 38 | 39 | // Opening one of your workspaces makes it the one you are in. | |
| 39 | 40 | if (!role) { | |
| 40 | − | const listed = await workspaceProjects(workspace.slug, viewer); | |
| 41 | − | return { workspace, role, welcome: false, projects: listed.ok ? listed.value.map(shortcutOf) : [] }; | |
| 41 | + | // The workspace is what the frame is about; its projects only fill the | |
| 42 | + | // sidebar, which goes without them rather than take every page down. | |
| 43 | + | const listed = await part(workspaceProjects(workspace.slug, viewer), "Projects"); | |
| 44 | + | return { workspace, role, welcome: false, projects: !isFailed(listed) && listed.ok ? listed.value.map(shortcutOf) : [] }; | |
| 42 | 45 | } | |
| 43 | 46 | const secure = new URL(request.url).protocol === "https:"; | |
| 44 | 47 | const headers = new Headers({ "Set-Cookie": rememberWorkspace(workspace.slug, secure) }); | |
| 51 | 51 | if (roleIn(viewer, params.owner) !== "owner") throw data(null, { status: 404 }); | |
| 52 | 52 | const [invites, workspace, free] = await Promise.all([ | |
| 53 | 53 | identity.workspaceInvites(params.owner, viewer), | |
| 54 | − | identity.getWorkspace(params.owner), | |
| 54 | + | // Only for the workspace's name, which its address stands in for. | |
| 55 | + | identity.getWorkspace(params.owner).catch(() => null), | |
| 55 | 56 | billing.freeWorkspaces([params.owner]).catch(() => [] as string[]), | |
| 56 | 57 | ]); | |
| 57 | 58 | return { |
| 41 | 41 | import { billing, identity } from "../../lib/services.server"; | |
| 42 | 42 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 43 | 43 | import { forgetWorkspace } from "../../lib/workspace-choice"; | |
| 44 | + | import { Unavailable } from "../../components/boundary"; | |
| 45 | + | import { isFailed, part, type Failed } from "../../lib/partial"; | |
| 44 | 46 | ||
| 45 | 47 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 46 | 48 | return page(args, { title: `Members · ${params.owner} · g1t` }); | |
| ⋯ | |||
| 60 | 62 | if (!role) throw data(null, { status: 404 }); | |
| 61 | 63 | const owner = role === "owner"; | |
| 62 | 64 | const [members, workspace, outside, teams, free, invites] = await Promise.all([ | |
| 65 | + | // The members are what the page is about. The rest is beside them, and | |
| 66 | + | // each part says so in its place when it cannot be read. | |
| 63 | 67 | identity.listMembers(params.owner, viewer), | |
| 64 | − | identity.getWorkspace(params.owner), | |
| 65 | − | owner ? identity.outsideCollaborators(viewer, params.owner).catch(() => null) : null, | |
| 68 | + | // Only for the workspace's name, which its address stands in for. | |
| 69 | + | identity.getWorkspace(params.owner).catch(() => null), | |
| 70 | + | owner ? part(identity.outsideCollaborators(viewer, params.owner), "Outside collaborators") : null, | |
| 66 | 71 | // Each member's teams, as the viewer may see them. | |
| 67 | − | identity.teamMemberships(viewer, params.owner).catch(() => null), | |
| 72 | + | part(identity.teamMemberships(viewer, params.owner), "Each member's teams"), | |
| 68 | 73 | // A free workspace adds no one until it starts the plan; identity | |
| 69 | 74 | // refuses it either way, so a failure here only hides the note. | |
| 70 | 75 | billing.freeWorkspaces([params.owner]).catch(() => [] as string[]), | |
| 71 | 76 | // How many invitations wait, for the line that leads to them. | |
| 72 | − | owner ? identity.workspaceInvites(params.owner, viewer).catch(() => null) : null, | |
| 77 | + | owner ? part(identity.workspaceInvites(params.owner, viewer), "Invitations") : null, | |
| 73 | 78 | ]); | |
| 74 | 79 | return { | |
| 75 | 80 | role, | |
| 76 | 81 | name: workspace?.name?.trim() || params.owner, | |
| 77 | 82 | free: free.includes(params.owner.toLowerCase()), | |
| 78 | 83 | members: unwrap(members), | |
| 79 | − | outside: outside?.ok ? outside.value : [], | |
| 80 | − | teams: Object.fromEntries((teams?.ok ? teams.value : []).map((person) => [person.username, person.teams])), | |
| 81 | − | pendingInvitations: invites?.ok ? invites.value.filter((invite) => invitationFilterOf(invite.status) === "pending").length : 0, | |
| 84 | + | outside: isFailed(outside) ? outside : outside?.ok ? outside.value : [], | |
| 85 | + | teams: Object.fromEntries((!isFailed(teams) && teams.ok ? teams.value : []).map((person) => [person.username, person.teams])), | |
| 86 | + | teamsFailed: isFailed(teams) ? teams : null, | |
| 87 | + | /** Null when how many wait could not be read: the line still leads to them. */ | |
| 88 | + | pendingInvitations: isFailed(invites) ? null : invites?.ok ? invites.value.filter((invite) => invitationFilterOf(invite.status) === "pending").length : 0, | |
| 82 | 89 | me: viewer?.username ?? null, | |
| 83 | 90 | }; | |
| 84 | 91 | } | |
| ⋯ | |||
| 366 | 373 | } | |
| 367 | 374 | ||
| 368 | 375 | export default function WorkspaceMembers({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 369 | − | const { role, members, outside, teams, free, me, name, pendingInvitations } = loaderData; | |
| 376 | + | const { role, members, outside, teams, teamsFailed, free, me, name, pendingInvitations } = loaderData; | |
| 370 | 377 | const owner = role === "owner"; | |
| 371 | 378 | const owners = members.filter((member) => member.role === "owner").length; | |
| 372 | 379 | const rowError = (username: string) => | |
| ⋯ | |||
| 386 | 393 | const membersTab = ( | |
| 387 | 394 | <> | |
| 388 | 395 | <MembersToolbar owner={owner} teams={teamChoices} invite={invite} /> | |
| 396 | + | {teamsFailed && ( | |
| 397 | + | <div className="mb-4"> | |
| 398 | + | <Unavailable what={teamsFailed.what} compact /> | |
| 399 | + | </div> | |
| 400 | + | )} | |
| 389 | 401 | {owner && free && ( | |
| 390 | 402 | <div className="mb-4"> | |
| 391 | 403 | <StartPlanToInvite workspace={params.owner} owner={owner} /> | |
| ⋯ | |||
| 446 | 458 | )} | |
| 447 | 459 | <p className="mt-2 text-xs text-faint"> | |
| 448 | 460 | {filtered ? `${shown.length} of ${members.length}` : members.length} {members.length === 1 ? "member" : "members"} | |
| 449 | − | {owner && pendingInvitations > 0 && ( | |
| 461 | + | {owner && pendingInvitations === null && ( | |
| 450 | 462 | <> | |
| 451 | 463 | {" · "} | |
| 452 | 464 | <Link to={`/${params.owner}/-/members/invitations?state=pending`} className="inline-flex items-center gap-1 text-muted underline-offset-4 hover:text-fg hover:underline"> | |
| 453 | 465 | <Mail size={12} aria-hidden="true" /> | |
| 466 | + | Invitations | |
| 467 | + | </Link> | |
| 468 | + | </> | |
| 469 | + | )} | |
| 470 | + | {owner && pendingInvitations != null && pendingInvitations > 0 && ( | |
| 471 | + | <> | |
| 472 | + | {" · "} | |
| 473 | + | <Link to={`/${params.owner}/-/members/invitations?state=pending`} className="inline-flex items-center gap-1 text-muted underline-offset-4 hover:text-fg hover:underline"> | |
| 474 | + | <Mail size={12} aria-hidden="true" /> | |
| 454 | 475 | {pendingInvitations} {pendingInvitations === 1 ? "invitation" : "invitations"} waiting | |
| 455 | 476 | </Link> | |
| 456 | 477 | </> | |
| ⋯ | |||
| 502 | 523 | > | |
| 503 | 524 | <TabsList className="mb-4"> | |
| 504 | 525 | <TabsTrigger value="members">Members · {members.length}</TabsTrigger> | |
| 505 | − | <TabsTrigger value="outside">Outside collaborators · {outside.length}</TabsTrigger> | |
| 526 | + | <TabsTrigger value="outside">{isFailed(outside) ? "Outside collaborators" : `Outside collaborators · ${outside.length}`}</TabsTrigger> | |
| 506 | 527 | </TabsList> | |
| 507 | 528 | <TabsContent value="members">{membersTab}</TabsContent> | |
| 508 | 529 | <TabsContent value="outside"> | |
| 509 | − | <OutsideCollaborators | |
| 510 | − | people={outside} | |
| 511 | − | slug={params.owner} | |
| 512 | − | free={free} | |
| 513 | − | error={actionData && "converting" in actionData && actionData.converting ? actionData.error : null} | |
| 514 | − | /> | |
| 530 | + | {isFailed(outside) ? ( | |
| 531 | + | <Unavailable what={outside.what} /> | |
| 532 | + | ) : ( | |
| 533 | + | <OutsideCollaborators | |
| 534 | + | people={outside} | |
| 535 | + | slug={params.owner} | |
| 536 | + | free={free} | |
| 537 | + | error={actionData && "converting" in actionData && actionData.converting ? actionData.error : null} | |
| 538 | + | /> | |
| 539 | + | )} | |
| 515 | 540 | </TabsContent> | |
| 516 | 541 | </Tabs> | |
| 517 | 542 | ) : ( | |
| ⋯ | |||
| 528 | 553 | free, | |
| 529 | 554 | error, | |
| 530 | 555 | }: { | |
| 531 | − | people: Route.ComponentProps["loaderData"]["outside"]; | |
| 556 | + | people: Exclude<Route.ComponentProps["loaderData"]["outside"], Failed>; | |
| 532 | 557 | slug: string; | |
| 533 | 558 | free: boolean; | |
| 534 | 559 | error: string | null | undefined; | |
| 19 | 19 | import { deployments, identity, packages, projects as projectsApi, work } from "../../lib/services.server"; | |
| 20 | 20 | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 21 | 21 | import { workspaceProjects } from "../../lib/workspace-projects.server"; | |
| 22 | + | import { Unavailable } from "../../components/boundary"; | |
| 23 | + | import { isFailed, part } from "../../lib/partial"; | |
| 22 | 24 | ||
| 23 | 25 | /** Projects whose open issues and pull requests are counted: the most active. */ | |
| 24 | 26 | const MAX_COUNTED = 30; | |
| ⋯ | |||
| 63 | 65 | const pathOf = (project: Project) => | |
| 64 | 66 | project.source.kind === "hosted" ? project.source.repo : { namespace: project.workspace, name: project.slug }; | |
| 65 | 67 | const counted = [...new Map([...pinned, ...active, ...byActivity].map((project) => [project.id, project])).values()].slice(0, Math.max(MAX_COUNTED, pinned.length + active.length)); | |
| 66 | − | const counts = await Promise.all(counted.map((project) => work.counts(pathOf(project), viewer))); | |
| 68 | + | // The projects are what the page is about. What is open in each, and the | |
| 69 | + | // pull requests in progress, are beside them: a project whose counts | |
| 70 | + | // could not be read shows none, and the page says the totals are short. | |
| 71 | + | const counts = await Promise.all(counted.map((project) => part(work.counts(pathOf(project), viewer), "Open issues and pull requests"))); | |
| 67 | 72 | const open: Record<string, { issues: number; pulls: number }> = {}; | |
| 68 | 73 | counted.forEach((project, i) => { | |
| 69 | 74 | const result = counts[i]; | |
| 70 | − | if (result.ok) open[project.id] = result.value; | |
| 75 | + | if (!isFailed(result) && result.ok) open[project.id] = result.value; | |
| 71 | 76 | }); | |
| 77 | + | const countsFailed = counts.find(isFailed) ?? null; | |
| 72 | 78 | ||
| 73 | 79 | const busy = counted.filter((project) => (open[project.id]?.pulls ?? 0) > 0).slice(0, MAX_LISTED); | |
| 74 | − | const lists = await Promise.all(busy.map((project) => work.listPulls(pathOf(project), viewer, "open"))); | |
| 80 | + | const lists = await Promise.all(busy.map((project) => part(work.listPulls(pathOf(project), viewer, "open"), "Pull requests in progress"))); | |
| 81 | + | const pullsFailed = lists.find(isFailed) ?? null; | |
| 75 | 82 | const pulls = lists | |
| 76 | − | .flatMap((result, i) => (result.ok ? result.value.map((pull) => ({ pull, project: busy[i] })) : [])) | |
| 83 | + | .flatMap((result, i) => (!isFailed(result) && result.ok ? result.value.map((pull) => ({ pull, project: busy[i] })) : [])) | |
| 77 | 84 | .sort((a, b) => b.pull.updatedAt.localeCompare(a.pull.updatedAt)) | |
| 78 | 85 | .slice(0, MAX_PULLS); | |
| 79 | 86 | ||
| ⋯ | |||
| 102 | 109 | deploys: bySlug, | |
| 103 | 110 | packageOf, | |
| 104 | 111 | pulls: pulls.map(({ pull, project }) => ({ pull, slug: project.slug, name: project.name })), | |
| 112 | + | countsFailed, | |
| 113 | + | pullsFailed, | |
| 105 | 114 | members: members?.ok ? members.value : null, | |
| 106 | 115 | usage, | |
| 107 | 116 | }; | |
| ⋯ | |||
| 277 | 286 | <Stat value={totals.issues} label="Open issues" /> | |
| 278 | 287 | <Stat value={totals.pulls} label="Pull requests in progress" /> | |
| 279 | 288 | </div> | |
| 289 | + | {loaderData.countsFailed && <Unavailable what={loaderData.countsFailed.what} compact />} | |
| 280 | 290 | ||
| 281 | 291 | {role && ( | |
| 282 | 292 | <section aria-labelledby="pinned"> | |
| ⋯ | |||
| 327 | 337 | </span> | |
| 328 | 338 | </Link> | |
| 329 | 339 | ||
| 340 | + | {loaderData.pullsFailed && pulls.length === 0 && ( | |
| 341 | + | <section> | |
| 342 | + | <h2 className="text-sm font-medium text-muted">In progress</h2> | |
| 343 | + | <div className="mt-3"> | |
| 344 | + | <Unavailable what={loaderData.pullsFailed.what} /> | |
| 345 | + | </div> | |
| 346 | + | </section> | |
| 347 | + | )} | |
| 330 | 348 | {pulls.length > 0 && ( | |
| 331 | 349 | <section> | |
| 332 | 350 | <h2 className="text-sm font-medium text-muted">In progress</h2> | |
| 25 | 25 | } from "../../lib/packages"; | |
| 26 | 26 | import { packages } from "../../lib/services.server"; | |
| 27 | 27 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 28 | + | import { Unavailable } from "../../components/boundary"; | |
| 29 | + | import { isFailed, orElse, part } from "../../lib/partial"; | |
| 28 | 30 | ||
| 29 | 31 | const DOCS = "https://docs.g1t.sh"; | |
| 30 | 32 | ||
| ⋯ | |||
| 50 | 52 | // then is there a Deleted packages view. | |
| 51 | 53 | const [found, deletedFound] = await Promise.all([ | |
| 52 | 54 | packages.list(params.owner, viewer, { ecosystem, query }), | |
| 53 | − | member ? packages.deleted(params.owner, viewer) : null, | |
| 55 | + | // Beside the list: when they cannot be read, their view says so. | |
| 56 | + | member ? part(packages.deleted(params.owner, viewer), "Deleted packages") : null, | |
| 54 | 57 | ]); | |
| 55 | 58 | const listed = unwrap(found); | |
| 56 | − | const deleted = deletedFound?.ok ? deletedFound.value : []; | |
| 59 | + | const deleted = isFailed(deletedFound) ? deletedFound : deletedFound?.ok ? deletedFound.value : []; | |
| 57 | 60 | // Whether the workspace has any at all decides between a filtered-out | |
| 58 | 61 | // list and the first-package page. | |
| 59 | − | const any = listed.length > 0 || deleted.length > 0 || ecosystem != null || query != null; | |
| 62 | + | const any = listed.length > 0 || orElse(deleted, []).length > 0 || ecosystem != null || query != null; | |
| 60 | 63 | return { | |
| 61 | 64 | member, | |
| 62 | 65 | list: arrange(listed, visibility, sort), | |
| ⋯ | |||
| 104 | 107 | export default function Packages({ loaderData, actionData }: Route.ComponentProps) { | |
| 105 | 108 | const { list, deleted, view, any, ecosystem, query, visibility, sort, workspace } = loaderData; | |
| 106 | 109 | if (!any) return loaderData.member ? <ChooseRegistry workspace={workspace} /> : <EmptyState title="No public packages">This workspace has no packages you can view yet.</EmptyState>; | |
| 107 | − | if (view === "deleted") return <DeletedPackages workspace={workspace} deleted={deleted} outcome={actionData as Outcome | undefined} />; | |
| 110 | + | if (view === "deleted") { | |
| 111 | + | if (isFailed(deleted)) return <Unavailable what={deleted.what} />; | |
| 112 | + | return <DeletedPackages workspace={workspace} deleted={deleted} outcome={actionData as Outcome | undefined} />; | |
| 113 | + | } | |
| 108 | 114 | const filters: Filters = { type: ecosystem, q: query, visibility, sort }; | |
| 109 | 115 | const filtered = ecosystem != null || query != null || visibility !== "all"; | |
| 110 | 116 | return ( | |
| ⋯ | |||
| 167 | 173 | )} | |
| 168 | 174 | </span> | |
| 169 | 175 | <span className="flex items-center gap-4"> | |
| 170 | − | {deleted.length > 0 && ( | |
| 176 | + | {!isFailed(deleted) && deleted.length > 0 && ( | |
| 171 | 177 | <Link to={`/${workspace}/-/packages?view=deleted`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 172 | 178 | <Trash2 size={12} /> | |
| 173 | 179 | Deleted packages <span className="rounded-full bg-raised px-1.5 text-faint tabular-nums">{deleted.length}</span> | |
| 15 | 15 | import { identity } from "../../lib/services.server"; | |
| 16 | 16 | import { currentTokensPath, lifetimeFromForm } from "../../lib/access-tokens"; | |
| 17 | 17 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 18 | + | import { Unavailable } from "../../components/boundary"; | |
| 19 | + | import { isFailed, orElse, part, type Failed } from "../../lib/partial"; | |
| 18 | 20 | ||
| 19 | 21 | // A workspace's rules for its members' personal access tokens, the tokens | |
| 20 | 22 | // that reach it, and approving the ones made for it. Owners only; identity | |
| ⋯ | |||
| 31 | 33 | if (moved) throw redirect(moved, 301); | |
| 32 | 34 | const viewer = getViewer(context); | |
| 33 | 35 | const role = roleIn(viewer, params.owner); | |
| 34 | − | if (!viewer || role !== "owner") return { slug, role, policy: null, tokens: [] as MemberToken[] }; | |
| 35 | − | const [policy, tokens] = await Promise.all([identity.getTokenPolicy(slug, viewer), identity.listMemberTokens(viewer, slug)]); | |
| 36 | − | return { slug, role, policy: unwrap(policy), tokens: unwrap(tokens) }; | |
| 36 | + | if (!viewer || role !== "owner") return { slug, role, policy: null, tokens: [] as MemberToken[] | Failed }; | |
| 37 | + | // The rules are what the page is about, and their form is never shown | |
| 38 | + | // without them. The tokens are listed beside them: when they cannot be | |
| 39 | + | // read, their sections say so and the rules can still be changed. | |
| 40 | + | const [policy, tokens] = await Promise.all([identity.getTokenPolicy(slug, viewer), part(identity.listMemberTokens(viewer, slug), "Members' tokens")]); | |
| 41 | + | return { slug, role, policy: unwrap(policy), tokens: isFailed(tokens) ? tokens : unwrap(tokens) }; | |
| 37 | 42 | } | |
| 38 | 43 | ||
| 39 | 44 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| ⋯ | |||
| 92 | 97 | </EmptyState> | |
| 93 | 98 | ); | |
| 94 | 99 | } | |
| 95 | − | const pending = tokens.filter((member) => member.token.status === "pending"); | |
| 96 | − | const listed = tokens.filter((member) => member.token.status !== "pending"); | |
| 100 | + | const pending = orElse(tokens, []).filter((member) => member.token.status === "pending"); | |
| 101 | + | const listed = orElse(tokens, []).filter((member) => member.token.status !== "pending"); | |
| 97 | 102 | const lifetime = policy.maxLifetimeDays == null ? "" : String(policy.maxLifetimeDays); | |
| 98 | 103 | return ( | |
| 99 | 104 | <div className="max-w-3xl space-y-10"> | |
| ⋯ | |||
| 169 | 174 | Waiting for approval | |
| 170 | 175 | {pending.length > 0 && <Badge tone="warn">{pending.length}</Badge>} | |
| 171 | 176 | </h2> | |
| 172 | − | {pending.length === 0 ? ( | |
| 177 | + | {isFailed(tokens) ? ( | |
| 178 | + | <div className="mt-2"> | |
| 179 | + | <Unavailable what={tokens.what} compact /> | |
| 180 | + | </div> | |
| 181 | + | ) : pending.length === 0 ? ( | |
| 173 | 182 | <p className="mt-2 text-sm text-muted">No token is waiting for approval.</p> | |
| 174 | 183 | ) : ( | |
| 175 | 184 | <Card asChild tone="plain" divided className="mt-3"> | |
| ⋯ | |||
| 209 | 218 | </Link> | |
| 210 | 219 | . | |
| 211 | 220 | </p> | |
| 212 | − | {listed.length === 0 ? ( | |
| 221 | + | {isFailed(tokens) ? ( | |
| 222 | + | <div className="mt-3"> | |
| 223 | + | <Unavailable what={tokens.what} /> | |
| 224 | + | </div> | |
| 225 | + | ) : listed.length === 0 ? ( | |
| 213 | 226 | <div className="mt-3"> | |
| 214 | 227 | <EmptyState title="No tokens">No member has a personal access token that can reach {slug}.</EmptyState> | |
| 215 | 228 | </div> | |
| 32 | 32 | import { deployments, projects } from "../../lib/services.server"; | |
| 33 | 33 | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 34 | 34 | import { workspaceProjects } from "../../lib/workspace-projects.server"; | |
| 35 | + | import { Unavailable } from "../../components/boundary"; | |
| 36 | + | import { isFailed, part } from "../../lib/partial"; | |
| 35 | 37 | ||
| 36 | 38 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 37 | 39 | return pageMeta(args, { title: `Projects · ${params.owner} · g1t` }); | |
| ⋯ | |||
| 49 | 51 | const query = readProjectQuery(new URL(request.url).searchParams); | |
| 50 | 52 | const [listed, deploys, shortcuts] = await Promise.all([ | |
| 51 | 53 | workspaceProjects(slug, viewer), | |
| 52 | − | role ? deployments.overview(slug, viewer).catch(() => null) : null, | |
| 54 | + | // Where each project runs: without it, rows lead to their homepage | |
| 55 | + | // rather than production, and the page says why. | |
| 56 | + | role ? part(deployments.overview(slug, viewer), "Production links") : null, | |
| 57 | + | // The viewer's pins only order the list; without them it is in its usual order. | |
| 53 | 58 | role ? projects.shortcuts(slug, viewer).catch(() => null) : null, | |
| 54 | 59 | ]); | |
| 55 | − | const live = new Map((deploys?.ok ? deploys.value : []).map((entry) => [entry.slug, entry])); | |
| 60 | + | const live = new Map((!isFailed(deploys) && deploys?.ok ? deploys.value : []).map((entry) => [entry.slug, entry])); | |
| 56 | 61 | const full = new Map((listed.ok ? listed.value : []).map((project) => [project.id, project])); | |
| 57 | 62 | const all: Listed[] = (listed.ok ? listed.value : []).map((project) => ({ | |
| 58 | 63 | id: project.id, | |
| ⋯ | |||
| 86 | 91 | facets: facetsOf(all), | |
| 87 | 92 | pinned: (shortcuts?.pinned ?? []).map((project) => project.id), | |
| 88 | 93 | failed: !listed.ok, | |
| 94 | + | deploysFailed: isFailed(deploys) ? deploys : null, | |
| 89 | 95 | }; | |
| 90 | 96 | } | |
| 91 | 97 | ||
| ⋯ | |||
| 494 | 500 | </div> | |
| 495 | 501 | ||
| 496 | 502 | <div ref={list} onKeyDown={onListKey} aria-busy={busy || undefined} className={`mt-4 transition-opacity ${busy ? "opacity-60" : ""}`}> | |
| 503 | + | {!failed && loaderData.deploysFailed && ( | |
| 504 | + | <div className="mb-3"> | |
| 505 | + | <Unavailable what={loaderData.deploysFailed.what} compact /> | |
| 506 | + | </div> | |
| 507 | + | )} | |
| 497 | 508 | {failed ? ( | |
| 498 | 509 | <EmptyState title="Projects cannot be listed right now">Try again in a moment.</EmptyState> | |
| 499 | 510 | ) : total === 0 ? ( | |
| 24 | 24 | } from "../../lib/repo-lifecycle"; | |
| 25 | 25 | import { projects, repos } from "../../lib/services.server"; | |
| 26 | 26 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 27 | + | import { Unavailable } from "../../components/boundary"; | |
| 28 | + | import { isFailed, orElse, part } from "../../lib/partial"; | |
| 27 | 29 | ||
| 28 | 30 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 29 | 31 | return page(args, { title: `Repositories · ${params.owner} · g1t` }); | |
| ⋯ | |||
| 37 | 39 | const owner = role === "owner"; | |
| 38 | 40 | const [list, deleted] = await Promise.all([ | |
| 39 | 41 | repos.list(viewer, { namespace: params.owner, memberOnly: true }), | |
| 40 | − | owner ? repos.deleted(viewer, params.owner) : Promise.resolve([]), | |
| 42 | + | // Beside the list: when it cannot be read, its section says so. | |
| 43 | + | owner ? part(repos.deleted(viewer, params.owner), "Recently deleted repositories") : Promise.resolve([]), | |
| 41 | 44 | ]); | |
| 42 | 45 | // A repository has no description: its project's is shown, and searched. | |
| 43 | − | const about = await projects.descriptions(list.map((repo) => repo.id)); | |
| 46 | + | // Without them each row goes without one, which is how a row without a description reads anyway. | |
| 47 | + | const about = orElse(await part(projects.descriptions(list.map((repo) => repo.id)), "Descriptions"), {} as Record<string, string | null>); | |
| 44 | 48 | return { | |
| 45 | 49 | repos: list.map((repo) => ({ ...repo, description: about[repo.id] ?? null })), | |
| 46 | 50 | deleted, | |
| ⋯ | |||
| 311 | 315 | Their names stay taken until then. Only owners see this list. | |
| 312 | 316 | </p> | |
| 313 | 317 | </div> | |
| 314 | − | {deleted.length === 0 ? ( | |
| 318 | + | {isFailed(deleted) ? ( | |
| 319 | + | <Unavailable what={deleted.what} /> | |
| 320 | + | ) : deleted.length === 0 ? ( | |
| 315 | 321 | <Card asChild tone="plain" className="border-dashed px-4 py-6 text-center text-sm text-faint"> | |
| 316 | 322 | <p> | |
| 317 | 323 | Nothing has been deleted in the last {RESTORE_DAYS} days. | |
| 12 | 12 | import { Card } from "../../components/ui/card"; | |
| 13 | 13 | import { securitySuite } from "../../lib/services.server"; | |
| 14 | 14 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 15 | + | import { isFailed, part } from "../../lib/partial"; | |
| 15 | 16 | ||
| 16 | 17 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 17 | 18 | return page(args, { title: `Bypass requests · ${params.owner} · g1t` }); | |
| ⋯ | |||
| 25 | 26 | const state = new URL(request.url).searchParams.get("state") ?? "pending"; | |
| 26 | 27 | const [list, pending] = await Promise.all([ | |
| 27 | 28 | securitySuite.bypassRequests(params.owner, viewer, STATES.has(state) ? state : null), | |
| 28 | − | securitySuite.bypassRequests(params.owner, viewer, "pending"), | |
| 29 | + | // Only the tabs' badge: without it, the badge goes without a count. | |
| 30 | + | part(securitySuite.bypassRequests(params.owner, viewer, "pending"), "Pending bypass requests"), | |
| 29 | 31 | ]); | |
| 30 | − | return { requests: unwrap(list), pending: pending.ok ? pending.value.length : 0, me: viewer?.username ?? "" }; | |
| 32 | + | return { requests: unwrap(list), pending: !isFailed(pending) && pending.ok ? pending.value.length : 0, me: viewer?.username ?? "" }; | |
| 31 | 33 | } | |
| 32 | 34 | ||
| 33 | 35 | export async function action({ params, context, request }: Route.ActionArgs) { | |
| 14 | 14 | import { repos, security, securitySuite } from "../../lib/services.server"; | |
| 15 | 15 | import { getViewer, managesSecurity, roleIn, unwrap } from "../../lib/session.server"; | |
| 16 | 16 | import { planPrice } from "../../lib/security-suite.server"; | |
| 17 | + | import { Unavailable } from "../../components/boundary"; | |
| 18 | + | import { isFailed, part } from "../../lib/partial"; | |
| 17 | 19 | ||
| 18 | 20 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 19 | 21 | return page(args, { title: `Security · ${params.owner} · g1t` }); | |
| ⋯ | |||
| 25 | 27 | const [scanned, current, overview, requests, price] = await Promise.all([ | |
| 26 | 28 | security.workspace(params.owner, viewer), | |
| 27 | 29 | repos.list(viewer, { namespace: params.owner.toLowerCase() }), | |
| 28 | − | securitySuite.overview(params.owner, viewer, 30), | |
| 29 | − | securitySuite.bypassRequests(params.owner, viewer, "pending"), | |
| 30 | + | // The alerts are what the page is about. The overview beside them says | |
| 31 | + | // so in its place when it cannot be read, rather than offering the plan | |
| 32 | + | // to a workspace that may have it; the pending count is only a badge. | |
| 33 | + | part(securitySuite.overview(params.owner, viewer, 30), "The security overview"), | |
| 34 | + | part(securitySuite.bypassRequests(params.owner, viewer, "pending"), "Pending bypass requests"), | |
| 30 | 35 | planPrice(params.owner, viewer), | |
| 31 | 36 | ]); | |
| 32 | 37 | // Only repositories that are still there: a deleted one's alerts stay | |
| ⋯ | |||
| 45 | 50 | (a, b) => | |
| 46 | 51 | SEVERITIES.reduce((order, severity) => order || b.counts[severity] - a.counts[severity], 0) || a.name.localeCompare(b.name), | |
| 47 | 52 | ); | |
| 48 | − | const full = overview.ok | |
| 53 | + | const full = !isFailed(overview) && overview.ok | |
| 49 | 54 | ? { | |
| 50 | 55 | ...overview.value, | |
| 51 | 56 | repos: overview.value.repos | |
| ⋯ | |||
| 57 | 62 | projects, | |
| 58 | 63 | total, | |
| 59 | 64 | overview: full, | |
| 60 | − | pending: requests.ok ? requests.value.length : 0, | |
| 65 | + | overviewFailed: isFailed(overview) ? overview : null, | |
| 66 | + | pending: !isFailed(requests) && requests.ok ? requests.value.length : 0, | |
| 61 | 67 | price, | |
| 62 | 68 | owner: managesSecurity(viewer, params.owner), | |
| 63 | 69 | }; | |
| ⋯ | |||
| 70 | 76 | }; | |
| 71 | 77 | ||
| 72 | 78 | export default function WorkspaceSecurity({ loaderData, params }: Route.ComponentProps) { | |
| 73 | − | const { projects, total, overview, pending, price, owner } = loaderData; | |
| 79 | + | const { projects, total, overview, overviewFailed, pending, price, owner } = loaderData; | |
| 74 | 80 | // The full overview counts private repositories with the plan; without it, a workspace | |
| 75 | 81 | // with private ones sees the free list of every repository's open alerts. | |
| 76 | 82 | const full = overview && (overview.activated || overview.privateHidden === 0); | |
| ⋯ | |||
| 102 | 108 | </> | |
| 103 | 109 | ) : ( | |
| 104 | 110 | <> | |
| 105 | − | <ActivationPrompt workspace={params.owner} feature="The security overview" monthlyCents={price} isOwner={owner} /> | |
| 111 | + | {overviewFailed ? ( | |
| 112 | + | <Unavailable what={overviewFailed.what} /> | |
| 113 | + | ) : ( | |
| 114 | + | <ActivationPrompt workspace={params.owner} feature="The security overview" monthlyCents={price} isOwner={owner} /> | |
| 115 | + | )} | |
| 106 | 116 | <div> | |
| 107 | 117 | <SeverityCountsGrid counts={total} /> | |
| 108 | 118 | <p className="mt-2 text-xs text-faint"> | |
| 38 | 38 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 39 | 39 | import { forgetWorkspace } from "../../lib/workspace-choice"; | |
| 40 | 40 | import { confirmsSlug, deletionRefusal, whatGoes } from "../../lib/workspace-deletion"; | |
| 41 | + | import { Unavailable } from "../../components/boundary"; | |
| 42 | + | import { isFailed, part, type Failed } from "../../lib/partial"; | |
| 41 | 43 | ||
| 42 | 44 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 43 | 45 | return page(args, { title: `Settings · ${params.owner} · g1t` }); | |
| ⋯ | |||
| 67 | 69 | } | |
| 68 | 70 | // What deleting it would take with it, and anything in the way, shown | |
| 69 | 71 | // before anyone types. Its apps are counted by deployments. | |
| 70 | − | let deletion: WorkspaceDeletion | null = null; | |
| 72 | + | // When that cannot be read, Delete says so in its place rather than | |
| 73 | + | // offering itself without saying what would go. How many apps go is only | |
| 74 | + | // a count in that list, which reads without it. | |
| 75 | + | let deletion: WorkspaceDeletion | Failed | null = null; | |
| 71 | 76 | let apps: number | null = null; | |
| 72 | 77 | if (viewer) { | |
| 73 | 78 | const [found, usage] = await Promise.all([ | |
| 74 | − | identity.checkWorkspaceDeletion(viewer, params.owner).catch(() => null), | |
| 79 | + | part(identity.checkWorkspaceDeletion(viewer, params.owner), "What deleting it would take with it"), | |
| 75 | 80 | deployments.usage(params.owner, viewer).catch(() => null), | |
| 76 | 81 | ]); | |
| 77 | − | deletion = found?.ok ? found.value : null; | |
| 82 | + | deletion = isFailed(found) ? found : found.ok ? found.value : null; | |
| 78 | 83 | apps = usage?.ok ? usage.value.apps : null; | |
| 79 | 84 | } | |
| 80 | 85 | // Where its repositories are kept: offered once g1t can keep them in the | |
| 81 | 86 | // EU, and shown to a workspace that chose it whatever happens since. | |
| 87 | + | // Each of these is a section's: one that cannot be read says so in its | |
| 88 | + | // place, rather than show a choice that saving would put over the real | |
| 89 | + | // one, or say no one would be held out when that is not known. | |
| 82 | 90 | const [storage, residency, members, twoFactor] = await Promise.all([ | |
| 91 | + | // Whether the EU is offered: without it, only a workspace that chose it sees the choice. | |
| 83 | 92 | repos.storageOptions().catch(() => ({ euAvailable: false })), | |
| 84 | − | identity.workspaceResidency(params.owner).catch(() => null), | |
| 93 | + | part(identity.workspaceResidency(params.owner), "Data residency"), | |
| 85 | 94 | // Who would be held out by requiring two-factor authentication. | |
| 86 | − | viewer ? identity.listMembers(params.owner, viewer).catch(() => null) : null, | |
| 87 | − | viewer ? accounts.twoFactorStatus(viewer).catch(() => null) : null, | |
| 95 | + | viewer ? part(identity.listMembers(params.owner, viewer), "Two-factor authentication") : null, | |
| 96 | + | viewer ? part(accounts.twoFactorStatus(viewer), "Two-factor authentication") : null, | |
| 88 | 97 | ]); | |
| 89 | − | const without = members?.ok ? members.value.filter((member) => member.two_factor === false).map((member) => member.username) : []; | |
| 98 | + | const without = isFailed(members) ? members : members?.ok ? members.value.filter((member) => member.two_factor === false).map((member) => member.username) : []; | |
| 90 | 99 | return { | |
| 91 | 100 | workspace, | |
| 92 | 101 | check, | |
| 93 | 102 | deletion, | |
| 94 | 103 | apps, | |
| 95 | 104 | euAvailable: storage.euAvailable, | |
| 96 | − | residency: residency ?? "anywhere", | |
| 105 | + | residency: isFailed(residency) ? residency : (residency ?? "anywhere"), | |
| 97 | 106 | withoutTwoFactor: without, | |
| 98 | − | ownTwoFactor: twoFactor?.ok ? twoFactor.value.enabled : false, | |
| 107 | + | ownTwoFactor: isFailed(twoFactor) ? twoFactor : twoFactor?.ok ? twoFactor.value.enabled : false, | |
| 99 | 108 | }; | |
| 100 | 109 | } | |
| 101 | 110 | ||
| ⋯ | |||
| 209 | 218 | error={actionData && "renameError" in actionData ? actionData.renameError : undefined} | |
| 210 | 219 | /> | |
| 211 | 220 | ||
| 212 | − | <TwoFactorRequirementSection | |
| 213 | − | key={String(workspace.twoFactorRequirementEnabled ?? false)} | |
| 214 | − | slug={workspace.slug} | |
| 215 | − | required={workspace.twoFactorRequirementEnabled ?? false} | |
| 216 | − | without={loaderData.withoutTwoFactor} | |
| 217 | − | ownTwoFactor={loaderData.ownTwoFactor} | |
| 218 | − | saved={Boolean(actionData && "saved" in actionData && actionData.saved === "two-factor-requirement")} | |
| 219 | − | error={actionData && "twoFactorError" in actionData ? actionData.twoFactorError : undefined} | |
| 220 | − | /> | |
| 221 | + | {isFailed(loaderData.withoutTwoFactor) || isFailed(loaderData.ownTwoFactor) ? ( | |
| 222 | + | <section id="two-factor" className="scroll-mt-20"> | |
| 223 | + | <h2 className="font-medium">Authentication security</h2> | |
| 224 | + | <div className="mt-5"> | |
| 225 | + | <Unavailable what="Two-factor authentication" /> | |
| 226 | + | </div> | |
| 227 | + | </section> | |
| 228 | + | ) : ( | |
| 229 | + | <TwoFactorRequirementSection | |
| 230 | + | key={String(workspace.twoFactorRequirementEnabled ?? false)} | |
| 231 | + | slug={workspace.slug} | |
| 232 | + | required={workspace.twoFactorRequirementEnabled ?? false} | |
| 233 | + | without={loaderData.withoutTwoFactor} | |
| 234 | + | ownTwoFactor={loaderData.ownTwoFactor} | |
| 235 | + | saved={Boolean(actionData && "saved" in actionData && actionData.saved === "two-factor-requirement")} | |
| 236 | + | error={actionData && "twoFactorError" in actionData ? actionData.twoFactorError : undefined} | |
| 237 | + | /> | |
| 238 | + | )} | |
| 221 | 239 | ||
| 222 | 240 | <section> | |
| 223 | 241 | <h2 className="font-medium">Permissions</h2> | |
| ⋯ | |||
| 231 | 249 | </p> | |
| 232 | 250 | </section> | |
| 233 | 251 | ||
| 234 | − | {(loaderData.euAvailable || loaderData.residency === "eu") && ( | |
| 252 | + | {isFailed(loaderData.residency) ? ( | |
| 253 | + | <section> | |
| 254 | + | <h2 className="font-medium">Data residency</h2> | |
| 255 | + | <div className="mt-5"> | |
| 256 | + | <Unavailable what={loaderData.residency.what} /> | |
| 257 | + | </div> | |
| 258 | + | </section> | |
| 259 | + | ) : (loaderData.euAvailable || loaderData.residency === "eu") && ( | |
| 235 | 260 | <ResidencySection | |
| 236 | 261 | // Starts from the saved choice whenever it changes. | |
| 237 | 262 | key={loaderData.residency} | |
| ⋯ | |||
| 243 | 268 | )} | |
| 244 | 269 | ||
| 245 | 270 | <DangerZone> | |
| 246 | − | <DeleteAction | |
| 247 | − | workspace={workspace} | |
| 248 | − | deletion={loaderData.deletion} | |
| 249 | − | apps={loaderData.apps} | |
| 250 | − | error={actionData && "deleteError" in actionData ? actionData.deleteError : undefined} | |
| 251 | − | /> | |
| 271 | + | {isFailed(loaderData.deletion) ? ( | |
| 272 | + | <div className="p-4"> | |
| 273 | + | <Unavailable what={loaderData.deletion.what} compact /> | |
| 274 | + | </div> | |
| 275 | + | ) : ( | |
| 276 | + | <DeleteAction | |
| 277 | + | workspace={workspace} | |
| 278 | + | deletion={loaderData.deletion} | |
| 279 | + | apps={loaderData.apps} | |
| 280 | + | error={actionData && "deleteError" in actionData ? actionData.deleteError : undefined} | |
| 281 | + | /> | |
| 282 | + | )} | |
| 252 | 283 | </DangerZone> | |
| 253 | 284 | </div> | |
| 254 | 285 | ); | |
| 13 | 13 | import { teamPath } from "../../../lib/teams"; | |
| 14 | 14 | import { identity, repos } from "../../../lib/services.server"; | |
| 15 | 15 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server"; | |
| 16 | + | import { Unavailable } from "../../../components/boundary"; | |
| 17 | + | import { isFailed, orElse, part } from "../../../lib/partial"; | |
| 16 | 18 | ||
| 17 | 19 | const isRole = (value: string): value is RepoRole => (REPO_ROLES as readonly string[]).includes(value); | |
| 18 | 20 | ||
| ⋯ | |||
| 21 | 23 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 22 | 24 | const [granted, list] = await Promise.all([ | |
| 23 | 25 | identity.teamRepos(viewer, params.owner, params.team).then(unwrap), | |
| 24 | − | repos.list(viewer, { namespace: params.owner, memberOnly: true }).catch(() => []), | |
| 26 | + | // The repositories to add it to: without them, adding says so in its place. | |
| 27 | + | part(repos.list(viewer, { namespace: params.owner, memberOnly: true }), "Repositories to add"), | |
| 25 | 28 | ]); | |
| 26 | − | return { granted, names: list.filter((repo) => !repo.archivedAt).map((repo) => repo.name) }; | |
| 29 | + | return { granted, names: isFailed(list) ? list : list.filter((repo) => !repo.archivedAt).map((repo) => repo.name) }; | |
| 27 | 30 | } | |
| 28 | 31 | ||
| 29 | 32 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| ⋯ | |||
| 64 | 67 | const team = useTeam(); | |
| 65 | 68 | const { granted, names } = loaderData; | |
| 66 | 69 | const own = new Set(granted.filter((repo) => repo.inherited_from == null).map((repo) => repo.repo.split("/")[1])); | |
| 67 | − | const addable = names.filter((name) => !own.has(name)); | |
| 70 | + | const addable = orElse(names, []).filter((name) => !own.has(name)); | |
| 68 | 71 | const [role, setRole] = useState<RepoRole>("write"); | |
| 69 | 72 | ||
| 70 | 73 | return ( | |
| ⋯ | |||
| 88 | 91 | </Card> | |
| 89 | 92 | )} | |
| 90 | 93 | ||
| 94 | + | {isFailed(names) && team.can_manage && <Unavailable what={names.what} compact />} | |
| 91 | 95 | {addable.length > 0 && ( | |
| 92 | 96 | <Card asChild key={granted.length} className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center"> | |
| 93 | 97 | <Form method="post"> | |
| 9 | 9 | import { page } from "../../lib/meta"; | |
| 10 | 10 | import { billing } from "../../lib/services.server"; | |
| 11 | 11 | import { getViewer, managesBilling, roleIn } from "../../lib/session.server"; | |
| 12 | + | import { Unavailable } from "../../components/boundary"; | |
| 12 | 13 | ||
| 13 | 14 | /** Where the usage API is documented. */ | |
| 14 | 15 | export const USAGE_API = "https://docs.g1t.sh/reference/api/#usage"; | |
| ⋯ | |||
| 129 | 130 | </UsageSkeleton> | |
| 130 | 131 | } | |
| 131 | 132 | > | |
| 132 | − | <Await resolve={report}> | |
| 133 | + | {/* When billing does not answer, the filters stay and the report says so in its place. */} | |
| 134 | + | <Await | |
| 135 | + | resolve={report} | |
| 136 | + | errorElement={ | |
| 137 | + | <div className="space-y-6"> | |
| 138 | + | <UsageFilterBar filters={filters} products={PRODUCTS} projects={filters.projects} report={null} apiHref={USAGE_API} /> | |
| 139 | + | <Unavailable what="Usage" /> | |
| 140 | + | </div> | |
| 141 | + | } | |
| 142 | + | > | |
| 133 | 143 | {(loaded) => <UsageView slug={slug} owner={owner} filters={filters} url={url} report={loaded.report} error={loaded.error} />} | |
| 134 | 144 | </Await> | |
| 135 | 145 | </Suspense> | |
| 15 | 15 | import { Avatar } from "../../components/ui/avatar"; | |
| 16 | 16 | import { Badge } from "../../components/ui/badge"; | |
| 17 | 17 | import { Card } from "../../components/ui/card"; | |
| 18 | + | import { Unavailable } from "../../components/boundary"; | |
| 19 | + | import { isFailed, part } from "../../lib/partial"; | |
| 18 | 20 | ||
| 19 | 21 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 20 | 22 | return page(args, { title: `Workspace · ${params.owner} · g1t` }); | |
| ⋯ | |||
| 37 | 39 | const setupAsked = new URL(request.url).searchParams.has("setup"); | |
| 38 | 40 | const onboarding = loadOnboarding(viewer, slug, !setupAsked).catch(() => null); | |
| 39 | 41 | const [members, usage, projects] = await Promise.all([ | |
| 40 | − | identity.listMembers(slug, viewer).catch(() => null), | |
| 42 | + | part(identity.listMembers(slug, viewer), "The member list"), | |
| 41 | 43 | usageFor(slug, viewer), | |
| 42 | 44 | workspaceProjects(slug, viewer).catch(() => null), | |
| 43 | 45 | ]); | |
| ⋯ | |||
| 46 | 48 | role, | |
| 47 | 49 | onboarding, | |
| 48 | 50 | setupAsked, | |
| 49 | − | members: members?.ok ? members.value : null, | |
| 51 | + | members: isFailed(members) ? members : members.ok ? members.value : null, | |
| 50 | 52 | usage, | |
| 51 | 53 | projects: projects?.ok ? projects.value.length : null, | |
| 52 | 54 | }; | |
| ⋯ | |||
| 73 | 75 | <div className="space-y-8"> | |
| 74 | 76 | <OnboardingOnPage slug={slug} view={loaderData.onboarding} asked={loaderData.setupAsked} /> | |
| 75 | 77 | <div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> | |
| 76 | − | <Tile icon={<Users size={12} />} label="Members" value={members ? String(members.length) : "—"} to={role === "owner" ? `/${slug}/-/members` : `/${slug}/-/people`} action={role === "owner" ? "Manage people" : "See everyone"} /> | |
| 78 | + | <Tile icon={<Users size={12} />} label="Members" value={members && !isFailed(members) ? String(members.length) : "—"} to={role === "owner" ? `/${slug}/-/members` : `/${slug}/-/people`} action={role === "owner" ? "Manage people" : "See everyone"} /> | |
| 77 | 79 | <Tile icon={<Box size={12} />} label="Projects" value={projects == null ? "—" : String(projects)} to={`/${slug}/-/projects`} action="Open Code" /> | |
| 78 | 80 | </div> | |
| 79 | 81 | <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]"> | |
| ⋯ | |||
| 125 | 127 | {role === "owner" ? "Manage" : "See all"} | |
| 126 | 128 | </Link> | |
| 127 | 129 | </div> | |
| 128 | − | {members ? ( | |
| 130 | + | {isFailed(members) ? ( | |
| 131 | + | <div className="mt-3"> | |
| 132 | + | <Unavailable what={members.what} compact /> | |
| 133 | + | </div> | |
| 134 | + | ) : members ? ( | |
| 129 | 135 | <> | |
| 130 | 136 | <ul className="mt-3 space-y-2"> | |
| 131 | 137 | {members.slice(0, MAX_FACES).map((member) => ( | |
Comments
Sign in to comment.