diff --git a/.changeset/fix-admin-tag-picker-navigation.md b/.changeset/fix-admin-tag-picker-navigation.md new file mode 100644 index 000000000..2006e25aa --- /dev/null +++ b/.changeset/fix-admin-tag-picker-navigation.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Fixes the content editor tag picker so every matching tag is reachable with a mouse or keyboard. diff --git a/packages/admin/src/components/TaxonomySidebar.tsx b/packages/admin/src/components/TaxonomySidebar.tsx index bbee14370..762b308be 100644 --- a/packages/admin/src/components/TaxonomySidebar.tsx +++ b/packages/admin/src/components/TaxonomySidebar.tsx @@ -6,11 +6,11 @@ * - Tag input for flat taxonomies (tags) */ -import { Button, Checkbox, Input, Label, Text, Toast } from "@cloudflare/kumo"; +import { Button, Checkbox, Combobox, Input, Label, Text, Toast } from "@cloudflare/kumo"; import { i18n } from "@lingui/core"; import { msg } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; -import { Plus, X } from "@phosphor-icons/react"; +import { Plus } from "@phosphor-icons/react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; @@ -37,6 +37,8 @@ interface TaxonomyDef { collections: string[]; } +type TagPickerOption = { kind: "term"; term: TaxonomyTerm } | { kind: "create"; label: string }; + interface TaxonomySidebarProps { collection: string; entryId?: string; @@ -179,139 +181,144 @@ function CategoryCheckboxTree({ function TagInput({ terms, selectedIds, - onAdd, - onRemove, + onChange, onCreate, + onCreateErrorClear, isCreating, + createError, label, }: { terms: TaxonomyTerm[]; selectedIds: Set; - onAdd: (termId: string) => void; - onRemove: (termId: string) => void; - onCreate: (label: string) => void; + onChange: (termIds: string[]) => void; + onCreate: (label: string) => Promise; + onCreateErrorClear: () => void; isCreating: boolean; + createError: unknown; label: string; }) { const { t } = useLingui(); const [input, setInput] = React.useState(""); const [isOpen, setIsOpen] = React.useState(false); - - const selectedTerms = terms.filter((term) => selectedIds.has(term.id)); - const trimmedInput = input.trim(); - const suggestions = React.useMemo(() => { - const availableTerms = terms.filter((term) => !selectedIds.has(term.id)); - if (!trimmedInput) return availableTerms.slice(0, 5); - return availableTerms.filter((term) => termMatches(term, trimmedInput)).slice(0, 5); - }, [trimmedInput, terms, selectedIds]); - const hasExactMatch = React.useMemo(() => { if (!trimmedInput) return false; return terms.some((term) => termExactMatches(term, trimmedInput)); }, [trimmedInput, terms]); const showCreateOption = trimmedInput.length > 0 && !hasExactMatch; + const termOptions = React.useMemo( + () => terms.map((term) => ({ kind: "term", term })), + [terms], + ); + const selectedOptions = React.useMemo( + () => termOptions.filter((option) => option.kind === "term" && selectedIds.has(option.term.id)), + [termOptions, selectedIds], + ); + const visibleOptions = React.useMemo(() => { + const matches = trimmedInput + ? termOptions.filter( + (option) => option.kind === "term" && termMatches(option.term, trimmedInput), + ) + : termOptions; + const ordered = trimmedInput + ? matches.toSorted((a, b) => { + if (a.kind !== "term" || b.kind !== "term") return 0; + return ( + Number(termExactMatches(b.term, trimmedInput)) - + Number(termExactMatches(a.term, trimmedInput)) + ); + }) + : matches; + + if (!showCreateOption) return ordered; + return [...ordered, { kind: "create", label: trimmedInput } satisfies TagPickerOption]; + }, [showCreateOption, termOptions, trimmedInput]); + + const handleValueChange = (options: TagPickerOption[]) => { + const createOption = options.find((option) => option.kind === "create"); + if (createOption?.kind === "create") { + if (isCreating) return; + void onCreate(createOption.label) + .then(() => setInput("")) + .catch(() => { + setInput(createOption.label); + setIsOpen(true); + }); + return; + } - const handleSelect = (term: TaxonomyTerm) => { - onAdd(term.id); - setInput(""); - setIsOpen(false); - }; - - const handleCreate = () => { - if (!trimmedInput || isCreating) return; - onCreate(trimmedInput); + if (createError) onCreateErrorClear(); + onChange(options.flatMap((option) => (option.kind === "term" ? [option.term.id] : []))); setInput(""); - setIsOpen(false); - }; - - const handleBlur = (e: React.FocusEvent) => { - const nextFocused = e.relatedTarget; - if (nextFocused instanceof Node && e.currentTarget.contains(nextFocused)) return; - setIsOpen(false); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - if (suggestions.length === 1 && !showCreateOption) { - handleSelect(suggestions[0]!); - } else if (showCreateOption && suggestions.length === 0) { - handleCreate(); - } - } }; return ( -
- {/* Selected tags */} - {selectedTerms.length > 0 && ( -
- {selectedTerms.map((term) => ( - { + if (!open && eventDetails.reason === "item-press") return; + setIsOpen(open); + }} + items={visibleOptions} + value={selectedOptions} + inputValue={input} + onInputValueChange={(value) => { + if (createError) onCreateErrorClear(); + setInput(value); + }} + onValueChange={handleValueChange} + isItemEqualToValue={(option, value) => + option.kind === "term" && value.kind === "term" && option.term.id === value.term.id + } + itemToStringLabel={(option) => (option.kind === "term" ? option.term.label : option.label)} + itemToStringValue={(option) => (option.kind === "term" ? option.term.id : option.label)} + filter={null} + autoHighlight + > + + option.kind === "term" ? ( + + {option.term.label} + + ) : null + } + /> + + {t`No results`} + + {(option: TagPickerOption) => ( + - {term.label} - - - ))} -
- )} - - {/* Input with autocomplete */} -
- { - setInput(e.target.value); - setIsOpen(true); - }} - onFocus={() => setIsOpen(true)} - onKeyDown={handleKeyDown} - placeholder={t`Add tags...`} - aria-label={t`Add ${label}`} - className="w-full text-sm" - /> - - {isOpen && (suggestions.length > 0 || showCreateOption) && ( -
- {suggestions.map((term) => ( - - ))} - {showCreateOption && ( - - )} -
- )} -
-
+ {option.kind === "term" ? ( + option.term.label + ) : ( + + + )} + + )} + + + ); } @@ -336,11 +343,19 @@ function TaxonomySection({ const toastManager = Toast.useToastManager(); const [newCategoryLabel, setNewCategoryLabel] = React.useState(""); const [showCategoryInput, setShowCategoryInput] = React.useState(false); + const [selectedIds, setSelectedIds] = React.useState>(new Set()); + const selectedIdsRef = React.useRef(selectedIds); // The count mode belongs in the key: the Taxonomies settings page reads the // same endpoint with counts and must not be served this count-free list. + const termsQueryKey = [ + "taxonomy-terms", + taxonomy.name, + entryLocale, + { includeCounts: false }, + ] as const; const { data: terms = EMPTY_TERMS } = useQuery({ - queryKey: ["taxonomy-terms", taxonomy.name, entryLocale, { includeCounts: false }], + queryKey: termsQueryKey, queryFn: () => fetchTerms(taxonomy.name, entryLocale), }); @@ -353,15 +368,18 @@ function TaxonomySection({ enabled: !!entryId, }); + const saveMutationKey = ["entry-terms-save", collection, entryId, taxonomy.name, entryLocale]; const saveMutation = useMutation({ + mutationKey: saveMutationKey, + scope: { + id: `entry-terms:${collection}:${entryId ?? "new"}:${taxonomy.name}:${entryLocale ?? ""}`, + }, mutationFn: (termIds: string[]) => { if (!entryId) throw new Error("No entry ID"); return setEntryTerms(collection, entryId, taxonomy.name, termIds, entryLocale); }, onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: ["entry-terms", collection, entryId, taxonomy.name, entryLocale], - }); + if (queryClient.isMutating({ mutationKey: saveMutationKey }) > 1) return; toastManager.add({ title: t`${taxonomy.label} updated` }); }, onError: (error) => { @@ -371,6 +389,12 @@ function TaxonomySection({ type: "error", }); }, + onSettled: () => { + if (queryClient.isMutating({ mutationKey: saveMutationKey }) > 1) return; + void queryClient.invalidateQueries({ + queryKey: ["entry-terms", collection, entryId, taxonomy.name, entryLocale], + }); + }, }); const createTermMutation = useMutation({ @@ -382,12 +406,15 @@ function TaxonomySection({ ...(entryLocale ? { locale: entryLocale } : {}), }), onSuccess: (newTerm) => { + queryClient.setQueryData(termsQueryKey, (current = []) => + current.some((term) => term.id === newTerm.id) ? current : [...current, newTerm], + ); void queryClient.invalidateQueries({ queryKey: ["taxonomy-terms", taxonomy.name, entryLocale], }); - // Auto-select the newly created term - const newSelected = new Set(selectedIds); + const newSelected = new Set(selectedIdsRef.current); newSelected.add(newTerm.id); + selectedIdsRef.current = newSelected; setSelectedIds(newSelected); const termIdsArray = [...newSelected]; @@ -403,11 +430,10 @@ function TaxonomySection({ }, }); - const [selectedIds, setSelectedIds] = React.useState>(new Set()); - - // Sync selected IDs from entry terms React.useEffect(() => { - setSelectedIds(new Set(entryTerms.map((term) => term.id))); + const nextSelected = new Set(entryTerms.map((term) => term.id)); + selectedIdsRef.current = nextSelected; + setSelectedIds(nextSelected); }, [entryTerms]); const handleToggle = (termId: string) => { @@ -417,26 +443,20 @@ function TaxonomySection({ } else { newSelected.add(termId); } - setSelectedIds(newSelected); + handleSelectionChange([...newSelected]); + }; - // Notify parent of change - const termIdsArray = [...newSelected]; + const handleSelectionChange = (termIdsArray: string[]) => { + const nextSelected = new Set(termIdsArray); + selectedIdsRef.current = nextSelected; + setSelectedIds(nextSelected); onChange?.(termIdsArray); - // Auto-save if entry exists if (entryId) { saveMutation.mutate(termIdsArray); } }; - const handleAdd = (termId: string) => { - handleToggle(termId); - }; - - const handleRemove = (termId: string) => { - handleToggle(termId); - }; - const handleCreateCategory = () => { const label = newCategoryLabel.trim(); if (!label || createTermMutation.isPending) return; @@ -445,10 +465,9 @@ function TaxonomySection({ return (
- - {taxonomy.hierarchical ? ( <> + {terms.length === 0 ? (

{t`No ${taxonomy.label.toLowerCase()} available.`} @@ -520,10 +539,13 @@ function TaxonomySection({ createTermMutation.mutate(label)} + onChange={handleSelectionChange} + onCreate={async (label) => { + await createTermMutation.mutateAsync(label); + }} + onCreateErrorClear={createTermMutation.reset} isCreating={createTermMutation.isPending} + createError={createTermMutation.error} label={taxonomy.label} /> )} diff --git a/packages/admin/tests/components/TaxonomySidebar.test.tsx b/packages/admin/tests/components/TaxonomySidebar.test.tsx index 0f758eb2c..7234d2dac 100644 --- a/packages/admin/tests/components/TaxonomySidebar.test.tsx +++ b/packages/admin/tests/components/TaxonomySidebar.test.tsx @@ -2,6 +2,7 @@ import { Toasty } from "@cloudflare/kumo"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import * as React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; +import { userEvent } from "vitest/browser"; import { TaxonomySidebar } from "../../src/components/TaxonomySidebar"; import { render } from "../utils/render.tsx"; @@ -54,6 +55,23 @@ const categoriesTaxonomy: TestTaxonomy = { const alphaTerm = makeTerm("term_alpha", "Alpha"); const betaTerm = makeTerm("term_beta", "Beta"); +const securityTerms = [ + makeTerm("term_application_security", "Application Security"), + makeTerm("term_cloud_security", "Cloud Security"), + makeTerm("term_data_security", "Data Security"), + makeTerm("term_email_security", "Email Security"), + makeTerm("term_network_security", "Network Security"), + makeTerm("term_security", "Security"), + makeTerm("term_web_security", "Web Security"), +]; +const overflowingSecurityTerms = [ + ...securityTerms, + makeTerm("term_api_security", "API Security"), + makeTerm("term_browser_security", "Browser Security"), + makeTerm("term_dns_security", "DNS Security"), + makeTerm("term_origin_security", "Origin Security"), + makeTerm("term_zero_trust_security", "Zero Trust Security"), +]; function makeTerm(id: string, label: string): TestTerm { return { @@ -75,15 +93,38 @@ function dataResponse(data: unknown) { ); } +function parseJsonBody(init?: RequestInit): T { + if (typeof init?.body !== "string") throw new TypeError("Expected a JSON request body"); + return JSON.parse(init.body) as T; +} + function mockApiFetch({ taxonomies = [tagsTaxonomy], terms = [alphaTerm, betaTerm], entryTerms = [], + deferSaves = false, + deferTermRefetch = false, + deferCreate = false, + createError, }: { taxonomies?: TestTaxonomy[]; terms?: TestTerm[]; entryTerms?: TestTerm[]; + deferSaves?: boolean; + deferTermRefetch?: boolean; + deferCreate?: boolean; + createError?: string; } = {}) { + const currentTerms = [...terms]; + let currentEntryTerms = [...entryTerms]; + const saveRequests: string[][] = []; + const pendingSaveResponses: Array<() => void> = []; + const pendingTermRefetches: Array<() => void> = []; + const pendingCreateResponses: Array<() => void> = []; + let entryTermFetches = 0; + let termFetches = 0; + let createRequests = 0; + vi.mocked(apiFetch).mockImplementation((url: string | URL | Request, init?: RequestInit) => { const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; const path = new URL(urlString, "http://localhost").pathname; @@ -94,7 +135,13 @@ function mockApiFetch({ } if (method === "GET" && path === "/_emdash/api/taxonomies/tags/terms") { - return dataResponse({ terms }); + termFetches += 1; + if (deferTermRefetch && termFetches > 1) { + return new Promise((resolve) => { + pendingTermRefetches.push(() => void dataResponse({ terms: currentTerms }).then(resolve)); + }); + } + return dataResponse({ terms: currentTerms }); } if (method === "GET" && path === "/_emdash/api/taxonomies/categories/terms") { @@ -102,11 +149,66 @@ function mockApiFetch({ } if (method === "GET" && path === "/_emdash/api/content/products/entry_1/terms/tags") { - return dataResponse({ terms: entryTerms }); + entryTermFetches += 1; + return dataResponse({ terms: currentEntryTerms }); + } + + if (method === "POST" && path === "/_emdash/api/taxonomies/tags/terms") { + createRequests += 1; + if (createError) { + return Promise.resolve( + new Response( + JSON.stringify({ error: { code: "TERM_CREATE_ERROR", message: createError } }), + { + status: 409, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + } + const body = parseJsonBody<{ label: string; slug: string }>(init); + const respond = () => { + const term = makeTerm(`term_${body.slug}`, body.label); + currentTerms.push(term); + return dataResponse({ term }); + }; + if (!deferCreate) return respond(); + return new Promise((resolve) => { + pendingCreateResponses.push(() => void respond().then(resolve)); + }); + } + + if (method === "POST" && path === "/_emdash/api/content/products/entry_1/terms/tags") { + const body = parseJsonBody<{ termIds: string[] }>(init); + saveRequests.push(body.termIds); + const respond = () => { + currentEntryTerms = currentTerms.filter((term) => body.termIds.includes(term.id)); + return dataResponse({}); + }; + if (!deferSaves) return respond(); + return new Promise((resolve) => { + pendingSaveResponses.push(() => void respond().then(resolve)); + }); } return dataResponse({}); }); + + return { + saveRequests, + releaseNextSave: () => pendingSaveResponses.shift()?.(), + releaseTermRefetch: () => pendingTermRefetches.shift()?.(), + releaseCreate: () => pendingCreateResponses.shift()?.(), + get entryTermFetches() { + return entryTermFetches; + }, + get termFetches() { + return termFetches; + }, + get createRequests() { + return createRequests; + }, + }; } function Wrapper({ children }: { children: React.ReactNode }) { @@ -134,27 +236,70 @@ describe("TaxonomySidebar", () => { it("shows existing flat taxonomy terms when the tag picker receives focus", async () => { const screen = await render(, { wrapper: Wrapper }); - await expect.element(screen.getByLabelText("Add Tags")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /^Alpha$/ }).query()).toBeNull(); + const input = screen.getByRole("combobox", { name: "Tags" }); + await expect.element(input).toBeInTheDocument(); + expect(screen.getByRole("option", { name: /^Alpha$/ }).query()).toBeNull(); - await screen.getByLabelText("Add Tags").click(); + await input.click(); - await expect.element(screen.getByRole("button", { name: /^Alpha$/ })).toBeInTheDocument(); - await expect.element(screen.getByRole("button", { name: /^Beta$/ })).toBeInTheDocument(); + await expect.element(screen.getByRole("option", { name: /^Alpha$/ })).toBeInTheDocument(); + await expect.element(screen.getByRole("option", { name: /^Beta$/ })).toBeInTheDocument(); }); it("filters flat taxonomy terms while preserving the create option for new input", async () => { const screen = await render(, { wrapper: Wrapper }); - const input = screen.getByLabelText("Add Tags"); + const input = screen.getByRole("combobox", { name: "Tags" }); await input.fill("Alp"); - await expect.element(screen.getByRole("button", { name: /^Alpha$/ })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /^Beta$/ }).query()).toBeNull(); + await expect.element(screen.getByRole("option", { name: /^Alpha$/ })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: /^Beta$/ }).query()).toBeNull(); await expect.element(screen.getByText('Create "Alp"')).toBeInTheDocument(); }); - it("does not suggest terms already assigned to the entry", async () => { + it("makes every matching term reachable and selects the exact match with Enter", async () => { + mockApiFetch({ terms: securityTerms }); + + const screen = await render(, { wrapper: Wrapper }); + const input = screen.getByRole("combobox", { name: "Tags" }); + + await input.fill("security"); + + for (const term of securityTerms) { + await expect.element(screen.getByText(term.label, { exact: true })).toBeInTheDocument(); + } + expect(screen.getByText('Create "security"').query()).toBeNull(); + + await userEvent.keyboard("{Enter}"); + + await expect.element(screen.getByLabelText("Remove Security")).toBeInTheDocument(); + }); + + it("caps long match lists and makes the remaining options scrollable", async () => { + mockApiFetch({ terms: overflowingSecurityTerms }); + const screen = await render(, { wrapper: Wrapper }); + + await screen.getByRole("combobox", { name: "Tags" }).fill("security"); + + const listbox = screen.getByRole("listbox").element(); + expect(listbox.scrollHeight).toBeGreaterThan(listbox.clientHeight); + }); + + it("selects a later matching term with the pointer", async () => { + mockApiFetch({ terms: securityTerms }); + + const screen = await render(, { wrapper: Wrapper }); + await screen.getByRole("combobox", { name: "Tags" }).fill("security"); + + await screen.getByRole("option", { name: "Web Security" }).click(); + + await expect.element(screen.getByLabelText("Remove Web Security")).toBeInTheDocument(); + await expect + .element(screen.getByRole("option", { name: "Application Security" })) + .toBeInTheDocument(); + }); + + it("shows assigned terms as removable selected options", async () => { mockApiFetch({ entryTerms: [alphaTerm] }); const screen = await render(, { @@ -162,10 +307,38 @@ describe("TaxonomySidebar", () => { }); await expect.element(screen.getByLabelText("Remove Alpha")).toBeInTheDocument(); - await screen.getByLabelText("Add Tags").click(); + await screen.getByRole("combobox", { name: "Tags" }).click(); + + await expect + .element(screen.getByRole("option", { name: /^Alpha$/ })) + .toHaveAttribute("aria-selected", "true"); + await expect.element(screen.getByRole("option", { name: /^Beta$/ })).toBeInTheDocument(); + + await screen.getByLabelText("Remove Alpha").click(); - expect(screen.getByRole("button", { name: /^Alpha$/ }).query()).toBeNull(); - await expect.element(screen.getByRole("button", { name: /^Beta$/ })).toBeInTheDocument(); + expect(screen.getByLabelText("Remove Alpha").query()).toBeNull(); + }); + + it("serializes rapid assignment saves and refetches after the final write", async () => { + const saves = mockApiFetch({ deferSaves: true }); + const screen = await render(, { + wrapper: Wrapper, + }); + const input = screen.getByRole("combobox", { name: "Tags" }); + + await input.click(); + await screen.getByRole("option", { name: "Alpha" }).click(); + await screen.getByRole("option", { name: "Beta" }).click(); + + expect(saves.saveRequests).toEqual([[alphaTerm.id]]); + saves.releaseNextSave(); + await vi.waitFor(() => expect(saves.saveRequests).toHaveLength(2)); + expect(saves.saveRequests[1]).toEqual([alphaTerm.id, betaTerm.id]); + + saves.releaseNextSave(); + await vi.waitFor(() => expect(saves.entryTermFetches).toBe(2)); + await expect.element(screen.getByLabelText("Remove Alpha")).toBeInTheDocument(); + await expect.element(screen.getByLabelText("Remove Beta")).toBeInTheDocument(); }); it("keeps the create prompt available when no flat taxonomy terms exist", async () => { @@ -173,7 +346,7 @@ describe("TaxonomySidebar", () => { const screen = await render(, { wrapper: Wrapper }); - const input = screen.getByLabelText("Add Tags"); + const input = screen.getByRole("combobox", { name: "Tags" }); await input.click(); expect(screen.getByText('Create "Gamma"').query()).toBeNull(); @@ -181,6 +354,56 @@ describe("TaxonomySidebar", () => { await input.fill("Gamma"); await expect.element(screen.getByText('Create "Gamma"')).toBeInTheDocument(); + + await userEvent.keyboard("{Enter}"); + + await expect.element(screen.getByLabelText("Remove Gamma")).toBeInTheDocument(); + }); + + it("keeps failed creation input and exposes the retryable API error", async () => { + mockApiFetch({ terms: [], createError: "Term already exists" }); + const screen = await render(, { wrapper: Wrapper }); + const input = screen.getByRole("combobox", { name: "Tags" }); + + await input.fill("Gamma"); + await userEvent.keyboard("{Enter}"); + + await expect.element(screen.getByText("Term already exists")).toBeInTheDocument(); + await expect.element(input).toHaveValue("Gamma"); + await expect.element(screen.getByText('Create "Gamma"')).toBeInTheDocument(); + + await input.fill("Delta"); + + expect(screen.getByText("Term already exists").query()).toBeNull(); + await expect.element(screen.getByText('Create "Delta"')).toBeInTheDocument(); + }); + + it("shows a created tag before the authoritative term refetch finishes", async () => { + const terms = mockApiFetch({ terms: [], deferTermRefetch: true }); + const screen = await render(, { wrapper: Wrapper }); + const input = screen.getByRole("combobox", { name: "Tags" }); + + await input.fill("Gamma"); + await userEvent.keyboard("{Enter}"); + await vi.waitFor(() => expect(terms.termFetches).toBe(2)); + + await expect.element(screen.getByLabelText("Remove Gamma")).toBeInTheDocument(); + terms.releaseTermRefetch(); + }); + + it("keeps selections made while term creation is pending", async () => { + const creation = mockApiFetch({ terms: [alphaTerm], deferCreate: true }); + const screen = await render(, { wrapper: Wrapper }); + const input = screen.getByRole("combobox", { name: "Tags" }); + + await input.fill("Gamma"); + await userEvent.keyboard("{Enter}"); + await vi.waitFor(() => expect(creation.createRequests).toBe(1)); + await screen.getByRole("option", { name: "Alpha" }).click(); + + creation.releaseCreate(); + await expect.element(screen.getByLabelText("Remove Gamma")).toBeInTheDocument(); + await expect.element(screen.getByLabelText("Remove Alpha")).toBeInTheDocument(); }); it("continues to render hierarchical taxonomies as a checkbox tree", async () => {