1import type * as React from "react";
2import type {
3 EnvironmentId,
4 PullRequestCheck,
5 PullRequestChecksState,
6 PullRequestRef,
7 ScopedThreadRef,
8} from "@t3tools/contracts";
9import { useState } from "react";
10
11import { useOpenLink } from "~/browser/useOpenLink";
12import { cn } from "~/lib/utils";
13import { pullRequestEnvironment } from "~/state/pullRequests";
14import { useEnvironmentQuery } from "~/state/query";
15
16import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
17import { Button } from "../ui/button";
18import { ScrollArea } from "../ui/scroll-area";
19import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
20import { toastManager } from "../ui/toast";
21import { groupPullRequestChecks } from "./pullRequestDetail.logic";
22import {
23 PullRequestCheckStatusIcon,
24 pullRequestCheckStatusLabel,
25 pullRequestChecksStatePresentation,
26 summarizePullRequestChecks,
27} from "./pullRequestPresentation";
28
29/**
30 * The checks behind the rollup, for a row that only carries the rollup. Mounted by the popup, so
31 * the read starts when somebody opens it rather than once per row of a listing — the detail read
32 * is a request per pull request, and a page of them at rest would be a hundred.
33 */
34function LazyChecksBody({
35 environmentId,
36 reference,
37 threadRef,
38}: {
39 environmentId: EnvironmentId;
40 reference: PullRequestRef;
41 threadRef: ScopedThreadRef | null;
42}) {
43 const detailQuery = useEnvironmentQuery(
44 pullRequestEnvironment.detail({ environmentId, input: reference }),
45 );
46 if (detailQuery.error !== null) {
47 return <p className="text-muted-foreground text-xs">{detailQuery.error}</p>;
48 }
49 if (detailQuery.data === null) {
50 return (
51 <p className="text-muted-foreground text-xs">
52 {detailQuery.isPending ? "Loading checks…" : "No checks reported"}
53 </p>
54 );
55 }
56 return <ChecksBody checks={detailQuery.data.checks} threadRef={threadRef} />;
57}
58
59function ChecksBody({
60 checks,
61 threadRef,
62}: {
63 checks: ReadonlyArray<PullRequestCheck>;
64 threadRef: ScopedThreadRef | null;
65}) {
66 const openLink = useOpenLink(threadRef);
67 const [showAll, setShowAll] = useState(false);
68 const { attention, running, completed } = groupPullRequestChecks(checks);
69 const canCollapse = attention.length + running.length > 0 && completed.length > 0;
70 const visibleChecks = [...attention, ...running, ...(showAll || !canCollapse ? completed : [])];
71 if (checks.length === 0) {
72 return <p className="text-muted-foreground text-xs">No checks reported</p>;
73 }
74 return (
75 <>
76 <ScrollArea className="max-h-64">
77 <ul className="flex flex-col gap-1">
78 {/* Keyed by position as well as by name: the host is the one that decides how many runs
79 share a name, and a repeated key is a rendering fault rather than a wrong list. */}
80 {visibleChecks.map((check, index) => (
81 <li key={`${index}:${check.name}`} className="flex items-center gap-2 text-xs">
82 <PullRequestCheckStatusIcon status={check.status} />
83 <Tooltip>
84 <TooltipTrigger
85 render={<span className="min-w-0 flex-1 truncate">{check.name}</span>}
86 />
87 <TooltipPopup side="top">{check.description ?? check.name}</TooltipPopup>
88 </Tooltip>
89 <span className="shrink-0 text-muted-foreground">
90 {pullRequestCheckStatusLabel(check)}
91 </span>
92 {check.url === null ? null : (
93 <button
94 type="button"
95 className="shrink-0 text-primary hover:underline"
96 onClick={() => {
97 if (!check.url) return;
98 void openLink(check.url).catch((error: unknown) => {
99 console.error(error);
100 toastManager.add({ type: "error", title: "Unable to open check details" });
101 });
102 }}
103 >
104 Details
105 </button>
106 )}
107 </li>
108 ))}
109 </ul>
110 </ScrollArea>
111 {canCollapse ? (
112 <Button
113 variant="ghost"
114 size="xs"
115 aria-expanded={showAll}
116 onClick={() => setShowAll(!showAll)}
117 >
118 {showAll ? "Show less" : "Show all"}
119 </Button>
120 ) : null}
121 </>
122 );
123}
124
125/**
126 * The checks indicator and what it opens, in both places a change request is shown: a listing
127 * row, which knows only the rollup, and the detail header, which is already holding every check.
128 *
129 * `checks` decides between the two. Given them, nothing is read; without them, the popup reads
130 * the detail itself, which is why the row must also say which environment it came from.
131 */
132export function PullRequestChecksPopover({
133 checksState,
134 checks,
135 stale = false,
136 environmentId,
137 reference,
138 threadRef = null,
139 variant = "icon",
140 className,
141 render,
142}: {
143 checksState: PullRequestChecksState;
144 /** The checks already in hand, for the detail header. Absent on a listing row. */
145 checks?: ReadonlyArray<PullRequestCheck>;
146 stale?: boolean;
147 environmentId?: EnvironmentId;
148 reference?: PullRequestRef;
149 /** Thread the popover sits beside; a listing row has none. */
150 threadRef?: ScopedThreadRef | null;
151 variant?: "icon" | "count";
152 className?: string;
153 render?: React.ReactElement;
154}) {
155 const presentation = pullRequestChecksStatePresentation(checksState);
156 // Counts beat the rollup's own wording where they are known, the way GitHub's own header reads.
157 const runningCount = checks?.filter((check) => check.status === "pending").length ?? 0;
158 const failedCount =
159 checks?.filter((check) => check.status === "failure" || check.status === "cancelled").length ??
160 0;
161 const summary = checks === undefined || stale ? null : summarizePullRequestChecks(checks);
162 return (
163 <Popover>
164 {/* A listing row is itself a button, so the trigger renders as a span: a nested button is
165 not valid inside one. The click is stopped here so opening the checks does not also
166 select the row it sits on. */}
167 <PopoverTrigger
168 nativeButton={variant === "count"}
169 aria-label={
170 variant === "count"
171 ? `Open checks: ${summary ?? presentation.label}`
172 : `Checks: ${presentation.label}`
173 }
174 render={
175 variant === "count" ? (
176 (render ?? <Button variant="ghost" size="sm" className={className} />)
177 ) : (
178 <span
179 role="button"
180 tabIndex={0}
181 className={cn("inline-flex shrink-0 cursor-pointer items-center", className)}
182 />
183 )
184 }
185 onClick={(event) => event.stopPropagation()}
186 >
187 <presentation.Icon aria-hidden className={cn("size-3.5", presentation.toneClassName)} />
188 {variant === "count" ? (
189 <>
190 {checks !== undefined && runningCount > 0 ? (
191 <span className="tabular-nums">
192 {runningCount}/{checks.length}
193 </span>
194 ) : failedCount > 0 ? (
195 <span className="tabular-nums">{failedCount} failed</span>
196 ) : null}
197 </>
198 ) : null}
199 </PopoverTrigger>
200 <PopoverPopup align="start" width="md" side="bottom">
201 <p className="mb-2 font-medium text-sm">{presentation.label}</p>
202 {summary === null ? null : <p className="mb-2 text-muted-foreground text-xs">{summary}</p>}
203 {stale ? (
204 <p className="text-muted-foreground text-xs">
205 Check details are out of date. Refresh the pull request to update them.
206 </p>
207 ) : checks !== undefined ? (
208 <ChecksBody checks={checks} threadRef={threadRef} />
209 ) : environmentId !== undefined && reference !== undefined ? (
210 <LazyChecksBody
211 environmentId={environmentId}
212 reference={reference}
213 threadRef={threadRef}
214 />
215 ) : null}
216 </PopoverPopup>
217 </Popover>
218 );
219}