| 1 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 2 | /** |
| 3 | * The review half of the floating composer: the summary and the verdict that sends it, together |
| 4 | * with whatever line comments the review is holding. The count of those lives on the composer's |
| 5 | * trigger and mode toggle, and each pending card can be dropped from the diff, so neither is |
| 6 | * repeated here. The popover around it belongs to PullRequestComposer. |
| 7 | */ |
| 8 | import type { EnvironmentId, PullRequestRef, PullRequestReviewVerdict } from "@t3tools/contracts"; |
| 9 | import { CheckIcon, MessageSquareIcon, XCircleIcon } from "lucide-react"; |
| 10 | import { useState, type ReactNode, type RefObject } from "react"; |
| 11 | |
| 12 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 13 | |
| 14 | import { Button } from "../ui/button"; |
| 15 | import { Select, SelectItem, SelectPopup, SelectTrigger } from "../ui/select"; |
| 16 | import { Textarea } from "../ui/textarea"; |
| 17 | import { toastManager } from "../ui/toast"; |
| 18 | import { |
| 19 | pullRequestReviewKey, |
| 20 | usePendingReviewComments, |
| 21 | usePullRequestReviewStore, |
| 22 | } from "./pullRequestReviewStore"; |
| 23 | |
| 24 | const VERDICTS: ReadonlyArray<{ |
| 25 | readonly value: PullRequestReviewVerdict; |
| 26 | readonly label: string; |
| 27 | readonly sent: string; |
| 28 | readonly icon: ReactNode; |
| 29 | }> = [ |
| 30 | { |
| 31 | value: "comment", |
| 32 | label: "Comment", |
| 33 | sent: "Review submitted", |
| 34 | icon: <MessageSquareIcon className="size-3" />, |
| 35 | }, |
| 36 | { |
| 37 | value: "approve", |
| 38 | label: "Approve", |
| 39 | sent: "Pull request approved", |
| 40 | icon: <CheckIcon className="size-3" />, |
| 41 | }, |
| 42 | { |
| 43 | value: "request-changes", |
| 44 | label: "Request changes", |
| 45 | sent: "Changes requested", |
| 46 | icon: <XCircleIcon className="size-3" />, |
| 47 | }, |
| 48 | ]; |
| 49 | |
| 50 | export function PullRequestReviewForm({ |
| 51 | environmentId, |
| 52 | reference, |
| 53 | verdicts, |
| 54 | requestChangesSummaryRequired, |
| 55 | textareaRef, |
| 56 | pending, |
| 57 | onPendingChange, |
| 58 | onSubmitted, |
| 59 | }: { |
| 60 | environmentId: EnvironmentId; |
| 61 | reference: PullRequestRef; |
| 62 | verdicts: ReadonlyArray<PullRequestReviewVerdict>; |
| 63 | requestChangesSummaryRequired: boolean; |
| 64 | textareaRef: RefObject<HTMLTextAreaElement | null>; |
| 65 | pending: boolean; |
| 66 | onPendingChange: (pending: boolean) => void; |
| 67 | onSubmitted: () => void; |
| 68 | }) { |
| 69 | const [requestedVerdict, setRequestedVerdict] = useState<PullRequestReviewVerdict>("comment"); |
| 70 | const comments = usePendingReviewComments(reference); |
| 71 | const reviewKey = pullRequestReviewKey(reference); |
| 72 | // The panel stays mounted while the selected pull request changes. Keeping summaries beside |
| 73 | // the keyed line-comment drafts makes the selected pull request's body correct on the first |
| 74 | // render, before an effect could reset state left behind by the previous one. |
| 75 | const body = usePullRequestReviewStore((store) => store.summaries[reviewKey] ?? ""); |
| 76 | const removeComments = usePullRequestReviewStore((store) => store.removeComments); |
| 77 | const setSummary = usePullRequestReviewStore((store) => store.setSummary); |
| 78 | const clearSummary = usePullRequestReviewStore((store) => store.clearSummary); |
| 79 | const submitReview = useAtomCommand(pullRequestEnvironment.submitReview, { |
| 80 | reportFailure: false, |
| 81 | }); |
| 82 | |
| 83 | const offered = VERDICTS.filter((verdict) => verdicts.includes(verdict.value)); |
| 84 | const selectedVerdict = |
| 85 | offered.find((verdict) => verdict.value === requestedVerdict) ?? offered[0]; |
| 86 | |
| 87 | const submit = async (verdict: (typeof VERDICTS)[number]) => { |
| 88 | if (pending) return; |
| 89 | const submittedBody = body; |
| 90 | const submittedComments = comments; |
| 91 | onPendingChange(true); |
| 92 | const result = await submitReview({ |
| 93 | environmentId, |
| 94 | input: { |
| 95 | ...reference, |
| 96 | verdict: verdict.value, |
| 97 | body: submittedBody, |
| 98 | comments: submittedComments, |
| 99 | }, |
| 100 | }); |
| 101 | onPendingChange(false); |
| 102 | if (result._tag === "Failure") { |
| 103 | // The draft is kept: whatever went wrong, retyping the review is not the answer. |
| 104 | toastManager.add({ type: "error", title: "The review could not be submitted" }); |
| 105 | return; |
| 106 | } |
| 107 | // More remarks may have been added while the host was accepting this snapshot. Leave those, |
| 108 | // and any summary revised in the meantime, ready for the next review. |
| 109 | removeComments( |
| 110 | reviewKey, |
| 111 | submittedComments.map((comment) => comment.id), |
| 112 | ); |
| 113 | clearSummary(reviewKey, submittedBody); |
| 114 | toastManager.add({ type: "success", title: verdict.sent }); |
| 115 | onSubmitted(); |
| 116 | }; |
| 117 | |
| 118 | // Forgejo requires a summary when requesting changes, even with inline comments. |
| 119 | const canSubmit = (verdict: PullRequestReviewVerdict, summary: string) => |
| 120 | verdict === "request-changes" && requestChangesSummaryRequired |
| 121 | ? summary.trim().length > 0 |
| 122 | : verdict === "approve" || summary.trim().length > 0 || comments.length > 0; |
| 123 | // The placeholder asks for exactly what the submit button is waiting on. |
| 124 | const placeholder = |
| 125 | selectedVerdict === undefined || canSubmit(selectedVerdict.value, "") |
| 126 | ? "Summarize your review (optional)" |
| 127 | : selectedVerdict.value === "request-changes" && requestChangesSummaryRequired |
| 128 | ? "Summarize your review (required to request changes)" |
| 129 | : "Summarize your review or add line comments"; |
| 130 | |
| 131 | return ( |
| 132 | <> |
| 133 | <Textarea |
| 134 | ref={textareaRef} |
| 135 | rows={3} |
| 136 | value={body} |
| 137 | placeholder={placeholder} |
| 138 | aria-label="Review summary" |
| 139 | onChange={(event) => setSummary(reviewKey, event.target.value)} |
| 140 | /> |
| 141 | <div className="mt-2 flex justify-end gap-2"> |
| 142 | {offered.length > 1 ? ( |
| 143 | <Select |
| 144 | value={selectedVerdict?.value ?? null} |
| 145 | disabled={pending} |
| 146 | onValueChange={(value) => { |
| 147 | if (value !== null) setRequestedVerdict(value); |
| 148 | }} |
| 149 | > |
| 150 | <SelectTrigger size="xs" className="mr-auto w-auto min-w-0" aria-label="Review verdict"> |
| 151 | <span className="flex items-center gap-1.5"> |
| 152 | {selectedVerdict?.icon} |
| 153 | {selectedVerdict?.label} |
| 154 | </span> |
| 155 | </SelectTrigger> |
| 156 | <SelectPopup side="top" alignItemWithTrigger={false}> |
| 157 | {offered.map((verdict) => ( |
| 158 | <SelectItem key={verdict.value} value={verdict.value}> |
| 159 | <span className="flex items-center gap-1.5"> |
| 160 | {verdict.icon} |
| 161 | {verdict.label} |
| 162 | </span> |
| 163 | </SelectItem> |
| 164 | ))} |
| 165 | </SelectPopup> |
| 166 | </Select> |
| 167 | ) : null} |
| 168 | <Button |
| 169 | size="xs" |
| 170 | disabled={ |
| 171 | pending || selectedVerdict === undefined || !canSubmit(selectedVerdict.value, body) |
| 172 | } |
| 173 | onClick={() => { |
| 174 | if (selectedVerdict !== undefined) void submit(selectedVerdict); |
| 175 | }} |
| 176 | > |
| 177 | {pending ? "Submitting..." : "Submit review"} |
| 178 | </Button> |
| 179 | </div> |
| 180 | </> |
| 181 | ); |
| 182 | } |