g1t/apps/web/app/components/commit-checks.tsx
| 1 | /** |
| 2 | * A commit's checks at a glance: a mark beside the commit (a check when |
| 3 | * every check passed, a cross when one failed, an amber dot while any is |
| 4 | * running) that opens the list of them, each with how it went and a link |
| 5 | * to its details. Every reporter's checks are listed alike: workflow jobs, |
| 6 | * check runs and statuses from integrations and deployments. |
| 7 | * |
| 8 | * Loaders pass the page's checks as one promise (lib/commit-checks.server.ts); |
| 9 | * each badge shows a placeholder until it settles. |
| 10 | */ |
| 11 | import { Ban, Check, CircleDashed, X } from "lucide-react"; |
| 12 | import { Suspense } from "react"; |
| 13 | import { Await, Link } from "react-router"; |
| 14 | |
| 15 | import type { CheckItem, CommitChecks } from "@g1t/contracts"; |
| 16 | |
| 17 | import { useAddresses } from "../lib/addresses"; |
| 18 | import { cn } from "../lib/cn"; |
| 19 | import { checkDetail, checksHeadline, checksTally, detailsLink } from "../lib/commit-checks"; |
| 20 | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; |
| 21 | import { Skeleton } from "./ui/skeleton"; |
| 22 | |
| 23 | /** A page's commits' checks by SHA, as loaders return them: on their way, read, or not to be had. */ |
| 24 | export type ChecksSource = Promise<Record<string, CommitChecks> | null> | Record<string, CommitChecks> | null | undefined; |
| 25 | |
| 26 | /** One check's state as an icon. */ |
| 27 | export function CheckStateIcon({ state, size = 16 }: { state: CheckItem["state"] | CommitChecks["state"]; size?: number }) { |
| 28 | switch (state) { |
| 29 | case "success": |
| 30 | return ( |
| 31 | <span className="inline-flex shrink-0 rounded-full bg-success/15 p-0.5 text-success" aria-label="Successful"> |
| 32 | <Check size={size - 4} strokeWidth={3} /> |
| 33 | </span> |
| 34 | ); |
| 35 | case "failure": |
| 36 | return ( |
| 37 | <span className="inline-flex shrink-0 rounded-full bg-danger/15 p-0.5 text-danger" aria-label="Failing"> |
| 38 | <X size={size - 4} strokeWidth={3} /> |
| 39 | </span> |
| 40 | ); |
| 41 | case "pending": |
| 42 | return ( |
| 43 | <span className="inline-flex shrink-0 items-center justify-center" style={{ width: size, height: size }} aria-label="In progress"> |
| 44 | <span className="size-2 rounded-full bg-warn motion-safe:animate-pulse" /> |
| 45 | </span> |
| 46 | ); |
| 47 | case "cancelled": |
| 48 | return <Ban size={size} className="shrink-0 text-faint" aria-label="Cancelled" />; |
| 49 | default: |
| 50 | return <CircleDashed size={size} className="shrink-0 text-faint" aria-label="Skipped" />; |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | /** The mark itself: what all of a commit's checks add up to. */ |
| 55 | function Mark({ state }: { state: CommitChecks["state"] }) { |
| 56 | if (state === "success") return <Check size={15} strokeWidth={2.75} className="text-success" />; |
| 57 | if (state === "failure") return <X size={15} strokeWidth={2.75} className="text-danger" />; |
| 58 | return <span className="size-2 rounded-full bg-warn" />; |
| 59 | } |
| 60 | |
| 61 | function CheckRow({ item }: { item: CheckItem }) { |
| 62 | const site = useAddresses().site; |
| 63 | const link = detailsLink(item, site); |
| 64 | const detail = checkDetail(item); |
| 65 | return ( |
| 66 | <li className="flex items-center gap-2.5 px-4 py-2"> |
| 67 | <CheckStateIcon state={item.state} size={16} /> |
| 68 | <p className="min-w-0 grow truncate text-[0.8125rem]"> |
| 69 | <span className="font-medium text-fg">{item.name}</span> <span className="text-muted">{detail}</span> |
| 70 | </p> |
| 71 | {link && |
| 72 | (link.external ? ( |
| 73 | <a href={link.href} target="_blank" rel="noreferrer" className="shrink-0 text-xs font-medium text-accent hover:underline"> |
| 74 | Details |
| 75 | </a> |
| 76 | ) : ( |
| 77 | <Link to={link.href} className="shrink-0 text-xs font-medium text-accent hover:underline"> |
| 78 | Details |
| 79 | </Link> |
| 80 | ))} |
| 81 | </li> |
| 82 | ); |
| 83 | } |
| 84 | |
| 85 | /** The list a commit's mark opens. */ |
| 86 | export function ChecksList({ checks, className }: { checks: CommitChecks; className?: string }) { |
| 87 | return ( |
| 88 | <div className={className}> |
| 89 | <div className="flex items-start gap-3 border-b border-line px-4 py-3"> |
| 90 | <span className="mt-0.5"> |
| 91 | <CheckStateIcon state={checks.state} size={20} /> |
| 92 | </span> |
| 93 | <div className="min-w-0"> |
| 94 | <p className="font-semibold text-fg">{checksHeadline(checks)}</p> |
| 95 | <p className="text-xs text-muted">{checksTally(checks)}</p> |
| 96 | </div> |
| 97 | </div> |
| 98 | <ul className="max-h-80 divide-y divide-line overflow-y-auto"> |
| 99 | {checks.checks.map((item) => ( |
| 100 | <CheckRow key={`${item.kind}:${item.id ?? item.name}`} item={item} /> |
| 101 | ))} |
| 102 | </ul> |
| 103 | </div> |
| 104 | ); |
| 105 | } |
| 106 | |
| 107 | /** A commit's mark, opening its checks; nothing when nothing reported on it. */ |
| 108 | export function CommitChecksMark({ checks, className }: { checks: CommitChecks | null | undefined; className?: string }) { |
| 109 | if (!checks || checks.state === "none" || checks.total === 0) return null; |
| 110 | const headline = checksHeadline(checks); |
| 111 | return ( |
| 112 | <Popover> |
| 113 | <PopoverTrigger |
| 114 | aria-label={`${headline}: ${checksTally(checks)}`} |
| 115 | className={cn( |
| 116 | "relative z-10 inline-flex size-6 shrink-0 animate-fade-in items-center justify-center rounded-md transition-colors hover:bg-raised data-[state=open]:bg-raised", |
| 117 | className, |
| 118 | )} |
| 119 | > |
| 120 | <Mark state={checks.state} /> |
| 121 | </PopoverTrigger> |
| 122 | <PopoverContent align="start" className="w-[28rem] p-0"> |
| 123 | <ChecksList checks={checks} /> |
| 124 | </PopoverContent> |
| 125 | </Popover> |
| 126 | ); |
| 127 | } |
| 128 | |
| 129 | /** Holds a mark's place while the page's checks are read. */ |
| 130 | export function CommitChecksPlaceholder({ className }: { className?: string }) { |
| 131 | return ( |
| 132 | <span aria-hidden="true" className={cn("inline-flex size-6 shrink-0 items-center justify-center", className)}> |
| 133 | <Skeleton className="size-3.5 rounded-full" /> |
| 134 | </span> |
| 135 | ); |
| 136 | } |
| 137 | |
| 138 | /** |
| 139 | * The mark for commit `sha` from a page's checks: a placeholder while they |
| 140 | * are on their way, then the mark, or nothing. |
| 141 | */ |
| 142 | export function CommitChecksBadge({ checks, sha, className }: { checks: ChecksSource; sha: string; className?: string }) { |
| 143 | if (!checks) return null; |
| 144 | if (!(checks instanceof Promise)) return <CommitChecksMark checks={checks[sha]} className={className} />; |
| 145 | return ( |
| 146 | <Suspense fallback={<CommitChecksPlaceholder className={className} />}> |
| 147 | <Await resolve={checks} errorElement={null}> |
| 148 | {(found) => <CommitChecksMark checks={found?.[sha]} className={className} />} |
| 149 | </Await> |
| 150 | </Suspense> |
| 151 | ); |
| 152 | } |