From f491ab0d1a701e2b02a29138f7daf87e5d3870da Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 03:42:15 +0000 Subject: [PATCH] feat(formulation): wire CatalogueToolbar into the builder page (#039) Extracts the CatalogueToolbar component from PR #2073 and wires it into formulation-builder-page.tsx in place of the ad hoc TextField+Select pairing, without that PR's DoseLine/AnswerFooter adoption-manifest changes (see #2073's closure comment: those extract new components but never wire them into a real consumer, which inflates the design-system adoption count without satisfying #267's actual backend-payload requirement). Also fixes three CodeRabbit-flagged correctness issues in catalogue-toolbar.tsx while taking ownership of the component: - isFilterTriggerProps only recognized objects carrying onToggle or activeCount, so a filterTrigger config with just e.g. { label, disabled } fell through to being rendered as a React child and threw. Now checks all seven declared keys. - The plural-noun fallback derived plurals by appending "s", which is wrong for irregular nouns (e.g. "status" -> "statu"/"status" instead of "statuses"). Added an explicit pluralNoun override, defaulting to the previous "${noun}s" behavior for existing callers. - Applied-filter chips rendered "undefined: value" when a chip had no groupLabel, and always announced groupLabel in the remove button's aria-label even when accessibleLabel was supplied. Now renders the group prefix only when present and prefers accessibleLabel. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RnhwRP5FsY5JktkbwAcubD --- src/components/catalogue-toolbar.tsx | 7 + .../formulation/formulation-builder-page.tsx | 50 ++-- src/components/ui/catalogue-toolbar.tsx | 255 ++++++++++++++++++ tests/catalogue-toolbar.dom.test.tsx | 149 ++++++++++ 4 files changed, 436 insertions(+), 25 deletions(-) create mode 100644 src/components/catalogue-toolbar.tsx create mode 100644 src/components/ui/catalogue-toolbar.tsx create mode 100644 tests/catalogue-toolbar.dom.test.tsx diff --git a/src/components/catalogue-toolbar.tsx b/src/components/catalogue-toolbar.tsx new file mode 100644 index 0000000000..da8d92624d --- /dev/null +++ b/src/components/catalogue-toolbar.tsx @@ -0,0 +1,7 @@ +export { + CatalogueToolbar, + type CatalogueToolbarProps, + type CatalogueToolbarSearchProps, + type CatalogueToolbarSortProps, + type CatalogueToolbarFilterTriggerProps, +} from "@/components/ui/catalogue-toolbar"; diff --git a/src/components/formulation/formulation-builder-page.tsx b/src/components/formulation/formulation-builder-page.tsx index 9785a2c38d..e79b2df1d8 100644 --- a/src/components/formulation/formulation-builder-page.tsx +++ b/src/components/formulation/formulation-builder-page.tsx @@ -24,9 +24,9 @@ import { SessionPrivacyNote, formulationCard, } from "@/components/formulation/formulation-ui"; -import { Select } from "@/components/ui/select"; -import { TextField } from "@/components/ui/text-field"; +import { CatalogueToolbar } from "@/components/ui/catalogue-toolbar"; import { cn, eyebrowText } from "@/components/ui-primitives"; + import { findFormulationMechanism, formulationDomains, @@ -351,31 +351,31 @@ export function FormulationBuilderPage({ )} -
- {/* Kept as a text input, not a `SearchField`: this filters the - mechanism list in place and never submits, so it is not a - second page composer (docs/search-chrome-behaviour.md). */} - setQuery(event.target.value)} - placeholder="Search mechanisms or patient language..." - className="font-semibold" - /> - ) => sort.onChange(e.target.value)} + options={[...sort.options]} + disabled={sort.disabled} + className={cn("font-semibold", sort.className)} + /> + ) : ( + sort + )} +
+ ) : null} + + {/* Filter trigger button */} + {filterTrigger ? ( +
+ {isFilterTriggerProps(filterTrigger) ? ( + + ) : ( + filterTrigger + )} +
+ ) : null} + + {/* Optional inline custom children */} + {children} + + + {/* Right side: Count readout & Actions */} +
+ {countLabel ? ( + + {countLabel} + + ) : null} + + {actions ?
{actions}
: null} +
+ + + {/* Applied Filter Chips Strip */} + {hasAppliedFilters ? ( +
+ Active filters: + {appliedFilters.map((chip) => ( + + + {chip.groupLabel ? {chip.groupLabel}: : null} + {chip.valueLabel} + + + + ))} + + {onClearFilters ? ( + + ) : null} +
+ ) : null} + + ); +} diff --git a/tests/catalogue-toolbar.dom.test.tsx b/tests/catalogue-toolbar.dom.test.tsx new file mode 100644 index 0000000000..6e4f871072 --- /dev/null +++ b/tests/catalogue-toolbar.dom.test.tsx @@ -0,0 +1,149 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { CatalogueToolbar } from "@/components/ui/catalogue-toolbar"; + +describe("CatalogueToolbar DOM and Interactions", () => { + it("renders search, sort, filter trigger, and match count cleanly", () => { + const onSearchChange = vi.fn(); + const onSortChange = vi.fn(); + const onToggleFilter = vi.fn(); + + render( + , + ); + + expect(screen.getByTestId("catalogue-toolbar")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Search differentials...")).toHaveValue("neuro"); + expect(screen.getByRole("combobox")).toHaveValue("relevance"); + + const trigger = screen.getByTestId("catalogue-filter-trigger"); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(trigger).toHaveAttribute("aria-controls", "diff-filter-panel"); + expect(screen.getByTestId("catalogue-filter-badge")).toHaveTextContent("2"); + + const matchCount = screen.getByTestId("catalogue-match-count"); + expect(matchCount).toHaveTextContent("14 differentials"); + }); + + it("handles user interactions for search, sort, and filter trigger", async () => { + const onSearchChange = vi.fn(); + const onSortChange = vi.fn(); + const onToggleFilter = vi.fn(); + + render( + , + ); + + const input = screen.getByRole("textbox"); + await userEvent.type(input, "bipolar"); + expect(onSearchChange).toHaveBeenCalled(); + + const select = screen.getByRole("combobox"); + await userEvent.selectOptions(select, "alpha"); + expect(onSortChange).toHaveBeenCalledWith("alpha"); + + const trigger = screen.getByTestId("catalogue-filter-trigger"); + await userEvent.click(trigger); + expect(onToggleFilter).toHaveBeenCalledTimes(1); + }); + + it("defaults the filter trigger to collapsed and disabled when no toggle handler is provided", () => { + render( + , + ); + + const trigger = screen.getByTestId("catalogue-filter-trigger"); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(trigger).toBeDisabled(); + }); + + it("renders active filter chips and handles remove and clear-all callbacks", async () => { + const onRemoveDomain = vi.fn(); + const onRemoveScope = vi.fn(); + const onClearAll = vi.fn(); + + render( + , + ); + + const chipsStrip = screen.getByTestId("catalogue-applied-filters"); + expect(chipsStrip).toBeInTheDocument(); + + const chips = within(chipsStrip).getAllByTestId("catalogue-applied-chip"); + expect(chips).toHaveLength(2); + expect(chips[0]).toHaveTextContent("Domain: Biological"); + expect(chips[1]).toHaveTextContent("Scope: Guides"); + + const removeDomainBtn = screen.getByRole("button", { name: "Remove filter Domain: Biological" }); + await userEvent.click(removeDomainBtn); + expect(onRemoveDomain).toHaveBeenCalledTimes(1); + + const clearAllBtn = screen.getByTestId("catalogue-clear-filters"); + await userEvent.click(clearAllBtn); + expect(onClearAll).toHaveBeenCalledTimes(1); + }); + + it("handles singular vs plural noun formatting in results count", () => { + const { rerender } = render(); + expect(screen.getByTestId("catalogue-match-count")).toHaveTextContent("1 mechanism"); + + rerender(); + expect(screen.getByTestId("catalogue-match-count")).toHaveTextContent("0 mechanisms"); + + rerender(); + expect(screen.getByTestId("catalogue-match-count")).toHaveTextContent("5 specifiers"); + }); +});