1import { useAtomCommand } from "~/state/use-atom-command";
2/**
3 * Putting a label on, and taking one off, from the row that says which it already wears.
4 *
5 * The repository's labels are read only once this menu opens, for the reason the reviewer menu
6 * reads its people then: they are worth a request when somebody wants them and worth nothing on
7 * every pull request they merely open.
8 */
9import type { EnvironmentId, PullRequestLabelCandidate, PullRequestRef } from "@t3tools/contracts";
10import { CheckIcon, TagIcon } from "lucide-react";
11import { useMemo, useState } from "react";
12
13import { pullRequestEnvironment } from "~/state/pullRequests";
14import { useEnvironmentQuery } from "~/state/query";
15import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
16
17import { toastManager } from "../ui/toast";
18import { PullRequestCandidatePicker } from "./PullRequestCandidatePicker";
19import { readableFailure } from "./pullRequestDetail.logic";
20import { pullRequestLabelColor } from "./pullRequestList.logic";
21
22/** Narrows only what arrived: the host is asked once, when the menu opens. */
23function matches(candidate: PullRequestLabelCandidate, query: string): boolean {
24 if (query.length === 0) return true;
25 const needle = query.toLowerCase();
26 return (
27 candidate.name.toLowerCase().includes(needle) ||
28 (candidate.description ?? "").toLowerCase().includes(needle)
29 );
30}
31
32export function PullRequestLabelPicker({
33 environmentId,
34 reference,
35 allowed,
36}: {
37 environmentId: EnvironmentId;
38 reference: PullRequestRef;
39 /** False where the host would refuse this account's change. Disabled with the reason rather
40 * than hidden, like the reviewer control beside it. */
41 allowed: boolean;
42}) {
43 const [open, setOpen] = useState(false);
44 const [query, setQuery] = useState("");
45 const [pending, setPending] = useState<string | null>(null);
46
47 // Mounted with the menu closed, so nothing is asked of the host until it opens.
48 const candidatesQuery = useEnvironmentQuery(
49 open ? pullRequestEnvironment.labelCandidates({ environmentId, input: reference }) : null,
50 );
51 const setLabels = useAtomCommand(pullRequestEnvironment.setLabels, {
52 reportFailure: false,
53 });
54
55 const candidates = useMemo(
56 () => (candidatesQuery.data?.candidates ?? []).filter((entry) => matches(entry, query)),
57 [candidatesQuery.data, query],
58 );
59
60 const toggle = async (candidate: PullRequestLabelCandidate) => {
61 if (pending !== null) return;
62 setPending(candidate.name);
63 const result = await setLabels({
64 environmentId,
65 input: { ...reference, labels: [candidate.name], applied: !candidate.isApplied },
66 });
67 setPending(null);
68 if (result._tag === "Failure") {
69 toastManager.add({
70 type: "error",
71 title: candidate.isApplied
72 ? `Could not take ${candidate.name} off`
73 : `Could not put ${candidate.name} on`,
74 description: readableFailure(
75 squashAtomCommandFailure(result),
76 "The host refused it. Check that you have triage access on this repository.",
77 ),
78 });
79 return;
80 }
81 };
82
83 return (
84 <PullRequestCandidatePicker
85 icon={<TagIcon className="size-3.5" />}
86 label="Change labels"
87 allowed={allowed}
88 disabledReason="Changing labels needs triage access on this repository"
89 open={open}
90 onOpenChange={setOpen}
91 query={query}
92 onQueryChange={setQuery}
93 searchLabel="Search labels"
94 isPending={candidatesQuery.isPending && candidatesQuery.data === null}
95 error={candidatesQuery.data === null ? candidatesQuery.error : null}
96 candidates={candidates}
97 emptyLabel="This repository has no labels."
98 noMatchLabel="No label matches that."
99 errorLabel="The labels could not be read."
100 truncated={candidatesQuery.data?.truncated === true}
101 truncatedLabel="This repository has more labels than are listed here. Apply the rest on the host."
102 candidateKey={(candidate) => candidate.name}
103 disabled={pending !== null}
104 onSelect={(candidate) => void toggle(candidate)}
105 >
106 {(candidate) => {
107 const dot = pullRequestLabelColor(candidate.color);
108 return (
109 <>
110 <span
111 aria-hidden
112 className="size-2 shrink-0 rounded-full bg-muted-foreground"
113 {...(dot ? { style: { backgroundColor: dot } } : {})}
114 />
115 <span className="min-w-0 flex-1 truncate">
116 {candidate.name}
117 {candidate.description ? (
118 <span className="text-muted-foreground"> · {candidate.description}</span>
119 ) : null}
120 </span>
121 {candidate.isApplied ? (
122 <CheckIcon aria-label="Applied" className="size-3.5 shrink-0" />
123 ) : null}
124 </>
125 );
126 }}
127 </PullRequestCandidatePicker>
128 );
129}