| 1 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; |
| 2 | /** |
| 3 | * The single floating control over a pull request. Commenting on the change and submitting the |
| 4 | * review that carries the Code tab's line comments used to float as two buttons that crowded |
| 5 | * each other and read as the same offer twice; they are two modes of one composer now. |
| 6 | * |
| 7 | * Opening picks the mode with work waiting in it, so a reader who has collected line comments |
| 8 | * lands on the review and everyone else lands on the comment box. Either mode's draft survives |
| 9 | * the toggle: they are separate texts going to separate places, and merging them would send a |
| 10 | * summary as a comment or the reverse. |
| 11 | */ |
| 12 | import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts"; |
| 13 | import { MessageSquareIcon, Trash2Icon, XIcon } from "lucide-react"; |
| 14 | import { useRef, useState } from "react"; |
| 15 | |
| 16 | import { Button } from "../ui/button"; |
| 17 | import { Popover, PopoverClose, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; |
| 18 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; |
| 19 | import { PullRequestCommentForm } from "./PullRequestCommentForm"; |
| 20 | import { PullRequestReviewForm } from "./PullRequestReviewForm"; |
| 21 | import { |
| 22 | pullRequestReviewKey, |
| 23 | usePendingReviewComments, |
| 24 | usePullRequestReviewStore, |
| 25 | } from "./pullRequestReviewStore"; |
| 26 | |
| 27 | export function PullRequestComposer({ |
| 28 | environmentId, |
| 29 | reference, |
| 30 | detail, |
| 31 | actionPending, |
| 32 | onCommentAction, |
| 33 | onCommented, |
| 34 | onReviewSubmitted, |
| 35 | openReviewRequest, |
| 36 | }: { |
| 37 | environmentId: EnvironmentId; |
| 38 | reference: PullRequestRef; |
| 39 | detail: PullRequestDetailView; |
| 40 | actionPending: boolean; |
| 41 | onCommentAction: ( |
| 42 | body: string, |
| 43 | action: "close" | "reopen", |
| 44 | ) => Promise<{ readonly commentPosted: boolean }>; |
| 45 | onCommented: () => void; |
| 46 | onReviewSubmitted: () => void; |
| 47 | /** |
| 48 | * Opens the composer on the review, each time the number changes. The guide's last stop asks |
| 49 | * for it, so finishing the tour lands on the comments it collected and the verdict. |
| 50 | */ |
| 51 | openReviewRequest?: number | undefined; |
| 52 | }) { |
| 53 | const [open, setOpen] = useState(false); |
| 54 | const [reviewPending, setReviewPending] = useState(false); |
| 55 | const [requestedMode, setRequestedMode] = useState<"comment" | "review">("comment"); |
| 56 | const commentRef = useRef<HTMLTextAreaElement>(null); |
| 57 | const reviewRef = useRef<HTMLTextAreaElement>(null); |
| 58 | const pendingComments = usePendingReviewComments(reference); |
| 59 | const clearComments = usePullRequestReviewStore((store) => store.clear); |
| 60 | // A summary typed but not sent is review work too, and it outlives the popover. Selected as a |
| 61 | // boolean rather than the text, so typing one does not re-render the composer per keystroke. |
| 62 | const reviewKey = pullRequestReviewKey(reference); |
| 63 | const summaryStarted = usePullRequestReviewStore( |
| 64 | (store) => (store.summaries[reviewKey] ?? "").trim().length > 0, |
| 65 | ); |
| 66 | const reviewStarted = pendingComments.length > 0 || summaryStarted; |
| 67 | const [answeredOpenRequest, setAnsweredOpenRequest] = useState(openReviewRequest); |
| 68 | if (openReviewRequest !== answeredOpenRequest) { |
| 69 | setAnsweredOpenRequest(openReviewRequest); |
| 70 | setRequestedMode("review"); |
| 71 | setOpen(true); |
| 72 | } |
| 73 | |
| 74 | // What is offered is the intersection of two different questions: what this host can do at |
| 75 | // all, and what this account may do on this repository. Either one saying no means a control |
| 76 | // that would only ever end in a refusal. |
| 77 | const canComment = detail.capabilities.comment && detail.viewerPermissions.comment; |
| 78 | const verdicts = detail.capabilities.review.verdicts.filter((verdict) => |
| 79 | detail.viewerPermissions.verdicts.includes(verdict), |
| 80 | ); |
| 81 | if (!canComment && verdicts.length === 0) return null; |
| 82 | |
| 83 | const mode = canComment ? (verdicts.length === 0 ? "comment" : requestedMode) : "review"; |
| 84 | |
| 85 | return ( |
| 86 | <Popover |
| 87 | open={open} |
| 88 | onOpenChange={(next) => { |
| 89 | if (next) setRequestedMode(reviewStarted ? "review" : "comment"); |
| 90 | setOpen(next); |
| 91 | }} |
| 92 | > |
| 93 | <PopoverTrigger |
| 94 | render={<Button size="icon" variant="glass" />} |
| 95 | // The only label this control has, so the pending count belongs in it: the badge beside |
| 96 | // the icon is decorative and a reader who cannot see it still needs the number. |
| 97 | aria-label={ |
| 98 | pendingComments.length > 0 |
| 99 | ? `Review pull request, ${pendingComments.length} ${pendingComments.length === 1 ? "comment" : "comments"} pending` |
| 100 | : reviewStarted || !canComment |
| 101 | ? "Review pull request" |
| 102 | : "Comment on pull request" |
| 103 | } |
| 104 | > |
| 105 | <MessageSquareIcon className="size-4" /> |
| 106 | {pendingComments.length > 0 ? ( |
| 107 | <span |
| 108 | aria-hidden |
| 109 | className="absolute -top-1 -right-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-info px-1 text-3xs font-semibold tabular-nums text-white" |
| 110 | > |
| 111 | {pendingComments.length} |
| 112 | </span> |
| 113 | ) : null} |
| 114 | </PopoverTrigger> |
| 115 | <PopoverPopup |
| 116 | keepMounted |
| 117 | side="top" |
| 118 | align="end" |
| 119 | sideOffset={8} |
| 120 | width="lg" |
| 121 | initialFocus={mode === "review" ? reviewRef : commentRef} |
| 122 | aria-label="Pull request composer" |
| 123 | > |
| 124 | <div className="mb-3 flex items-center justify-between gap-2"> |
| 125 | {canComment && verdicts.length > 0 ? ( |
| 126 | <ToggleGroup |
| 127 | aria-label="Composer mode" |
| 128 | variant="segmented" |
| 129 | value={[mode]} |
| 130 | onValueChange={(next) => { |
| 131 | const value = next[0]; |
| 132 | if (value === "comment" || value === "review") setRequestedMode(value); |
| 133 | }} |
| 134 | > |
| 135 | <Toggle value="comment">Comment</Toggle> |
| 136 | <Toggle value="review"> |
| 137 | {pendingComments.length > 0 ? `Review (${pendingComments.length})` : "Review"} |
| 138 | </Toggle> |
| 139 | </ToggleGroup> |
| 140 | ) : ( |
| 141 | <PopoverTitle> |
| 142 | {mode === "review" ? "Review pull request" : "Comment on pull request"} |
| 143 | </PopoverTitle> |
| 144 | )} |
| 145 | <div className="flex items-center gap-1"> |
| 146 | {mode === "review" && pendingComments.length > 0 ? ( |
| 147 | <Button |
| 148 | size="icon-xs" |
| 149 | variant="ghost" |
| 150 | aria-label="Discard pending line comments" |
| 151 | title="Discard pending line comments" |
| 152 | disabled={reviewPending} |
| 153 | onClick={() => clearComments(reviewKey)} |
| 154 | > |
| 155 | <Trash2Icon className="size-3.5" /> |
| 156 | </Button> |
| 157 | ) : null} |
| 158 | <PopoverClose |
| 159 | render={<Button size="icon-xs" variant="ghost" />} |
| 160 | aria-label="Close composer" |
| 161 | > |
| 162 | <XIcon className="size-3.5" /> |
| 163 | </PopoverClose> |
| 164 | </div> |
| 165 | </div> |
| 166 | {/* Keep both forms mounted across toggles and dismissal so drafts and in-flight |
| 167 | submit guards survive closing and reopening the composer. */} |
| 168 | {verdicts.length > 0 ? ( |
| 169 | <div hidden={mode !== "review"}> |
| 170 | <PullRequestReviewForm |
| 171 | environmentId={environmentId} |
| 172 | reference={reference} |
| 173 | verdicts={verdicts} |
| 174 | requestChangesSummaryRequired={sourceControlClients |
| 175 | .get(detail.provider) |
| 176 | .reviewSummaryRequired("request-changes")} |
| 177 | textareaRef={reviewRef} |
| 178 | pending={reviewPending} |
| 179 | onPendingChange={setReviewPending} |
| 180 | onSubmitted={() => { |
| 181 | setOpen(false); |
| 182 | onReviewSubmitted(); |
| 183 | }} |
| 184 | /> |
| 185 | </div> |
| 186 | ) : null} |
| 187 | {canComment ? ( |
| 188 | <div hidden={mode !== "comment"}> |
| 189 | <PullRequestCommentForm |
| 190 | environmentId={environmentId} |
| 191 | reference={reference} |
| 192 | detail={detail} |
| 193 | actionPending={actionPending} |
| 194 | textareaRef={commentRef} |
| 195 | onCommentAction={onCommentAction} |
| 196 | onCommented={onCommented} |
| 197 | onClose={() => setOpen(false)} |
| 198 | /> |
| 199 | </div> |
| 200 | ) : null} |
| 201 | </PopoverPopup> |
| 202 | </Popover> |
| 203 | ); |
| 204 | } |