1import { 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 */
8import type { EnvironmentId, PullRequestRef, PullRequestReviewVerdict } from "@t3tools/contracts";
9import { CheckIcon, MessageSquareIcon, XCircleIcon } from "lucide-react";
10import { useState, type ReactNode, type RefObject } from "react";
11
12import { pullRequestEnvironment } from "~/state/pullRequests";
13
14import { Button } from "../ui/button";
15import { Select, SelectItem, SelectPopup, SelectTrigger } from "../ui/select";
16import { Textarea } from "../ui/textarea";
17import { toastManager } from "../ui/toast";
18import {
19 pullRequestReviewKey,
20 usePendingReviewComments,
21 usePullRequestReviewStore,
22} from "./pullRequestReviewStore";
23
24const 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
50export 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}