Skip to content

g1t/apps/web/app/components/commit-checks.tsx

152 lines6,283 bytesCodeBlame
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 */
11import { Ban, Check, CircleDashed, X } from "lucide-react";
12import { Suspense } from "react";
13import { Await, Link } from "react-router";
14
15import type { CheckItem, CommitChecks } from "@g1t/contracts";
16
17import { useAddresses } from "../lib/addresses";
18import { cn } from "../lib/cn";
19import { checkDetail, checksHeadline, checksTally, detailsLink } from "../lib/commit-checks";
20import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
21import { 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. */
24export type ChecksSource = Promise<Record<string, CommitChecks> | null> | Record<string, CommitChecks> | null | undefined;
25
26/** One check's state as an icon. */
27export 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. */
55function 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
61function 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. */
86export 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. */
108export 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. */
130export 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 */
142export 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}