From 4a5919f9365d658127103ae89e7af3b7574142a0 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:38:47 +0100 Subject: [PATCH 1/6] fix(admin): make taxonomy tag matches reachable --- .../admin/src/components/TaxonomySidebar.tsx | 241 +++++++++--------- .../tests/components/TaxonomySidebar.test.tsx | 142 +++++++++-- 2 files changed, 241 insertions(+), 142 deletions(-) diff --git a/packages/admin/src/components/TaxonomySidebar.tsx b/packages/admin/src/components/TaxonomySidebar.tsx index bbee14370b..74a7001421 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,16 +181,14 @@ function CategoryCheckboxTree({ function TagInput({ terms, selectedIds, - onAdd, - onRemove, + onChange, onCreate, isCreating, label, }: { terms: TaxonomyTerm[]; selectedIds: Set; - onAdd: (termId: string) => void; - onRemove: (termId: string) => void; + onChange: (termIds: string[]) => void; onCreate: (label: string) => void; isCreating: boolean; label: string; @@ -196,122 +196,110 @@ function TagInput({ 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; + onCreate(createOption.label); + setInput(""); + return; + } - const handleSelect = (term: TaxonomyTerm) => { - onAdd(term.id); - setInput(""); - setIsOpen(false); - }; - - const handleCreate = () => { - if (!trimmedInput || isCreating) return; - onCreate(trimmedInput); + 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={setInput} + 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 + ) : ( + + + )} + + )} + + + ); } @@ -353,15 +341,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 +362,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({ @@ -417,26 +414,18 @@ function TaxonomySection({ } else { newSelected.add(termId); } - setSelectedIds(newSelected); + handleSelectionChange([...newSelected]); + }; - // Notify parent of change - const termIdsArray = [...newSelected]; + const handleSelectionChange = (termIdsArray: string[]) => { + setSelectedIds(new Set(termIdsArray)); 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 +434,9 @@ function TaxonomySection({ return (
- - {taxonomy.hierarchical ? ( <> + {terms.length === 0 ? (

{t`No ${taxonomy.label.toLowerCase()} available.`} @@ -520,8 +508,7 @@ function TaxonomySection({ createTermMutation.mutate(label)} isCreating={createTermMutation.isPending} label={taxonomy.label} diff --git a/packages/admin/tests/components/TaxonomySidebar.test.tsx b/packages/admin/tests/components/TaxonomySidebar.test.tsx index 0f758eb2c3..82e068b71c 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,15 @@ 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"), +]; function makeTerm(id: string, label: string): TestTerm { return { @@ -79,11 +89,19 @@ function mockApiFetch({ taxonomies = [tagsTaxonomy], terms = [alphaTerm, betaTerm], entryTerms = [], + deferSaves = false, }: { taxonomies?: TestTaxonomy[]; terms?: TestTerm[]; entryTerms?: TestTerm[]; + deferSaves?: boolean; } = {}) { + const currentTerms = [...terms]; + let currentEntryTerms = [...entryTerms]; + const saveRequests: string[][] = []; + const pendingSaveResponses: Array<() => void> = []; + let entryTermFetches = 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 +112,7 @@ function mockApiFetch({ } if (method === "GET" && path === "/_emdash/api/taxonomies/tags/terms") { - return dataResponse({ terms }); + return dataResponse({ terms: currentTerms }); } if (method === "GET" && path === "/_emdash/api/taxonomies/categories/terms") { @@ -102,11 +120,40 @@ 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") { + const body = JSON.parse(String(init?.body)) as { label: string; slug: string }; + const term = makeTerm(`term_${body.slug}`, body.label); + currentTerms.push(term); + return dataResponse({ term }); + } + + if (method === "POST" && path === "/_emdash/api/content/products/entry_1/terms/tags") { + const body = JSON.parse(String(init?.body)) as { termIds: string[] }; + 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()?.(), + get entryTermFetches() { + return entryTermFetches; + }, + }; } function Wrapper({ children }: { children: React.ReactNode }) { @@ -134,27 +181,60 @@ 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("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 +242,38 @@ describe("TaxonomySidebar", () => { }); await expect.element(screen.getByLabelText("Remove Alpha")).toBeInTheDocument(); - await screen.getByLabelText("Add Tags").click(); + await screen.getByRole("combobox", { name: "Tags" }).click(); - expect(screen.getByRole("button", { name: /^Alpha$/ }).query()).toBeNull(); - await expect.element(screen.getByRole("button", { name: /^Beta$/ })).toBeInTheDocument(); + 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.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 +281,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 +289,10 @@ 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("continues to render hierarchical taxonomies as a checkbox tree", async () => { From 2e6bf6e93eddc310c9e28b0b5575a119692ec080 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:47:16 +0100 Subject: [PATCH 2/6] fix(admin): preserve tag creation state --- .../admin/src/components/TaxonomySidebar.tsx | 59 +++++++++--- .../tests/components/TaxonomySidebar.test.tsx | 94 ++++++++++++++++++- 2 files changed, 139 insertions(+), 14 deletions(-) diff --git a/packages/admin/src/components/TaxonomySidebar.tsx b/packages/admin/src/components/TaxonomySidebar.tsx index 74a7001421..985aa79142 100644 --- a/packages/admin/src/components/TaxonomySidebar.tsx +++ b/packages/admin/src/components/TaxonomySidebar.tsx @@ -183,14 +183,18 @@ function TagInput({ selectedIds, onChange, onCreate, + onCreateErrorClear, isCreating, + createError, label, }: { terms: TaxonomyTerm[]; selectedIds: Set; onChange: (termIds: string[]) => void; - onCreate: (label: string) => void; + onCreate: (label: string) => Promise; + onCreateErrorClear: () => void; isCreating: boolean; + createError: unknown; label: string; }) { const { t } = useLingui(); @@ -236,11 +240,16 @@ function TagInput({ const createOption = options.find((option) => option.kind === "create"); if (createOption?.kind === "create") { if (isCreating) return; - onCreate(createOption.label); - setInput(""); + void onCreate(createOption.label) + .then(() => setInput("")) + .catch(() => { + setInput(createOption.label); + setIsOpen(true); + }); return; } + if (createError) onCreateErrorClear(); onChange(options.flatMap((option) => (option.kind === "term" ? [option.term.id] : []))); setInput(""); }; @@ -249,6 +258,13 @@ function TagInput({ { if (!open && eventDetails.reason === "item-press") return; @@ -257,7 +273,10 @@ function TagInput({ items={visibleOptions} value={selectedOptions} inputValue={input} - onInputValueChange={setInput} + onInputValueChange={(value) => { + if (createError) onCreateErrorClear(); + setInput(value); + }} onValueChange={handleValueChange} isItemEqualToValue={(option, value) => option.kind === "term" && value.kind === "term" && option.term.id === value.term.id @@ -324,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), }); @@ -379,12 +406,16 @@ 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]; @@ -400,11 +431,11 @@ 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) => { @@ -418,7 +449,9 @@ function TaxonomySection({ }; const handleSelectionChange = (termIdsArray: string[]) => { - setSelectedIds(new Set(termIdsArray)); + const nextSelected = new Set(termIdsArray); + selectedIdsRef.current = nextSelected; + setSelectedIds(nextSelected); onChange?.(termIdsArray); if (entryId) { @@ -509,8 +542,12 @@ function TaxonomySection({ terms={terms} selectedIds={selectedIds} onChange={handleSelectionChange} - onCreate={(label) => createTermMutation.mutate(label)} + 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 82e068b71c..76c08a6fb9 100644 --- a/packages/admin/tests/components/TaxonomySidebar.test.tsx +++ b/packages/admin/tests/components/TaxonomySidebar.test.tsx @@ -90,17 +90,27 @@ function mockApiFetch({ 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; @@ -112,6 +122,12 @@ function mockApiFetch({ } if (method === "GET" && path === "/_emdash/api/taxonomies/tags/terms") { + termFetches += 1; + if (deferTermRefetch && termFetches > 1) { + return new Promise((resolve) => { + pendingTermRefetches.push(() => void dataResponse({ terms: currentTerms }).then(resolve)); + }); + } return dataResponse({ terms: currentTerms }); } @@ -125,10 +141,28 @@ function mockApiFetch({ } 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 = JSON.parse(String(init?.body)) as { label: string; slug: string }; - const term = makeTerm(`term_${body.slug}`, body.label); - currentTerms.push(term); - return dataResponse({ term }); + 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") { @@ -150,9 +184,17 @@ function mockApiFetch({ return { saveRequests, releaseNextSave: () => pendingSaveResponses.shift()?.(), + releaseTermRefetch: () => pendingTermRefetches.shift()?.(), + releaseCreate: () => pendingCreateResponses.shift()?.(), get entryTermFetches() { return entryTermFetches; }, + get termFetches() { + return termFetches; + }, + get createRequests() { + return createRequests; + }, }; } @@ -295,6 +337,52 @@ describe("TaxonomySidebar", () => { 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 () => { mockApiFetch({ taxonomies: [categoriesTaxonomy], terms: [alphaTerm] }); From cf3b4f969d3e130e2fbc601ae8510c8605d4907e Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:48:52 +0100 Subject: [PATCH 3/6] docs(changeset): record tag picker fix --- .changeset/fix-admin-tag-picker-navigation.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-admin-tag-picker-navigation.md diff --git a/.changeset/fix-admin-tag-picker-navigation.md b/.changeset/fix-admin-tag-picker-navigation.md new file mode 100644 index 0000000000..2006e25aa2 --- /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. From dddfe6065838fbaa7ef64fc7dc71f9a3a3ac9579 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:56:47 +0100 Subject: [PATCH 4/6] test(admin): parse taxonomy request bodies safely --- packages/admin/tests/components/TaxonomySidebar.test.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/admin/tests/components/TaxonomySidebar.test.tsx b/packages/admin/tests/components/TaxonomySidebar.test.tsx index 76c08a6fb9..bf7e51e152 100644 --- a/packages/admin/tests/components/TaxonomySidebar.test.tsx +++ b/packages/admin/tests/components/TaxonomySidebar.test.tsx @@ -85,6 +85,11 @@ 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], @@ -153,7 +158,7 @@ function mockApiFetch({ ), ); } - const body = JSON.parse(String(init?.body)) as { label: string; slug: string }; + const body = parseJsonBody<{ label: string; slug: string }>(init); const respond = () => { const term = makeTerm(`term_${body.slug}`, body.label); currentTerms.push(term); @@ -166,7 +171,7 @@ function mockApiFetch({ } if (method === "POST" && path === "/_emdash/api/content/products/entry_1/terms/tags") { - const body = JSON.parse(String(init?.body)) as { termIds: string[] }; + const body = parseJsonBody<{ termIds: string[] }>(init); saveRequests.push(body.termIds); const respond = () => { currentEntryTerms = currentTerms.filter((term) => body.termIds.includes(term.id)); From 6604d982807f9406710512f0216bab28e554bfc6 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:52:32 +0100 Subject: [PATCH 5/6] fix(admin): cap tag picker results height --- .../admin/src/components/TaxonomySidebar.tsx | 2 +- .../tests/components/TaxonomySidebar.test.tsx | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/admin/src/components/TaxonomySidebar.tsx b/packages/admin/src/components/TaxonomySidebar.tsx index 985aa79142..f8e5e02218 100644 --- a/packages/admin/src/components/TaxonomySidebar.tsx +++ b/packages/admin/src/components/TaxonomySidebar.tsx @@ -299,7 +299,7 @@ function TagInput({ /> {t`No results`} - + {(option: TagPickerOption) => ( { 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 }); From 48981c7c99bf37590becce715aa2eda3b8758792 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:09:20 +0100 Subject: [PATCH 6/6] chore(admin): remove redundant taxonomy comments --- packages/admin/src/components/TaxonomySidebar.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/admin/src/components/TaxonomySidebar.tsx b/packages/admin/src/components/TaxonomySidebar.tsx index f8e5e02218..762b308bee 100644 --- a/packages/admin/src/components/TaxonomySidebar.tsx +++ b/packages/admin/src/components/TaxonomySidebar.tsx @@ -412,7 +412,6 @@ function TaxonomySection({ void queryClient.invalidateQueries({ queryKey: ["taxonomy-terms", taxonomy.name, entryLocale], }); - // Auto-select the newly created term const newSelected = new Set(selectedIdsRef.current); newSelected.add(newTerm.id); selectedIdsRef.current = newSelected; @@ -431,7 +430,6 @@ function TaxonomySection({ }, }); - // Sync selected IDs from entry terms React.useEffect(() => { const nextSelected = new Set(entryTerms.map((term) => term.id)); selectedIdsRef.current = nextSelected;