| 1 | import type * as React from "react"; |
| 2 | import type { |
| 3 | EnvironmentId, |
| 4 | PullRequestCheck, |
| 5 | PullRequestChecksState, |
| 6 | PullRequestRef, |
| 7 | ScopedThreadRef, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import { useState } from "react"; |
| 10 | |
| 11 | import { useOpenLink } from "~/browser/useOpenLink"; |
| 12 | import { cn } from "~/lib/utils"; |
| 13 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 14 | import { useEnvironmentQuery } from "~/state/query"; |
| 15 | |
| 16 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 17 | import { Button } from "../ui/button"; |
| 18 | import { ScrollArea } from "../ui/scroll-area"; |
| 19 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 20 | import { toastManager } from "../ui/toast"; |
| 21 | import { groupPullRequestChecks } from "./pullRequestDetail.logic"; |
| 22 | import { |
| 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 | */ |
| 34 | function 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 | |
| 59 | function 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 | */ |
| 132 | export 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 | } |