/** * Putting a label on, and taking one off, from the row that says which it already wears. * * The repository's labels are read only once this menu opens, for the reason the reviewer menu * reads its people then: they are worth a request when somebody wants them and worth nothing on * every pull request they merely open. */ import type { EnvironmentId, PullRequestLabelCandidate, PullRequestRef } from "@t3tools/contracts"; import { CheckIcon, TagIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { toastManager } from "../ui/toast"; import { PullRequestCandidatePicker } from "./PullRequestCandidatePicker"; import { readableFailure } from "./pullRequestDetail.logic"; import { pullRequestLabelColor } from "./pullRequestList.logic"; /** Narrows only what arrived: the host is asked once, when the menu opens. */ function matches(candidate: PullRequestLabelCandidate, query: string): boolean { if (query.length === 0) return true; const needle = query.toLowerCase(); return ( candidate.name.toLowerCase().includes(needle) || (candidate.description ?? "").toLowerCase().includes(needle) ); } export function PullRequestLabelPicker({ environmentId, reference, allowed, }: { environmentId: EnvironmentId; reference: PullRequestRef; /** False where the host would refuse this account's change. Disabled with the reason rather * than hidden, like the reviewer control beside it. */ allowed: boolean; }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [pending, setPending] = useState(null); // Mounted with the menu closed, so nothing is asked of the host until it opens. const candidatesQuery = useEnvironmentQuery( open ? pullRequestEnvironment.labelCandidates({ environmentId, input: reference }) : null, ); const setLabels = useAtomCommand(pullRequestEnvironment.setLabels, { reportFailure: false }); const candidates = useMemo( () => (candidatesQuery.data?.candidates ?? []).filter((entry) => matches(entry, query)), [candidatesQuery.data, query], ); const toggle = async (candidate: PullRequestLabelCandidate) => { if (pending !== null) return; setPending(candidate.name); const result = await setLabels({ environmentId, input: { ...reference, labels: [candidate.name], applied: !candidate.isApplied }, }); setPending(null); if (result._tag === "Failure") { toastManager.add({ type: "error", title: candidate.isApplied ? `Could not take ${candidate.name} off` : `Could not put ${candidate.name} on`, description: readableFailure( squashAtomCommandFailure(result), "The host refused it. Check that you have triage access on this repository.", ), }); return; } }; return ( } label="Change labels" allowed={allowed} disabledReason="Changing labels needs triage access on this repository" open={open} onOpenChange={setOpen} query={query} onQueryChange={setQuery} searchLabel="Search labels" isPending={candidatesQuery.isPending && candidatesQuery.data === null} error={candidatesQuery.data === null ? candidatesQuery.error : null} candidates={candidates} emptyLabel="This repository has no labels." noMatchLabel="No label matches that." errorLabel="The labels could not be read." truncated={candidatesQuery.data?.truncated === true} truncatedLabel="This repository has more labels than are listed here. Apply the rest on the host." candidateKey={(candidate) => candidate.name} disabled={pending !== null} onSelect={(candidate) => void toggle(candidate)} > {(candidate) => { const dot = pullRequestLabelColor(candidate.color); return ( <> {candidate.name} {candidate.description ? ( ยท {candidate.description} ) : null} {candidate.isApplied ? ( ) : null} ); }} ); }