diff --git a/docs/branch-review-records/3cb7ccda715349528ce874a104d8f7086772b310145b8ef3f028b82ae6b4f63c.record.md b/docs/branch-review-records/3cb7ccda715349528ce874a104d8f7086772b310145b8ef3f028b82ae6b4f63c.record.md new file mode 100644 index 0000000000..fc3972403c --- /dev/null +++ b/docs/branch-review-records/3cb7ccda715349528ce874a104d8f7086772b310145b8ef3f028b82ae6b4f63c.record.md @@ -0,0 +1 @@ +| 2026-08-17 | codex/pr-1998-fix | 89d764ec9df835c3cb477d4e71859e62b55311cb | pr | PARTIAL-FIX | typecheck, tests(sheets+ui-tools), merge main, docs format | diff --git a/docs/branch-review-records/81782f186cfefcfe52006b705b15b5018bd6bc01d0998839c9544151db434263.record.md b/docs/branch-review-records/81782f186cfefcfe52006b705b15b5018bd6bc01d0998839c9544151db434263.record.md new file mode 100644 index 0000000000..1e59a4924e --- /dev/null +++ b/docs/branch-review-records/81782f186cfefcfe52006b705b15b5018bd6bc01d0998839c9544151db434263.record.md @@ -0,0 +1 @@ +| 2026-08-17 | codex/filter-system-overhaul | 7e55c066e20be34beca8441b2e0f9c40be5fcb95 | pr #1998 clinical filter overhaul | PASS | typecheck, vitest (filters, sheets, search band, dom panels), prettier | diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 6e0d103d0e..ef90f84310 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -999,7 +999,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `RetrievalStateBanner` | answer | yes | yes | inherited-global-root | yes | no | 1 | | `SafeBoldText` | layout | yes | yes | inherited-global-root | yes | no | 8 | | `SearchField` | controls | yes | yes | no | yes | no | 0 | -| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 10 | +| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 6 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | | `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 25 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | @@ -1012,7 +1012,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `TextField` | controls | yes | yes | inherited-global-root | yes | no | 4 | | `TextLink` | controls | yes | yes | no | yes | no | 0 | | `ToastRegion` | feedback | yes | yes | no | yes | no | 0 | -| `ToggleSwitch` | controls | yes | yes | inherited-global-root | yes | no | 2 | +| `ToggleSwitch` | controls | yes | yes | inherited-global-root | yes | no | 1 | | `Tooltip` | feedback | yes | yes | no | yes | no | 0 | | `VerificationNotice` | answer | yes | yes | inherited-global-root | yes | no | 1 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 1eafffd1c2..f79c22334f 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -112,7 +112,6 @@ "tests/accessible-table.dom.test.tsx", "tests/design-sync-visual-exports.test.ts", "tests/design-system-adoption.test.ts", - "tests/ui-tools.spec.ts", "tests/ward-output.test.ts" ], "baseline": { @@ -1397,26 +1396,18 @@ "entryExported": true, "directImportFiles": [ "src/components/applications-launcher-page.tsx", - "src/components/clinical-dashboard/differentials-home.tsx", - "src/components/clinical-dashboard/medication-prescribing-workspace.tsx", "src/components/clinical-dashboard/patient-profile-panel.tsx", "src/components/clinical-dashboard/settings-dialog.tsx", "src/components/factsheets/factsheets-search-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", - "src/components/services/services-navigator-page.tsx", - "src/components/specifiers/specifiers-home-page.tsx", "src/components/tools/tools-search-results-page.tsx" ], "productImportFiles": [ "src/components/applications-launcher-page.tsx", - "src/components/clinical-dashboard/differentials-home.tsx", - "src/components/clinical-dashboard/medication-prescribing-workspace.tsx", "src/components/clinical-dashboard/patient-profile-panel.tsx", "src/components/clinical-dashboard/settings-dialog.tsx", "src/components/factsheets/factsheets-search-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", - "src/components/services/services-navigator-page.tsx", - "src/components/specifiers/specifiers-home-page.tsx", "src/components/tools/tools-search-results-page.tsx" ], "designSync": { @@ -1468,6 +1459,7 @@ "tests/answer-progress-ui-smoke.spec.ts", "tests/design-sync-visual-exports.test.ts", "tests/eval-process-safety.test.ts", + "tests/search-results-header-band.dom.test.tsx", "tests/ui-formulation.spec.ts", "tests/ui-smoke.spec.ts", "tests/ui-v2-components.dom.test.tsx" @@ -1513,9 +1505,9 @@ "src/components/document-viewer/document-clinical-summary.tsx", "src/components/document-viewer/section-nav.tsx", "src/components/forms/form-detail-page.tsx", - "src/components/forms/forms-search-results-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", + "src/components/services/service-group-nav.tsx", "src/components/tools/tools-search-results-page.tsx", "src/components/ui/confirm-dialog.tsx" ], @@ -1541,9 +1533,9 @@ "src/components/document-viewer/document-clinical-summary.tsx", "src/components/document-viewer/section-nav.tsx", "src/components/forms/form-detail-page.tsx", - "src/components/forms/forms-search-results-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", + "src/components/services/service-group-nav.tsx", "src/components/tools/tools-search-results-page.tsx" ], "designSync": { @@ -1901,14 +1893,8 @@ "source": "src/components/ui-primitives.tsx", "sourceExported": true, "entryExported": true, - "directImportFiles": [ - "src/components/clinical-dashboard/patient-profile-panel.tsx", - "src/components/forms/forms-search-results-page.tsx" - ], - "productImportFiles": [ - "src/components/clinical-dashboard/patient-profile-panel.tsx", - "src/components/forms/forms-search-results-page.tsx" - ], + "directImportFiles": ["src/components/clinical-dashboard/patient-profile-panel.tsx"], + "productImportFiles": ["src/components/clinical-dashboard/patient-profile-panel.tsx"], "designSync": { "listedInSourceMap": true, "listedInDtsProps": true, diff --git a/docs/search-results-bar-decisions.md b/docs/search-results-bar-decisions.md index 3ba51e9dcf..e04386b4bd 100644 --- a/docs/search-results-bar-decisions.md +++ b/docs/search-results-bar-decisions.md @@ -60,28 +60,24 @@ contract: see [search-chrome-behaviour.md](search-chrome-behaviour.md). one control sitting flush against the sort group rendered as a different component. It now uses the band's own control recipe — the same string `Save search` and `Retry` use — with the active/resting colours as mutually exclusive branches. -- **Every mode's phone filter is now the badged trigger, not a select.** The `w-full` - native select is gone from all seven surfaces that shipped one — differentials, services, - factsheets, prescribing, the tools launcher, and formulation and specifiers, which each - passed _two_ in a two-column grid. Each now passes one `ResultFilterTrigger` - (`result-filter-control.tsx`) with `mobileControlsPlacement="inline"`, so the one-line - band is universal rather than a documents/therapy-compass exception. What the select cost: - a whole second band row; no way to say how many filters were active without spending - label width on it; and, because the iOS anti-zoom rule pins every native select to 16 px - below `sm`, a value rendered at the same size as the query heading above it. Single-choice - dimensions moved into `ResultFilterSheet`, one `role="radiogroup"` per dimension — real - radio semantics, because these are one-of-N and an `aria-pressed` bank asserts otherwise. - Documents keeps its own panel; multi-select facet groups with counts, a find-a-filter - field and collapse-by-default are not radios. +- **Every mode uses one adaptive filter system, not a phone-only substitute.** Each result + surface passes `ResultFilterTrigger` at phone and desktop breakpoints. The trigger opens + `ResultFilterSheet`, which is a bottom sheet on phones and a restrained right drawer from + `sm` up. The old native selects, bespoke desktop dropdowns and competing rails are gone. + Lenses render as roving radio groups; facets render as individually reachable multi-select + rows; `Search in` is a labelled, count-bearing scope selector. Dense catalogues gain search + and collapsible groups from the shared component rather than from route-local panels. + Documents now uses the same panel with staged application and a typed secondary Browse all + sources action. The filter contract, including mode ownership, is in + [filter-contract.md](filter-contract.md). - **`mobileControlsPlacement` still defaults to `row`.** Nothing relies on that fallback now that every caller passes `inline`, and it stays anyway: a new mode that forgets the prop, or one with a genuine reason to hand over something full-width, should degrade to a second row rather than to an unreadable 58 px line at 320 px. Do not flip the default. -- **Filter at the right edge, Sort inboard.** Sort is set about once a session. Filter is - the only control carrying state and the one returned to repeatedly, and on a phone the - right edge is where the thumb already is. The page filter is therefore the utility - rail's **last** child; `tests/ui-tools.spec.ts` asserts that placement and a matched - phone tap height, rather than the Sort/Filter adjacency it asserted before. +- **Filter is pinned at the right edge; Sort stays inboard.** Sort is set about once a + session. Filter carries narrowing state and is returned to repeatedly, so it is a pinned + sibling after the optional utility rail rather than a child that can scroll away. + `tests/ui-tools.spec.ts` asserts the placement and matched phone tap height. - **The shelf.** A labelled `Filtered by` row under the bar, one tap to remove each filter, trailing `Clear` once more than one is applied. The chips scroll in an inner track; the label and `Clear` are pinned outside it, because the shelf was one @@ -98,31 +94,23 @@ contract: see [search-chrome-behaviour.md](search-chrome-behaviour.md). on every keystroke. Only a fault removes it, because filtering a result set that never loaded is meaningless. -## The shelf is scoped to two modes, on purpose +## The shelf is shared state, not a mode exception -`documents` and `therapy-compass` only. Both have multi-valued filters hidden behind a -panel, so what is applied is not otherwise visible. Of the other ten results-band modes: +Any mode with hidden, multi-dimensional or non-default scope state passes `appliedFilters` +and `onClearFilters`. The shelf is prop-driven; the shared bar never infers refinement state +from context. `AppliedFilterChip` carries a compact `valueLabel`, an optional desktop +`groupLabel`, a complete `accessibleLabel` when necessary, and `onRemove`. -- Differentials, prescribing, specifiers, formulation, services, and factsheets keep a - single-choice dimension whose desktop control is already on screen, and whose phone - trigger carries a count badge, so a shelf would restate what is visible either way. -- Forms still ships a Filter trigger whose panel is a coming-soon placeholder, not applied - filter state, so there is nothing for a shelf to show. -- Favourites renders its own active-filter chips inside `filterControls` rather than the - shared shelf props. -- DSM filters by category through navigation links, and tools through a single category - dimension — neither passes `appliedFilters`. +The shelf is required for Documents, Services, Forms, Differential workspaces, DSM, +Specifiers, Formulation, Medication, Therapy, Calculators when narrowed, and Favourites. A +compact category lens may omit it only when the active value remains directly visible at that +breakpoint. Selected retrieval and result facets remain visible at zero results so recovery is +always possible. -Two traps met while drawing that line: - -- **Count what a control does, not how many there are.** Formulation's "Pattern" and - factsheets' "Category" look like filters and are navigation (`router.push`). Services' - "quick filter" rewrites the query. None of them belong behind a filter surface. -- **A shared component must not read filter state from context.** The original shelf - pulled `commandScopes` from a context that no page populated. It passed a DOM test which - constructed that context by hand, and rendered for nobody in production. The replacement - is prop-driven — the page supplies `appliedFilters` (`id`, `label`, `onRemove`) and an - optional `onClearFilters`. Keep it that way. +Count what narrows results, not how many controls happen to be visible. Scope is counted when +non-default. Sort, display density, grouping, comparison state, suggested searches and +Recently used views are excluded. Query-replacing presets belong near the composer, never in +the shelf or filter badge. ## Deliberately not done diff --git a/src/app/(search-app)/dsm/search/page.tsx b/src/app/(search-app)/dsm/search/page.tsx index 72c6873d37..e19b6912fa 100644 --- a/src/app/(search-app)/dsm/search/page.tsx +++ b/src/app/(search-app)/dsm/search/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { DsmSearchPage } from "@/components/dsm/dsm-search-page"; -import { dsmCategories, dsmDiagnoses, listDsmDiagnosisSummaries } from "@/lib/dsm"; +import { dsmCategories, listDsmDiagnosisSummaries } from "@/lib/dsm"; export const metadata: Metadata = { title: "Search DSM diagnoses | Clinical KB", @@ -13,7 +13,6 @@ type DsmSearchRouteProps = { searchParams?: Promise<{ q?: string | string[]; query?: string | string[]; - category?: string | string[]; ids?: string | string[]; }>; }; @@ -25,8 +24,6 @@ function firstValue(value?: string | string[]) { export default async function DsmSearchRoute({ searchParams }: DsmSearchRouteProps) { const params = searchParams ? await searchParams : {}; const query = (firstValue(params.q) ?? firstValue(params.query) ?? "").trim(); - const requestedCategory = firstValue(params.category)?.trim(); - const category = dsmCategories.some((item) => item.key === requestedCategory) ? requestedCategory : undefined; const rawIds = firstValue(params.ids) ?? ""; const initialIds = rawIds ? rawIds @@ -38,10 +35,8 @@ export default async function DsmSearchRoute({ searchParams }: DsmSearchRoutePro return ( ); diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 3d31ed7949..1a5e604a2a 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -2187,7 +2187,7 @@ export function ClinicalDashboard({ if (searchMode === "documents" && trimmedQuery) { rememberRecentQuery(trimmedQuery); autoRunSearchSignatureRef.current = searchSubmissionSignature(searchMode, trimmedQuery, navigationContext); - window.history.pushState( + window.history[replaceExistingAnswer ? "replaceState" : "pushState"]( null, "", documentsSearchHref({ @@ -3217,6 +3217,18 @@ export function ClinicalDashboard({ const handleCrossModeSearch = useEventCallback(crossModeSearch); const handleDocumentTagSearch = useEventCallback(handleTagSearch); const handleScopeFiltersChange = useScopeFilterRelax(query, queryMode, setScopeFilters, ask); + const handleDocumentFiltersApply = useEventCallback((filters: SearchScopeFilters, documentIds: string[]) => { + setScopeFilters(filters); + setSelectedDocumentIds(documentIds); + const trimmedQuery = query.trim(); + if (!trimmedQuery) return; + // Let the selected-source state commit before `ask` builds the private + // scope reference. Calling the live ref prevents a stale render closure + // from submitting the previous source selection. + window.requestAnimationFrame(() => { + void askRef.current(trimmedQuery, { queryMode, scopeFilters: filters }, true); + }); + }); const handleOpenRecentDocuments = useEventCallback(openRecentDocuments); const handleOpenSourceLibrary = useEventCallback(openSourceLibrary); const handleDocumentsDrawerOpenChange = useEventCallback((nextOpen: boolean) => { @@ -3762,6 +3774,8 @@ export function ClinicalDashboard({ onTagSearch={handleDocumentTagSearch} scopeFilters={searchMode === "documents" ? scopeFilters : null} onScopeFiltersChange={searchMode === "documents" ? handleScopeFiltersChange : undefined} + selectedDocumentIds={searchMode === "documents" ? selectedDocumentIds : []} + onDocumentFiltersApply={searchMode === "documents" ? handleDocumentFiltersApply : undefined} showHome={searchMode === "documents" && !modeSearchSubmitted} desktopComposerSlotId={desktopHomeComposerSlotId} /> diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index 80bc4344b6..d21492da7c 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -54,6 +54,12 @@ type LauncherFilter = "all" | LauncherArea | "more"; type LauncherApp = ToolCatalogRecord & { icon: LucideIcon }; +function launcherAppMatchesFilter(app: LauncherApp, filter: LauncherFilter): boolean { + if (filter === "all") return true; + if (filter === "more") return app.area === "coordination" || app.area === "saved"; + return app.area === filter; +} + const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; @@ -382,10 +388,12 @@ function FilterTabs({ activeFilter, onFilterChange, canAccessFavourites, + filterCounts, }: { activeFilter: LauncherFilter; onFilterChange: (filter: LauncherFilter) => void; canAccessFavourites: boolean; + filterCounts: Readonly>; }) { const desktopFilters = desktopFiltersForSession(canAccessFavourites); const filterPanelId = useId(); @@ -395,7 +403,11 @@ function FilterTabs({ // One array for the desktop rail and the phone sheet. The categories partition // the tool list, so this is a lens at both breakpoints — the rail used to say // many-of-N with `aria-pressed` while the sheet said one-of-N. - const launcherFilterOptions = desktopFilters.map((filter) => ({ value: filter.id, label: filter.label })); + const launcherFilterOptions = desktopFilters.map((filter) => ({ + value: filter.id, + label: filter.label, + hint: String(filterCounts[filter.id] ?? 0), + })); return ( <>
@@ -446,6 +458,10 @@ function FilterTabs({ onFilterChange("all"); } } + summary={{ + count: filterCounts[resolvedFilter] ?? 0, + noun: (filterCounts[resolvedFilter] ?? 0) === 1 ? "tool" : "tools", + }} />
@@ -757,16 +773,29 @@ export function ApplicationsLauncherWorkspace({ const selectedId = detailOpen || selection.queryKey === normalizedQuery ? selection.id : queryDerivedId; const effectiveFilter: LauncherFilter = activeFilter === "saved" && !canAccessFavourites ? "all" : activeFilter; + const queryMatchedApps = useMemo( + () => + launcherApps.filter( + (app) => + !normalizedQuery || + [app.title, app.mobileTitle, app.description, app.bestFor, app.detail, areaLabels[app.area], ...app.keywords] + .filter(Boolean) + .join(" ") + .toLowerCase() + .includes(normalizedQuery), + ), + [launcherApps, normalizedQuery], + ); + const filterCounts = Object.fromEntries( + desktopFilters.map((filter) => [ + filter.id, + queryMatchedApps.filter((app) => launcherAppMatchesFilter(app, filter.id)).length, + ]), + ); + const filteredApps = useMemo(() => { return launcherApps.filter((app) => { - const matchesFilter = - effectiveFilter === "all" - ? true - : effectiveFilter === "more" - ? app.area === "coordination" || app.area === "saved" - : effectiveFilter === "saved" - ? app.area === "saved" - : app.area === effectiveFilter; + const matchesFilter = launcherAppMatchesFilter(app, effectiveFilter); const matchesQuery = !normalizedQuery || [app.title, app.mobileTitle, app.description, app.bestFor, app.detail, areaLabels[app.area], ...app.keywords] @@ -882,6 +911,7 @@ export function ApplicationsLauncherWorkspace({ activeFilter={effectiveFilter} onFilterChange={setActiveFilter} canAccessFavourites={canAccessFavourites} + filterCounts={filterCounts} /> } /> @@ -895,6 +925,7 @@ export function ApplicationsLauncherWorkspace({ activeFilter={effectiveFilter} onFilterChange={setActiveFilter} canAccessFavourites={canAccessFavourites} + filterCounts={filterCounts} />

Sorted A to Z diff --git a/src/components/calculators/search-page.tsx b/src/components/calculators/search-page.tsx index 6599ba091c..94f5877f86 100644 --- a/src/components/calculators/search-page.tsx +++ b/src/components/calculators/search-page.tsx @@ -374,7 +374,8 @@ export function CalculatorsSearchPage({ initialQuery = "" }: { initialQuery?: st .filter((domain) => selectedDomains.has(domain)) .map((domain) => ({ id: `domain-${domain}`, - label: `Clinical domain: ${domainLabels[domain]}`, + groupLabel: "Clinical domain", + valueLabel: domainLabels[domain], onRemove: () => toggleDomain(domain), })), ...(progress === "all" @@ -382,7 +383,8 @@ export function CalculatorsSearchPage({ initialQuery = "" }: { initialQuery?: st : [ { id: `progress-${progress}`, - label: `Session progress: ${optionLabel(progressOptions, progress)}`, + groupLabel: "Session progress", + valueLabel: optionLabel(progressOptions, progress), onRemove: () => setProgress("all"), }, ]), @@ -391,7 +393,8 @@ export function CalculatorsSearchPage({ initialQuery = "" }: { initialQuery?: st : [ { id: `time-${time}`, - label: `Completion time: ${optionLabel(timeOptions, time)}`, + groupLabel: "Completion time", + valueLabel: optionLabel(timeOptions, time), onRemove: () => setTime("all"), }, ]), @@ -446,7 +449,7 @@ export function CalculatorsSearchPage({ initialQuery = "" }: { initialQuery?: st description="Choose any clinical domains, then narrow by session progress and completion time." groups={[domainGroup, progressGroup, timeGroup]} onClearAll={activeFilterCount > 0 ? clearFilters : undefined} - footerNote={`${results.length} showing`} + summary={{ count: results.length, noun: results.length === 1 ? "calculator" : "calculators" }} chromeResetKey={query} /> diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index cd6bea2a9c..b10cd3ffe6 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -23,7 +23,10 @@ import { } from "lucide-react"; import { ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template"; -import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; +import { + SearchResultsHeaderBand, + type AppliedFilterChip, +} from "@/components/clinical-dashboard/search-results-header-band"; import { ResultFilterSheet, ResultFilterTrigger, @@ -34,7 +37,6 @@ import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/univ import { useDifferentialSearch } from "@/components/clinical-dashboard/use-differential-catalog"; import { useResultSort } from "@/components/use-result-sort"; import { Chip as DesignChip } from "@/components/ui/chip"; -import { SegmentedControl } from "@/components/ui/segmented-control"; import { cn } from "@/components/ui-primitives"; import { appModeHomeHref } from "@/lib/app-modes"; import { @@ -831,6 +833,17 @@ function SearchResultsView({ () => sortResultItems(relevanceResults, sortValue, (result) => result.title), [relevanceResults, sortValue], ); + const appliedFilters: AppliedFilterChip[] = + kindFilter === "all" + ? [] + : [ + { + id: "result-type", + groupLabel: "Show", + valueLabel: kindFilter === "presentation" ? "Presentations" : "Diagnoses", + onRemove: () => setKindFilter("all"), + }, + ]; // Keep the feature card inside the active result type while preserving the // relevance winner when the visible list is presented alphabetically. const best = relevanceResults[0] ?? null; @@ -949,6 +962,8 @@ function SearchResultsView({ } sortValue={sortValue} onSortChange={setSortValue} + appliedFilters={appliedFilters} + onClearFilters={kindFilter === "all" ? undefined : () => setKindFilter("all")} filterLabel="Filter differential result type" // A compact badged trigger, so it shares the count line. mobileControlsPlacement="inline" @@ -963,11 +978,13 @@ function SearchResultsView({ /> } filterControls={ - setFilterOpen((current) => !current)} /> } /> @@ -989,7 +1006,10 @@ function SearchResultsView({ }), ]} onClearAll={kindFilter === "all" ? undefined : () => setKindFilter("all")} - footerNote={`${visibleResults.length} showing`} + summary={{ + count: visibleResults.length, + noun: visibleResults.length === 1 ? "result" : "results", + }} /> {catalogLoading ? (

diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index 2d869298f1..d400def4a6 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -12,6 +12,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import { BookOpen, Clock3, @@ -35,6 +36,7 @@ import { ResultFilterTrigger, resultFilterFacetGroup, resultFilterGroup, + type ResultFilterGroup, } from "@/components/clinical-dashboard/result-filter-control"; import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; import { isDeployedClinicalKb } from "@/lib/deployed-app"; @@ -73,8 +75,34 @@ import { } from "@/lib/document-tags"; import type { ServiceSearchMatch } from "@/lib/services"; import type { FormSearchMatch } from "@/lib/forms"; -import type { SearchScopeFilters } from "@/lib/search-scope"; +import { + extractionQualityValues, + sourceStatusValues, + validationStatusValues, + type SearchScopeFilters, +} from "@/lib/search-scope"; import { removeScopeFilterValue, scopeFilterChips } from "@/lib/search-scope-filter-chips"; +import { + documentLabelFilterFields, + documentRetrievalFilterValueCount, + deriveDocumentLabelOptions, + extractionQualityLabels, + filterDocumentsByRetrievalScope, + mergePublicDocumentScopeFilters, + projectedDocumentScopeCount, + publicDocumentScopeFilters, + sameDocumentScope, + sourceStatusLabels, + validationStatusLabels, + type DocumentLabelFilterKey, +} from "@/lib/document-filter-model"; +import { + readResultFilterValue, + readResultFilterValues, + replaceResultFilterUrl, + writeResultFilterValue, + writeResultFilterValues, +} from "@/lib/result-filter-url"; import type { ClinicalDocument, DocumentMatch, SearchScopeSummary } from "@/lib/types"; import type { RegistryRequestStatus } from "@/lib/use-registry-records"; import { sortResultItems } from "@/lib/result-sort"; @@ -82,6 +110,14 @@ import { documentRelevancePercent } from "./relevance-score"; type SearchFacet = { value: string; count: number }; type ResultTypeFilter = "all" | "tables" | "images" | "pdfs"; +const resultTypeFilterValues = new Set(["all", "tables", "images", "pdfs"]); +type DocumentFilterDraft = { + query: string; + facetKeys: string[]; + resultType: ResultTypeFilter; + scopeFilters: SearchScopeFilters; + selectedDocumentIds: string[]; +}; /** Initial DOM budget for document result cards; further rows reveal on demand. */ const DOCUMENT_RESULTS_INITIAL_WINDOW = 25; @@ -108,8 +144,6 @@ export type SearchFacets = { type SearchRecordMode = "services" | "forms"; type SearchRecordMatch = ServiceSearchMatch | FormSearchMatch; -const EMPTY_APPLIED_FILTERS: AppliedFilterChip[] = []; - const searchRecordConfig: Record< SearchRecordMode, { @@ -142,9 +176,8 @@ const searchRecordConfig: Record< // The filter sheet itself (source type as a lens, smart-tag facets as facet groups) is // built in DocumentSearchResultsPanelImpl below and rendered through the shared // ResultFilterSheet/ResultFilterTrigger (src/components/clinical-dashboard/result-filter-control.tsx), -// which grew documents' own dense tier (find-a-filter, collapse-by-default) and a -// meterContent/footerOverride slot for its progress meter and "Show N documents" / -// "Browse all sources" footer during the filter-contract rollout. Source type is +// which grew documents' own dense tier (find-a-filter, collapse-by-default) and +// typed coverage, result-action and secondary-action anatomy. Result type is // single-select and the tag facets are multi-select (OR within a group, AND across // groups, per filterDocumentsBySmartTagFacetIndex), so the two still carry different // affordances: role="radio"+aria-checked for source type, aria-pressed for facets — @@ -182,6 +215,10 @@ function filterMatchesByResultType(matches: DocumentMatch[], filter: ResultTypeF return matches; } +function loadedSourceCountHint(count: number) { + return `${count.toLocaleString()} loaded ${count === 1 ? "source" : "sources"}`; +} + function relevanceTone(document: DocumentMatch) { const verdict = document.relevance?.verdict as string | undefined; const percent = documentRelevancePercent(document); @@ -758,8 +795,11 @@ function DocumentSearchResultsPanelImpl({ onOpenLibrary, onOpenSourcePdf, onTagSearch, + recentDocuments = [], + selectedDocumentIds = [], scopeFilters, onScopeFiltersChange, + onDocumentFiltersApply, showHome = false, desktopComposerSlotId, }: { @@ -784,6 +824,7 @@ function DocumentSearchResultsPanelImpl({ onOpenLibrary: () => void; onOpenSourcePdf: () => void; onTagSearch: (tag: SmartDocumentTag | SmartDocumentTagFacet) => void; + selectedDocumentIds?: string[]; /** * The scope filters the current results were requested with. Paired with * `searchScope.activeFilterCount` (the server's count of what it actually @@ -797,14 +838,15 @@ function DocumentSearchResultsPanelImpl({ * retrieval, so no client-side control can undo them. Omit to hide that route. */ onScopeFiltersChange?: (filters: SearchScopeFilters) => void; + /** Commits the staged retrieval scope and performs the one resulting search. */ + onDocumentFiltersApply?: (filters: SearchScopeFilters, selectedDocumentIds: string[]) => void; showHome?: boolean; desktopComposerSlotId?: string; }) { void _facets; const [sortValue, setSortValue] = useResultSort(); + const searchParams = useSearchParams(); const trimmedQuery = query.trim(); - const [activeFacetState, setActiveFacetState] = useState<{ query: string; keys: string[] }>({ query: "", keys: [] }); - const [activeResultType, setActiveResultType] = useState("all"); const filterPanelId = useId(); // Query-scope the open flag the same way facets are scoped: a new search must // not leave the panel covering a different result set (especially on phones). @@ -814,11 +856,24 @@ function DocumentSearchResultsPanelImpl({ open: false, }); const filterPanelOpen = filterPanelState.query === query && filterPanelState.open; + const tagFacetIndex = useMemo(() => buildSmartDocumentTagFacetIndex(matches, { query }), [matches, query]); + const availableFacetKeys = useMemo( + () => new Set(tagFacetIndex.groups.flatMap((group) => group.facets.map((facet) => facet.key))), + [tagFacetIndex], + ); const activeFacetKeys = useMemo( - () => (activeFacetState.query === query ? activeFacetState.keys : []), - [activeFacetState, query], + () => readResultFilterValues(searchParams, "facet", availableFacetKeys), + [availableFacetKeys, searchParams], ); - const tagFacetIndex = useMemo(() => buildSmartDocumentTagFacetIndex(matches, { query }), [matches, query]); + const activeResultType = readResultFilterValue(searchParams, "resultType", resultTypeFilterValues, "all"); + const committedScopeFilters = scopeFilters ?? {}; + const [filterDraft, setFilterDraft] = useState({ + query: "", + facetKeys: [], + resultType: "all", + scopeFilters: {}, + selectedDocumentIds: [], + }); // Counts must describe the set the reader is looking at. `tagFacetIndex.groups` // counts against the whole match set, so once a facet is selected the rest of // the panel reports numbers for a set that no longer exists — several of them @@ -867,17 +922,21 @@ function DocumentSearchResultsPanelImpl({ // rather than suppressing the dependency check. const toggleTagFacet = useCallback( (key: string) => { - setActiveFacetState((current) => { - const keys = current.query === query ? current.keys : []; - return { - query, - keys: keys.includes(key) ? keys.filter((existing) => existing !== key) : [...keys, key], - }; + replaceResultFilterUrl((params) => { + const next = new Set(readResultFilterValues(params, "facet", availableFacetKeys)); + if (!next.delete(key)) next.add(key); + writeResultFilterValues(params, "facet", next, availableFacetKeys); }); }, - [query], + [availableFacetKeys], ); + const setResultType = useCallback((value: ResultTypeFilter) => { + replaceResultFilterUrl((params) => + writeResultFilterValue(params, "resultType", value, "all", resultTypeFilterValues), + ); + }, []); + const unavailable = deriveDocumentSearchUnavailable({ apiUnavailable, authUnavailable, @@ -893,12 +952,19 @@ function DocumentSearchResultsPanelImpl({ const recordBandOwnsFault = showRecordMatches && (recordStatus === "error" || recordStatus === "not_found" || recordStatus === "unauthorized"); const showResultsControls = matches.length > 0 && !loading; - const activeFilterCount = activeFacetKeys.length + (effectiveResultType === "all" ? 0 : 1); + const activeFilterCount = + activeFacetKeys.length + + (effectiveResultType === "all" ? 0 : 1) + + documentRetrievalFilterValueCount(committedScopeFilters, selectedDocumentIds.length); // Both the source-type tabs and the tag facets are derived from the current // match set, so a query that yields one uniform kind of document has nothing // to offer. Advertising Filter there would open an empty panel. const hasFilters = resultTabs.length > 1 || tagFacetGroups.length > 0; - const showFilterControl = showResultsControls && hasFilters; + const hasRetrievalFilters = + recentDocuments.length > 0 || + documentRetrievalFilterValueCount(committedScopeFilters, selectedDocumentIds.length) > 0; + const showFilterControl = + !showRecordMatches && !loading && Boolean(trimmedQuery) && (hasFilters || hasRetrievalFilters); /* The registry is still answering. `loading` covers only the document search, so on the services and forms paths the zero-result body used to render "No matches for …" directly beneath a spine reading "Searching…" — the band @@ -927,6 +993,20 @@ function DocumentSearchResultsPanelImpl({ ) : null} ); + const openOrCloseFilters = () => { + if (filterPanelOpen) { + setFilterPanelState({ query, open: false }); + return; + } + setFilterDraft({ + query, + facetKeys: activeFacetKeys, + resultType: effectiveResultType, + scopeFilters: publicDocumentScopeFilters(committedScopeFilters), + selectedDocumentIds, + }); + setFilterPanelState({ query, open: true }); + }; const renderFilterTrigger = (testId: string) => showFilterControl ? ( - setFilterPanelState((current) => ({ - query, - open: current.query === query ? !current.open : true, - })) - } + onToggle={openOrCloseFilters} /> ) : null; - // The shelf's contents. Facet chips carry their group's own label so a bare - // "Policy" is not ambiguous across ten groups, and the source-type chip joins - // them because it narrows the same list by the same act. - const appliedFilters = useMemo(() => { - const selected = new Set(activeFacetKeys); - const chips = tagFacetGroups.flatMap((group) => + + const activeDraft: DocumentFilterDraft = + filterPanelOpen && filterDraft.query === query + ? filterDraft + : { + query, + facetKeys: activeFacetKeys, + resultType: effectiveResultType, + scopeFilters: publicDocumentScopeFilters(committedScopeFilters), + selectedDocumentIds, + }; + // These projections exist solely to render the open filter sheet. Keep the + // library-sized scans dormant while it is closed so composer keystrokes do + // not rebuild every projected count. + const draftSourceDocuments = filterPanelOpen ? recentDocuments : []; + const loadedSourceCountsAreComplete = documentCount > 0 && draftSourceDocuments.length >= documentCount; + const draftSelectedDocumentIds = new Set(filterPanelOpen ? activeDraft.selectedDocumentIds : []); + const draftTagFacetGroups = filterPanelOpen ? projectSmartTagFacetGroups(tagFacetIndex, activeDraft.facetKeys) : []; + const draftVisibleMatches = filterPanelOpen + ? filterDocumentsBySmartTagFacetIndex(tagFacetIndex, activeDraft.facetKeys) + : []; + const draftResultTabs = filterPanelOpen ? resultTypeTabs(draftVisibleMatches) : []; + const draftResultType = + filterPanelOpen && draftResultTabs.some((tab) => tab.key === activeDraft.resultType) + ? activeDraft.resultType + : "all"; + const draftDisplayedMatches = filterPanelOpen ? filterMatchesByResultType(draftVisibleMatches, draftResultType) : []; + + function toggleDraftListFilter( + key: DocumentLabelFilterKey | "sourceStatuses" | "validationStatuses" | "extractionQualities", + value: string, + ) { + setFilterDraft((current) => { + const selected = new Set((current.scopeFilters[key] as string[] | undefined) ?? []); + if (!selected.delete(value)) selected.add(value); + return { ...current, scopeFilters: { ...current.scopeFilters, [key]: [...selected] } }; + }); + } + + function commitRetrievalFilters(filters: SearchScopeFilters, documentIds: string[]) { + if (onDocumentFiltersApply) onDocumentFiltersApply(filters, documentIds); + else if (onScopeFiltersChange) onScopeFiltersChange(filters); + } + + function applyDocumentFilters() { + replaceResultFilterUrl((params) => { + writeResultFilterValues(params, "facet", activeDraft.facetKeys, availableFacetKeys); + writeResultFilterValue(params, "resultType", draftResultType, "all", resultTypeFilterValues); + }); + const nextScope = mergePublicDocumentScopeFilters(committedScopeFilters, activeDraft.scopeFilters); + const selectedChanged = + [...activeDraft.selectedDocumentIds].sort().join("\0") !== [...selectedDocumentIds].sort().join("\0"); + if (!sameDocumentScope(nextScope, committedScopeFilters) || selectedChanged) { + commitRetrievalFilters(nextScope, activeDraft.selectedDocumentIds); + } + setFilterPanelState({ query, open: false }); + } + + function clearAllFilters() { + replaceResultFilterUrl((params) => { + params.delete("facet"); + params.delete("resultType"); + }); + const nextScope = mergePublicDocumentScopeFilters(committedScopeFilters, {}); + if (!sameDocumentScope(nextScope, committedScopeFilters) || selectedDocumentIds.length > 0) { + commitRetrievalFilters(nextScope, []); + } + } + + const selectedFacetKeys = new Set(activeFacetKeys); + const appliedFilters: AppliedFilterChip[] = [ + ...selectedDocumentIds.map((documentId) => { + const document = recentDocuments.find((item) => item.id === documentId); + return { + id: `source-${documentId}`, + groupLabel: "Source", + valueLabel: document ? documentDisplayTitle(document) : "Selected source", + onRemove: () => + commitRetrievalFilters( + committedScopeFilters, + selectedDocumentIds.filter((id) => id !== documentId), + ), + }; + }), + ...scopeFilterChips(publicDocumentScopeFilters(committedScopeFilters)).map((chip) => ({ + ...chip, + onRemove: () => + commitRetrievalFilters( + mergePublicDocumentScopeFilters( + committedScopeFilters, + removeScopeFilterValue(publicDocumentScopeFilters(committedScopeFilters), chip.id), + ), + selectedDocumentIds, + ), + })), + ...tagFacetGroups.flatMap((group) => group.facets - .filter((facet) => selected.has(facet.key)) + .filter((facet) => selectedFacetKeys.has(facet.key)) .map((facet) => ({ id: facet.key, - label: facet.label, + groupLabel: group.group, + valueLabel: facet.label, onRemove: () => toggleTagFacet(facet.key), })), - ); - if (effectiveResultType !== "all") { - const tab = resultTabs.find((entry) => entry.key === effectiveResultType); - if (tab) { - chips.push({ - id: `result-type-${tab.key}`, - label: tab.label, - onRemove: () => setActiveResultType("all"), - }); - } + ), + ]; + if (effectiveResultType !== "all") { + const tab = resultTabs.find((entry) => entry.key === effectiveResultType); + if (tab) { + appliedFilters.push({ + id: `result-type-${tab.key}`, + groupLabel: "Result type", + valueLabel: tab.label, + onRemove: () => setResultType("all"), + }); } - return chips; - }, [tagFacetGroups, activeFacetKeys, effectiveResultType, resultTabs, toggleTagFacet]); - const clearAllFilters = () => { - setActiveFacetState({ query, keys: [] }); - setActiveResultType("all"); - }; - /* The scope filters the API applied BEFORE retrieval, as removable chips. - Only used on the zero-result path: while matches exist the facet chips above - describe what is narrowing the visible list, and stacking both would show a - reader two filter shelves doing different jobs. At zero there is no match - set to derive facets from, so without these the constraint that emptied the - search is invisible — and unclearable, since `showResultsControls` gates the - Filter trigger on `matches.length > 0`. */ + } + + const documentFilterGroups: ResultFilterGroup[] = []; + if (draftSourceDocuments.length > 0) { + documentFilterGroups.push( + resultFilterFacetGroup({ + id: "selected-sources", + label: "Selected sources", + description: "Choose specific indexed sources. Leave empty to search across the filtered source set.", + selected: draftSelectedDocumentIds, + options: [...draftSourceDocuments] + .sort((left, right) => documentDisplayTitle(left).localeCompare(documentDisplayTitle(right))) + .map((document) => { + const withCandidate = new Set(draftSelectedDocumentIds); + if (!withCandidate.has(document.id)) withCandidate.add(document.id); + const count = filterDocumentsByRetrievalScope( + draftSourceDocuments, + activeDraft.scopeFilters, + withCandidate, + ).length; + return { + value: document.id, + label: documentDisplayTitle(document), + searchText: `${document.title} ${document.file_name}`, + hint: loadedSourceCountHint(count), + disabled: count === 0 && !draftSelectedDocumentIds.has(document.id), + }; + }), + onToggle: (documentId) => + setFilterDraft((current) => { + const next = new Set(current.selectedDocumentIds); + if (!next.delete(documentId)) next.add(documentId); + return { ...current, selectedDocumentIds: [...next] }; + }), + }), + ); + } + + const governanceGroups = filterPanelOpen + ? [ + { + key: "sourceStatuses" as const, + label: "Source status", + values: sourceStatusValues, + labels: sourceStatusLabels, + }, + { + key: "validationStatuses" as const, + label: "Clinical validation", + values: validationStatusValues, + labels: validationStatusLabels, + }, + { + key: "extractionQualities" as const, + label: "Extraction quality", + values: extractionQualityValues, + labels: extractionQualityLabels, + }, + ] + : []; + for (const group of governanceGroups) { + const selected = new Set((activeDraft.scopeFilters[group.key] as string[] | undefined) ?? []); + documentFilterGroups.push( + resultFilterFacetGroup({ + id: group.key, + label: group.label, + description: "Source governance is applied before document retrieval.", + selected, + options: group.values.map((value) => { + const count = projectedDocumentScopeCount({ + documents: draftSourceDocuments, + filters: activeDraft.scopeFilters, + selectedDocumentIds: draftSelectedDocumentIds, + key: group.key, + value, + }); + return { + value, + label: group.labels[value] ?? value, + hint: loadedSourceCountHint(count), + disabled: loadedSourceCountsAreComplete && count === 0 && !selected.has(value), + }; + }), + onToggle: (value) => toggleDraftListFilter(group.key, value), + }), + ); + } + + if (filterPanelOpen) { + documentFilterGroups.push( + resultFilterGroup({ + id: "locality", + label: "Source locality", + description: "Separate WA and health-service sources from non-local guidance.", + value: activeDraft.scopeFilters.locality ?? "all", + options: [ + { + value: "all", + label: "Any locality", + hint: loadedSourceCountHint( + filterDocumentsByRetrievalScope( + draftSourceDocuments, + { ...activeDraft.scopeFilters, locality: undefined }, + draftSelectedDocumentIds, + ).length, + ), + }, + ...(["local", "non_local"] as const).map((value) => { + const count = filterDocumentsByRetrievalScope( + draftSourceDocuments, + { ...activeDraft.scopeFilters, locality: value }, + draftSelectedDocumentIds, + ).length; + return { + value, + label: value === "local" ? "Local" : "Non-local", + hint: loadedSourceCountHint(count), + disabled: loadedSourceCountsAreComplete && count === 0 && activeDraft.scopeFilters.locality !== value, + }; + }), + ], + onChange: (value) => + setFilterDraft((current) => ({ + ...current, + scopeFilters: { + ...current.scopeFilters, + locality: value === "all" ? undefined : value, + }, + })), + }), + ); + } + + for (const field of filterPanelOpen ? documentLabelFilterFields : []) { + const selected = new Set(activeDraft.scopeFilters[field.key] ?? []); + const values = [...new Set([...deriveDocumentLabelOptions(draftSourceDocuments, field.labelType), ...selected])]; + if (values.length === 0) continue; + documentFilterGroups.push( + resultFilterFacetGroup({ + id: field.key, + label: field.label, + description: "Advanced clinical label. Values are OR alternatives within this group.", + selected, + options: values.map((value) => { + const count = projectedDocumentScopeCount({ + documents: draftSourceDocuments, + filters: activeDraft.scopeFilters, + selectedDocumentIds: draftSelectedDocumentIds, + key: field.key, + value, + }); + return { + value, + label: value, + hint: loadedSourceCountHint(count), + disabled: loadedSourceCountsAreComplete && count === 0 && !selected.has(value), + }; + }), + onToggle: (value) => toggleDraftListFilter(field.key, value), + }), + ); + } + + if (filterPanelOpen && draftResultTabs.length > 1) { + documentFilterGroups.push( + resultFilterGroup({ + id: "result-type", + label: "Result type", + description: "Refine the retrieved matches without running retrieval again.", + note: "one only", + value: draftResultType, + options: draftResultTabs.map((tab) => ({ value: tab.key, label: tab.label, hint: String(tab.count) })), + onChange: (value) => setFilterDraft((current) => ({ ...current, resultType: value })), + }), + ); + } + for (const group of draftTagFacetGroups) { + const selected = new Set( + group.facets.filter((facet) => activeDraft.facetKeys.includes(facet.key)).map((facet) => facet.key), + ); + documentFilterGroups.push( + resultFilterFacetGroup({ + id: `smart-${group.group}`, + label: group.group, + description: "Smart tags refine the matches already retrieved.", + selected, + options: group.facets.map((facet) => ({ + value: facet.key, + label: facet.label, + hint: String(facet.count), + searchText: facet.searchText, + disabled: facet.count === 0 && !selected.has(facet.key), + })), + onToggle: (facetKey) => + setFilterDraft((current) => { + const next = new Set(current.facetKeys); + if (!next.delete(facetKey)) next.add(facetKey); + return { ...current, facetKeys: [...next] }; + }), + }), + ); + } + const draftActiveFilterCount = filterPanelOpen + ? activeDraft.facetKeys.length + + Number(draftResultType !== "all") + + documentRetrievalFilterValueCount(activeDraft.scopeFilters, activeDraft.selectedDocumentIds.length) + : 0; + /* A retrieval layer errored, so no count from this search is trustworthy — including a non-zero one. The band owns that claim: it renders `matchCount` inside the only `role="status"` region on the page, and the zero-result @@ -991,15 +1353,51 @@ function DocumentSearchResultsPanelImpl({ above zero it is the only thing that says the list is a floor rather than the answer. (Raised by Devin review on PR #1640.) */ const retrievalDegraded = Boolean(searchScope?.retrieval?.degraded); - const activeScopeFilters = scopeFilters ?? null; - const scopeEmptiedResults = matches.length === 0 && (searchScope?.activeFilterCount ?? 0) > 0; - const scopeAppliedFilters = useMemo(() => { - if (!scopeEmptiedResults || !activeScopeFilters || !onScopeFiltersChange) return EMPTY_APPLIED_FILTERS; - return scopeFilterChips(activeScopeFilters).map((chip) => ({ - ...chip, - onRemove: () => onScopeFiltersChange(removeScopeFilterValue(activeScopeFilters, chip.id)), - })); - }, [scopeEmptiedResults, activeScopeFilters, onScopeFiltersChange]); + const documentFilterSheet = + showFilterControl && filterPanelOpen ? ( + setFilterPanelState({ query, open: false })} + panelId={filterPanelId} + testId="document-filter-panel" + title="Filter documents" + description="Set retrieval scope and refine the matches already returned. Source-scope counts cover loaded sources; changes run together across the full indexed library." + chromeResetKey={query} + groups={documentFilterGroups} + applicationMode="staged" + primaryActionLabel="Update search" + onApply={applyDocumentFilters} + onClearAll={ + draftActiveFilterCount > 0 + ? () => + setFilterDraft((current) => ({ + ...current, + facetKeys: [], + resultType: "all", + scopeFilters: {}, + selectedDocumentIds: [], + })) + : undefined + } + summary={{ + count: draftDisplayedMatches.length, + noun: draftDisplayedMatches.length === 1 ? "match" : "matches", + }} + coverage={{ + visibleCount: draftDisplayedMatches.length, + totalCount: matches.length, + label: "Visible retrieved matches", + }} + secondaryAction={{ + label: "Browse all sources", + count: documentCount > 0 ? documentCount : undefined, + onClick: () => { + setFilterPanelState({ query, open: false }); + onOpenLibrary(); + }, + }} + /> + ) : null; const showIdentityHeader = recordMatchCount > 0 || matches.length > 0 || @@ -1053,7 +1451,7 @@ function DocumentSearchResultsPanelImpl({ // footer, and the zero-result state, which are the two moments // browsing is actually the next step. appliedFilters={appliedFilters} - onClearFilters={clearAllFilters} + onClearFilters={activeFilterCount > 0 ? clearAllFilters : undefined} filterLabel="Filter documents" // The same trigger goes in both slots: the ribbon shows `mobileControls` // below `sm` and `filterControls` from `sm` up, never both at once. @@ -1065,6 +1463,8 @@ function DocumentSearchResultsPanelImpl({ /> ) : null} + {documentFilterSheet} + {/* When the ribbon is shown it owns this message in its fault panel. This standalone alert remains for the routes that render no ribbon, so the message is never lost. */} @@ -1116,10 +1516,8 @@ function DocumentSearchResultsPanelImpl({ // shared state already carries for filtered-to-zero, which the // documents path could not reach because it only ever passed // client-derived facet chips (always empty at zero matches). - appliedFilters={scopeAppliedFilters} - onClearFilters={ - scopeAppliedFilters.length > 0 && onScopeFiltersChange ? () => onScopeFiltersChange({}) : undefined - } + appliedFilters={appliedFilters} + onClearFilters={activeFilterCount > 0 ? clearAllFilters : undefined} // A retrieval layer errored, so this zero is not evidence of absence. degraded={retrievalDegraded} onBrowseAll={onOpenLibrary} @@ -1138,166 +1536,7 @@ function DocumentSearchResultsPanelImpl({ ) ) : ( <> - {/* Opened by the ribbon's Filter trigger. Previously this was gated on - `activeFacetKeys.length > 0`, which nothing else could satisfy — - the only writers of that state lived inside the gated subtree — so - the facets were unreachable. The trigger is now the way in. - Mounted unconditionally: `Sheet` returns null while closed and owns - its own open/close transition, so gating the mount here would cut - the dismiss animation off mid-flight. */} - {showFilterControl ? ( - setFilterPanelState({ query, open: false })} - panelId={filterPanelId} - testId="document-filter-panel" - title="Filter documents" - chromeResetKey={query} - groups={[ - ...(resultTabs.length > 1 - ? [ - // Radio semantics, not toggles: picking one source type replaces the - // last, so `aria-pressed` on four buttons would describe a state the - // filter cannot be in. `note` states the shape once, because facet - // groups directly below render as near-identical chips. - resultFilterGroup({ - id: "source-type", - label: "Source type", - note: "one only", - value: effectiveResultType, - options: resultTabs.map((tab) => ({ - value: tab.key, - label: tab.label, - hint: String(tab.count), - })), - onChange: setActiveResultType, - }), - ] - : []), - ...tagFacetGroups.map((group) => { - const selected = new Set( - group.facets.filter((facet) => activeFacetKeys.includes(facet.key)).map((facet) => facet.key), - ); - return resultFilterFacetGroup({ - id: group.group, - label: group.group, - selected, - options: group.facets.map((facet) => ({ - value: facet.key, - label: facet.label, - hint: String(facet.count), - searchText: facet.searchText, - // Zero-count unselected facets stay visible so the list does not jump, - // but they are disabled: selecting them would empty the set. - disabled: facet.count === 0 && !selected.has(facet.key), - })), - onToggle: toggleTagFacet, - }); - }), - ]} - onClearAll={activeFilterCount > 0 ? clearAllFilters : undefined} - meterContent={ - // The proportion, once, at the top. A meter rather than a second number: - // "12 of 2,014" is a ratio the reader is judging, not a figure they are - // reading off. It goes to `--warning` at zero so the state that needs - // explaining is the one that looks different. - <> - -

- - {sortedMatches.length} - {" "} - - of {documentCount > 0 ? documentCount.toLocaleString() : "—"} documents shown - -

- - } - footerOverride={ -
-
- {/* The count is the point of the panel: it tells the reader whether the - combination they have built still returns anything before they - dismiss it. `aria-live` is deliberate — the number changes under - them as they toggle, and the sheet covers the results it - describes. The bare repeat of the number beside the button is gone; - the button carries it, and the readout at the top carries the - proportion. */} - - {sortedMatches.length} document{sortedMatches.length === 1 ? "" : "s"} match the current filters - - -
- {/* Below a rule, and phrased as reach rather than refinement. Library - spent the utility rail competing with Filter for the same edge while - answering a different question — Filter narrows what this query - returned, Library opens the whole corpus. Here it is the actual next - step, and it keeps the in-context route that stopped it being - deleted: the documents action menu clears the query. */} - -
- } - /> - ) : null} - {showResultsControls && !hasFilters ? browseLibraryControl : null} + {showResultsControls && !showFilterControl ? browseLibraryControl : null} {/* With the panel closed the active filters are otherwise invisible apart from the trigger's badge, so the reader needs the count to explain why the list is shorter than the ribbon's total. */} diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index bb7c10199c..c0f6a89623 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -19,10 +19,9 @@ import { Search, ShieldCheck, Trash2, - X, type LucideIcon, } from "lucide-react"; -import { useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { useId, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; @@ -40,6 +39,11 @@ import { SearchResultsEmptyState, SearchResultsHeaderBand, } from "@/components/clinical-dashboard/search-results-header-band"; +import { + ResultFilterSheet, + ResultFilterTrigger, + resultFilterFacetGroup, +} from "@/components/clinical-dashboard/result-filter-control"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; import { appModeIcons } from "@/lib/app-mode-icons"; import { canAccessFavouritesMode } from "@/lib/app-modes"; @@ -51,7 +55,7 @@ type FavouriteType = "Medication" | "Document" | "Table" | "Saved search" | "Source" | "Service" | "Form" | "Differential"; // Previously imported from `favourites-library-nav`, which this redesign // retired along with the sidebar and the two phone rails it exported. -type ViewMode = "all" | "source-backed" | "pinned" | "recent"; +type ViewMode = "all" | "recent"; type SortMode = "last-used" | "title" | "type"; type FavouriteItem = { @@ -230,14 +234,18 @@ function filterAndSortItems( items: FavouriteItem[], { searchTerm, - selectedTypeId, - selectedSet, + selectedTypeIds, + selectedSetTitles, + pinnedOnly, + sourceBackedOnly, viewMode, sortMode, }: { searchTerm: string; - selectedTypeId: string; - selectedSet: FavouriteSet | null; + selectedTypeIds: ReadonlySet; + selectedSetTitles: ReadonlySet; + pinnedOnly: boolean; + sourceBackedOnly: boolean; viewMode: ViewMode; sortMode: SortMode; }, @@ -246,13 +254,10 @@ function filterAndSortItems( const effectiveSort: SortMode = viewMode === "recent" ? "last-used" : sortMode; return items - .filter((item) => selectedTypeId === "all" || item.tabId === selectedTypeId) - .filter((item) => !selectedSet || item.set === selectedSet.title) - .filter((item) => { - if (viewMode === "source-backed") return isSourceBacked(item); - if (viewMode === "pinned") return item.pinned === true; - return true; - }) + .filter((item) => selectedTypeIds.size === 0 || selectedTypeIds.has(item.tabId)) + .filter((item) => selectedSetTitles.size === 0 || selectedSetTitles.has(item.set)) + .filter((item) => !pinnedOnly || item.pinned === true) + .filter((item) => !sourceBackedOnly || isSourceBacked(item)) .filter((item) => normalizedSearch ? [item.title, item.description, item.type, item.set, item.evidence].some((field) => @@ -300,62 +305,6 @@ function SmallChip({ children, appearance }: { children: React.ReactNode; appear ); } -function ActiveFilterChips({ - searchTerm, - selectedTypeId, - selectedSet, - viewMode, - onClearSearch, - onClearType, - onClearSet, - onClearViewMode, - includeSearch = true, -}: { - searchTerm: string; - selectedTypeId: string; - selectedSet: FavouriteSet | null; - viewMode: ViewMode; - onClearSearch: () => void; - onClearType: () => void; - onClearSet: () => void; - onClearViewMode: () => void; - includeSearch?: boolean; -}) { - const typeLabel = favouriteTabs.find((tab) => tab.id === selectedTypeId)?.label; - const chips: { key: string; label: string; onClear: () => void }[] = []; - - if (includeSearch && searchTerm.trim()) { - chips.push({ key: "search", label: `Search: ${searchTerm.trim()}`, onClear: onClearSearch }); - } - if (selectedSet) chips.push({ key: "set", label: selectedSet.title, onClear: onClearSet }); - if (selectedTypeId !== "all" && typeLabel) chips.push({ key: "type", label: typeLabel, onClear: onClearType }); - if (viewMode === "source-backed") chips.push({ key: "view", label: "Source-backed", onClear: onClearViewMode }); - if (viewMode === "pinned") chips.push({ key: "view", label: "Pinned", onClear: onClearViewMode }); - if (viewMode === "recent") chips.push({ key: "view", label: "Recently used", onClear: onClearViewMode }); - - if (chips.length === 0) return null; - - return ( -
- {chips.map((chip) => ( - - ))} -
- ); -} - function ContinueStrip({ item }: { item: FavouriteItem }) { const Icon = item.icon; return ( @@ -608,133 +557,6 @@ function FavouritesEmptyMatches() { ); } -/** - * One horizontal rail replacing three separate navigation surfaces: the - * desktop `FavouritesSidebar`, the phone `FavouritesMobileQuickViews`, and the - * phone `FavouritesMobileBrowseRail`. Direction B (ledger #164) collapses sets, - * quick views and types into a single row of chips, so the same control reads - * identically at every width instead of three components disagreeing about - * what "browse" means. - * - * Each chip toggles its own dimension, so a set and a type still compose the - * way the sidebar allowed. "All" is the only chip that clears every dimension, - * and it reads as pressed exactly when nothing else is. - */ -function FavouritesFilterRail({ - items, - sets, - selectedSetId, - selectedTypeId, - viewMode, - onSelectSet, - onSelectType, - onSelectViewMode, - onClearAll, -}: { - items: FavouriteItem[]; - sets: FavouriteSet[]; - selectedSetId: string | null; - selectedTypeId: string; - viewMode: ViewMode; - onSelectSet: (id: string | null) => void; - onSelectType: (id: string) => void; - onSelectViewMode: (mode: ViewMode) => void; - onClearAll: () => void; -}) { - const typeChips = favouriteTabs - .filter((tab) => tab.id !== "all" && tab.id !== "sets") - .map((tab) => ({ id: tab.id, label: tab.label, count: items.filter((item) => item.tabId === tab.id).length })) - .filter((tab) => tab.count > 0); - const pinnedCount = items.filter((item) => item.pinned === true).length; - const sourceBackedCount = items.filter((item) => isSourceBacked(item)).length; - const nothingActive = selectedSetId === null && selectedTypeId === "all" && viewMode === "all"; - - return ( - - ); -} - -function FilterRailChip({ - label, - count, - pressed, - onClick, -}: { - label: string; - count: number | null; - pressed: boolean; - onClick: () => void; -}) { - return ( - - ); -} - /** * The empty-query dashboard band. Both cards read from the same derived data * the table does — no separate store — so they cannot drift from it. @@ -1296,14 +1118,24 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: ? "ready" : favouritesHookStatus; const sets = useMemo(() => buildFavouriteSets(items), [items]); - const [selectedTypeId, setSelectedTypeId] = useState("all"); - const [selectedSetId, setSelectedSetId] = useState(null); + const filterPanelId = useId(); + const [filterOpen, setFilterOpen] = useState(false); + const [selectedTypeIds, setSelectedTypeIds] = useState>(() => new Set()); + const [selectedSetIds, setSelectedSetIds] = useState>(() => new Set()); + const [pinnedOnly, setPinnedOnly] = useState(false); + const [sourceBackedOnly, setSourceBackedOnly] = useState(false); const [viewMode, setViewMode] = useState("all"); const [sortMode, setSortMode] = useState("last-used"); const [selectedItemId, setSelectedItemId] = useState(null); - const effectiveSelectedSetId = selectedSetId && sets.some((set) => set.id === selectedSetId) ? selectedSetId : null; - const selectedSet = effectiveSelectedSetId ? (sets.find((set) => set.id === effectiveSelectedSetId) ?? null) : null; + const effectiveSelectedSetIds = useMemo( + () => new Set([...selectedSetIds].filter((id) => sets.some((set) => set.id === id))), + [selectedSetIds, sets], + ); + const selectedSetTitles = useMemo( + () => new Set(sets.filter((set) => effectiveSelectedSetIds.has(set.id)).map((set) => set.title)), + [effectiveSelectedSetIds, sets], + ); // Single source of truth for "what is in the list right now". The band's // match count, the Continue gate, the empty-state branch and the table all @@ -1312,12 +1144,14 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: () => filterAndSortItems(items, { searchTerm: activeQuery, - selectedTypeId, - selectedSet, + selectedTypeIds, + selectedSetTitles, + pinnedOnly, + sourceBackedOnly, viewMode, sortMode, }), - [items, activeQuery, selectedTypeId, selectedSet, viewMode, sortMode], + [items, activeQuery, selectedTypeIds, selectedSetTitles, pinnedOnly, sourceBackedOnly, viewMode, sortMode], ); const continueItem = useMemo(() => getMostRecentlyUsedItem(items), [items]); const showContinueStrip = @@ -1338,11 +1172,147 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: } function clearAllFilters() { - setSelectedSetId(null); - setSelectedTypeId("all"); - setViewMode("all"); + setSelectedSetIds(new Set()); + setSelectedTypeIds(new Set()); + setPinnedOnly(false); + setSourceBackedOnly(false); } + const toggleSet = (id: string) => { + const next = new Set(effectiveSelectedSetIds); + if (next.has(id)) next.delete(id); + else next.add(id); + setSelectedSetIds(next); + }; + const toggleType = (id: string) => { + const next = new Set(selectedTypeIds); + if (next.has(id)) next.delete(id); + else next.add(id); + setSelectedTypeIds(next); + }; + const countWith = ({ + typeIds = selectedTypeIds, + setIds = effectiveSelectedSetIds, + pinned = pinnedOnly, + sourceBacked = sourceBackedOnly, + }: { + typeIds?: ReadonlySet; + setIds?: ReadonlySet; + pinned?: boolean; + sourceBacked?: boolean; + }) => { + const setTitles = new Set(sets.filter((set) => setIds.has(set.id)).map((set) => set.title)); + return filterAndSortItems(items, { + searchTerm: activeQuery, + selectedTypeIds: typeIds, + selectedSetTitles: setTitles, + pinnedOnly: pinned, + sourceBackedOnly: sourceBacked, + viewMode, + sortMode, + }).length; + }; + const setOptions = sets.map((set) => { + const projected = effectiveSelectedSetIds.has(set.id) + ? effectiveSelectedSetIds + : new Set([...effectiveSelectedSetIds, set.id]); + const count = countWith({ setIds: projected }); + return { + value: set.id, + label: set.title, + hint: String(count), + disabled: !effectiveSelectedSetIds.has(set.id) && count === 0, + }; + }); + const typeOptions = favouriteTabs + .filter((tab) => tab.id !== "all" && tab.id !== "sets") + .map((tab) => { + const projected = selectedTypeIds.has(tab.id) ? selectedTypeIds : new Set([...selectedTypeIds, tab.id]); + const count = countWith({ typeIds: projected }); + return { + value: tab.id, + label: tab.label, + hint: String(count), + disabled: !selectedTypeIds.has(tab.id) && count === 0, + }; + }) + .filter((option) => items.some((item) => item.tabId === option.value) || selectedTypeIds.has(option.value)); + const pinnedCount = countWith({ pinned: true }); + const sourceBackedCount = countWith({ sourceBacked: true }); + const activeFilterCount = + effectiveSelectedSetIds.size + selectedTypeIds.size + Number(pinnedOnly) + Number(sourceBackedOnly); + const filterGroups = [ + resultFilterFacetGroup({ + id: "set", + label: "Set", + selected: effectiveSelectedSetIds, + options: setOptions, + onToggle: toggleSet, + }), + resultFilterFacetGroup({ + id: "type", + label: "Type", + selected: selectedTypeIds, + options: typeOptions, + onToggle: toggleType, + }), + resultFilterFacetGroup({ + id: "pinned", + label: "Pinned", + selected: new Set(pinnedOnly ? ["pinned"] : []), + options: [ + { + value: "pinned", + label: "Pinned only", + hint: String(pinnedCount), + disabled: !pinnedOnly && pinnedCount === 0, + }, + ], + onToggle: () => setPinnedOnly((current) => !current), + }), + resultFilterFacetGroup({ + id: "source", + label: "Source support", + selected: new Set(sourceBackedOnly ? ["source-backed"] : []), + options: [ + { + value: "source-backed", + label: "Source-backed only", + hint: String(sourceBackedCount), + disabled: !sourceBackedOnly && sourceBackedCount === 0, + }, + ], + onToggle: () => setSourceBackedOnly((current) => !current), + }), + ]; + const appliedFilters = [ + ...[...effectiveSelectedSetIds].map((id) => ({ + id: `set-${id}`, + groupLabel: "Set", + valueLabel: sets.find((set) => set.id === id)?.title ?? id, + onRemove: () => toggleSet(id), + })), + ...[...selectedTypeIds].map((id) => ({ + id: `type-${id}`, + groupLabel: "Type", + valueLabel: favouriteTabs.find((tab) => tab.id === id)?.label ?? id, + onRemove: () => toggleType(id), + })), + ...(pinnedOnly + ? [{ id: "pinned", groupLabel: "Status", valueLabel: "Pinned", onRemove: () => setPinnedOnly(false) }] + : []), + ...(sourceBackedOnly + ? [ + { + id: "source-backed", + groupLabel: "Support", + valueLabel: "Source-backed", + onRemove: () => setSourceBackedOnly(false), + }, + ] + : []), + ]; + if (!favouritesAccessible) { return (
setFilterOpen((current) => !current)} + /> + } filterControls={ - selectedTypeId !== "all" || selectedSet || viewMode !== "all" ? ( - setSelectedTypeId("all")} - onClearSet={() => setSelectedSetId(null)} - onClearViewMode={() => setViewMode("all")} - includeSearch={false} - /> - ) : null + setFilterOpen((current) => !current)} + /> + } + utilityControls={ + } + appliedFilters={appliedFilters} + onClearFilters={activeFilterCount > 0 ? clearAllFilters : undefined} /> - setFilterOpen(false)} + panelId={filterPanelId} + testId="favourites-filter-panel" + title="Filter favourites" + description="Combine sets, item types, pinned status and source support. Recently used remains a view choice." + chromeResetKey={[ + activeQuery, + Array.from(effectiveSelectedSetIds).sort().join(","), + Array.from(selectedTypeIds).sort().join(","), + String(pinnedOnly), + String(sourceBackedOnly), + ].join("|")} + groups={filterGroups} + onClearAll={activeFilterCount > 0 ? clearAllFilters : undefined} + summary={{ count: filteredItems.length, noun: filteredItems.length === 1 ? "favourite" : "favourites" }} /> {showContinueStrip && continueItem ? : null} @@ -1487,7 +1489,7 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: setSelectedSetIds(new Set([id]))} onShowRecent={() => setViewMode("recent")} /> ) : null} @@ -1496,7 +1498,13 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: faulted an empty list means we could not look, not that the library is empty; the band's fault panel reports that. */} {searching && filteredItems.length === 0 && favouritesRegistryStatus === "ready" ? ( - + 0 ? clearAllFilters : undefined} + onClearSearch={clearSearch} + /> ) : ( = [ + { value: "all", label: "All qualities" }, + { value: "exact", label: "Exact clinical fit" }, + { value: "good", label: "Good clinical fit" }, + { value: "related", label: "Related match" }, +]; -const medicationResultFilters: Array<{ id: MedicationResultFilter; label: string; icon: LucideIcon }> = [ - { id: "best", label: "Best", icon: Sparkles }, - { id: "indication", label: "Indication", icon: Target }, - { id: "safety", label: "Safety", icon: ShieldAlert }, - { id: "monitoring", label: "Monitor", icon: Activity }, +const medicationSignalOptions: ReadonlyArray<{ value: MedicationClinicalSignal; label: string }> = [ + { value: "safety", label: "Safety" }, + { value: "monitoring", label: "Monitoring" }, ]; const medicationCapabilities: Capability[] = [ @@ -297,22 +317,6 @@ function MedicationHome({ ); } -function resultMatchesFilter(result: MedicationResult, filter: MedicationResultFilter) { - if (filter === "best") return true; - if (filter === "indication") return result.match !== "Related match"; - // actionTone is source-derived (contraindication vs caution vs monitoring content), - // so it is a stronger signal than the text heuristics — any row whose action shows - // a safety icon (danger or warning) must be reachable through the Safety chip. The - // chips are lenses, not partitions, so warning rows may also appear under Monitor. - if (filter === "safety") { - return result.actionTone !== "neutral" || /check|avoid|caution|ceiling|max/i.test(result.action); - } - return ( - result.actionTone === "warning" || - /monitor|level|review|renal|hepatic/i.test(`${result.action} ${result.dose} ${result.ceiling}`) - ); -} - function ResultToneIcon({ result, accent }: { result: MedicationResult; accent?: string }) { const tone = result.tone === "teal" ? "teal" : result.tone === "blue" ? "blue" : "slate"; if (accent) { @@ -412,14 +416,15 @@ function MedicationResults({ // identity consumers still use `fields=index`. const catalog = useMedicationCatalog(query); const { profile, isEmpty: profileEmpty } = usePatientProfile(); - const [activeFilter, setActiveFilter] = useState("best"); + const searchParams = useSearchParams(); const filterPanelId = useId(); const [filterOpen, setFilterOpen] = useState(false); - const { rows, counts, totalAvailable } = useMemo(() => { + const { bestRows, allRows } = useMemo(() => { const governance = catalog.data?.governance; const toRow = (result: MedicationResult, medication?: MedicationRecord): MedicationRow => { const badges = medication ? medicationIdentityBadges(medication, governance?.[medication.slug]) : []; const accent = medication?.accent; + const drugClass = medication?.class || medication?.category || "Other"; // Prepend a per-patient verdict badge so the highest-severity signal // surfaces first in the row's badge cluster (priority-sorted by tone). // The verdict folds BOTH engines together — physiology considerations and @@ -438,59 +443,207 @@ function MedicationResults({ unreachableCounterpartyCount: interactions.unreachableCounterparties.length, }); const verdictBadge = verdictSummaryBadge(verdict); - return { result, badges: verdictBadge ? [verdictBadge, ...badges] : badges, accent, verdict }; + return { + result, + medication, + drugClass, + badges: verdictBadge ? [verdictBadge, ...badges] : badges, + accent, + verdict, + }; } - return { result, badges, accent }; + return { result, medication, drugClass, badges, accent }; }; - const sourceRows = - catalog.data?.matches?.map((match) => toRow(match.result, match.medication)) ?? - (catalog.data?.records ?? []).slice(0, 12).map((record) => - toRow( - { - id: record.slug, - name: record.name, - indication: record.subclass || record.category, - match: "Catalogue match", - dose: "See reference", - ceiling: "See reference", - action: "Open full prescribing reference.", - actionTone: "neutral" as const, - tone: "slate" as const, - href: `/medications/${record.slug}`, - }, - record, + const ranked = catalog.data?.matches?.map((match) => toRow(match.result, match.medication)) ?? []; + const rankedSlugs = new Set(ranked.map((row) => row.medication?.slug ?? row.result.id)); + // Widening to the full catalogue must not reshuffle the ranked query + // matches. Keep them in provider rank order, then append catalogue-only + // records in catalogue order. + const complete = [ + ...ranked, + ...(catalog.data?.records ?? []) + .filter((record) => !rankedSlugs.has(record.slug)) + .map((record) => + toRow( + { + id: record.slug, + name: record.name, + indication: record.subclass || record.category, + match: "Catalogue match", + dose: "See reference", + ceiling: "See reference", + action: "Open full prescribing reference.", + actionTone: "neutral" as const, + tone: "slate" as const, + href: `/medications/${record.slug}`, + }, + record, + ), ), - ); - const scoped = sourceRows; - const filterCounts: Record = { best: 0, indication: 0, safety: 0, monitoring: 0 }; - for (const row of scoped) { - for (const filter of medicationResultFilters) { - if (resultMatchesFilter(row.result, filter.id)) filterCounts[filter.id] += 1; - } - } + ]; + return { bestRows: ranked, allRows: complete }; + }, [catalog.data, profile, profileEmpty]); + + const scope = readResultFilterValue(searchParams, "scope", medicationScopeValues, "best"); + const matchFilter = readResultFilterValue(searchParams, "match", medicationMatchValues, "all"); + const classValues = useMemo( + () => + [...new Set(allRows.map((row) => row.drugClass).filter(Boolean))].sort((left, right) => + left.localeCompare(right), + ), + [allRows], + ); + const classValueSet = useMemo(() => new Set(classValues), [classValues]); + const classFilters = useMemo( + () => new Set(readResultFilterValues(searchParams, "class", classValueSet)), + [classValueSet, searchParams], + ); + const signalFilters = useMemo( + () => new Set(readResultFilterValues(searchParams, "signal", medicationSignalValues)), + [searchParams], + ); + + const { rows, scopeCounts, matchCounts, classCounts, signalCounts, totalAvailable } = useMemo(() => { + const filters = { match: matchFilter, classes: classFilters, signals: signalFilters }; + const rowsForScope = (nextScope: MedicationScope) => (nextScope === "best" ? bestRows : allRows); + const matches = (candidateRows: MedicationRow[], candidateFilters = filters) => + candidateRows.filter((row) => medicationRowMatchesFilters(row, candidateFilters)); + const baseRows = rowsForScope(scope); + const visible = matches(baseRows); + return { - rows: scoped.filter((row) => resultMatchesFilter(row.result, activeFilter)), - counts: filterCounts, - totalAvailable: scoped.length, + rows: visible, + totalAvailable: baseRows.length, + scopeCounts: { + best: matches(bestRows).length, + all: matches(allRows).length, + } satisfies Record, + matchCounts: Object.fromEntries( + medicationMatchOptions.map((option) => [ + option.value, + matches(baseRows, { ...filters, match: option.value }).length, + ]), + ) as Record, + classCounts: Object.fromEntries( + classValues.map((drugClass) => { + const projected = classFilters.has(drugClass) ? classFilters : new Set([...classFilters, drugClass]); + return [drugClass, matches(baseRows, { ...filters, classes: projected }).length]; + }), + ) as Record, + signalCounts: Object.fromEntries( + medicationSignalOptions.map((option) => { + const projected = signalFilters.has(option.value) ? signalFilters : new Set([...signalFilters, option.value]); + return [option.value, matches(baseRows, { ...filters, signals: projected }).length]; + }), + ) as Record, }; - }, [activeFilter, catalog.data, profile, profileEmpty]); + }, [allRows, bestRows, classFilters, classValues, matchFilter, scope, signalFilters]); + + const setScope = (value: MedicationScope) => + replaceResultFilterUrl((params) => writeResultFilterValue(params, "scope", value, "best", medicationScopeValues)); + const setMatchFilter = (value: MedicationMatchQuality) => + replaceResultFilterUrl((params) => writeResultFilterValue(params, "match", value, "all", medicationMatchValues)); + const toggleClass = (value: string) => { + if (classValueSet.size === 0) return; + replaceResultFilterUrl((params) => { + const next = new Set(readResultFilterValues(params, "class", classValueSet)); + if (!next.delete(value)) next.add(value); + writeResultFilterValues(params, "class", next, classValueSet); + }); + }; + const toggleSignal = (value: MedicationClinicalSignal) => { + replaceResultFilterUrl((params) => { + const next = new Set(readResultFilterValues(params, "signal", medicationSignalValues)); + if (!next.delete(value)) next.add(value); + writeResultFilterValues(params, "signal", next, medicationSignalValues); + }); + }; + const clearFilters = () => + replaceResultFilterUrl((params) => { + params.delete("scope"); + params.delete("match"); + params.delete("class"); + params.delete("signal"); + }); + const resultCount = rows.length; - // One array feeds the desktop rail and the phone sheet, so the counts cannot - // drift between them and the dimension states one-of-N at both breakpoints. - const filterOptions = useMemo>>( - () => - medicationResultFilters.map((filter) => ({ - value: filter.id, - label: filter.label, - icon: filter.icon, - hint: String(counts[filter.id]), + const activeFilterCount = + (scope === "best" ? 0 : 1) + (matchFilter === "all" ? 0 : 1) + classFilters.size + signalFilters.size; + const filterGroups = [ + resultFilterGroup({ + id: "match-quality", + label: "Match quality", + value: matchFilter, + options: medicationMatchOptions.map((option) => ({ + ...option, + hint: String(matchCounts[option.value]), + disabled: option.value !== matchFilter && matchCounts[option.value] === 0, })), - [counts], - ); + onChange: setMatchFilter, + }), + resultFilterFacetGroup({ + id: "drug-class", + label: "Drug class", + selected: classFilters, + options: classValues.map((value) => ({ + value, + label: value, + hint: String(classCounts[value] ?? 0), + disabled: !classFilters.has(value) && (classCounts[value] ?? 0) === 0, + })), + onToggle: toggleClass, + }), + resultFilterFacetGroup({ + id: "clinical-signal", + label: "Clinical signal", + description: "Safety and monitoring are one OR group; choosing either keeps records with that signal.", + selected: signalFilters, + options: medicationSignalOptions.map((option) => ({ + ...option, + hint: String(signalCounts[option.value]), + disabled: !signalFilters.has(option.value) && signalCounts[option.value] === 0, + })), + onToggle: toggleSignal, + }), + ]; + const appliedFilters = [ + ...(scope === "all" + ? [ + { + id: "scope", + groupLabel: "Search in", + valueLabel: "All medications", + accessibleLabel: "Search in all medications", + onRemove: () => setScope("best"), + }, + ] + : []), + ...(matchFilter === "all" + ? [] + : [ + { + id: "match", + groupLabel: "Match", + valueLabel: medicationMatchOptions.find((option) => option.value === matchFilter)?.label ?? matchFilter, + onRemove: () => setMatchFilter("all"), + }, + ]), + ...[...classFilters].map((value) => ({ + id: `class-${value}`, + groupLabel: "Class", + valueLabel: value, + onRemove: () => toggleClass(value), + })), + ...[...signalFilters].map((value) => ({ + id: `signal-${value}`, + groupLabel: "Signal", + valueLabel: medicationSignalOptions.find((option) => option.value === value)?.label ?? value, + onRemove: () => toggleSignal(value), + })), + ]; // The match-quality badge only earns its slot when it differentiates: hide it on // "Exact clinical fit" rows when every visible row says the same thing. const showMatchBadge = useMemo(() => new Set(rows.map((row) => row.result.match)).size > 1, [rows]); - const activeFilterLabel = medicationResultFilters.find((filter) => filter.id === activeFilter)?.label ?? "filtered"; const initialCatalogLoading = catalog.loading && !catalog.data; const catalogRefetching = catalog.loading && Boolean(catalog.data); @@ -513,44 +666,55 @@ function MedicationResults({ testId="medication-filter-trigger-phone" title="Filter medication results" open={filterOpen} - activeCount={activeFilter === "best" ? 0 : 1} + activeCount={activeFilterCount} onToggle={() => setFilterOpen((current) => !current)} /> } filterControls={ - setFilterOpen((current) => !current)} /> } + appliedFilters={appliedFilters} + onClearFilters={activeFilterCount > 0 ? clearFilters : undefined} /> - {/* Phone-only by construction: the trigger that opens it lives in the - ribbon's `mobileControls` slot, which the band hides from `sm` up. */} setFilterOpen(false)} panelId={filterPanelId} testId="medication-filter-panel" title="Filter medication results" - groups={[ - resultFilterGroup({ - id: "result-filter", - label: "Show", - value: activeFilter, - options: filterOptions, - onChange: setActiveFilter, - }), - ]} - onClearAll={activeFilter === "best" ? undefined : () => setActiveFilter("best")} - footerNote={`${resultCount} showing`} + description="Narrow the ranked matches without changing prescribing content or patient safety notices." + scope={{ + label: "Search in", + value: scope, + options: [ + { + value: "best", + label: "Best matches", + count: scopeCounts.best, + description: "The ranked results for this query.", + }, + { + value: "all", + label: "All medications", + count: scopeCounts.all, + description: "The complete medication catalogue.", + }, + ], + onChange: (value) => setScope(value as MedicationScope), + }} + groups={filterGroups} + onClearAll={activeFilterCount > 0 ? clearFilters : undefined} + summary={{ count: resultCount, noun: resultCount === 1 ? "medication" : "medications" }} /> {/* Phone gets the docked pill + sheet instead of the in-flow strip: two @@ -576,16 +740,16 @@ function MedicationResults({
) : ( diff --git a/src/components/clinical-dashboard/result-filter-control.tsx b/src/components/clinical-dashboard/result-filter-control.tsx index b53b6b2a50..0bcb8756c3 100644 --- a/src/components/clinical-dashboard/result-filter-control.tsx +++ b/src/components/clinical-dashboard/result-filter-control.tsx @@ -1,7 +1,7 @@ "use client"; import { Check, ChevronDown, Funnel, Search, X } from "lucide-react"; -import { type ReactNode, useCallback, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { Sheet } from "@/components/ui/sheet"; import { cn } from "@/components/ui-primitives"; @@ -25,11 +25,9 @@ import { cn } from "@/components/ui-primitives"; * - `ResultFilterSheet` — the shared sheet for every mode's filters, lens and * facet alike. Below the density threshold (`docs/filter-contract.md` §5) a * facet group is a plain chip row; above it, the sheet grows a find-a-filter - * field and collapse-by-default per group. Documents (the largest surface — - * up to 11 facet groups) converged onto this in PR F; `meterContent` and - * `footerOverride` exist because its progress meter and "Show N documents" / - * "Browse all sources" footer are richer than every other mode's plain - * `footerNote` + "Done". + * field and collapse-by-default per group. Typed scope, coverage, summary and + * secondary-action props describe the non-group anatomy without arbitrary + * caller-owned markup. * * Desktop is untouched. The ribbon renders `filterControls` from `sm` up and * `mobileControls` below it, never both, so each mode keeps the chip row or tab @@ -47,14 +45,21 @@ export type ResultFilterOption = { disabled?: boolean; }; +export type ResultFilterOptionSection = { + id: string; + label: string; + description?: string; + optionValues: ReadonlyArray; +}; + /** * A dimension the sheet can render, discriminated by what it MEANS rather than * by how it should look. * * `lens` — one-of-N. The options partition the result set and exactly one is - * active: differentials' All/Presentations/Diagnoses, medication's - * Best/Indication/Safety/Monitor. This is the shape every call site uses today, - * so it is the default and `kind` may be omitted. + * active: differentials' All/Presentations/Diagnoses or medication match + * quality. This is the shape every call site uses today, so it is the default + * and `kind` may be omitted. * * `facet` — many-of-N, OR within the group and AND across groups. Formulation's * domains and the documents tag groups are facets; rendering them as radios (as @@ -71,7 +76,11 @@ type ResultFilterGroupBase = { /** Stable within one sheet; used for the group's own labelling ids. */ id: string; label: string; + /** Optional clinical context rendered below the group heading. */ + description?: string; options: ReadonlyArray>; + /** Visual grouping only. It never changes the group's lens/facet predicate. */ + optionSections?: ReadonlyArray; }; export type ResultFilterLensGroup = ResultFilterGroupBase & { @@ -113,18 +122,22 @@ export function isFacetGroup(group: ResultFilterGroup): group is ResultFilterFac export function resultFilterGroup(group: { id: string; label: string; + description?: string; value: Value; options: ReadonlyArray>; onChange: (value: Value) => void; note?: string; + optionSections?: ReadonlyArray; }): ResultFilterGroup { return { kind: "lens", id: group.id, label: group.label, + description: group.description, value: group.value, options: group.options, note: group.note, + optionSections: group.optionSections, // The one narrowing, isolated here rather than repeated at seven call sites. onChange: (value) => group.onChange(value as Value), }; @@ -148,9 +161,11 @@ export function resultFilterGroup(group: { export function resultFilterFacetGroup(group: { id: string; label: string; + description?: string; selected: ReadonlySet; options: ReadonlyArray>; onToggle: (value: Value) => void; + optionSections?: ReadonlyArray; // Returns the narrow facet type, not the union: a mode hands the same group // to `ResultFilterSheet` (which takes the union) and to // `ResultFilterFacetChips` for its desktop rail (which does not). @@ -159,8 +174,10 @@ export function resultFilterFacetGroup(group: { kind: "facet", id: group.id, label: group.label, + description: group.description, selected: group.selected as ReadonlySet, options: group.options, + optionSections: group.optionSections, onToggle: (value) => group.onToggle(value as Value), }; } @@ -255,10 +272,9 @@ export function ResultFilterTrigger({ * the interaction. * * Arrow keys select as they move, which is the ARIA default and also what the - * native `` this replaced already did on desktop. Live catalogue + * lenses therefore commit as focus moves, while staged panels keep their + * changes in draft state until the primary action runs. * * A dead-end option stays on the arrow path but is never selected by it. That is * the ARIA guidance for a disabled radio, and it is the only arrangement that @@ -268,11 +284,9 @@ export function ResultFilterTrigger({ * would make them reachable — and would add a second, third and fourth tab stop * to a control whose whole point is having one. * - * No call site produces a dead end today: `deadEnd` is `disabled && !selected`, - * and every placeholder this component ships with (services' "Current search" / - * "All services", formulation's "Current search") is rendered only while it is - * the *selected* option. This path is therefore defensive, and is asserted in the - * DOM tests so it cannot rot before the first mode needs it. + * `deadEnd` is `disabled && !selected`. These options remain reachable for an + * explanation while the selected zero-result value stays removable through + * the applied shelf. DOM coverage keeps that distinction explicit. */ function FilterRadioGroup({ group, panelId }: { group: ResultFilterLensGroup; panelId: string }) { const refs = useRef(new Map()); @@ -334,6 +348,9 @@ function FilterRadioGroup({ group, panelId }: { group: ResultFilterLensGroup; pa {group.label} {group.note ? {group.note} : null} + {group.description ? ( +

{group.description}

+ ) : null}
>) => + items.map((option) => { + const selected = group.selected.has(option.value); + const deadEnd = Boolean(option.disabled) && !selected; + const deadEndDescId = `${panelId}-${group.id}-${option.value.replace(/[^A-Za-z0-9_-]/g, "-")}-note`; + return ( + + ); + }); return (
@@ -512,71 +575,150 @@ export function ResultFilterFacetChips({ )} + {group.description ? ( +

{group.description}

+ ) : null} +
+ ); +} + +export type ResultFilterScopeOption = { + value: Value; + label: string; + count: number; + description?: string; +}; + +export type ResultFilterScopeConfig = { + label?: string; + value: string; + options: ReadonlyArray; + onChange: (value: string) => void; +}; + +/** + * A compact, count-bearing scope choice for a catalogue that can widen beyond + * the current query. It is deliberately not a generic toggle: the legend names + * the dimension, native radios provide keyboard behaviour, and each choice + * explains the result universe it will use. + */ +export function ResultFilterScopeSelector({ + id, + label = "Search in", + value, + options, + onChange, +}: { + id: string; + label?: string; + value: Value; + options: ReadonlyArray>; + onChange: (value: Value) => void; +}) { + return ( +
+ + {label} + +
+ {options.map((option) => { + const descriptionId = option.description + ? `${id}-scope-${option.value.replace(/[^A-Za-z0-9_-]/g, "-")}-description` + : undefined; return ( - + + + {option.label} + + {option.description ? ( + + {option.description} + + ) : null} + + {option.count.toLocaleString()} + + ); })}
- +
); } +export type ResultFilterSummary = { + count: number; + noun: string; + totalCount?: number; +}; + +export type ResultFilterSecondaryAction = { + label: string; + count?: number; + onClick: () => void; +}; + /** * A single-choice filter sheet: one radio group per dimension, plus * many-of-N facet groups. @@ -612,23 +754,19 @@ export function ResultFilterSheet({ description, groups, onClearAll, - footerNote, + summary, + scope, + coverage, + primaryActionLabel, + onApply, + secondaryAction, + applicationMode = "live", /** Result-set identity for the dense-mode chrome below (the find-a-filter text and per-group collapse state). Changing it clears that chrome, so a new search or scope change cannot leave a stale needle filtering a list it no longer describes. Only meaningful once a sheet goes dense (more than three facet groups); a sheet that never does can omit it. */ chromeResetKey = "", - /** The scope segment (docs/filter-contract.md section 4) — "These results - N | All items N" — rendered above the groups when the calling mode has - a catalogue meaningfully larger than its current result set. Built by - the caller from `SegmentedControl` rather than owned here: the sheet - only reserves the slot, since "meaningfully larger" and what the two - counts mean are per-mode judgements, not something a shared filter - renderer can decide. */ - scopeControl, - meterContent, - footerOverride, }: { open: boolean; onClose: () => void; @@ -640,18 +778,14 @@ export function ResultFilterSheet({ /** Omit to hide the header's Clear. A control that advertises an action must perform one, so pass this only when something is actually clearable. */ onClearAll?: () => void; - footerNote?: ReactNode; + summary?: ResultFilterSummary; + scope?: ResultFilterScopeConfig; + coverage?: { visibleCount: number; totalCount: number; label?: string }; + primaryActionLabel?: string; + onApply?: () => void; + secondaryAction?: ResultFilterSecondaryAction; + applicationMode?: "live" | "staged"; chromeResetKey?: string; - scopeControl?: ReactNode; - /** Content rendered first in the body, above `scopeControl` and the find-a-filter field — - documents' `N of M documents shown` progress meter. No other mode needs this; omit - otherwise. */ - meterContent?: ReactNode; - /** Replaces the entire default footer (the `footerNote` span plus the "Done" button) rather - than composing with it. For a mode whose commit action needs its own label/count (documents' - "Show N documents") or a secondary action beside it (its "Browse all sources"), supplying - those loses nothing the default footer offered. `footerNote` is ignored when this is set. */ - footerOverride?: ReactNode; }) { // Hooks run unconditionally — the empty-groups early return happens below, // after every hook the render needs has already been declared. @@ -705,7 +839,7 @@ export function ResultFilterSheet({ }); }; - if (groups.length === 0) return null; + if (groups.length === 0 && !scope) return null; const facetGroups = groups.filter(isFacetGroup); const totalFacetOptions = facetGroups.reduce((total, group) => total + group.options.length, 0); @@ -746,6 +880,7 @@ export function ResultFilterSheet({ title={title} description={description} portal + placement="responsive-right" id={panelId} testId={testId} headerActions={ @@ -755,7 +890,7 @@ export function ResultFilterSheet({ onClick={onClearAll} data-testid={`${testId}-clear`} className={cn( - "search-band-ghost inline-flex min-h-tap items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-2 text-2xs font-bold text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)] sm:min-h-8", + "search-band-ghost inline-flex min-h-tap items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-2 text-2xs font-bold text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)] sm:min-h-10", "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", )} > @@ -765,28 +900,89 @@ export function ResultFilterSheet({ ) : null } footer={ - footerOverride ?? ( +
- {footerNote} + + {summary + ? `${summary.count.toLocaleString()} ${summary.noun}${ + summary.totalCount == null ? "" : ` of ${summary.totalCount.toLocaleString()}` + }` + : null} + {applicationMode === "staged" ? . Changes apply together. : null} +
- ) + {secondaryAction ? ( + + ) : null} +
} >
- {meterContent ?
{meterContent}
: null} - {scopeControl ? ( -
{scopeControl}
+ {coverage ? ( +
+
+ 0 + ? `${Math.min(100, Math.max(0, (coverage.visibleCount / coverage.totalCount) * 100))}%` + : "0%", + }} + /> +
+

+ {coverage.visibleCount.toLocaleString()} of{" "} + {coverage.totalCount.toLocaleString()} retrieved matches visible +

+
+ ) : null} + {scope ? ( +
+ +
) : null} {dense ? (
@@ -802,14 +998,14 @@ export function ResultFilterSheet({ onChange={(event) => setNeedle(event.target.value)} placeholder="Find a filter…" data-testid={`${testId}-find`} - className="min-h-tap min-w-0 flex-1 bg-transparent text-xs font-semibold text-[color:var(--text)] outline-none placeholder:font-medium placeholder:text-[color:var(--text-placeholder)] sm:min-h-9" + className="min-h-tap min-w-0 flex-1 bg-transparent text-xs font-semibold text-[color:var(--text)] outline-none placeholder:font-medium placeholder:text-[color:var(--text-placeholder)] sm:min-h-10" /> {needle ? ( diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index dd3ebcebe3..ae70d088bb 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -112,7 +112,12 @@ function useRailOverflow() { export type AppliedFilterChip = { id: string; - label: string; + /** Compact value shown on phones, for example `High`. */ + valueLabel: string; + /** Context restored on larger screens, for example `Risk: High`. */ + groupLabel?: string; + /** Complete label announced by assistive technology. */ + accessibleLabel?: string; onRemove: () => void; }; @@ -591,9 +596,10 @@ export function SearchResultsHeaderBand({ toolbar. It deliberately survives `loading` and a zero result: nothing matching is exactly when you need to relax a filter, and dropping it mid-search would flicker the chips out and back on every keystroke. - Only a fault removes it, because filtering a result set that never - loaded is meaningless. */} - {appliedFilters.length > 0 && !faulted ? ( + A fault still keeps it: retrieval and result constraints are user + state, not a claim that the faulted result count is trustworthy, and + the controls remain the shortest recovery path. */} + {appliedFilters.length > 0 ? (
- {filter.label} + {filter.groupLabel ? ( + + {filter.groupLabel}: + {filter.valueLabel} + + ) : null} + {filter.valueLabel} ))} @@ -786,11 +798,11 @@ export function ResultSortControl({ * escape, and on therapy-compass `Clear search` is the only one for a query-only * zero result. Shipping the escape hatch below the floor the same change raised * the facets, the find field and the disclosure headings to would contradict the - * rule this component's own redesign argues for. Relaxes to 36px from `sm`, - * exactly like the facets. + * rule this component's own redesign argues for. It keeps the 40px pointer + * floor from `sm`, exactly like the filter controls. */ const emptyStateAction = - "inline-flex min-h-tap items-center gap-1.5 rounded-lg border border-[color:var(--border)] px-3 text-xs font-extrabold text-[color:var(--text-muted)] hover:text-[color:var(--text)] sm:min-h-9"; + "inline-flex min-h-tap items-center gap-1.5 rounded-lg border border-[color:var(--border)] px-3 text-xs font-extrabold text-[color:var(--text-muted)] hover:text-[color:var(--text)] sm:min-h-10"; export function SearchResultsEmptyState({ modeId, @@ -992,7 +1004,7 @@ export function SearchResultsEmptyState({ )} > - Remove “{lastFilter.label}” + Remove “{lastFilter.accessibleLabel ?? lastFilter.valueLabel}” ) : null} {onClearFilters && appliedFilters.length > 1 ? ( diff --git a/src/components/differentials/differential-stream-workspace.tsx b/src/components/differentials/differential-stream-workspace.tsx index 4a4aea03be..d0de5fe203 100644 --- a/src/components/differentials/differential-stream-workspace.tsx +++ b/src/components/differentials/differential-stream-workspace.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { ArrowLeft, ArrowRight, FileText, GitCompareArrows, ShieldAlert } from "lucide-react"; @@ -9,6 +9,7 @@ import { ArrowLeft, ArrowRight, FileText, GitCompareArrows, ShieldAlert } from " import { ResultFilterSheet, ResultFilterTrigger, + resultFilterFacetGroup, resultFilterGroup, type ResultFilterGroup, } from "@/components/clinical-dashboard/result-filter-control"; @@ -19,6 +20,13 @@ import { import { ContextualBackLink } from "@/components/contextual-back-link"; import { appModeHomeHref } from "@/lib/app-modes"; import { normalizeSearchText } from "@/lib/catalog-search"; +import { + readResultFilterValue, + readResultFilterValues, + replaceResultFilterUrl, + writeResultFilterValue, + writeResultFilterValues, +} from "@/lib/result-filter-url"; import { differentialRouteWithQuery, differentialSelectedCompareHref } from "@/lib/differentials-navigation"; import { differentialsMobileCompareAddonSlotId } from "@/lib/mode-home-composer"; import type { @@ -30,6 +38,10 @@ import type { DifferentialLikelihood } from "@/lib/differential-snapshot"; type BrowseGrouping = "urgency" | "presentation"; type PresentationPriority = "all" | DifferentialStreamItem["status"]; +type DifferentialResultScope = "matches" | "all"; + +const priorityValues = new Set(["all", "emergent", "urgent", "routine"]); +const scopeValues = new Set(["matches", "all"]); type DifferentialStreamWorkspaceProps = { model: DifferentialStreamModel; @@ -293,32 +305,13 @@ function StreamCard({ ); } -function StreamFilterControls({ - stream, - showGrouping, +function StreamGroupingControl({ browseGrouping, onBrowseGroupingChange, - presentationPriority, - onPresentationPriorityChange, - focusedTitle, - showConnectionFilter, - focusMode, - onFocusModeChange, }: { - stream: DifferentialStreamType; - showGrouping: boolean; browseGrouping: BrowseGrouping; onBrowseGroupingChange: (grouping: BrowseGrouping) => void; - presentationPriority: PresentationPriority; - onPresentationPriorityChange: (priority: PresentationPriority) => void; - focusedTitle: string | null; - showConnectionFilter: boolean; - focusMode: boolean; - onFocusModeChange: (enabled: boolean) => void; }) { - const isPresentation = stream === "presentations"; - if (!showGrouping && !isPresentation && !showConnectionFilter) return null; - const optionClass = (selected: boolean) => [ "inline-flex min-h-10 items-center rounded-lg border px-3 text-xs font-bold transition-colors motion-reduce:transition-none", @@ -329,80 +322,30 @@ function StreamFilterControls({ ].join(" "); return ( -
- {showGrouping ? ( -
- - -
- ) : null} - {isPresentation ? ( -
- {( - [ - ["all", "All priorities"], - ["emergent", "Emergent"], - ["urgent", "Urgent"], - ["routine", "Routine"], - ] as const - ).map(([value, label]) => ( - - ))} -
- ) : null} - {showConnectionFilter && focusedTitle ? ( -
- - -
- ) : null} +
+ +
); } export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: DifferentialStreamWorkspaceProps) { const router = useRouter(); + const searchParams = useSearchParams(); const copy = streamCopy[model.stream]; const isPresentation = model.stream === "presentations"; // Match buildDifferentialStreamModel: punctuation-only queries are browse mode. @@ -410,20 +353,59 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: const matchItems = useMemo(() => model.items.filter((item) => item.isMatch), [model.items]); const itemBySlug = useMemo(() => new Map(model.items.map((item) => [item.slug, item])), [model.items]); const itemById = useMemo(() => new Map(model.items.map((item) => [item.id, item])), [model.items]); + const chapterFilterOptions = useMemo( + () => [ + ...model.chapters.map((chapter) => ({ + value: `chapter:${chapter.id}`, + label: chapter.title, + section: "chapters" as const, + itemIds: new Set(chapter.itemIds), + })), + ...(model.stream === "diagnoses" + ? model.presentationChapters.map((chapter) => ({ + value: `presentation:${chapter.id}`, + label: chapter.title, + section: "presentations" as const, + itemIds: new Set(chapter.itemIds), + })) + : []), + ], + [model.chapters, model.presentationChapters, model.stream], + ); + const chapterItemIdsByValue = useMemo( + () => new Map(chapterFilterOptions.map((option) => [option.value, option.itemIds])), + [chapterFilterOptions], + ); + const chapterFilterValues = useMemo( + () => new Set(chapterFilterOptions.map((option) => option.value)), + [chapterFilterOptions], + ); + const defaultScope: DifferentialResultScope = hasQuery ? "matches" : "all"; + const resultScope = readResultFilterValue(searchParams, "scope", scopeValues, defaultScope); + const presentationPriority = readResultFilterValue(searchParams, "priority", priorityValues, "all"); + const selectedChapterFilters = new Set(readResultFilterValues(searchParams, "chapter", chapterFilterValues)); const [focusedSlug, setFocusedSlug] = useState(() => { const requested = initialFocus.trim().toLowerCase(); if (requested && itemBySlug.has(requested)) return requested; return matchItems[0]?.slug ?? null; }); - const [familyMode, setFamilyMode] = useState(false); const [browseGrouping, setBrowseGrouping] = useState("urgency"); - const [presentationPriority, setPresentationPriority] = useState("all"); const [selectedIds, setSelectedIds] = useState>(() => new Set()); const [filterOpen, setFilterOpen] = useState(false); const filterPanelId = useId(); const didAutoJumpForFocus = useRef(""); + const setResultScope = (value: DifferentialResultScope) => + replaceResultFilterUrl((params) => writeResultFilterValue(params, "scope", value, defaultScope, scopeValues)); + const setPresentationPriority = (value: PresentationPriority) => + replaceResultFilterUrl((params) => writeResultFilterValue(params, "priority", value, "all", priorityValues)); + const setFamilyMode = (enabled: boolean) => + replaceResultFilterUrl((params) => { + if (enabled) params.set("related", "1"); + else params.delete("related"); + }); + const resultSignature = matchItems.map((item) => item.slug).join("|"); const [lastResultSignature, setLastResultSignature] = useState(""); if (lastResultSignature !== resultSignature) { @@ -436,10 +418,10 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: if (!initialFocus.trim() && matchItems[0]) { setFocusedSlug(matchItems[0].slug); } - setFamilyMode(false); } const focusedItem = focusedSlug ? (itemBySlug.get(focusedSlug) ?? null) : null; + const familyMode = Boolean(focusedItem && searchParams.get("related") === "1"); const relatedSlugSet = useMemo(() => { if (!focusedItem) return new Set(); return new Set( @@ -449,14 +431,29 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: ); }, [focusedItem, isPresentation]); - const visibleItems = useMemo(() => { - const connectedItems = - familyMode && focusedItem - ? model.items.filter((item) => item.slug === focusedItem.slug || relatedSlugSet.has(item.slug)) - : model.items; - if (!isPresentation || presentationPriority === "all") return connectedItems; - return connectedItems.filter((item) => item.status === presentationPriority); - }, [familyMode, focusedItem, isPresentation, model.items, presentationPriority, relatedSlugSet]); + const filterStreamItems = ( + scope: DifferentialResultScope, + priority: PresentationPriority, + chapters: ReadonlySet, + related: boolean, + ) => + model.items.filter((item) => { + if (scope === "matches" && !item.isMatch) return false; + if (related && focusedItem && item.slug !== focusedItem.slug && !relatedSlugSet.has(item.slug)) return false; + if (priority !== "all" && item.status !== priority) return false; + if (chapters.size > 0) { + let inSelectedChapter = false; + for (const token of chapters) { + if (chapterItemIdsByValue.get(token)?.has(item.id)) { + inSelectedChapter = true; + break; + } + } + if (!inSelectedChapter) return false; + } + return true; + }); + const visibleItems = filterStreamItems(resultScope, presentationPriority, selectedChapterFilters, familyMode); const activeChapters = !hasQuery && model.stream === "diagnoses" @@ -528,78 +525,135 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: const showBrowseGrouping = !hasQuery && model.stream === "diagnoses"; const showConnectionFilter = Boolean(focusedItem && (!isPresentation || focusedItem.relatedPathways.length > 0)); const mobileFilterGroups: ResultFilterGroup[] = []; - if (showBrowseGrouping) { - mobileFilterGroups.push( - resultFilterGroup({ - id: "grouping", - label: "Group diagnoses", - value: browseGrouping, - options: [ - { value: "urgency", label: "By urgency" }, - { value: "presentation", label: "By presentation" }, - ], - onChange: setBrowseGrouping, - }), - ); - } - if (isPresentation) { + if (filterOpen) { mobileFilterGroups.push( resultFilterGroup({ id: "priority", - label: "Presentation priority", + label: "Clinical urgency", value: presentationPriority, - options: [ - { value: "all", label: "All priorities" }, - { value: "emergent", label: "Emergent" }, - { value: "urgent", label: "Urgent" }, - { value: "routine", label: "Routine" }, - ], + options: (["all", "emergent", "urgent", "routine"] as const).map((value) => ({ + value, + label: value === "all" ? "All priorities" : statusLabel(value), + hint: String(filterStreamItems(resultScope, value, selectedChapterFilters, familyMode).length), + })), onChange: setPresentationPriority, }), ); - } - if (focusedItem && showConnectionFilter) { - mobileFilterGroups.push( - resultFilterGroup<"all" | "family">({ - id: isPresentation ? "pathways" : "family", - label: isPresentation ? "Related pathways" : "Family view", - value: familyMode ? "family" : "all", - options: [ - { value: "all", label: isPresentation ? "All pathways" : "All entries" }, - { - value: "family", - label: isPresentation ? "Related pathways" : "Focused family", - hint: focusedItem.title, - }, - ], - onChange: (value) => setFamilyMode(value === "family"), - }), - ); + if (model.stream === "diagnoses" && chapterFilterOptions.length > 0) { + mobileFilterGroups.push( + resultFilterFacetGroup({ + id: "chapter", + label: "Chapter or presentation", + description: "Alternatives in this group combine with OR.", + selected: selectedChapterFilters, + optionSections: [ + { + id: "chapters", + label: "Diagnostic chapters", + optionValues: chapterFilterOptions + .filter((option) => option.section === "chapters") + .map((option) => option.value), + }, + { + id: "presentations", + label: "Presentation families", + optionValues: chapterFilterOptions + .filter((option) => option.section === "presentations") + .map((option) => option.value), + }, + ], + options: chapterFilterOptions.map((option) => { + const withCandidate = new Set(selectedChapterFilters); + if (!withCandidate.has(option.value)) withCandidate.add(option.value); + const count = filterStreamItems(resultScope, presentationPriority, withCandidate, familyMode).length; + return { + value: option.value, + label: option.label, + hint: String(count), + disabled: count === 0 && !selectedChapterFilters.has(option.value), + }; + }), + onToggle: (value) => + replaceResultFilterUrl((params) => { + const next = new Set(readResultFilterValues(params, "chapter", chapterFilterValues)); + if (!next.delete(value)) next.add(value); + writeResultFilterValues(params, "chapter", next, chapterFilterValues); + }), + }), + ); + } + if (focusedItem && showConnectionFilter) { + mobileFilterGroups.push( + resultFilterGroup<"all" | "family">({ + id: isPresentation ? "pathways" : "family", + label: isPresentation ? "Related pathways" : "Family view", + value: familyMode ? "family" : "all", + options: [ + { value: "all", label: isPresentation ? "All pathways" : "All entries" }, + { + value: "family", + label: isPresentation ? "Related pathways" : "Focused family", + hint: focusedItem.title, + }, + ], + onChange: (value) => setFamilyMode(value === "family"), + }), + ); + } } const activeFilterCount = Number(familyMode) + - Number(showBrowseGrouping && browseGrouping !== "urgency") + - Number(isPresentation && presentationPriority !== "all"); + Number(resultScope !== defaultScope) + + Number(presentationPriority !== "all") + + selectedChapterFilters.size; const appliedFilters: AppliedFilterChip[] = []; - if (isPresentation && presentationPriority !== "all") { + if (resultScope !== defaultScope) { + appliedFilters.push({ + id: "scope", + groupLabel: "Search in", + valueLabel: resultScope === "all" ? "All entries" : "Matches", + onRemove: () => setResultScope(defaultScope), + }); + } + if (presentationPriority !== "all") { appliedFilters.push({ id: "priority", - label: `${statusLabel(presentationPriority)} priority`, + groupLabel: "Priority", + valueLabel: statusLabel(presentationPriority), onRemove: () => setPresentationPriority("all"), }); } + for (const token of selectedChapterFilters) { + const option = chapterFilterOptions.find((item) => item.value === token); + if (!option) continue; + appliedFilters.push({ + id: token, + groupLabel: option.section === "presentations" ? "Presentation" : "Chapter", + valueLabel: option.label, + onRemove: () => + replaceResultFilterUrl((params) => { + const next = new Set(readResultFilterValues(params, "chapter", chapterFilterValues)); + next.delete(token); + writeResultFilterValues(params, "chapter", next, chapterFilterValues); + }), + }); + } if (familyMode && focusedItem) { appliedFilters.push({ id: isPresentation ? "pathways" : "family", - label: isPresentation ? `${focusedItem.title} related pathways` : `${focusedItem.title} family`, + groupLabel: isPresentation ? "Related pathways" : "Family", + valueLabel: focusedItem.title, onRemove: () => setFamilyMode(false), }); } function clearStreamFilters() { - setBrowseGrouping("urgency"); - setFamilyMode(false); - setPresentationPriority("all"); + replaceResultFilterUrl((params) => { + params.delete("scope"); + params.delete("priority"); + params.delete("chapter"); + params.delete("related"); + }); } function renderCardGrid(items: DifferentialStreamItem[]): ReactNode { @@ -643,37 +697,34 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: 0 ? ( - setFilterOpen((current) => !current)} - title={`Filter ${copy.heading.toLowerCase()}`} - /> + setFilterOpen((current) => !current)} + title={`Filter ${copy.heading.toLowerCase()}`} + /> + } + utilityControls={ + showBrowseGrouping ? ( + ) : undefined } filterControls={ - mobileFilterGroups.length > 0 ? ( - - ) : undefined + setFilterOpen((current) => !current)} + title={`Filter ${copy.heading.toLowerCase()}`} + /> } appliedFilters={appliedFilters} onClearFilters={activeFilterCount > 0 ? clearStreamFilters : undefined} @@ -686,20 +737,40 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: title={`Filter ${copy.heading.toLowerCase()}`} description={ isPresentation - ? "Narrow pathways by clinical priority or shared diagnostic candidates." - : "Choose how catalogue entries are grouped or narrowed." + ? "Choose query scope, urgency, or pathways related to the focused presentation." + : "Choose query scope, urgency, diagnostic chapter, presentation family, or related entries." } groups={mobileFilterGroups} + scope={{ + label: "Search in", + value: resultScope, + onChange: (value) => setResultScope(value as DifferentialResultScope), + options: [ + { + value: "matches", + label: "Matches", + count: filterStreamItems("matches", presentationPriority, selectedChapterFilters, familyMode).length, + description: "Keep entries matched by the current query.", + }, + { + value: "all", + label: "All entries", + count: filterStreamItems("all", presentationPriority, selectedChapterFilters, familyMode).length, + description: "Apply refinements across this whole stream.", + }, + ], + }} onClearAll={activeFilterCount > 0 ? clearStreamFilters : undefined} - footerNote={`${visibleItems.length} ${ - isPresentation + summary={{ + count: visibleItems.length, + noun: isPresentation ? visibleItems.length === 1 ? "pathway" : "pathways" : visibleItems.length === 1 ? "entry" - : "entries" - } showing`} + : "entries", + }} /> {!hasQuery && model.presets.length > 0 ? ( diff --git a/src/components/dsm/dsm-search-page.tsx b/src/components/dsm/dsm-search-page.tsx index 1aa44f4baf..af945f553a 100644 --- a/src/components/dsm/dsm-search-page.tsx +++ b/src/components/dsm/dsm-search-page.tsx @@ -1,262 +1,149 @@ "use client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { - useId, - useMemo, - useRef, - useState, - type FocusEvent as ReactFocusEvent, - type KeyboardEvent as ReactKeyboardEvent, -} from "react"; -import { - BookOpenCheck, - Check, - ChevronDown, - ChevronRight, - CircleAlert, - GitCompareArrows, - ListFilter, - SearchX, - X, -} from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import { useId, useMemo, useState } from "react"; +import { BookOpenCheck, Check, ChevronRight, CircleAlert, GitCompareArrows, SearchX } from "lucide-react"; -import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; +import { + SearchResultsHeaderBand, + type AppliedFilterChip, +} from "@/components/clinical-dashboard/search-results-header-band"; import { ResultFilterSheet, ResultFilterTrigger, - resultFilterGroup, + resultFilterFacetGroup, } from "@/components/clinical-dashboard/result-filter-control"; -import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { cn, codeText, EmptyState, metadataPill, pageContainer, searchFocusRing } from "@/components/ui-primitives"; import type { DsmCategory, DsmDiagnosisSummary } from "@/lib/dsm"; - -function categoryHref(query: string, category?: string, ids: string[] = []) { - const params = new URLSearchParams(); - if (query) params.set("q", query); - if (category) params.set("category", category); - if (ids.length) params.set("ids", ids.join(",")); - const suffix = params.toString(); - return suffix ? `/dsm/search?${suffix}` : "/dsm/search"; -} +import { readResultFilterValues, replaceResultFilterUrl, writeResultFilterValues } from "@/lib/result-filter-url"; function compareHref(slugs: string[]) { const params = new URLSearchParams({ ids: slugs.join(",") }); return `/dsm/compare?${params.toString()}`; } -// Compact category filter: a single trigger that opens an anchored menu of -// category links, replacing the multi-row pill wall so results sit higher on the -// page. Each option is a real navigation link (server-driven filtering), styled as -// a menuitemradio so the active category reads as the checked option. -function CategoryFilterDropdown({ - query, - categories, - activeCategory, - totalCount, - selected, -}: { - query: string; - categories: DsmCategory[]; - activeCategory?: DsmCategory; - totalCount: number; - selected: string[]; -}) { - const [open, setOpen] = useState(false); - const rootRef = useRef(null); - const triggerRef = useRef(null); - const optionRefs = useRef>([]); - const menuId = useId(); - - const options = useMemo( - () => [ - { key: undefined as string | undefined, label: "All categories", count: totalCount }, - ...categories.map((item) => ({ key: item.key, label: item.label, count: item.diagnosis_count })), - ], - [categories, totalCount], - ); - const activeIndex = activeCategory ? options.findIndex((option) => option.key === activeCategory.key) : 0; - - useDismissableLayer({ - enabled: open, - refs: [rootRef], - restoreFocusRef: triggerRef, - onDismiss: () => setOpen(false), - }); - - function focusOption(index: number) { - const total = options.length; - const next = ((index % total) + total) % total; - optionRefs.current[next]?.focus(); - } - - // Single source of truth for initial focus: whoever opens the menu picks the - // option to land on and schedules the one focus call. A parallel open-effect - // that also focused the active item would race this and clobber ArrowUp's - // reverse-entry onto the last option. - function openMenu(focusIndex: number) { - setOpen(true); - window.requestAnimationFrame(() => focusOption(focusIndex)); - } - - function handleTriggerKeyDown(event: ReactKeyboardEvent) { - if (event.key === "ArrowDown") { - event.preventDefault(); - openMenu(Math.max(0, activeIndex)); - } else if (event.key === "ArrowUp") { - event.preventDefault(); - openMenu(options.length - 1); - } - } - - function handleOptionKeyDown(event: ReactKeyboardEvent, index: number) { - if (event.key === "ArrowDown") { - event.preventDefault(); - focusOption(index + 1); - } else if (event.key === "ArrowUp") { - event.preventDefault(); - focusOption(index - 1); - } else if (event.key === "Home") { - event.preventDefault(); - focusOption(0); - } else if (event.key === "End") { - event.preventDefault(); - focusOption(options.length - 1); - } else if (event.key === " ") { - // A menuitemradio announces Space as an activation key, but the option is - // an anchor (Space would otherwise scroll), so activate it like click/Enter. - event.preventDefault(); - event.currentTarget.click(); - } - // Escape (dismiss + restore focus to the trigger) is owned by - // useDismissableLayer's document-level handler, so it isn't duplicated here. - } - - // Close when focus leaves the widget entirely (e.g. Tab off the last option), - // so the menu never lingers open over the results. Keep it open while focus - // moves between the trigger and its options, and don't prevent the focus move. - function handleRootBlur(event: ReactFocusEvent) { - if (!open) return; - const nextTarget = event.relatedTarget as Node | null; - if (nextTarget && rootRef.current?.contains(nextTarget)) return; - setOpen(false); - } - - const activeLabel = activeCategory ? activeCategory.label : "All categories"; - const activeCount = activeCategory ? activeCategory.diagnosis_count : totalCount; - - return ( -
- - - {open ? ( - - ) : null} -
- ); -} - export function DsmSearchPage({ query, - category, categories, - results, - totalCount, + results: queryResults, initialIds = [], }: { query: string; - category?: string; categories: DsmCategory[]; results: DsmDiagnosisSummary[]; - totalCount: number; initialIds?: string[]; + /** Retained for older server/test callers; filtering now uses the query-matched summaries. */ + totalCount?: number; }) { - const router = useRouter(); + const searchParams = useSearchParams(); const [selected, setSelected] = useState(initialIds.slice(0, 3)); const [filterOpen, setFilterOpen] = useState(false); const filterPanelId = useId(); - const activeCategory = categories.find((item) => item.key === category); + const categoryKeys = useMemo(() => new Set(categories.map((item) => item.key)), [categories]); + const selectedCategories = new Set(readResultFilterValues(searchParams, "category", categoryKeys)); + const supportValues = new Set(["specifiers", "differentials"] as const); + const selectedSupport = new Set(readResultFilterValues(searchParams, "support", supportValues)); + const filterDiagnoses = ( + categorySelection: ReadonlySet, + supportSelection: ReadonlySet<"specifiers" | "differentials">, + ) => + queryResults.filter((result) => { + if (categorySelection.size > 0 && !categorySelection.has(result.category.key)) return false; + if (supportSelection.has("specifiers") && result.specifierCount === 0) return false; + if (supportSelection.has("differentials") && result.differentialCount === 0) return false; + return true; + }); + const results = filterDiagnoses(selectedCategories, selectedSupport); + const activeFilterCount = selectedCategories.size + selectedSupport.size; const selectedSet = useMemo(() => new Set(selected), [selected]); const canCompare = selected.length >= 2; + function toggleCategory(value: string) { + replaceResultFilterUrl((params) => { + const current = new Set(readResultFilterValues(params, "category", categoryKeys)); + if (!current.delete(value)) current.add(value); + writeResultFilterValues(params, "category", current, categoryKeys); + }); + } + + function toggleSupport(value: "specifiers" | "differentials") { + replaceResultFilterUrl((params) => { + const current = new Set(readResultFilterValues(params, "support", supportValues)); + if (!current.delete(value)) current.add(value); + writeResultFilterValues(params, "support", current, supportValues); + }); + } + + function clearFilters() { + replaceResultFilterUrl((params) => { + params.delete("category"); + params.delete("support"); + }); + } + + const appliedFilters: AppliedFilterChip[] = [ + ...[...selectedCategories].map((key) => ({ + id: `category-${key}`, + groupLabel: "Category", + valueLabel: categories.find((item) => item.key === key)?.label ?? key, + onRemove: () => toggleCategory(key), + })), + ...[...selectedSupport].map((support) => ({ + id: `support-${support}`, + groupLabel: "Support", + valueLabel: support === "specifiers" ? "Has specifiers" : "Has differential guidance", + onRemove: () => toggleSupport(support), + })), + ]; + + const filterGroups = [ + resultFilterFacetGroup({ + id: "category", + label: "Category", + description: "Select one or more query-matched diagnostic categories.", + selected: selectedCategories, + options: categories.map((category) => { + const next = new Set(selectedCategories); + if (!next.has(category.key)) next.add(category.key); + const count = filterDiagnoses(next, selectedSupport).length; + return { + value: category.key, + label: category.label, + hint: String(count), + disabled: count === 0 && !selectedCategories.has(category.key), + }; + }), + onToggle: toggleCategory, + }), + resultFilterFacetGroup({ + id: "has-specifiers", + label: "Specifier support", + selected: new Set(selectedSupport.has("specifiers") ? ["specifiers"] : []), + options: [ + { + value: "specifiers", + label: "Has specifiers", + hint: String(filterDiagnoses(selectedCategories, new Set([...selectedSupport, "specifiers"])).length), + }, + ], + onToggle: () => toggleSupport("specifiers"), + }), + resultFilterFacetGroup({ + id: "has-differentials", + label: "Differential guidance", + selected: new Set(selectedSupport.has("differentials") ? ["differentials"] : []), + options: [ + { + value: "differentials", + label: "Has differential guidance", + hint: String(filterDiagnoses(selectedCategories, new Set([...selectedSupport, "differentials"])).length), + }, + ], + onToggle: () => toggleSupport("differentials"), + }), + ]; + function toggleDiagnosis(slug: string) { setSelected((current) => { if (current.includes(slug)) return current.filter((item) => item !== slug); @@ -274,6 +161,8 @@ export function DsmSearchPage({ matchCount={results.length} headingLevel={1} filterLabel="Filter diagnoses by category" + appliedFilters={appliedFilters} + onClearFilters={activeFilterCount > 0 ? clearFilters : undefined} mobileControlsPlacement="inline" mobileControls={ setFilterOpen((current) => !current)} /> } @@ -298,27 +187,14 @@ export function DsmSearchPage({ ) : null } filterControls={ -
- - {activeCategory ? ( - - - Clear category - - ) : null} -
+ setFilterOpen((current) => !current)} + /> } /> @@ -328,34 +204,10 @@ export function DsmSearchPage({ panelId={filterPanelId} testId="dsm-category-filter-panel" title="Filter DSM diagnoses" - groups={[ - resultFilterGroup({ - id: "category", - label: "Category", - value: activeCategory?.key ?? "all", - options: [ - { value: "all", label: "All categories", hint: String(totalCount) }, - ...categories.map((item) => ({ - value: item.key, - label: item.label, - hint: String(item.diagnosis_count), - })), - ], - onChange: (value) => { - setFilterOpen(false); - router.push(categoryHref(query, value === "all" ? undefined : value, selected)); - }, - }), - ]} - onClearAll={ - activeCategory - ? () => { - setFilterOpen(false); - router.push(categoryHref(query, undefined, selected)); - } - : undefined - } - footerNote={`${results.length} ${results.length === 1 ? "diagnosis" : "diagnoses"} showing`} + description="Narrow the query-matched summary set without changing diagnosis or comparison content." + groups={filterGroups} + onClearAll={activeFilterCount > 0 ? clearFilters : undefined} + summary={{ count: results.length, noun: results.length === 1 ? "diagnosis" : "diagnoses" }} /> {results.length ? ( @@ -464,7 +316,7 @@ export function DsmSearchPage({ {selected.length ? (
    {selected.map((slug, index) => { - const item = results.find((result) => result.slug === slug); + const item = queryResults.find((result) => result.slug === slug); return (
  1. entry.value === value); if (!option || option.disabled) return; setFilterOpen(false); - router.push(searchHref(query, value === "all" ? undefined : value)); + router.replace(searchHref(query, value === "all" ? undefined : value), { scroll: false }); }; return ( @@ -182,8 +182,8 @@ export function FactsheetsSearchPage({ {/* Phone-only by construction: the trigger that opens it lives in the ribbon's `mobileControls` slot, which the band hides from `sm` up. - Selecting a category is a navigation here, so the sheet closes with the - push — leaving it open would float over a page it no longer describes. */} + Selecting a category is replacement navigation here, so the sheet closes + before the route updates and repeated filtering does not pollute Back. */} setFilterOpen(false)} @@ -194,13 +194,14 @@ export function FactsheetsSearchPage({ resultFilterGroup({ id: "category", label: "Category", + description: activeCategoryDeadEndMessage, value: activeCategory ?? "all", options: categoryOptions, onChange: applyCategory, }), ]} onClearAll={activeCategory ? () => applyCategory("all") : undefined} - footerNote={activeCategoryDeadEndMessage ?? `${results.length} showing`} + summary={{ count: results.length, noun: results.length === 1 ? "factsheet" : "factsheets" }} /> {results.length === 0 ? ( diff --git a/src/components/forms/forms-search-results-page.tsx b/src/components/forms/forms-search-results-page.tsx index c37b896e7b..f3efeff52b 100644 --- a/src/components/forms/forms-search-results-page.tsx +++ b/src/components/forms/forms-search-results-page.tsx @@ -1,24 +1,27 @@ "use client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { - Check, - ChevronDown, - ChevronRight, - ExternalLink, - FileText, - Search, - Shield, - ShieldCheck, - SlidersHorizontal, - Workflow, - type LucideIcon, -} from "lucide-react"; -import { useId, useMemo, useState, useDeferredValue } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Check, ChevronRight, ExternalLink, ShieldCheck, Workflow } from "lucide-react"; +import { useCallback, useDeferredValue, useId, useMemo, useState } from "react"; import { appModeHomeHref } from "@/lib/app-modes"; import { formCatalogDetails, rankFormRecords, type FormSearchMatch } from "@/lib/form-ranker"; +import { + deriveFormCategories, + filterFormMatches, + formAvailabilityFilterLabels, + formAvailabilityFilterValues, + formFilterCandidateCount, + formFilterSelectionFromParams, + formFilterSelectionSize, + formMatchRisk, + formRiskFilterLabels, + formRiskFilterValues, + writeFormFilterSelection, + type FormFilterSelection, + type FormRiskFilter, +} from "@/lib/form-filters"; import { useRegistryRecords } from "@/lib/use-registry-records"; import { cn, @@ -30,16 +33,20 @@ import { searchFocusRing, searchPageCanvas, searchResultsSection, - ToggleSwitch, } from "@/components/ui-primitives"; import { SearchResultsEmptyState, SearchResultsHeaderBand, + type AppliedFilterChip, } from "@/components/clinical-dashboard/search-results-header-band"; -import { ResultFilterTrigger } from "@/components/clinical-dashboard/result-filter-control"; +import { + ResultFilterSheet, + ResultFilterTrigger, + resultFilterFacetGroup, +} from "@/components/clinical-dashboard/result-filter-control"; import { FormCodeBadge } from "@/components/forms/form-code-badge"; -import { Sheet } from "@/components/ui/sheet"; import { sortResultItems, type ResultSortValue } from "@/lib/result-sort"; +import { replaceResultFilterUrl } from "@/lib/result-filter-url"; import { useResultSort } from "@/components/use-result-sort"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; @@ -49,21 +56,6 @@ type FormsSearchResultsPageProps = { const supportsPathwayClaims = false; -const refineFilters: { - icon: LucideIcon; - title: string; - subtitle: string; - enabled: boolean; - danger?: boolean; -}[] = [ - { icon: Shield, title: "High risk only", subtitle: "Show high risk forms", enabled: false, danger: true }, - { icon: FileText, title: "Official forms", subtitle: "Limit to official forms", enabled: true }, - ...(supportsPathwayClaims - ? [{ icon: Workflow, title: "Pathway linked", subtitle: "Show pathway-linked", enabled: true }] - : []), - { icon: Search, title: "Source matches", subtitle: "Require source match", enabled: false }, -]; - function resultCode(match: FormSearchMatch, index: number) { return formCatalogDetails(match.service)?.form ?? String(index + 1); } @@ -91,34 +83,14 @@ function compactMatchReason(match: FormSearchMatch, query: string) { return "Content match in the forms catalogue"; } -type FormRiskLevel = "high" | "medium" | "low"; - -const formRiskLevels: readonly FormRiskLevel[] = ["high", "medium", "low"]; - // Risk is the only badge a phone result card carries, so it must never out-rank // itself: high is solid danger, medium is a bordered wash, low stays neutral. -const riskBadgeToneClass: Record = { +const riskBadgeToneClass: Record = { high: "bg-[color:var(--danger-solid)] text-[color:var(--danger-solid-contrast)]", medium: "border border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]", low: "border border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]", }; -/** - * Prefer the catalogue's typed `riskLevel`. Records that reach the registry - * without a catalogue payload still carry the level as their first status chip - * (`"high risk"`), so fall back to that rather than dropping the safety signal. - */ -function formRiskLevel(match: FormSearchMatch): FormRiskLevel | null { - const level = formCatalogDetails(match.service)?.riskLevel; - if (level && formRiskLevels.includes(level)) return level; - for (const chip of match.service.statusChips ?? []) { - const label = chip.label?.trim().toLowerCase() ?? ""; - const matched = formRiskLevels.find((candidate) => label === `${candidate} risk`); - if (matched) return matched; - } - return null; -} - // The catalogue generates a `purpose` for every form, but most are boilerplate // that only restates the title ("Official form source: Transfer Order. Review // the source snippets and approved form before use."). Rendering one of those @@ -191,102 +163,6 @@ function findExactFormCodeMatch(items: CodedFormMatch[], query: string): CodedFo ); } -function RefineFilterItem({ - icon: Icon, - title, - subtitle, - enabled, - danger, -}: { - icon: LucideIcon; - title: string; - subtitle: string; - enabled: boolean; - danger?: boolean; -}) { - return ( -
    - - - -
    -

    {title}

    -

    {subtitle}

    -
    - -
    - ); -} - -function RefineBar({ open, onToggle, panelId }: { open: boolean; onToggle: () => void; panelId: string }) { - return ( - - ); -} - -function RefinePanel({ open, panelId }: { open: boolean; panelId: string }) { - if (!open) return null; - return ( -
    -
    -
    -

    Refine results

    -

    Filter controls are coming soon.

    -
    - -
    -
    - {refineFilters.map((filter) => ( - - ))} -
    -
    - ); -} - const resultsGridColumns = "md:grid-cols-[72px_minmax(0,1.35fr)_minmax(0,0.85fr)_minmax(0,1.35fr)_minmax(88px,auto)]"; function ResultsTable({ @@ -505,7 +381,7 @@ const metaChipClass = "rounded-md px-2 py-1 text-3xs font-extrabold uppercase le function MobileExactMatchHero({ match, code }: CodedFormMatch) { const form = match.service; const details = formCatalogDetails(form); - const risk = formRiskLevel(match); + const risk = formMatchRisk(match); const purpose = editorialPurpose(form); // Derive availability label from the actual availability field rather than // positional statusChips indexing to avoid fragility. @@ -578,7 +454,7 @@ function MobileExactMatchHero({ match, code }: CodedFormMatch) { function MobileResultCard({ match, code }: CodedFormMatch) { const form = match.service; - const risk = formRiskLevel(match); + const risk = formMatchRisk(match); // `subtitle` is the catalogue's `purpose` — what the form is actually for. // It replaces the old "Content match in record details" line, which said the // same thing on every card. Boilerplate purposes are dropped entirely rather @@ -737,11 +613,10 @@ export function FormsSearchResultsPage(props: FormsSearchResultsPageProps) { function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { const router = useRouter(); + const searchParams = useSearchParams(); const [sortValue, setSortValue] = useResultSort(); const registry = useRegistryRecords("form"); const registryReady = registry.status === "ready" || registry.status === "refetching"; - const [refineOpen, setRefineOpen] = useState(false); - const refinePanelId = useId(); const [filterOpen, setFilterOpen] = useState(false); const filterPanelId = useId(); const deferredQuery = useDeferredValue(query); @@ -753,9 +628,114 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { if (!deferredQuery.trim()) return []; return rankFormRecords(registry.records, deferredQuery); }, [registryReady, registry.records, deferredQuery, query]); + const categoryOptions = useMemo(() => deriveFormCategories(matches), [matches]); + const filterSelection = useMemo( + () => formFilterSelectionFromParams(searchParams, categoryOptions), + [categoryOptions, searchParams], + ); + const filteredMatches = useMemo(() => filterFormMatches(matches, filterSelection), [filterSelection, matches]); const displayedMatches = useMemo( - () => sortResultItems(matches, sortValue, (match) => match.service.title), - [matches, sortValue], + () => sortResultItems(filteredMatches, sortValue, (match) => match.service.title), + [filteredMatches, sortValue], + ); + const activeFilterCount = formFilterSelectionSize(filterSelection); + + const updateFilterSelection = useCallback((next: FormFilterSelection) => { + replaceResultFilterUrl((params) => writeFormFilterSelection(params, next)); + }, []); + + const toggleFilter = useCallback( + (dimension: keyof FormFilterSelection, value: string) => { + replaceResultFilterUrl((params) => { + const current = formFilterSelectionFromParams(params, categoryOptions); + const nextValues = new Set(current[dimension] as ReadonlySet); + if (!nextValues.delete(value)) nextValues.add(value); + writeFormFilterSelection(params, { ...current, [dimension]: nextValues }); + }); + }, + [categoryOptions], + ); + + const clearFilters = useCallback(() => { + updateFilterSelection({ categories: new Set(), risks: new Set(), availability: new Set() }); + }, [updateFilterSelection]); + + const filterGroups = useMemo( + () => [ + resultFilterFacetGroup({ + id: "category", + label: "Category", + description: "Statutory and clinical form families.", + selected: filterSelection.categories, + options: categoryOptions.map((category) => { + const count = formFilterCandidateCount(matches, filterSelection, "categories", category); + return { + value: category, + label: category, + hint: String(count), + disabled: count === 0 && !filterSelection.categories.has(category), + }; + }), + onToggle: (value) => toggleFilter("categories", value), + }), + resultFilterFacetGroup({ + id: "risk", + label: "Clinical risk", + description: "Risk classification recorded in the form catalogue.", + selected: filterSelection.risks, + options: formRiskFilterValues.map((risk) => { + const count = formFilterCandidateCount(matches, filterSelection, "risks", risk); + return { + value: risk, + label: formRiskFilterLabels[risk], + hint: String(count), + disabled: count === 0 && !filterSelection.risks.has(risk), + }; + }), + onToggle: (value) => toggleFilter("risks", value), + }), + resultFilterFacetGroup({ + id: "availability", + label: "Availability", + description: "How the catalogue record can be obtained.", + selected: filterSelection.availability, + options: formAvailabilityFilterValues.map((availability) => { + const count = formFilterCandidateCount(matches, filterSelection, "availability", availability); + return { + value: availability, + label: formAvailabilityFilterLabels[availability], + hint: String(count), + disabled: count === 0 && !filterSelection.availability.has(availability), + }; + }), + onToggle: (value) => toggleFilter("availability", value), + }), + ], + [categoryOptions, filterSelection, matches, toggleFilter], + ); + + const appliedFilters = useMemo( + () => [ + ...[...filterSelection.categories].map((category) => ({ + id: `category-${category}`, + groupLabel: "Category", + valueLabel: category, + onRemove: () => toggleFilter("categories", category), + })), + ...[...filterSelection.risks].map((risk) => ({ + id: `risk-${risk}`, + groupLabel: "Risk", + valueLabel: formRiskFilterLabels[risk], + onRemove: () => toggleFilter("risks", risk), + })), + ...[...filterSelection.availability].map((availability) => ({ + id: `availability-${availability}`, + groupLabel: "Availability", + valueLabel: formAvailabilityFilterLabels[availability], + onRemove: () => toggleFilter("availability", availability), + })), + ], + [filterSelection, toggleFilter], ); const renderFilterTrigger = (testId: string) => ( @@ -764,7 +744,7 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { testId={testId} title="Filter form results" open={filterOpen} - activeCount={0} + activeCount={activeFilterCount} onToggle={() => setFilterOpen((current) => !current)} /> ); @@ -809,80 +789,39 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { } sortValue={sortValue} onSortChange={setSortValue} + appliedFilters={appliedFilters} + onClearFilters={activeFilterCount > 0 ? clearFilters : undefined} filterLabel="Filter form results" - // Same Documents idiom: compact funnel trigger in both slots so the - // one-line band stays universal. The panel is a coming-soon placeholder - // until form facets ship (see docs/search-results-bar-decisions.md). mobileControlsPlacement="inline" mobileControls={renderFilterTrigger("form-filter-trigger-phone")} - filterControls={ - supportsPathwayClaims ? ( -
    - {renderFilterTrigger("form-filter-trigger-wide")} - setRefineOpen((open) => !open)} panelId={refinePanelId} /> -
    - ) : ( - renderFilterTrigger("form-filter-trigger-wide") - ) - } + filterControls={renderFilterTrigger("form-filter-trigger-wide")} /> - setFilterOpen(false)} - title="Filter form results" - description="Form filters are not available yet." - portal - id={filterPanelId} + panelId={filterPanelId} testId="form-filter-panel" - footer={ -
    - -
    - } - > -
    -

    - Form filters are coming soon. Sorting still works from the results bar. -

    - - - Form filters are coming soon. - -
    -
    + title="Filter form results" + description="Narrow by form family, recorded clinical risk, and access route." + groups={filterGroups} + onClearAll={activeFilterCount > 0 ? clearFilters : undefined} + summary={{ count: displayedMatches.length, noun: displayedMatches.length === 1 ? "form" : "forms" }} + chromeResetKey={query} + /> {registryReady ? ( <> {query.trim() && deferredQuery === query && displayedMatches.length === 0 ? ( 0 ? clearFilters : undefined} onTryExample={(example) => router.push(appModeHomeHref("forms", { query: example, focus: true, run: true })) } /> ) : ( <> - {supportsPathwayClaims ? : null}
    diff --git a/src/components/formulation/formulation-home-page.tsx b/src/components/formulation/formulation-home-page.tsx index 9fd31b8774..63a28f68aa 100644 --- a/src/components/formulation/formulation-home-page.tsx +++ b/src/components/formulation/formulation-home-page.tsx @@ -1,8 +1,8 @@ "use client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useCallback, useId, useMemo, useState, useDeferredValue } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useDeferredValue, useId, useMemo, useState } from "react"; import { ArrowRight, CheckCircle2, @@ -24,9 +24,11 @@ import { } from "@/components/formulation/formulation-ui"; import { ClinicalPathwayStrip } from "@/components/clinical-record-panels"; import { ModeHomeMain, ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template"; -import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; import { - ResultFilterFacetChips, + SearchResultsHeaderBand, + type AppliedFilterChip, +} from "@/components/clinical-dashboard/search-results-header-band"; +import { ResultFilterSheet, ResultFilterTrigger, resultFilterFacetGroup, @@ -36,12 +38,14 @@ import { cn, eyebrowText } from "@/components/ui-primitives"; import { appModeHomeHref } from "@/lib/app-modes"; import { formulationDomainsInUse, + formulationDomainGroups, formulationSearchPresets, formulationTemplates, searchFormulationMechanisms, } from "@/lib/formulation"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; +import { readResultFilterValues, replaceResultFilterUrl, writeResultFilterValues } from "@/lib/result-filter-url"; function presetHref(query: string) { return appModeHomeHref("formulation", { query, run: true, focus: true }); @@ -152,9 +156,14 @@ function EmptySearchResults({ query }: { query: string }) { function FormulationResults({ query }: { query: string }) { const router = useRouter(); + const searchParams = useSearchParams(); // Many-of-N. A mechanism carries 3.92 domains on average, so a radio set // claimed the reader could not hold Affect and Risk at once, which is false. - const [domains, setDomains] = useState>(() => new Set()); + const domainValues = useMemo(() => new Set(formulationDomainsInUse), []); + const domains = useMemo( + () => new Set(readResultFilterValues(searchParams, "domain", domainValues)), + [domainValues, searchParams], + ); const filterPanelId = useId(); const [filterOpen, setFilterOpen] = useState(false); const deferredQuery = useDeferredValue(query); @@ -177,13 +186,16 @@ function FormulationResults({ query }: { query: string }) { }, [domains, deferredQuery, query]); const hasUniqueTopMatch = results.length > 0 && (results.length < 2 || results[0].score !== results[1].score); - const toggleDomain = useCallback((value: string) => { - setDomains((current) => { - const next = new Set(current); - if (!next.delete(value)) next.add(value); - return next; - }); - }, []); + const toggleDomain = useCallback( + (value: string) => { + replaceResultFilterUrl((params) => { + const next = new Set(readResultFilterValues(params, "domain", domainValues)); + if (!next.delete(value)) next.add(value); + writeResultFilterValues(params, "domain", next, domainValues); + }); + }, + [domainValues], + ); // "How many would I have if I ticked this as well" — the same predicate as the // filter, run with the candidate added. Under OR-within-group adding an option @@ -194,7 +206,14 @@ function FormulationResults({ query }: { query: string }) { resultFilterFacetGroup({ id: "domain", label: "Domain", + description: "Domains combine with OR. Sections organise the taxonomy without changing that predicate.", selected: domains, + optionSections: formulationDomainGroups.map((section) => ({ + id: section.id, + label: section.label, + description: section.description, + optionValues: section.domains.filter((domain) => domainValues.has(domain)), + })), options: formulationDomainsInUse.map((item) => { const withCandidate = pendingRanking ? 0 @@ -217,8 +236,18 @@ function FormulationResults({ query }: { query: string }) { }), onToggle: toggleDomain, }), - [domains, pendingRanking, searchQuery, toggleDomain], + [domains, domainValues, pendingRanking, searchQuery, toggleDomain], ); + const appliedFilters: AppliedFilterChip[] = [...domains].map((domain) => ({ + id: `domain-${domain}`, + groupLabel: "Domain", + valueLabel: domain, + onRemove: () => toggleDomain(domain), + })); + const clearDomains = () => + replaceResultFilterUrl((params) => { + params.delete("domain"); + }); return ( @@ -233,6 +262,8 @@ function FormulationResults({ query }: { query: string }) { status={rankingReady ? "ready" : "refetching"} headingLevel={1} filterLabel="Filter formulation mechanisms" + appliedFilters={appliedFilters} + onClearFilters={domains.size > 0 ? clearDomains : undefined} // One compact badged trigger replaces the two-column grid of selects, so // the band collapses to one line here too. mobileControlsPlacement="inline" @@ -250,7 +281,16 @@ function FormulationResults({ query }: { query: string }) { // drift. The preset row that used to sit here has moved below the band: // it replaced the query rather than narrowing it, which a control // labelled "Filter" must not do. - filterControls={} + filterControls={ + setFilterOpen((current) => !current)} + /> + } /> {/* Phone-only by construction: the trigger that opens it lives in the @@ -266,8 +306,8 @@ function FormulationResults({ query }: { query: string }) { testId="formulation-filter-panel" title="Filter formulation mechanisms" groups={[domainGroup]} - onClearAll={domains.size === 0 ? undefined : () => setDomains(new Set())} - footerNote={`${results.length} showing`} + onClearAll={domains.size === 0 ? undefined : clearDomains} + summary={{ count: results.length, noun: results.length === 1 ? "mechanism" : "mechanisms" }} /> {/* Evicted from the filter sheet, and all five rather than the first four: diff --git a/src/components/services/service-group-nav.tsx b/src/components/services/service-group-nav.tsx new file mode 100644 index 0000000000..b0866e99f4 --- /dev/null +++ b/src/components/services/service-group-nav.tsx @@ -0,0 +1,113 @@ +"use client"; + +import Link from "next/link"; +import { ChevronDown } from "lucide-react"; +import { useId, useState } from "react"; + +import { Sheet } from "@/components/ui/sheet"; +import { cn } from "@/components/ui-primitives"; +import { serviceCoreGroups, type ServiceCoreGroupId } from "@/lib/service-core-groups"; + +type GroupValue = ServiceCoreGroupId | null; + +export function ServiceGroupNav({ + activeGroup, + hrefForGroup, + counts, + className, +}: { + activeGroup: GroupValue; + hrefForGroup: (group: GroupValue) => string; + counts?: Partial> & { all?: number }; + className?: string; +}) { + const [moreOpen, setMoreOpen] = useState(false); + const panelId = useId(); + const phonePrimary = serviceCoreGroups.slice(0, 2); + const phoneMore = serviceCoreGroups.slice(2); + const moreActive = phoneMore.some((group) => group.id === activeGroup); + + const groupLink = (group: (typeof serviceCoreGroups)[number], phone = false) => ( + setMoreOpen(false)} + className={cn( + "inline-flex min-h-tap min-w-0 items-center justify-center gap-1.5 rounded-lg border px-3 text-xs font-bold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-10", + activeGroup === group.id + ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" + : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)]", + )} + > + {phone ? group.shortLabel : group.label} + {typeof counts?.[group.id] === "number" ? ( + {counts[group.id]} + ) : null} + + ); + + return ( + + ); +} diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index 3b3cad3a27..3e24327226 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -18,10 +18,10 @@ import { useAccountData } from "@/components/account-data-provider"; import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { SearchResultsLayout } from "@/components/clinical-dashboard/search-results-layout"; import { - type AppliedFilterChip, SearchResultsEmptyState, SearchResultsHeaderBand, SearchResultsSkeleton, + type AppliedFilterChip, } from "@/components/clinical-dashboard/search-results-header-band"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; import { @@ -30,18 +30,17 @@ import { resultFilterFacetGroup, resultFilterGroup, } from "@/components/clinical-dashboard/result-filter-control"; -import { SegmentedControl } from "@/components/ui/segmented-control"; +import { ServiceGroupNav } from "@/components/services/service-group-nav"; import { Chip as DesignChip, type ChipStatusTone } from "@/components/ui/chip"; import { cn } from "@/components/ui-primitives"; import { useResultSort } from "@/components/use-result-sort"; import { compactBestUseTitle } from "@/lib/compact-best-use-title"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { - readServiceCoreGroupSelection, + readServiceCoreGroup, serviceCoreGroupLabel, serviceCoreGroups, - serviceMatchesCoreGroupSelection, - writeServiceCoreGroupSelectionToParams, + serviceMatchesCoreGroup, type ServiceCoreGroupId, } from "@/lib/service-core-groups"; import { @@ -60,6 +59,7 @@ import { type ServiceFacetDimension, } from "@/lib/service-facets"; import { rankServiceRecords, type ServiceRecord, type ServiceStatusChip } from "@/lib/service-ranker"; +import { replaceResultFilterUrl } from "@/lib/result-filter-url"; import { sortResultItems } from "@/lib/result-sort"; import { useRegistryRecords } from "@/lib/use-registry-records"; @@ -259,63 +259,44 @@ const referralStages = [ type ReferralStageId = (typeof referralStages)[number]["id"]; /** - * A compact progress rail, not the old four-card walkthrough. It sits directly - * below the results heading because it describes the current search journey; - * category and suggestion navigation no longer compete for this position. + * The progressive replacement for the old four-card numbered walkthrough + * (direction B, ledger #163): one ~20px line of dots that says where you are + * without spending a third of the fold saying it. * - * The accessible name stays distinct from `ServiceReferralFlow` on the service - * record. "Refer" is reached there, but remains visible here so the whole path - * is understandable before a reader starts a shortlist. + * The accessible name is deliberately NOT "Referral workflow" — that name + * belongs to `ServiceReferralFlow` on the service record, and + * `tests/ui-tools.spec.ts` asserts it is absent from the results route so the + * removed walkthrough cannot creep back in. Reusing it here would satisfy that + * assertion's letter and defeat its purpose. + * + * "Refer" is never the active stage here; it is reached on the record itself. + * It stays in the rail because the point is showing the whole path, not just + * the part this page owns. */ function ServiceReferralProgress({ active }: { active: ReferralStageId }) { const activeIndex = referralStages.findIndex((stage) => stage.id === active); return ( -
) : ( <> diff --git a/src/components/specifiers/specifiers-home-page.tsx b/src/components/specifiers/specifiers-home-page.tsx index 0d2637a840..04dd907b25 100644 --- a/src/components/specifiers/specifiers-home-page.tsx +++ b/src/components/specifiers/specifiers-home-page.tsx @@ -1,38 +1,51 @@ "use client"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import { useId, useMemo, useState } from "react"; import { ArrowRight, ChevronRight, GitCompareArrows, ListChecks, Search, Tags } from "lucide-react"; import { ClinicalPathwayStrip } from "@/components/clinical-record-panels"; import { ModeHomeMain, ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template"; -import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; +import { + SearchResultsHeaderBand, + type AppliedFilterChip, +} from "@/components/clinical-dashboard/search-results-header-band"; import { ResultFilterSheet, ResultFilterTrigger, + resultFilterFacetGroup, resultFilterGroup, } from "@/components/clinical-dashboard/result-filter-control"; import { CategoryTag, ReviewStatusBadge, - SpecifierDiagnosisFilter, SpecifierMatchCard, SpecifierPageShell, SpecifierSafetyNote, specifierCard, } from "@/components/specifiers/specifier-ui"; -import { SegmentedControl } from "@/components/ui/segmented-control"; import { cn } from "@/components/ui-primitives"; import { appModeHomeHref } from "@/lib/app-modes"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { searchSpecifiers, specifierFamilies, specifierSearchPresets, type SpecifierFamily } from "@/lib/specifiers"; import { searchSpecifierCatalog, type SpecifierCatalogMatch } from "@/lib/specifiers-search-index"; +import { + readResultFilterValue, + readResultFilterValues, + replaceResultFilterUrl, + writeResultFilterValue, + writeResultFilterValues, +} from "@/lib/result-filter-url"; // The curated set covers a small number of high-signal mood-episode specifiers. // The full DSM-5-TR catalogue (~585 items) is surfaced additively beneath the // curated matches so a search still reaches the broader taxonomy without displacing // the richer curated cards. const CATALOGUE_RESULT_LIMIT = 24; +const CATALOGUE_RESULT_INCREMENT = 24; +type SpecifierResultScope = "guides" | "catalogue"; +const specifierScopeValues = new Set(["guides", "catalogue"]); const diagnosisOptions = [ { value: "", label: "All diagnoses" }, @@ -184,34 +197,186 @@ function SpecifierCatalogueMatches({ matches }: { matches: SpecifierCatalogMatch } function SpecifierResults({ query }: { query: string }) { - const [family, setFamily] = useState<"all" | SpecifierFamily>("all"); - const [diagnosis, setDiagnosis] = useState(""); + const searchParams = useSearchParams(); const filterPanelId = useId(); const [filterOpen, setFilterOpen] = useState(false); - const results = useMemo(() => searchSpecifiers(query, { family, diagnosis }), [diagnosis, family, query]); - // The full-catalogue section is additive and diagnosis-specific, so it is NOT - // de-duped against the curated cards: those are generic mood-only specifiers, and - // a label-only match would wrongly hide the disorder-specific catalogue rows (e.g. - // a curated "With catatonia" card must not remove schizophrenia/autism catatonia). - // It still drives the shared count and empty-state so a catalog-only query never - // shows "0 matches" with an empty-state banner above real results. - const catalogueMatches = useMemo(() => searchSpecifierCatalog(query).slice(0, CATALOGUE_RESULT_LIMIT), [query]); - const totalMatches = results.length + catalogueMatches.length; - // One array for the desktop rail and the phone sheet. The full label is safe - // at both: the band hides `filterControls` below `sm` whenever a page also - // supplies `mobileControls`, so the chips' phone-only short label could never - // render and the two breakpoints never showed different text. + const allGuideMatches = useMemo(() => searchSpecifiers(query, { family: "all", diagnosis: "" }), [query]); + const allCatalogueMatches = useMemo(() => searchSpecifierCatalog(query), [query]); + const defaultScope: SpecifierResultScope = allGuideMatches.length > 0 ? "guides" : "catalogue"; + const scope = readResultFilterValue(searchParams, "scope", specifierScopeValues, defaultScope); + const familyValues = useMemo(() => new Set(specifierFamilies.map((option) => option.id)), []); + const diagnosisValues = useMemo(() => new Set(diagnosisOptions.map((option) => option.value)), []); + const family = readResultFilterValue(searchParams, "family", familyValues, "all") as "all" | SpecifierFamily; + const diagnosis = readResultFilterValue(searchParams, "diagnosis", diagnosisValues, ""); + const categoryOptions = useMemo( + () => + [...new Map(allCatalogueMatches.map(({ item }) => [item.categoryId, item.category])).entries()] + .map(([value, label]) => ({ value, label })) + .sort((left, right) => left.label.localeCompare(right.label)), + [allCatalogueMatches], + ); + const categoryValues = useMemo(() => new Set(categoryOptions.map((option) => option.value)), [categoryOptions]); + const selectedCategories = new Set(readResultFilterValues(searchParams, "category", categoryValues)); + const reviewedOnly = searchParams.get("reviewed") === "1"; + const guideMatches = useMemo(() => searchSpecifiers(query, { family, diagnosis }), [diagnosis, family, query]); + const catalogueMatches = allCatalogueMatches.filter( + ({ item }) => + (selectedCategories.size === 0 || selectedCategories.has(item.categoryId)) && + (!reviewedOnly || item.src === "source-verified"), + ); + const matchingTotal = scope === "guides" ? guideMatches.length : catalogueMatches.length; + const resultSignature = `${query}|${scope}|${[...selectedCategories].sort().join(",")}|${reviewedOnly}`; + const [visibleState, setVisibleState] = useState({ signature: resultSignature, count: CATALOGUE_RESULT_LIMIT }); + if (visibleState.signature !== resultSignature) { + setVisibleState({ signature: resultSignature, count: CATALOGUE_RESULT_LIMIT }); + } + const visibleCatalogueMatches = catalogueMatches.slice(0, visibleState.count); + const familyOptions = useMemo( () => specifierFamilies.map((option) => ({ value: option.id, label: option.label })), [], ); + const updateLens = ( + key: "scope" | "family" | "diagnosis", + value: Value, + fallback: Value, + allowed: ReadonlySet, + ) => replaceResultFilterUrl((params) => writeResultFilterValue(params, key, value, fallback, allowed)); + const toggleCategory = (value: string) => + replaceResultFilterUrl((params) => { + const next = new Set(readResultFilterValues(params, "category", categoryValues)); + if (!next.delete(value)) next.add(value); + writeResultFilterValues(params, "category", next, categoryValues); + }); + const setReviewedOnly = (enabled: boolean) => + replaceResultFilterUrl((params) => { + if (enabled) params.set("reviewed", "1"); + else params.delete("reviewed"); + }); + const clearFilters = () => + replaceResultFilterUrl((params) => { + params.delete("scope"); + params.delete("family"); + params.delete("diagnosis"); + params.delete("category"); + params.delete("reviewed"); + }); + const activeFilterCount = + Number(scope !== defaultScope) + + (scope === "guides" + ? Number(family !== "all") + Number(Boolean(diagnosis)) + : selectedCategories.size + Number(reviewedOnly)); + const appliedFilters: AppliedFilterChip[] = []; + if (scope !== defaultScope) { + appliedFilters.push({ + id: "scope", + groupLabel: "Search in", + valueLabel: scope === "guides" ? "Clinical guides" : "Full catalogue", + onRemove: () => updateLens("scope", defaultScope, defaultScope, specifierScopeValues), + }); + } + if (scope === "guides") { + if (family !== "all") { + appliedFilters.push({ + id: "family", + groupLabel: "Family", + valueLabel: familyOptions.find((option) => option.value === family)?.label ?? family, + onRemove: () => updateLens("family", "all", "all", familyValues), + }); + } + if (diagnosis) { + appliedFilters.push({ + id: "diagnosis", + groupLabel: "Diagnosis", + valueLabel: diagnosisOptions.find((option) => option.value === diagnosis)?.label ?? diagnosis, + onRemove: () => updateLens("diagnosis", "", "", diagnosisValues), + }); + } + } else { + for (const category of selectedCategories) { + appliedFilters.push({ + id: `category-${category}`, + groupLabel: "Category", + valueLabel: categoryOptions.find((option) => option.value === category)?.label ?? category, + onRemove: () => toggleCategory(category), + }); + } + if (reviewedOnly) { + appliedFilters.push({ + id: "reviewed", + groupLabel: "Source", + valueLabel: "Source verified", + onRemove: () => setReviewedOnly(false), + }); + } + } + + const groups = + scope === "guides" + ? [ + resultFilterGroup({ + id: "family", + label: "Family", + value: family, + options: familyOptions.map((option) => ({ + ...option, + hint: String(searchSpecifiers(query, { family: option.value, diagnosis }).length), + })), + onChange: (value) => updateLens("family", value, "all", familyValues), + }), + resultFilterGroup({ + id: "diagnosis", + label: "Diagnosis", + value: diagnosis, + options: diagnosisOptions.map((option) => ({ + ...option, + hint: String(searchSpecifiers(query, { family, diagnosis: option.value }).length), + })), + onChange: (value) => updateLens("diagnosis", value, "", diagnosisValues), + }), + ] + : [ + resultFilterFacetGroup({ + id: "category", + label: "Category", + selected: selectedCategories, + options: categoryOptions.map((option) => { + const next = new Set(selectedCategories); + if (!next.has(option.value)) next.add(option.value); + const count = allCatalogueMatches.filter( + ({ item }) => next.has(item.categoryId) && (!reviewedOnly || item.src === "source-verified"), + ).length; + return { ...option, hint: String(count), disabled: count === 0 && !selectedCategories.has(option.value) }; + }), + onToggle: toggleCategory, + }), + resultFilterFacetGroup({ + id: "reviewed", + label: "Source verification", + selected: new Set(reviewedOnly ? ["reviewed"] : []), + options: [ + { + value: "reviewed", + label: "Source verified", + hint: String( + allCatalogueMatches.filter( + ({ item }) => + item.src === "source-verified" && + (selectedCategories.size === 0 || selectedCategories.has(item.categoryId)), + ).length, + ), + }, + ], + onToggle: () => setReviewedOnly(!reviewedOnly), + }), + ]; return ( setFilterOpen((current) => !current)} /> } filterControls={ -
- - -
+ setFilterOpen((current) => !current)} + /> } + appliedFilters={appliedFilters} + onClearFilters={activeFilterCount > 0 ? clearFilters : undefined} /> {/* Phone-only by construction: the trigger that opens it lives in the ribbon's `mobileControls` slot, which the band hides from `sm` up. Both @@ -250,48 +415,55 @@ function SpecifierResults({ query }: { query: string }) { panelId={filterPanelId} testId="specifier-filter-panel" title="Filter specifiers" - groups={[ - resultFilterGroup({ - id: "family", - label: "Family", - value: family, - options: familyOptions, - onChange: setFamily, - }), - resultFilterGroup({ - id: "diagnosis", - label: "Diagnosis", - value: diagnosis, - options: diagnosisOptions, - onChange: setDiagnosis, - }), - ]} - onClearAll={ - family === "all" && diagnosis === "" - ? undefined - : () => { - setFamily("all"); - setDiagnosis(""); - } - } - // `results`, not `totalMatches`. Both groups narrow only the curated - // list; `catalogueMatches` keys on the query alone, so reporting the sum - // claimed the sheet scoped a total its filters could not move — the - // count sat still while a filter visibly changed the list. - footerNote={`${results.length} showing`} + scope={{ + label: "Search in", + value: scope, + onChange: (value) => updateLens("scope", value as SpecifierResultScope, defaultScope, specifierScopeValues), + options: [ + { + value: "guides", + label: "Clinical guides", + count: guideMatches.length, + description: "Curated decision-support guides.", + }, + { + value: "catalogue", + label: "Full catalogue", + count: catalogueMatches.length, + description: "The complete disorder-specific catalogue.", + }, + ], + }} + groups={groups} + onClearAll={activeFilterCount > 0 ? clearFilters : undefined} + summary={{ count: matchingTotal, noun: matchingTotal === 1 ? "specifier" : "specifiers" }} /> - {totalMatches === 0 ? ( + {matchingTotal === 0 ? ( - ) : results.length > 0 ? ( + ) : scope === "guides" ? (
- {results.map(({ record }, index) => ( + {guideMatches.map(({ record }, index) => ( ))}
) : null} - + {scope === "catalogue" ? : null} + {scope === "catalogue" && visibleCatalogueMatches.length < catalogueMatches.length ? ( + + ) : null}
diff --git a/src/components/therapy-compass/screens/search-screen.tsx b/src/components/therapy-compass/screens/search-screen.tsx index 894554c24f..7dca4908e1 100644 --- a/src/components/therapy-compass/screens/search-screen.tsx +++ b/src/components/therapy-compass/screens/search-screen.tsx @@ -45,9 +45,18 @@ export function SearchScreen() { // shelf. The query is deliberately absent: it is stated in the composer and // removing it is not a filter operation. const appliedFilters = [ - ...b.search.tags.map((tag) => ({ id: `topic-${tag}`, label: tag, onRemove: () => b.toggleTag(tag) })), - ...(b.search.reviewedOnly ? [{ id: "reviewed", label: "Reviewed only", onRemove: b.toggleReviewedOnly }] : []), - ...(b.search.briefOnly ? [{ id: "brief", label: "Brief available", onRemove: b.toggleBriefOnly }] : []), + ...b.search.tags.map((tag) => ({ + id: `topic-${tag}`, + groupLabel: "Topic", + valueLabel: tag, + onRemove: () => b.toggleTag(tag), + })), + ...(b.search.reviewedOnly + ? [{ id: "reviewed", groupLabel: "Evidence", valueLabel: "Reviewed", onRemove: b.toggleReviewedOnly }] + : []), + ...(b.search.briefOnly + ? [{ id: "brief", groupLabel: "Availability", valueLabel: "Brief available", onRemove: b.toggleBriefOnly }] + : []), ]; const filterPanelId = useId(); const [filterOpen, setFilterOpen] = useState(false); @@ -204,7 +213,7 @@ export function SearchScreen() { // "Clear search" control (every breakpoint) already covers wiping the // query; this sheet only ever clears what it itself narrows. onClearAll={activeFilterCount > 0 ? b.clearSearchFilters : undefined} - footerNote={`${results.length} therap${results.length === 1 ? "y" : "ies"}`} + summary={{ count: results.length, noun: results.length === 1 ? "therapy" : "therapies" }} /> {/* The band's fault panel owns the failure. Without this guard an error diff --git a/src/components/tools/tools-search-results-page.tsx b/src/components/tools/tools-search-results-page.tsx index 43f5e5ef5e..dd74cbafc3 100644 --- a/src/components/tools/tools-search-results-page.tsx +++ b/src/components/tools/tools-search-results-page.tsx @@ -418,7 +418,7 @@ export function ToolsSearchResultsPage({ }), ]} onClearAll={effectiveActiveFilter === "all" ? undefined : () => setActiveFilter("all")} - footerNote={`${filteredTools.length} showing`} + summary={{ count: filteredTools.length, noun: filteredTools.length === 1 ? "tool" : "tools" }} />
diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 1baba55e63..7072c0690e 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -61,7 +61,8 @@ type SheetBaseProps = { bodyRef?: RefObject; onBodyScroll?: UIEventHandler; footerClassName?: string; - placement?: "default" | "left"; + /** Side placement is opt-in so existing dialogs keep their centred layout. */ + placement?: "default" | "left" | "right" | "responsive-right"; mobilePlacement?: "bottom" | "top" | "fullscreen"; mobileSize?: SheetMobileSize; /** @@ -357,9 +358,11 @@ export function Sheet({ } const resolvedAriaLabel = ariaLabel || (!title && !labelledBy ? "Dialog" : undefined); const resolvedLabelledBy = labelledBy ?? (title ? titleId : undefined); - const defaultSheetIsFullscreen = placement !== "left" && mobilePlacement === "fullscreen"; - const defaultSheetIsTopAligned = placement !== "left" && mobilePlacement === "top"; - const defaultSheetUsesViewportSize = placement !== "left" && mobileSize === "viewport"; + const sideSheet = placement === "left" || placement === "right"; + const responsiveSideSheet = placement === "responsive-right"; + const defaultSheetIsFullscreen = !sideSheet && mobilePlacement === "fullscreen"; + const defaultSheetIsTopAligned = !sideSheet && mobilePlacement === "top"; + const defaultSheetUsesViewportSize = !sideSheet && mobileSize === "viewport"; const resolvedMobileHeaderSafeArea = mobileHeaderSafeArea ?? (defaultSheetIsFullscreen ? "padding" : "none"); const contentClassTokens = contentClassName?.split(/\s+/) ?? []; const hasMobileMaxHeight = contentClassTokens.some((token) => /^!?max-h-/.test(token)); @@ -373,14 +376,18 @@ export function Sheet({ // branch still stacks above sibling chrome. "pointer-events-auto fixed inset-0 z-[var(--z-modal)] flex bg-[color:var(--overlay-backdrop)] backdrop-blur-[2px] motion-reduce:animate-none motion-reduce:transition-none", desktopBackdropClassName, - placement !== "left" && "motion-safe:animate-overlay-in", + !sideSheet && !responsiveSideSheet && "motion-safe:animate-overlay-in", placement === "left" ? "items-stretch justify-start" - : defaultSheetIsFullscreen - ? "items-stretch justify-center p-0 lg:items-center lg:p-6" - : defaultSheetIsTopAligned - ? "items-start justify-center px-3 pb-3 pt-[max(0.75rem,env(safe-area-inset-top))] sm:items-center sm:p-6" - : "items-end justify-center sm:items-center sm:p-6", + : placement === "right" + ? "items-stretch justify-end" + : placement === "responsive-right" + ? "items-end justify-center sm:items-stretch sm:justify-end sm:p-0" + : defaultSheetIsFullscreen + ? "items-stretch justify-center p-0 lg:items-center lg:p-6" + : defaultSheetIsTopAligned + ? "items-start justify-center px-3 pb-3 pt-[max(0.75rem,env(safe-area-inset-top))] sm:items-center sm:p-6" + : "items-end justify-center sm:items-center sm:p-6", )} // Dismiss on click (not pointerdown) so the sheet stays mounted through // pointerup and the same gesture cannot click-through into content below. @@ -414,30 +421,34 @@ export function Sheet({ "transition duration-[var(--duration-moderate)] motion-reduce:transition-none sm:duration-[var(--duration-quick)]", placement === "left" ? "h-full max-h-full max-w-[min(22rem,calc(100vw-1rem))] rounded-r-2xl border-y-0 border-l-0 pt-safe sm:max-h-dvh sm:max-w-[22rem] sm:rounded-l-none sm:rounded-r-2xl sm:pb-0" - : cn( - defaultSheetIsFullscreen - ? // Fullscreen panels size from the inset-0 backdrop (h-full), not - // 100dvh: iOS Safari resolves dvh stale across toolbar - // collapse, which strands a dead band under the sheet. - "h-full max-h-full rounded-none border-0 motion-safe:animate-pop-in sm:max-w-none sm:rounded-none lg:h-auto lg:max-h-[calc(100dvh-3rem)] lg:rounded-2xl lg:border lg:border-[color:var(--border-lux)] lg:pb-0 lg:motion-safe:animate-dialog-rise" - : cn( - "sm:max-w-lg sm:rounded-2xl sm:pb-0 sm:motion-safe:animate-dialog-rise", - defaultSheetIsTopAligned - ? cn( - "max-h-[calc(100dvh-1.5rem)] rounded-2xl motion-safe:animate-pop-in", - defaultSheetUsesViewportSize && "min-h-[calc(100dvh-2rem)] sm:min-h-0", - ) - : cn( - "rounded-t-2xl motion-safe:animate-sheet-up", - defaultSheetUsesViewportSize - ? "min-h-[calc(100dvh-2rem)] max-h-[calc(100dvh-1rem)] sm:min-h-0" - : cn( - !hasMobileMaxHeight && "max-h-[calc(100dvh-2rem)]", - !hasSmallScreenMaxHeight && "sm:max-h-[88dvh]", - ), - ), - ), - ), + : placement === "right" + ? "h-full max-h-full max-w-[min(32rem,calc(100vw-1rem))] rounded-l-2xl border-y-0 border-r-0 pt-safe sm:max-h-dvh sm:max-w-[32rem] sm:rounded-l-2xl sm:rounded-r-none sm:pb-0" + : placement === "responsive-right" + ? "max-h-[calc(100dvh-2rem)] rounded-t-2xl motion-safe:animate-sheet-up sm:h-full sm:max-h-full sm:max-w-[32rem] sm:rounded-l-2xl sm:rounded-r-none sm:border-y-0 sm:border-r-0 sm:pb-0 sm:motion-safe:animate-dialog-rise" + : cn( + defaultSheetIsFullscreen + ? // Fullscreen panels size from the inset-0 backdrop (h-full), not + // 100dvh: iOS Safari resolves dvh stale across toolbar + // collapse, which strands a dead band under the sheet. + "h-full max-h-full rounded-none border-0 motion-safe:animate-pop-in sm:max-w-none sm:rounded-none lg:h-auto lg:max-h-[calc(100dvh-3rem)] lg:rounded-2xl lg:border lg:border-[color:var(--border-lux)] lg:pb-0 lg:motion-safe:animate-dialog-rise" + : cn( + "sm:max-w-lg sm:rounded-2xl sm:pb-0 sm:motion-safe:animate-dialog-rise", + defaultSheetIsTopAligned + ? cn( + "max-h-[calc(100dvh-1.5rem)] rounded-2xl motion-safe:animate-pop-in", + defaultSheetUsesViewportSize && "min-h-[calc(100dvh-2rem)] sm:min-h-0", + ) + : cn( + "rounded-t-2xl motion-safe:animate-sheet-up", + defaultSheetUsesViewportSize + ? "min-h-[calc(100dvh-2rem)] max-h-[calc(100dvh-1rem)] sm:min-h-0" + : cn( + !hasMobileMaxHeight && "max-h-[calc(100dvh-2rem)]", + !hasSmallScreenMaxHeight && "sm:max-h-[88dvh]", + ), + ), + ), + ), "motion-reduce:animate-none", contentClassName, )} @@ -445,7 +456,7 @@ export function Sheet({
{title ? (
= [ + { key: "medications", label: "Medication", labelType: "medication" }, + { key: "topics", label: "Topic", labelType: "topic" }, + { key: "documentTypes", label: "Document type", labelType: "document_type" }, + { key: "sites", label: "Site", labelType: "site" }, + { key: "services", label: "Service", labelType: "service" }, + { key: "settings", label: "Setting", labelType: "setting" }, + { key: "populations", label: "Population", labelType: "population" }, + { key: "risks", label: "Risk", labelType: "risk" }, + { key: "workflows", label: "Workflow", labelType: "workflow" }, + { key: "clinicalActions", label: "Clinical action", labelType: "clinical_action" }, + { key: "carePhases", label: "Care phase", labelType: "care_phase" }, + { key: "documentIntents", label: "Document intent", labelType: "document_intent" }, + { key: "contentFeatures", label: "Content feature", labelType: "content_feature" }, +]; + +export const sourceStatusLabels: Record = { + current: "Current", + review_due: "Review due", + outdated: "Outdated", + unknown: "Unknown", +}; + +export const validationStatusLabels: Record = { + approved: "Approved", + locally_reviewed: "Locally reviewed", + unverified: "Unverified", + unknown: "Unknown", +}; + +export const extractionQualityLabels: Record = { + good: "Good", + partial: "Partial", + poor: "Poor", + unknown: "Unknown", +}; + +function normalize(value: string): string { + return value.trim().toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " "); +} + +function isLocalDocument(document: ClinicalDocument): boolean { + const metadata = normalizeSourceMetadata(document.metadata); + const locality = `${metadata.jurisdiction ?? ""} ${metadata.publisher ?? ""}`.toLowerCase(); + return /\b(?:wa|western australia|north metropolitan|east metropolitan|south metropolitan|perth|health service)\b/.test( + locality, + ); +} + +function documentHasAnyLabel(document: ClinicalDocument, type: DocumentLabelType, values: readonly string[]): boolean { + if (values.length === 0) return true; + const wanted = new Set(values.map(normalize)); + return (document.labels ?? []).some((label) => label.label_type === type && wanted.has(normalize(label.label))); +} + +export function deriveDocumentLabelOptions( + documents: ReadonlyArray, + labelType: DocumentLabelType, +): string[] { + return [ + ...new Set( + documents.flatMap((document) => + (document.labels ?? []) + .filter((label) => label.label_type === labelType && label.label.trim()) + .map((label) => label.label.trim()), + ), + ), + ].sort((left, right) => left.localeCompare(right)); +} + +export function filterDocumentsByRetrievalScope( + documents: ReadonlyArray, + filters: SearchScopeFilters, + selectedDocumentIds: ReadonlySet = new Set(), +): ClinicalDocument[] { + return documents.filter((document) => { + if (selectedDocumentIds.size > 0 && !selectedDocumentIds.has(document.id)) return false; + const metadata = normalizeSourceMetadata(document.metadata); + if (filters.sourceStatuses?.length && !filters.sourceStatuses.includes(metadata.document_status)) return false; + if ( + filters.validationStatuses?.length && + (metadata.clinical_validation_status === "unknown" || + !filters.validationStatuses.includes(metadata.clinical_validation_status)) + ) + return false; + if (filters.extractionQualities?.length && !filters.extractionQualities.includes(metadata.extraction_quality)) + return false; + if (filters.locality === "local" && !isLocalDocument(document)) return false; + if (filters.locality === "non_local" && isLocalDocument(document)) return false; + for (const field of documentLabelFilterFields) { + if (!documentHasAnyLabel(document, field.labelType, filters[field.key] ?? [])) return false; + } + return true; + }); +} + +export function projectedDocumentScopeCount(args: { + documents: ReadonlyArray; + filters: SearchScopeFilters; + selectedDocumentIds?: ReadonlySet; + key: DocumentLabelFilterKey | "sourceStatuses" | "validationStatuses" | "extractionQualities"; + value: string; +}): number { + const current = new Set((args.filters[args.key] as string[] | undefined) ?? []); + if (!current.has(args.value)) current.add(args.value); + return filterDocumentsByRetrievalScope( + args.documents, + { ...args.filters, [args.key]: [...current] }, + args.selectedDocumentIds, + ).length; +} + +export function documentRetrievalFilterValueCount(filters: SearchScopeFilters, selectedDocumentCount = 0): number { + return ( + selectedDocumentCount + + documentLabelFilterFields.reduce((total, field) => total + (filters[field.key]?.length ?? 0), 0) + + (filters.sourceStatuses?.length ?? 0) + + (filters.validationStatuses?.length ?? 0) + + (filters.extractionQualities?.length ?? 0) + + Number(Boolean(filters.locality)) + ); +} + +export function publicDocumentScopeFilters(filters: SearchScopeFilters): SearchScopeFilters { + const next = { ...filters }; + // Internal request constraints remain preserved by the caller but are not + // represented as clinician-facing controls. + delete next.importBatchIds; + delete next.labelTypesAny; + return next; +} + +export function mergePublicDocumentScopeFilters( + existing: SearchScopeFilters, + publicFilters: SearchScopeFilters, +): SearchScopeFilters { + return { + ...publicFilters, + ...(existing.importBatchIds?.length ? { importBatchIds: existing.importBatchIds } : {}), + ...(existing.labelTypesAny?.length ? { labelTypesAny: existing.labelTypesAny } : {}), + }; +} + +export function sameDocumentScope(left: SearchScopeFilters, right: SearchScopeFilters): boolean { + const canonical = (filters: SearchScopeFilters) => + JSON.stringify( + Object.fromEntries( + Object.entries(filters) + .filter(([, value]) => (Array.isArray(value) ? value.length > 0 : value != null)) + .map(([key, value]) => [key, Array.isArray(value) ? [...value].sort() : value] as const) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)), + ), + ); + return canonical(left) === canonical(right); +} diff --git a/src/lib/form-filters.ts b/src/lib/form-filters.ts new file mode 100644 index 0000000000..f5312a88ae --- /dev/null +++ b/src/lib/form-filters.ts @@ -0,0 +1,94 @@ +import { formCatalogDetails, type FormAvailability, type FormSearchMatch } from "@/lib/form-ranker"; +import { readResultFilterValues, writeResultFilterValues } from "@/lib/result-filter-url"; + +export type FormRiskFilter = "high" | "medium" | "low"; + +export type FormFilterSelection = { + categories: ReadonlySet; + risks: ReadonlySet; + availability: ReadonlySet; +}; + +export const formRiskFilterValues = ["high", "medium", "low"] as const; +export const formAvailabilityFilterValues = ["downloadable", "contact_ocp", "unavailable"] as const; + +export const formRiskFilterLabels: Record = { + high: "High", + medium: "Medium", + low: "Low", +}; + +export const formAvailabilityFilterLabels: Record = { + downloadable: "Downloadable", + contact_ocp: "Contact required", + unavailable: "Unavailable", +}; + +export function formMatchCategory(match: FormSearchMatch): string { + return formCatalogDetails(match.service)?.category?.trim() || "Other forms"; +} + +export function formMatchRisk(match: FormSearchMatch): FormRiskFilter | null { + const level = formCatalogDetails(match.service)?.riskLevel; + if (level && formRiskFilterValues.includes(level)) return level; + for (const chip of match.service.statusChips ?? []) { + const label = chip.label?.trim().toLowerCase(); + const matched = formRiskFilterValues.find((candidate) => label === `${candidate} risk`); + if (matched) return matched; + } + return null; +} + +export function formMatchAvailability(match: FormSearchMatch): FormAvailability { + return formCatalogDetails(match.service)?.availability ?? "unavailable"; +} + +export function deriveFormCategories(matches: ReadonlyArray): string[] { + return [...new Set(matches.map(formMatchCategory))].sort((left, right) => left.localeCompare(right)); +} + +export function formFilterSelectionFromParams( + params: Pick, + categories: ReadonlyArray, +): FormFilterSelection { + return { + categories: new Set(readResultFilterValues(params, "category", new Set(categories))), + risks: new Set(readResultFilterValues(params, "risk", new Set(formRiskFilterValues))), + availability: new Set(readResultFilterValues(params, "availability", new Set(formAvailabilityFilterValues))), + }; +} + +export function writeFormFilterSelection(params: URLSearchParams, selection: FormFilterSelection): void { + const categories = [...selection.categories]; + writeResultFilterValues(params, "category", categories, new Set(categories)); + writeResultFilterValues(params, "risk", selection.risks, new Set(formRiskFilterValues)); + writeResultFilterValues(params, "availability", selection.availability, new Set(formAvailabilityFilterValues)); +} + +export function formFilterSelectionSize(selection: FormFilterSelection): number { + return selection.categories.size + selection.risks.size + selection.availability.size; +} + +export function filterFormMatches( + matches: ReadonlyArray, + selection: FormFilterSelection, +): FormSearchMatch[] { + return matches.filter((match) => { + if (selection.categories.size > 0 && !selection.categories.has(formMatchCategory(match))) return false; + const risk = formMatchRisk(match); + if (selection.risks.size > 0 && (!risk || !selection.risks.has(risk))) return false; + if (selection.availability.size > 0 && !selection.availability.has(formMatchAvailability(match))) return false; + return true; + }); +} + +export function formFilterCandidateCount( + matches: ReadonlyArray, + selection: FormFilterSelection, + dimension: keyof FormFilterSelection, + value: string, +): number { + const next = new Set(selection[dimension] as ReadonlySet); + if (!next.has(value)) next.add(value); + return filterFormMatches(matches, { ...selection, [dimension]: next }).length; +} diff --git a/src/lib/medication-filters.ts b/src/lib/medication-filters.ts new file mode 100644 index 0000000000..bffb8d1bfe --- /dev/null +++ b/src/lib/medication-filters.ts @@ -0,0 +1,58 @@ +export const medicationScopeValues = new Set(["best", "all"] as const); +export type MedicationScope = "best" | "all"; + +export const medicationMatchValues = new Set(["all", "exact", "good", "related"] as const); +export type MedicationMatchQuality = "all" | "exact" | "good" | "related"; + +export const medicationSignalValues = new Set(["safety", "monitoring"] as const); +export type MedicationClinicalSignal = "safety" | "monitoring"; + +export type MedicationFilterRow = { + drugClass: string; + result: { + match: string; + action: string; + actionTone: "danger" | "warning" | "neutral"; + dose: string; + ceiling: string; + }; +}; + +export type MedicationFilters = { + match: MedicationMatchQuality; + classes: ReadonlySet; + signals: ReadonlySet; +}; + +export function medicationMatchQuality(match: string): Exclude | null { + if (match === "Exact clinical fit") return "exact"; + if (match === "Good clinical fit") return "good"; + if (match === "Related match") return "related"; + return null; +} + +export function medicationResultHasSignal( + result: MedicationFilterRow["result"], + signal: MedicationClinicalSignal, +): boolean { + if (signal === "safety") { + return result.actionTone !== "neutral" || /check|avoid|caution|ceiling|max/i.test(result.action); + } + return ( + result.actionTone === "warning" || + /monitor|level|review|renal|hepatic/i.test(`${result.action} ${result.dose} ${result.ceiling}`) + ); +} + +/** OR within class/signal groups, AND across groups. */ +export function medicationRowMatchesFilters(row: MedicationFilterRow, filters: MedicationFilters): boolean { + if (filters.match !== "all" && medicationMatchQuality(row.result.match) !== filters.match) return false; + if (filters.classes.size > 0 && !filters.classes.has(row.drugClass)) return false; + if ( + filters.signals.size > 0 && + ![...filters.signals].some((signal) => medicationResultHasSignal(row.result, signal)) + ) { + return false; + } + return true; +} diff --git a/src/lib/result-filter-url.ts b/src/lib/result-filter-url.ts new file mode 100644 index 0000000000..18dda35a1e --- /dev/null +++ b/src/lib/result-filter-url.ts @@ -0,0 +1,72 @@ +type ReadableSearchParams = Pick; + +function normalizeValues(values: Iterable, allowedValues: ReadonlySet): string[] { + return [...new Set([...values].map((value) => value.trim()))] + .filter((value) => value.length > 0 && allowedValues.has(value)) + .sort((left, right) => left.localeCompare(right)); +} + +/** + * Read a deterministic comma-separated catalogue facet. Unknown values are + * ignored so stale links cannot create invisible filters. + */ +export function readResultFilterValues( + params: ReadableSearchParams, + key: string, + allowedValues: ReadonlySet, +): Value[] { + const raw = params.get(key); + if (!raw) return []; + return normalizeValues(raw.split(","), allowedValues) as Value[]; +} + +/** Write sorted values, omitting the parameter when no refinement remains. */ +export function writeResultFilterValues( + params: URLSearchParams, + key: string, + values: Iterable, + allowedValues: ReadonlySet, +): void { + const normalized = normalizeValues(values, allowedValues); + if (normalized.length === 0) params.delete(key); + else params.set(key, normalized.join(",")); +} + +/** Read a one-of-N value, falling back when the URL is absent or invalid. */ +export function readResultFilterValue( + params: ReadableSearchParams, + key: string, + allowedValues: ReadonlySet, + fallback: Value, +): Value { + const value = params.get(key); + return value && allowedValues.has(value as Value) ? (value as Value) : fallback; +} + +/** Write a one-of-N value and omit its default. */ +export function writeResultFilterValue( + params: URLSearchParams, + key: string, + value: Value, + fallback: Value, + allowedValues: ReadonlySet, +): void { + if (value === fallback || !allowedValues.has(value)) params.delete(key); + else params.set(key, value); +} + +/** + * Replace only filter-owned parameters while preserving the route, hash, query, + * comparison ids, focus intent and every unrelated navigation parameter. + */ +export function replaceResultFilterUrl(mutator: (params: URLSearchParams) => void): void { + if (typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + mutator(params); + const query = params.toString(); + window.history.replaceState( + null, + "", + `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`, + ); +} diff --git a/src/lib/search-scope-filter-chips.ts b/src/lib/search-scope-filter-chips.ts index 7f8e6e4987..3ecf11f3ce 100644 --- a/src/lib/search-scope-filter-chips.ts +++ b/src/lib/search-scope-filter-chips.ts @@ -47,8 +47,8 @@ const listFilterKeys = Object.keys(listFilterLabels) as ListFilterKey[]; export type ScopeFilterChip = { /** Stable identity, also the token `removeScopeFilterValue` resolves. */ id: string; - /** "Topic: agitation" — the group is named because a bare value is ambiguous across 19 keys. */ - label: string; + groupLabel: string; + valueLabel: string; }; function chipId(key: string, value: string) { @@ -66,13 +66,14 @@ export function scopeFilterChips(filters: SearchScopeFilters | null | undefined) const chips: ScopeFilterChip[] = []; for (const key of listFilterKeys) { for (const value of filters[key] ?? []) { - chips.push({ id: chipId(key, value), label: `${listFilterLabels[key]}: ${value}` }); + chips.push({ id: chipId(key, value), groupLabel: listFilterLabels[key], valueLabel: value }); } } if (filters.locality) { chips.push({ id: chipId("locality", filters.locality), - label: `Locality: ${filters.locality === "local" ? "local" : "non-local"}`, + groupLabel: "Locality", + valueLabel: filters.locality === "local" ? "Local" : "Non-local", }); } return chips; diff --git a/tests/differential-stream-page.dom.test.tsx b/tests/differential-stream-page.dom.test.tsx index 038e1ae135..dd950a20a0 100644 --- a/tests/differential-stream-page.dom.test.tsx +++ b/tests/differential-stream-page.dom.test.tsx @@ -1,7 +1,7 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { DifferentialStreamPage } from "@/components/differentials/differential-stream-page"; import { buildDifferentialStreamModel } from "@/lib/differential-stream"; @@ -17,7 +17,7 @@ vi.mock("next/navigation", () => ({ refresh: vi.fn(), }), usePathname: () => "/differentials/presentations", - useSearchParams: () => new URLSearchParams(), + useSearchParams: () => new URLSearchParams(window.location.search), })); vi.mock("next/link", () => ({ @@ -28,8 +28,14 @@ vi.mock("next/link", () => ({ ), })); +afterEach(() => { + cleanup(); + window.history.replaceState(null, "", "/"); +}); + describe("DifferentialStreamPage", () => { - it("renders the presentations catalogue as a compact symptom-led pathway workspace", () => { + it("renders the presentations catalogue as a compact symptom-led pathway workspace", async () => { + const user = userEvent.setup(); render(); expect(screen.getByRole("heading", { level: 1, name: "Presentations" })).toBeInTheDocument(); @@ -40,7 +46,8 @@ describe("DifferentialStreamPage", () => { expect(screen.getByRole("heading", { level: 2, name: "All" })).toBeInTheDocument(); expect(screen.getByText("Symptom-led pathways with priority and safety cues")).toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Presentation pathways" })).toBeInTheDocument(); - expect(screen.getByRole("group", { name: "Presentation priority" })).toBeInTheDocument(); + await user.click(screen.getByTestId("differentials-stream-filter-trigger")); + expect(screen.getByRole("radiogroup", { name: "Clinical urgency" })).toBeInTheDocument(); expect(screen.getByText("High-priority presentation pathways")).toBeInTheDocument(); expect(screen.queryByText("Differentials: Presentations")).not.toBeInTheDocument(); @@ -75,10 +82,13 @@ describe("DifferentialStreamPage", () => { .find((item) => item?.status === "emergent"); expect(safetyItem).toBeTruthy(); - render(); + const view = render(); - const priorityGroup = screen.getByRole("group", { name: "Presentation priority" }); - await user.click(within(priorityGroup).getByRole("button", { name: "Urgent" })); + await user.click(screen.getByTestId("differentials-stream-filter-trigger")); + const priorityGroup = screen.getByRole("radiogroup", { name: "Clinical urgency" }); + await user.click(within(priorityGroup).getByRole("radio", { name: /^Urgent/ })); + expect(new URL(window.location.href).searchParams.get("priority")).toBe("urgent"); + view.rerender(); expect(screen.queryByTestId(`differential-stream-card-${safetyItem!.slug}`)).not.toBeInTheDocument(); scrollIntoView.mockClear(); @@ -88,36 +98,41 @@ describe("DifferentialStreamPage", () => { }), ); + expect(new URL(window.location.href).searchParams.has("priority")).toBe(false); + view.rerender(); await waitFor(() => expect(screen.getByTestId(`differential-stream-card-${safetyItem!.slug}`)).toBeInTheDocument()); - expect(within(priorityGroup).getByRole("button", { name: "All priorities" })).toHaveAttribute( - "aria-pressed", - "true", - ); expect(scrollIntoView).toHaveBeenCalledWith(expect.objectContaining({ block: "center" })); }); it("filters presentations by priority and shared-candidate pathways", async () => { const user = userEvent.setup(); - render(); + const view = render(); const initialCards = screen.getAllByTestId(/^differential-stream-card-/).length; - const priorityGroup = screen.getByRole("group", { name: "Presentation priority" }); - await user.click(within(priorityGroup).getByRole("button", { name: "Emergent" })); + await user.click(screen.getByTestId("differentials-stream-filter-trigger")); + const priorityGroup = screen.getByRole("radiogroup", { name: "Clinical urgency" }); + await user.click(within(priorityGroup).getByRole("radio", { name: /^Emergent/ })); + expect(new URL(window.location.href).searchParams.get("priority")).toBe("emergent"); + view.rerender(); const emergentCards = screen.getAllByTestId(/^differential-stream-card-/); expect(emergentCards.length).toBeLessThan(initialCards); expect(emergentCards.every((card) => card.dataset.status === "emergent")).toBe(true); - expect(screen.getByRole("button", { name: "Remove Emergent priority filter" })).toBeInTheDocument(); - - await user.click(screen.getByRole("button", { name: "Remove Emergent priority filter" })); - const pathwayGroup = screen.getByRole("group", { name: "Related pathway filter" }); - await user.click(within(pathwayGroup).getByRole("button", { name: "Related pathways" })); + expect(screen.getByRole("button", { name: "Remove Priority: Emergent filter" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Remove Priority: Emergent filter" })); + expect(new URL(window.location.href).searchParams.has("priority")).toBe(false); + view.rerender(); + const pathwayGroup = screen.getByRole("radiogroup", { name: "Related pathways" }); + await user.click(within(pathwayGroup).getByRole("radio", { name: /^Related pathways/ })); + expect(new URL(window.location.href).searchParams.get("related")).toBe("1"); + view.rerender(); expect(screen.getAllByTestId(/^differential-stream-card-/).length).toBeLessThan(initialCards); - expect(screen.getByRole("button", { name: /Remove .+ related pathways filter/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Remove Related pathways: .+ filter/ })).toBeInTheDocument(); }); it("replaces match navigation with a removable focused-family filter", async () => { const user = userEvent.setup(); - render(); + const view = render(); expect(screen.getByRole("heading", { level: 1, name: "Diagnoses" })).toBeInTheDocument(); expect(screen.getByText("Compare likely causes and exclusion clues.")).toBeInTheDocument(); @@ -129,13 +144,18 @@ describe("DifferentialStreamPage", () => { expect(screen.queryByText(/Related cluster lit for/)).not.toBeInTheDocument(); const initialCards = screen.getAllByTestId(/^differential-stream-card-/).length; - await user.click(screen.getByRole("button", { name: "Focused family" })); + await user.click(screen.getByTestId("differentials-stream-filter-trigger")); + await user.click(screen.getByRole("radio", { name: /^Focused family/ })); + expect(new URL(window.location.href).searchParams.get("related")).toBe("1"); + view.rerender(); - const removeFamily = screen.getByRole("button", { name: /Remove .+ family filter/ }); + const removeFamily = screen.getByRole("button", { name: /Remove Family: .+ filter/ }); expect(removeFamily).toBeInTheDocument(); expect(screen.getAllByTestId(/^differential-stream-card-/).length).toBeLessThan(initialCards); await user.click(removeFamily); + expect(new URL(window.location.href).searchParams.has("related")).toBe(false); + view.rerender(); expect(screen.getAllByTestId(/^differential-stream-card-/)).toHaveLength(initialCards); }); @@ -151,6 +171,7 @@ describe("DifferentialStreamPage", () => { await user.click(byPresentation); expect(byUrgency).toHaveAttribute("aria-pressed", "false"); expect(byPresentation).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByTestId("differentials-stream-filter-trigger")).toHaveAccessibleName(/No filters active/); }); it("scrolls only for an explicit focus parameter", async () => { @@ -160,7 +181,8 @@ describe("DifferentialStreamPage", () => { initialRender.unmount(); scrollIntoView.mockClear(); - const focused = differentialDiagnosesCards[0]; + const focusedMatch = buildDifferentialStreamModel("diagnoses", "pain").items.find((item) => item.isMatch); + const focused = differentialDiagnosesCards.find((item) => item.href.endsWith(`/${focusedMatch?.slug ?? ""}`)); expect(focused).toBeTruthy(); const focusedSlug = focused!.href.split("/").at(-1); expect(focusedSlug).toBeTruthy(); diff --git a/tests/differentials-compare-selection.dom.test.tsx b/tests/differentials-compare-selection.dom.test.tsx index a021834579..75ea0f2dcb 100644 --- a/tests/differentials-compare-selection.dom.test.tsx +++ b/tests/differentials-compare-selection.dom.test.tsx @@ -143,9 +143,15 @@ describe("DifferentialsHome compare selection URL handoff", () => { expect(screen.getAllByText("Anorexia nervosa").length).toBeGreaterThan(0); }); + await act(async () => { + screen.getByTestId("differential-filter-trigger-phone").click(); + }); await act(async () => { screen.getByRole("radio", { name: "Presentations (0)" }).click(); }); + await act(async () => { + screen.getByTestId("differential-filter-panel-done").click(); + }); expect(screen.getByTestId("differentials-filter-empty-results")).toBeVisible(); expect(screen.getByRole("heading", { name: "No presentations in this result set" })).toBeVisible(); diff --git a/tests/document-filter-model.test.ts b/tests/document-filter-model.test.ts new file mode 100644 index 0000000000..6a1cce8236 --- /dev/null +++ b/tests/document-filter-model.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { + filterDocumentsByRetrievalScope, + mergePublicDocumentScopeFilters, + sameDocumentScope, +} from "@/lib/document-filter-model"; +import type { ClinicalDocument } from "@/lib/types"; + +function document(id: string, site: string, risk: string, status = "current"): ClinicalDocument { + return { + id, + title: id, + description: null, + file_name: `${id}.pdf`, + file_type: "application/pdf", + file_size: 1, + storage_path: id, + status: "indexed", + page_count: 1, + chunk_count: 1, + image_count: 0, + error_message: null, + metadata: { document_status: status }, + labels: [ + { id: `${id}-site`, document_id: id, label: site, label_type: "site", metadata: null, created_at: "" }, + { id: `${id}-risk`, document_id: id, label: risk, label_type: "risk", metadata: null, created_at: "" }, + ], + created_at: "", + updated_at: "", + } as ClinicalDocument; +} + +const documents = [document("a", "FSH", "High"), document("b", "RPH", "High"), document("c", "FSH", "Low")]; + +describe("document retrieval filter model", () => { + it("uses OR within a group and AND across groups", () => { + expect(filterDocumentsByRetrievalScope(documents, { sites: ["FSH", "RPH"], risks: ["High"] })).toHaveLength(2); + expect( + filterDocumentsByRetrievalScope(documents, { sites: ["FSH"], risks: ["High"] }).map((item) => item.id), + ).toEqual(["a"]); + }); + + it("preserves internal request constraints while public filters change", () => { + const merged = mergePublicDocumentScopeFilters( + { importBatchIds: ["00000000-0000-0000-0000-000000000001"], labelTypesAny: ["service"] }, + { risks: ["High"] }, + ); + expect(merged.risks).toEqual(["High"]); + expect(merged.importBatchIds).toHaveLength(1); + expect(merged.labelTypesAny).toEqual(["service"]); + }); + + it("compares order-independent scope state", () => { + expect(sameDocumentScope({ risks: ["High", "Low"] }, { risks: ["Low", "High"] })).toBe(true); + }); +}); diff --git a/tests/document-filter-panel.dom.test.tsx b/tests/document-filter-panel.dom.test.tsx index 6bc8a4a51d..5e69d8ee69 100644 --- a/tests/document-filter-panel.dom.test.tsx +++ b/tests/document-filter-panel.dom.test.tsx @@ -1,9 +1,9 @@ -import { render, screen, within } from "@testing-library/react"; +import { cleanup, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { DocumentSearchResultsPanel } from "@/components/clinical-dashboard/document-search-results"; -import type { DocumentLabel, DocumentMatch } from "@/lib/types"; +import type { ClinicalDocument, DocumentLabel, DocumentMatch } from "@/lib/types"; vi.mock("next/navigation", () => ({ useRouter: () => ({ @@ -14,10 +14,16 @@ vi.mock("next/navigation", () => ({ forward: vi.fn(), prefetch: vi.fn(), }), - useSearchParams: () => new URLSearchParams(), + useSearchParams: () => new URLSearchParams(window.location.search), usePathname: () => "/documents/search", })); +afterEach(() => { + cleanup(); + window.history.replaceState(null, "", "/"); + vi.clearAllMocks(); +}); + vi.mock("@/lib/supabase/client", () => ({ useAuthSession: () => ({ status: "signed_out", @@ -69,6 +75,32 @@ const lithiumDoc = match({ labels: [label("22222222-2222-4222-8222-222222222222", "lithium", "medication")], }); +function sourceDocument(id: string, title: string, jurisdiction: string): ClinicalDocument { + return { + id, + title, + description: null, + file_name: `${id}.pdf`, + file_type: "application/pdf", + file_size: 1024, + storage_path: `documents/${id}.pdf`, + status: "indexed", + page_count: 2, + chunk_count: 1, + image_count: 0, + error_message: null, + metadata: { + document_status: "current", + clinical_validation_status: "approved", + extraction_quality: "good", + jurisdiction, + }, + labels: [], + created_at: "2026-08-01T00:00:00.000Z", + updated_at: "2026-08-01T00:00:00.000Z", + }; +} + const baseProps = { matches: [clozapineDoc, lithiumDoc], recordMatches: [], @@ -96,9 +128,9 @@ function resultTitles() { async function openPanel(props = baseProps) { const user = userEvent.setup(); - render(); + const view = render(); await user.click(screen.getByTestId("document-filter-trigger-phone")); - return { user, panel: screen.getByTestId("document-filter-panel") }; + return { user, view, panel: screen.getByTestId("document-filter-panel") }; } describe("document filter panel", () => { @@ -119,64 +151,96 @@ describe("document filter panel", () => { expect(trigger).toHaveAttribute("aria-expanded", "true"); }); - it("carries source type and tag facets in one panel", async () => { - // Source type used to be a separate chip row in the ribbon on desktop and a - // native select on phones. One panel means one place to see and undo - // everything narrowing the list. + it("carries result type and smart-tag facets in one panel", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByTestId("document-filter-trigger-phone")); const panel = screen.getByTestId("document-filter-panel"); - expect(within(panel).getByRole("radiogroup", { name: "Source type" })).toBeInTheDocument(); - // Mutually exclusive, so radio semantics rather than four independent toggles. + expect(within(panel).getByRole("radiogroup", { name: "Result type" })).toBeInTheDocument(); expect(within(panel).getByRole("radio", { name: /All/ })).toHaveAttribute("aria-checked", "true"); + await user.click(within(panel).getByRole("button", { name: /^Medication/ })); expect(within(panel).getByRole("button", { name: /Clozapine/ })).toBeInTheDocument(); }); - it("filters the result list when a facet is selected", async () => { + it("stages a local facet and commits it without running document retrieval", async () => { const user = userEvent.setup(); - render(); + const onDocumentFiltersApply = vi.fn(); + const props = { ...baseProps, onDocumentFiltersApply }; + const view = render(); expect(resultTitles()).toHaveLength(2); await user.click(screen.getByTestId("document-filter-trigger-phone")); + await user.click(within(screen.getByTestId("document-filter-panel")).getByRole("button", { name: /^Medication/ })); await user.click(within(screen.getByTestId("document-filter-panel")).getByRole("button", { name: /Clozapine/ })); + expect(resultTitles()).toHaveLength(2); + await user.click(within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-panel-done")); + view.rerender(); expect(resultTitles()).toEqual([expect.stringContaining("Clozapine Monitoring Protocol")]); expect(screen.getByTestId("document-filter-trigger-phone")).toHaveTextContent("1"); + expect(onDocumentFiltersApply).not.toHaveBeenCalled(); }); - it("filters by source type from the same panel", async () => { + it("stages result type from the same panel", async () => { const user = userEvent.setup(); - render(); + const view = render(); await user.click(screen.getByTestId("document-filter-trigger-phone")); await user.click(within(screen.getByTestId("document-filter-panel")).getByRole("radio", { name: /Tables/ })); - // Only the clozapine document carries tables. + expect(resultTitles()).toHaveLength(2); + await user.click(within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-panel-done")); + view.rerender(); expect(resultTitles()).toEqual([expect.stringContaining("Clozapine Monitoring Protocol")]); }); + it("performs one retrieval when staged source scope changes", async () => { + const user = userEvent.setup(); + const onDocumentFiltersApply = vi.fn(); + const recentDocuments = [ + sourceDocument(clozapineDoc.document_id, clozapineDoc.title, "WA"), + sourceDocument(lithiumDoc.document_id, lithiumDoc.title, "National"), + ]; + render( + , + ); + + await user.click(screen.getByTestId("document-filter-trigger-phone")); + const panel = screen.getByTestId("document-filter-panel"); + await user.click(within(panel).getByRole("radio", { name: /^Local \(1 loaded source\)$/ })); + expect(onDocumentFiltersApply).not.toHaveBeenCalled(); + await user.click(within(panel).getByTestId("document-filter-panel-done")); + + expect(onDocumentFiltersApply).toHaveBeenCalledTimes(1); + expect(onDocumentFiltersApply).toHaveBeenCalledWith(expect.objectContaining({ locality: "local" }), []); + }); + it("hides Clear filters until a document filter is active", async () => { const { panel } = await openPanel(); expect(within(panel).queryByTestId("document-filter-panel-clear")).toBeNull(); }); - it("clears both filter kinds at once", async () => { + it("clears staged result and smart-tag refinements at once", async () => { const user = userEvent.setup(); - render(); + window.history.replaceState(null, "", "/documents/search?resultType=tables&facet=medication%3Aclozapine"); + const view = render(); + expect(resultTitles()).toHaveLength(1); await user.click(screen.getByTestId("document-filter-trigger-phone")); const panel = () => screen.getByTestId("document-filter-panel"); - await user.click(within(panel()).getByRole("radio", { name: /Tables/ })); - await user.click(within(panel()).getByRole("button", { name: /Clozapine/ })); - expect(resultTitles()).toHaveLength(1); - await user.click(within(panel()).getByTestId("document-filter-panel-clear")); + await user.click(within(panel()).getByTestId("document-filter-panel-done")); + view.rerender(); expect(resultTitles()).toHaveLength(2); - expect(within(panel()).getByRole("radio", { name: /All/ })).toHaveAttribute("aria-checked", "true"); + expect(new URLSearchParams(window.location.search).has("resultType")).toBe(false); + expect(new URLSearchParams(window.location.search).has("facet")).toBe(false); }); it("is a dialog, so it overlays the results rather than pushing them down", async () => { @@ -234,13 +298,13 @@ describe("document filter panel", () => { expect(document.body.style.overflow).not.toBe("hidden"); }); - it("closes on Show N documents", async () => { + it("closes on Update search", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByTestId("document-filter-trigger-phone")); - const done = within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-done"); - expect(done).toHaveTextContent("Show 2 documents"); + const done = within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-panel-done"); + expect(done).toHaveTextContent("Update search"); await user.click(done); expect(screen.queryByTestId("document-filter-panel")).toBeNull(); @@ -262,45 +326,55 @@ describe("document filter panel", () => { }); describe("applied-filter shelf", () => { - async function selectClozapine(user: ReturnType) { + async function selectClozapine( + user: ReturnType, + view: ReturnType, + props = baseProps, + ) { await user.click(screen.getByTestId("document-filter-trigger-phone")); + await user.click(within(screen.getByTestId("document-filter-panel")).getByRole("button", { name: /^Medication/ })); await user.click(within(screen.getByTestId("document-filter-panel")).getByRole("button", { name: /Clozapine/ })); - await user.click(within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-done")); + await user.click(within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-panel-done")); + view.rerender(); } it("shows an applied facet as a chip and removes it in one tap", async () => { const user = userEvent.setup(); - render(); + const view = render(); expect(screen.queryByTestId("search-query-ribbon-shelf")).toBeNull(); - await selectClozapine(user); + await selectClozapine(user, view); const shelf = screen.getByTestId("search-query-ribbon-shelf"); expect(shelf).toHaveTextContent("Filtered by"); expect(resultTitles()).toHaveLength(1); // One tap, and the count follows immediately. - await user.click(within(shelf).getByRole("button", { name: /Remove Clozapine filter/ })); + await user.click(within(shelf).getByRole("button", { name: /Remove Medication: Clozapine filter/ })); + view.rerender(); expect(resultTitles()).toHaveLength(2); expect(screen.queryByTestId("search-query-ribbon-shelf")).toBeNull(); }); it("carries the source type alongside facets, and Clear tears both down at once", async () => { const user = userEvent.setup(); - render(); + const view = render(); await user.click(screen.getByTestId("document-filter-trigger-phone")); const panel = screen.getByTestId("document-filter-panel"); + await user.click(within(panel).getByRole("button", { name: /^Medication/ })); await user.click(within(panel).getByRole("button", { name: /Clozapine/ })); await user.click(within(panel).getByRole("radio", { name: /Tables/ })); - await user.click(within(panel).getByTestId("document-filter-done")); + await user.click(within(panel).getByTestId("document-filter-panel-done")); + view.rerender(); const shelf = screen.getByTestId("search-query-ribbon-shelf"); - expect(within(shelf).getByRole("button", { name: /Remove Clozapine filter/ })).toBeInTheDocument(); - expect(within(shelf).getByRole("button", { name: /Remove Tables filter/ })).toBeInTheDocument(); + expect(within(shelf).getByRole("button", { name: /Remove Medication: Clozapine filter/ })).toBeInTheDocument(); + expect(within(shelf).getByRole("button", { name: /Remove Result type: Tables filter/ })).toBeInTheDocument(); // Clear appears only past one chip — teardown that used to be one tap each. await user.click(within(shelf).getByTestId("search-query-ribbon-shelf-clear")); + view.rerender(); expect(screen.queryByTestId("search-query-ribbon-shelf")).toBeNull(); expect(resultTitles()).toHaveLength(2); }); @@ -308,7 +382,11 @@ describe("applied-filter shelf", () => { it("survives a pending search, because chips must not flicker on every keystroke", async () => { const user = userEvent.setup(); const { rerender } = render(); - await selectClozapine(user); + await user.click(screen.getByTestId("document-filter-trigger-phone")); + await user.click(within(screen.getByTestId("document-filter-panel")).getByRole("button", { name: /^Medication/ })); + await user.click(within(screen.getByTestId("document-filter-panel")).getByRole("button", { name: /Clozapine/ })); + await user.click(within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-panel-done")); + rerender(); expect(screen.getByTestId("search-query-ribbon-shelf")).toBeInTheDocument(); rerender(); @@ -316,14 +394,14 @@ describe("applied-filter shelf", () => { expect(screen.getByTestId("search-query-ribbon-shelf")).toBeInTheDocument(); }); - it("drops only on a fault, where filtering a set that never loaded is meaningless", async () => { + it("keeps applied recovery visible when retrieval faults", async () => { const user = userEvent.setup(); - const { rerender } = render(); - await selectClozapine(user); + const view = render(); + await selectClozapine(user, view); - rerender(); + view.rerender(); - expect(screen.queryByTestId("search-query-ribbon-shelf")).toBeNull(); + expect(screen.getByTestId("search-query-ribbon-shelf")).toBeInTheDocument(); }); }); @@ -389,29 +467,32 @@ describe("filter sheet — density, exclusivity and reach", () => { const denseProps = { ...baseProps, matches: [denseDoc, clozapineDoc, lithiumDoc], documentCount: 2014 }; it("puts every facet on the tap floor, not the 28px it shipped with", async () => { - const { panel } = await openPanel(); + const { user, panel } = await openPanel(); // The sheet is the primary phone filtering surface and these are its only // interactive elements. `min-h-7` was 28px, packed at `gap-1.5`, so a // neighbouring mis-tap was likely. + await user.click(within(panel).getByRole("button", { name: /^Medication/ })); const facet = within(panel).getByRole("button", { name: /Clozapine/ }); expect(facet.className).toContain("min-h-tap"); expect(facet.className).not.toContain("min-h-7"); - // Compact density returns from `sm`, where a pointer is likely. - expect(facet.className).toContain("sm:min-h-9"); - expect(facet.className).toContain("lg:min-h-8"); + // Pointer layouts remain compact without dropping below the filter + // system's 40px desktop target floor. + expect(facet.className).toContain("sm:min-h-10"); + expect(facet.className).not.toContain("lg:min-h-8"); }); it("says which group replaces and which accumulate", async () => { - const { panel } = await openPanel(); + const { user, panel } = await openPanel(); - // Source type is a radiogroup and the facets are aria-pressed toggles, but + // Result type is a radiogroup and the facets are aria-pressed toggles, but // both rendered as chips of near-identical size, colour and radius, directly // adjacent — so the OR-within-group, AND-across-groups model had to be // discovered by experiment. The joined control is the shape cue; this is the // words. expect(within(panel).getByText("one only")).toBeVisible(); expect(within(panel).getByRole("radio", { name: /All/ })).toHaveAttribute("aria-checked", "true"); + await user.click(within(panel).getByRole("button", { name: /^Medication/ })); expect(within(panel).getByRole("button", { name: /Clozapine/ })).toHaveAttribute("aria-pressed", "false"); }); @@ -502,7 +583,7 @@ describe("filter sheet — density, exclusivity and reach", () => { expect(within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-panel-find")).toHaveValue( "clozapine", ); - await user.click(within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-done")); + await user.click(within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-panel-done")); rerender(); await user.click(screen.getByTestId("document-filter-trigger-phone")); @@ -524,14 +605,12 @@ describe("filter sheet — density, exclusivity and reach", () => { expect(within(panel).getByRole("button", { name: /Community/ })).toBeVisible(); }); - it("shows neither the field nor a collapse control for a handful of groups", async () => { + it("keeps dense search for the unified retrieval and result sections", async () => { const { panel } = await openPanel(); - // Below the threshold every group is open permanently, so a heading that - // advertised a collapse would be a control that does nothing. - expect(within(panel).queryByTestId("document-filter-panel-find")).toBeNull(); - expect(within(panel).queryByRole("button", { name: /^Medication/ })).toBeNull(); - expect(within(panel).getByRole("button", { name: /Clozapine/ })).toBeVisible(); + expect(within(panel).getByTestId("document-filter-panel-find")).toBeVisible(); + expect(within(panel).getByRole("button", { name: /^Medication/ })).toHaveAttribute("aria-expanded", "false"); + expect(within(panel).getByRole("button", { name: /^Source status/ })).toBeVisible(); }); it("moves Library off the rail into the sheet footer", async () => { @@ -541,7 +620,7 @@ describe("filter sheet — density, exclusivity and reach", () => { // questions, and Library occupied the space the pinned Filter needs. expect(screen.queryByRole("button", { name: "Open source library" })).toBeNull(); - const browse = within(panel).getByTestId("document-filter-browse-library"); + const browse = within(panel).getByRole("button", { name: /Browse all sources/ }); expect(browse).toHaveTextContent("Browse all sources"); // The corpus count, beside it — reach, stated as a size. expect(browse).toHaveTextContent("2,014"); @@ -552,9 +631,10 @@ describe("filter sheet — density, exclusivity and reach", () => { it("states the proportion once, and warns when the combination returns nothing", async () => { const { user, panel } = await openPanel(denseProps); - expect(within(panel).getByText(/of 2,014 documents shown/)).toBeVisible(); - // The footer no longer prints the number twice: the button carries it. - expect(within(panel).getByTestId("document-filter-done")).toHaveTextContent("Show 3 documents"); + expect( + within(panel).getByRole("progressbar", { name: "Visible retrieved matches" }).parentElement, + ).toHaveTextContent("3 of 3 retrieved matches visible"); + expect(within(panel).getByTestId("document-filter-panel-done")).toHaveTextContent("Update search"); // Clozapine and Suicide sit on different fixture documents, so the pair // would return nothing. The panel does not let you build that: once @@ -579,13 +659,9 @@ describe("filter sheet — density, exclusivity and reach", () => { await user.click(suicide); // Unchanged: the guarded click did not empty the list behind the reader. - expect(within(panel).getByTestId("document-filter-done")).toHaveTextContent("Show 1 document"); - // The number is its own span so it can carry the emphasis, so assert the - // containing paragraph rather than the matched node. + expect(within(panel).getByTestId("document-filter-panel-done")).toHaveTextContent("Update search"); expect( - within(panel) - .getByText(/of 2,014 documents shown/) - .closest("p"), - ).toHaveTextContent("1 of 2,014 documents shown"); + within(panel).getByRole("progressbar", { name: "Visible retrieved matches" }).parentElement, + ).toHaveTextContent("1 of 3 retrieved matches visible"); }); }); diff --git a/tests/document-search-scope-zero-results.dom.test.tsx b/tests/document-search-scope-zero-results.dom.test.tsx index 9f5e2a80e8..91541987af 100644 --- a/tests/document-search-scope-zero-results.dom.test.tsx +++ b/tests/document-search-scope-zero-results.dom.test.tsx @@ -236,9 +236,9 @@ describe("documents zero-result state when retrieval degraded", () => { describe("scope filter chips", () => { it("names the group so a bare value is not ambiguous", () => { expect(scopeFilterChips({ topics: ["agitation"], sites: ["FSH"], locality: "local" })).toEqual([ - { id: "scope:topics:agitation", label: "Topic: agitation" }, - { id: "scope:sites:FSH", label: "Site: FSH" }, - { id: "scope:locality:local", label: "Locality: local" }, + { id: "scope:topics:agitation", groupLabel: "Topic", valueLabel: "agitation" }, + { id: "scope:sites:FSH", groupLabel: "Site", valueLabel: "FSH" }, + { id: "scope:locality:local", groupLabel: "Locality", valueLabel: "Local" }, ]); }); diff --git a/tests/dsm-search-empty-state.dom.test.tsx b/tests/dsm-search-empty-state.dom.test.tsx index 15922fc60d..3a001c687b 100644 --- a/tests/dsm-search-empty-state.dom.test.tsx +++ b/tests/dsm-search-empty-state.dom.test.tsx @@ -3,10 +3,11 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { DsmSearchPage } from "@/components/dsm/dsm-search-page"; -import type { DsmCategory } from "@/lib/dsm"; +import type { DsmCategory, DsmDiagnosisSummary } from "@/lib/dsm"; vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), + useSearchParams: () => new URLSearchParams(window.location.search), })); // `/dsm/search` rendered "No diagnosis matches" as an `

` until it adopted @@ -35,14 +36,65 @@ describe("DsmSearchPage empty state", () => { expect(screen.getByTestId("search-query-ribbon")).toBeVisible(); expect(screen.getByRole("heading", { level: 1, name: "Delirium" })).toBeVisible(); - expect(screen.getByTestId("dsm-category-filter")).toBeVisible(); - expect(screen.getByTestId("dsm-category-filter")).toHaveTextContent("12"); + expect(screen.getByTestId("dsm-category-filter-desktop")).toBeVisible(); const phoneFilter = screen.getByTestId("dsm-category-filter-phone"); expect(phoneFilter).toHaveAccessibleName(/Filter\s*No filters active/); await user.click(phoneFilter); expect(screen.getByRole("dialog", { name: "Filter DSM diagnoses" })).toBeVisible(); - expect(screen.getByRole("radiogroup", { name: "Category" })).toBeVisible(); - expect(screen.getByRole("radio", { name: "All categories (12)" })).toHaveAttribute("aria-checked", "true"); + expect(screen.getByRole("group", { name: "Category" })).toBeVisible(); + expect(screen.getByRole("button", { name: /Mood disorders \(0\)/ })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("group", { name: "Specifier support" })).toBeVisible(); + expect(screen.getByRole("group", { name: "Differential guidance" })).toBeVisible(); + }); + + it("accepts comma-separated categories and preserves comparison ids while refining", async () => { + const user = userEvent.setup(); + const anxiety = { + key: "anxiety", + label: "Anxiety disorders", + css_class: "anxiety", + color: "#654321", + diagnosis_count: 8, + }; + const results: DsmDiagnosisSummary[] = [ + { + slug: "mood-1", + title: "Mood one", + icd_code: "F30", + category: { key: "mood", label: "Mood disorders" }, + summary: "Mood summary", + criteriaCount: 2, + differentialCount: 1, + specifierCount: 1, + }, + { + slug: "anxiety-1", + title: "Anxiety one", + icd_code: "F40", + category: { key: "anxiety", label: "Anxiety disorders" }, + summary: "Anxiety summary", + criteriaCount: 2, + differentialCount: 1, + specifierCount: 0, + }, + ]; + window.history.replaceState(null, "", "/dsm/search?q=review&ids=mood-1,anxiety-1&category=mood"); + + render( + , + ); + await user.click(screen.getByTestId("dsm-category-filter-phone")); + await user.click(screen.getByRole("button", { name: /Anxiety disorders \(2\)/ })); + + const params = new URLSearchParams(window.location.search); + expect(params.get("category")).toBe("anxiety,mood"); + expect(params.get("ids")).toBe("mood-1,anxiety-1"); + expect(params.get("q")).toBe("review"); }); }); diff --git a/tests/factsheets-search-page.dom.test.tsx b/tests/factsheets-search-page.dom.test.tsx index 09410628b8..3ef35691a1 100644 --- a/tests/factsheets-search-page.dom.test.tsx +++ b/tests/factsheets-search-page.dom.test.tsx @@ -4,9 +4,9 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -const push = vi.fn(); +const replace = vi.fn(); vi.mock("next/navigation", () => ({ - useRouter: () => ({ push }), + useRouter: () => ({ replace }), })); import { FactsheetsSearchPage } from "@/components/factsheets/factsheets-search-page"; @@ -45,14 +45,14 @@ describe("FactsheetsSearchPage category filter", () => { it("selecting a category narrows within the query rather than replacing it", async () => { const user = userEvent.setup(); - push.mockClear(); + replace.mockClear(); render(); const desktopGroup = screen.getByRole("radiogroup", { name: "Category" }); await user.click(within(desktopGroup).getByRole("radio", { name: "Medications (1)" })); - expect(push).toHaveBeenCalledTimes(1); - const href = push.mock.calls[0][0] as string; + expect(replace).toHaveBeenCalledTimes(1); + const href = replace.mock.calls[0][0] as string; // Both params survive — category narrows, it does not replace the search. expect(href).toContain(`q=${encodeURIComponent(query)}`); expect(href).toContain("category=Medications"); @@ -60,21 +60,21 @@ describe("FactsheetsSearchPage category filter", () => { it("clearing the category preserves the query", async () => { const user = userEvent.setup(); - push.mockClear(); + replace.mockClear(); render(); await user.click(screen.getByTestId("factsheet-filter-trigger-phone")); await user.click(screen.getByTestId("factsheet-filter-panel-clear")); - expect(push).toHaveBeenCalledTimes(1); - const href = push.mock.calls[0][0] as string; + expect(replace).toHaveBeenCalledTimes(1); + const href = replace.mock.calls[0][0] as string; expect(href).toContain(`q=${encodeURIComponent(query)}`); expect(href).not.toContain("category="); }); it("keeps a selected zero-count category as an explained, inert dead end", async () => { const user = userEvent.setup(); - push.mockClear(); + replace.mockClear(); render( , ); @@ -90,7 +90,7 @@ describe("FactsheetsSearchPage category filter", () => { expect(within(desktopGroup).queryByRole("radio", { name: "Tests & procedures (0)" })).not.toBeInTheDocument(); await user.click(selectedZero); - expect(push).not.toHaveBeenCalled(); + expect(replace).not.toHaveBeenCalled(); await user.click(screen.getByTestId("factsheet-filter-trigger-phone")); const sheet = screen.getByTestId("factsheet-filter-panel"); @@ -100,11 +100,11 @@ describe("FactsheetsSearchPage category filter", () => { expect(within(sheet).getByText(selectedZeroMessage)).toBeInTheDocument(); await user.click(sheetSelectedZero); - expect(push).not.toHaveBeenCalled(); + expect(replace).not.toHaveBeenCalled(); await user.click(within(sheetGroup).getByRole("radio", { name: "Medications (1)" })); - expect(push).toHaveBeenCalledTimes(1); - const href = push.mock.calls[0][0] as string; + expect(replace).toHaveBeenCalledTimes(1); + const href = replace.mock.calls[0][0] as string; expect(href).toContain(`q=${encodeURIComponent(query)}`); expect(href).toContain("category=Medications"); }); diff --git a/tests/favourites-auth-gate.dom.test.tsx b/tests/favourites-auth-gate.dom.test.tsx index 5d98170a2d..ff22f5a508 100644 --- a/tests/favourites-auth-gate.dom.test.tsx +++ b/tests/favourites-auth-gate.dom.test.tsx @@ -252,7 +252,7 @@ describe("favourites auth gate DOM", () => { it("keeps Tools Saved workflows available when Favourites access is granted", () => { render(); - expect(screen.getByRole("radio", { name: "Saved" })).toBeVisible(); + expect(screen.getByRole("radio", { name: "Saved (1)" })).toBeVisible(); expect(screen.getByTestId("tool-shortcut-favourites")).toBeVisible(); expect( toolCatalogRecordsForSession({ authenticated: true, demoMode: false }).some((t) => t.id === "favourites"), diff --git a/tests/favourites-empty-state.dom.test.tsx b/tests/favourites-empty-state.dom.test.tsx index 77dd9c9c12..22c2acf5a5 100644 --- a/tests/favourites-empty-state.dom.test.tsx +++ b/tests/favourites-empty-state.dom.test.tsx @@ -1,4 +1,5 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { FavouritesCommandLibraryPage } from "@/components/clinical-dashboard/favourites-command-library-page"; @@ -43,4 +44,31 @@ describe("favourites empty rendering", () => { screen.getAllByRole("status").filter((status) => status.textContent?.includes("No favourites match")), ).toHaveLength(1); }); + + it("uses Set and Type facets while keeping Recently used outside the filter count", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByTestId("favourites-filter-rail")).toBeNull(); + const trigger = screen.getByTestId("favourites-filter-trigger-phone"); + expect(trigger).toHaveAccessibleName(/No filters active/); + + await user.click(trigger); + const panel = screen.getByTestId("favourites-filter-panel"); + expect(within(panel).getByRole("button", { name: "Set" })).toHaveAttribute("aria-expanded", "false"); + expect(within(panel).getByRole("button", { name: "Type" })).toHaveAttribute("aria-expanded", "false"); + expect(within(panel).getByRole("button", { name: "Pinned" })).toHaveAttribute("aria-expanded", "false"); + expect(within(panel).getByRole("button", { name: "Source support" })).toHaveAttribute("aria-expanded", "false"); + + await user.click(within(panel).getByRole("button", { name: "Pinned" })); + await user.click(within(panel).getByRole("button", { name: /^Pinned only \(/ })); + expect(trigger).toHaveAccessibleName(/1 filter active/); + await user.click(within(panel).getByTestId("favourites-filter-panel-done")); + expect(screen.getByRole("button", { name: "Remove Status: Pinned filter" })).toBeVisible(); + + const recent = screen.getByRole("button", { name: "Recently used" }); + await user.click(recent); + expect(recent).toHaveAttribute("aria-pressed", "true"); + expect(trigger).toHaveAccessibleName(/1 filter active/); + }); }); diff --git a/tests/filter-review-regressions.dom.test.tsx b/tests/filter-review-regressions.dom.test.tsx new file mode 100644 index 0000000000..ec965b9d55 --- /dev/null +++ b/tests/filter-review-regressions.dom.test.tsx @@ -0,0 +1,285 @@ +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { DocumentSearchResultsPanel } from "@/components/clinical-dashboard/document-search-results"; +import { MedicationPrescribingWorkspace } from "@/components/clinical-dashboard/medication-prescribing-workspace"; +import { PatientProfileProvider } from "@/components/clinical-dashboard/patient-profile-context"; +import { DifferentialStreamWorkspace } from "@/components/differentials/differential-stream-workspace"; +import { buildDifferentialStreamModel } from "@/lib/differential-stream"; +import type { ClinicalDocument, DocumentMatch } from "@/lib/types"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + prefetch: vi.fn(), + }), + useSearchParams: () => new URLSearchParams(window.location.search), + usePathname: () => window.location.pathname, +})); + +vi.mock("next/link", () => ({ + default: ({ children, href, ...rest }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => ({ + status: "signed_out", + session: null, + isConfigured: true, + authorizationHeader: () => null, + registerAuthRequest: vi.fn(), + isAuthEpochCurrent: () => true, + markSessionExpired: vi.fn(), + }), +})); + +vi.mock("@/components/clinical-dashboard/universal-search-also-matches", () => ({ + UniversalSearchAlsoMatches: () => null, +})); + +type MockMedicationResult = { + id: string; + name: string; + indication: string; + match: string; + dose: string; + ceiling: string; + action: string; + actionTone: "danger" | "warning" | "neutral"; + tone: "teal" | "blue" | "slate"; +}; + +type MockMedicationCatalogState = { + data: null | { + records: []; + matches: Array<{ + medication: undefined; + result: MockMedicationResult; + score: number; + reasons: string[]; + }>; + total: number; + governance: Record; + }; + loading: boolean; + error: string | null; +}; + +const medicationCatalogState = vi.hoisted(() => ({ + current: { data: null, loading: true, error: null } as MockMedicationCatalogState, +})); + +vi.mock("@/components/clinical-dashboard/use-medication-catalog", () => ({ + useMedicationCatalog: () => medicationCatalogState.current, +})); + +const documentId = "11111111-1111-4111-8111-111111111111"; + +function documentMatch(): DocumentMatch { + return { + document_id: documentId, + title: "Clozapine Monitoring Protocol", + file_name: "clozapine-monitoring.pdf", + labels: [], + summarySnippet: "Synthetic summary.", + bestPages: [1], + bestChunkIds: ["clozapine-chunk"], + imageCount: 0, + tableCount: 0, + matchReason: "Matched indexed passage", + score: 0.9, + }; +} + +function sourceDocument(): ClinicalDocument { + return { + id: documentId, + title: "Clozapine Monitoring Protocol", + description: null, + file_name: "clozapine-monitoring.pdf", + file_type: "application/pdf", + file_size: 1024, + storage_path: `documents/${documentId}.pdf`, + status: "indexed", + page_count: 2, + chunk_count: 1, + image_count: 0, + error_message: null, + metadata: { + document_status: "current", + clinical_validation_status: "approved", + extraction_quality: "good", + jurisdiction: "WA", + }, + labels: [], + created_at: "2026-08-01T00:00:00.000Z", + updated_at: "2026-08-01T00:00:00.000Z", + }; +} + +const documentProps = { + matches: [documentMatch()], + recordMatches: [], + showRecordMatches: false, + query: "clozapine", + loading: false, + documentCount: 42, + realDataReady: true, + authUnavailable: false, + apiUnavailable: false, + setupWarning: null, + onScopeDocument: vi.fn(), + onAnswerFromDocument: vi.fn(), + onOpenRecentDocuments: vi.fn(), + onOpenLibrary: vi.fn(), + onOpenSourcePdf: vi.fn(), + onTagSearch: vi.fn(), +}; + +function renderMedicationWorkspace() { + return render( + + + , + ); +} + +afterEach(() => { + cleanup(); + window.history.replaceState(null, "", "/"); + medicationCatalogState.current = { data: null, loading: true, error: null }; + vi.clearAllMocks(); +}); + +describe("filter review regressions", () => { + it("does not derive paginated document retrieval facets before the panel opens", async () => { + const user = userEvent.setup(); + const source = sourceDocument(); + const metadata = source.metadata; + let metadataReads = 0; + Object.defineProperty(source, "metadata", { + configurable: true, + get: () => { + metadataReads += 1; + return metadata; + }, + }); + + render(); + + expect(metadataReads).toBe(0); + await user.click(screen.getByTestId("document-filter-trigger-phone")); + expect(metadataReads).toBeGreaterThan(0); + }); + + it("labels document retrieval projections as loaded-source counts", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("document-filter-trigger-phone")); + + const panel = screen.getByTestId("document-filter-panel"); + expect(within(panel).getByRole("radio", { name: /^Local \(1 loaded source\)$/ })).toBeInTheDocument(); + }); + + it("defers differential projections until open and avoids repeated chapter-option scans", async () => { + const user = userEvent.setup(); + const model = buildDifferentialStreamModel("diagnoses", ""); + const optionValues = [ + ...model.chapters.map((chapter) => `chapter:${chapter.id}`), + ...model.presentationChapters.map((chapter) => `presentation:${chapter.id}`), + ]; + const originalHas = Set.prototype.has; + let optionMembershipChecks = 0; + const hasSpy = vi.spyOn(Set.prototype, "has").mockImplementation(function (this: Set, value: unknown) { + if (typeof value === "string" && optionValues.includes(value)) optionMembershipChecks += 1; + return originalHas.call(this, value); + }); + + try { + render(); + expect(optionMembershipChecks).toBe(0); + + await user.click(screen.getByTestId("differentials-stream-filter-trigger")); + + expect(optionMembershipChecks).toBeLessThan(optionValues.length * 8); + } finally { + hasSpy.mockRestore(); + } + }); + + it("retains a medication class deep link through initial loading and unrelated filter changes", () => { + window.history.replaceState(null, "", "/medications/search?class=Other"); + const view = renderMedicationWorkspace(); + + expect(new URLSearchParams(window.location.search).get("class")).toBe("Other"); + + medicationCatalogState.current = { + data: { + records: [], + matches: [ + { + medication: undefined, + result: { + id: "clozapine", + name: "Clozapine", + indication: "Treatment-resistant schizophrenia", + match: "Exact clinical fit", + dose: "12.5 mg", + ceiling: "900 mg", + action: "Avoid abrupt cessation", + actionTone: "danger", + tone: "teal", + }, + score: 1, + reasons: [], + }, + ], + total: 1, + governance: {}, + }, + loading: false, + error: null, + }; + view.rerender( + + + , + ); + + expect(new URLSearchParams(window.location.search).get("class")).toBe("Other"); + expect(screen.getByRole("button", { name: "Remove Class: Other filter" })).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("medication-filter-trigger-desktop")); + fireEvent.click(screen.getByRole("radio", { name: /^All medications/ })); + expect(new URLSearchParams(window.location.search).get("class")).toBe("Other"); + }); +}); diff --git a/tests/form-filters.test.ts b/tests/form-filters.test.ts new file mode 100644 index 0000000000..15b8ef9ec9 --- /dev/null +++ b/tests/form-filters.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { + filterFormMatches, + formFilterSelectionFromParams, + writeFormFilterSelection, + type FormFilterSelection, +} from "@/lib/form-filters"; +import type { FormSearchMatch } from "@/lib/form-ranker"; + +function match( + id: string, + category: string, + riskLevel: "high" | "medium" | "low", + availability: "downloadable" | "contact_ocp" | "unavailable", +): FormSearchMatch { + return { + service: { + id, + slug: id, + title: id, + catalogPayload: { form: id, name: id, category, riskLevel, availability }, + }, + score: 1, + reasons: [], + } as FormSearchMatch; +} + +const matches = [ + match("a", "Orders", "high", "downloadable"), + match("b", "Orders", "medium", "contact_ocp"), + match("c", "Notices", "low", "unavailable"), +]; + +describe("form filters", () => { + it("uses OR within a facet and AND across facets", () => { + const selection: FormFilterSelection = { + categories: new Set(["Orders"]), + risks: new Set(["high", "medium"]), + availability: new Set(["downloadable"]), + }; + expect(filterFormMatches(matches, selection).map((item) => item.service.slug)).toEqual(["a"]); + }); + + it("ignores invalid URL values and writes stable query-preserving state", () => { + const params = new URLSearchParams("q=transfer&focus=1&category=Unknown,Orders&risk=bogus,high"); + const selection = formFilterSelectionFromParams(params, ["Orders", "Notices"]); + expect([...selection.categories]).toEqual(["Orders"]); + expect([...selection.risks]).toEqual(["high"]); + writeFormFilterSelection(params, selection); + expect(params.get("q")).toBe("transfer"); + expect(params.get("focus")).toBe("1"); + expect(params.get("category")).toBe("Orders"); + expect(params.get("risk")).toBe("high"); + }); +}); diff --git a/tests/forms-search-filters.dom.test.tsx b/tests/forms-search-filters.dom.test.tsx new file mode 100644 index 0000000000..8c30b130e2 --- /dev/null +++ b/tests/forms-search-filters.dom.test.tsx @@ -0,0 +1,100 @@ +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { FormsSearchResultsPage } from "@/components/forms/forms-search-results-page"; +import { deriveFormCategories, filterFormMatches } from "@/lib/form-filters"; +import { formRecords } from "@/lib/forms"; +import { rankFormRecords } from "@/lib/form-ranker"; + +const navigation = vi.hoisted(() => ({ push: vi.fn() })); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: navigation.push }), + useSearchParams: () => new URLSearchParams(window.location.search), +})); + +vi.mock("next/link", () => ({ + default: ({ children, href, ...rest }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + +vi.mock("@/lib/use-registry-records", () => ({ + useRegistryRecords: () => ({ + status: "ready", + records: formRecords, + total: formRecords.length, + demoMode: true, + governance: {}, + refetch: vi.fn(), + }), +})); + +vi.mock("@/components/use-result-sort", () => ({ + useResultSort: () => ["relevance", vi.fn()] as const, +})); + +vi.mock("@/components/clinical-dashboard/universal-search-also-matches", () => ({ + UniversalSearchAlsoMatches: () => null, +})); + +afterEach(() => { + cleanup(); + window.history.replaceState(null, "", "/"); + navigation.push.mockReset(); +}); + +function escapePattern(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +describe("FormsSearchResultsPage filters", () => { + it("renders functional category, clinical-risk, and availability facets", async () => { + const user = userEvent.setup(); + const query = "form"; + window.history.replaceState(null, "", "/forms?q=form"); + const matches = rankFormRecords(formRecords, query); + const category = deriveFormCategories(matches).find((candidate) => { + const filtered = filterFormMatches(matches, { + categories: new Set([candidate]), + risks: new Set(), + availability: new Set(), + }); + return filtered.length > 0 && filtered.length < matches.length; + }); + expect(category).toBeTruthy(); + + const view = render(); + await user.click(screen.getByTestId("form-filter-trigger-phone")); + const panel = screen.getByTestId("form-filter-panel"); + expect(within(panel).getByRole("group", { name: "Category" })).toBeVisible(); + expect(within(panel).getByRole("group", { name: "Clinical risk" })).toBeVisible(); + expect(within(panel).getByRole("group", { name: "Availability" })).toBeVisible(); + expect(within(panel).getByRole("button", { name: /^Downloadable \(/ })).toBeVisible(); + expect(within(panel).getByRole("button", { name: /^Contact required \(/ })).toBeVisible(); + expect(within(panel).getByRole("button", { name: /^Unavailable \(/ })).toBeVisible(); + + const categoryOption = within(within(panel).getByRole("group", { name: "Category" })).getByRole("button", { + name: new RegExp(`^${escapePattern(category!)} \\(`), + }); + await user.click(categoryOption); + + expect(window.location.search).toContain("q=form"); + expect(window.location.search).toContain("category="); + view.rerender(); + + const expected = filterFormMatches(matches, { + categories: new Set([category!]), + risks: new Set(), + availability: new Set(), + }); + expect(screen.getByRole("status")).toHaveTextContent( + `${expected.length} ${expected.length === 1 ? "form" : "forms"}`, + ); + expect(screen.getByRole("button", { name: `Remove Category: ${category} filter` })).toBeVisible(); + }); +}); diff --git a/tests/formulation-search-filters.dom.test.tsx b/tests/formulation-search-filters.dom.test.tsx new file mode 100644 index 0000000000..6d17f3caaf --- /dev/null +++ b/tests/formulation-search-filters.dom.test.tsx @@ -0,0 +1,55 @@ +/** @vitest-environment jsdom */ + +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { FormulationHomePage } from "@/components/formulation/formulation-home-page"; +import { searchFormulationMechanisms } from "@/lib/formulation"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), + useSearchParams: () => new URLSearchParams(window.location.search), +})); + +vi.mock("next/link", () => ({ + default: ({ children, href, ...rest }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + +vi.mock("@/components/clinical-dashboard/universal-search-also-matches", () => ({ + UniversalSearchAlsoMatches: () => null, +})); + +afterEach(() => { + cleanup(); + window.history.replaceState(null, "", "/"); +}); + +describe("FormulationHomePage filters", () => { + it("keeps one OR facet while round-tripping domain state in the URL", async () => { + const user = userEvent.setup(); + const query = "coping"; + const domain = searchFormulationMechanisms(query)[0]?.mechanism.domains[0]; + expect(domain).toBeTruthy(); + window.history.replaceState(null, "", "/formulation?q=coping&focus=1"); + + const view = render(); + await user.click(screen.getByTestId("formulation-filter-trigger-phone")); + const panel = screen.getByTestId("formulation-filter-panel"); + expect(within(panel).getAllByRole("group", { name: "Domain" })).toHaveLength(1); + expect(within(panel).getByText(/Domains combine with OR/)).toBeVisible(); + await user.click(within(panel).getByRole("button", { name: new RegExp(`^${domain} \\(`) })); + + const params = new URLSearchParams(window.location.search); + expect(params.get("domain")).toBe(domain); + expect(params.get("q")).toBe("coping"); + expect(params.get("focus")).toBe("1"); + view.rerender(); + expect(screen.getByRole("button", { name: `Remove Domain: ${domain} filter` })).toBeVisible(); + }); +}); diff --git a/tests/medication-filters.test.ts b/tests/medication-filters.test.ts new file mode 100644 index 0000000000..eb79d44afd --- /dev/null +++ b/tests/medication-filters.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { + medicationResultHasSignal, + medicationRowMatchesFilters, + type MedicationFilterRow, +} from "@/lib/medication-filters"; + +function row( + drugClass: string, + match: string, + action: string, + actionTone: "danger" | "warning" | "neutral" = "neutral", +): MedicationFilterRow { + return { + drugClass, + result: { match, action, actionTone, dose: "10 mg", ceiling: "20 mg" }, + }; +} + +describe("medication filters", () => { + it("uses OR within class and signal groups and AND across groups", () => { + const antipsychoticSafety = row("Antipsychotic", "Exact clinical fit", "Avoid when ANC is low", "danger"); + const antidepressantMonitoring = row("Antidepressant", "Good clinical fit", "Monitor sodium", "warning"); + const moodStabiliser = row("Mood stabiliser", "Related match", "Open reference"); + const filters = { + match: "all" as const, + classes: new Set(["Antipsychotic", "Antidepressant"]), + signals: new Set(["safety", "monitoring"] as const), + }; + + expect(medicationRowMatchesFilters(antipsychoticSafety, filters)).toBe(true); + expect(medicationRowMatchesFilters(antidepressantMonitoring, filters)).toBe(true); + expect(medicationRowMatchesFilters(moodStabiliser, filters)).toBe(false); + }); + + it("separates match quality without changing row order", () => { + const rows = [ + row("A", "Exact clinical fit", "Open reference"), + row("A", "Good clinical fit", "Open reference"), + row("A", "Related match", "Open reference"), + ]; + const visible = rows.filter((item) => + medicationRowMatchesFilters(item, { match: "good", classes: new Set(), signals: new Set() }), + ); + + expect(visible).toEqual([rows[1]]); + }); + + it("keeps safety and monitoring as overlapping clinical signals", () => { + const warning = row("A", "Exact clinical fit", "Review renal function", "warning"); + expect(medicationResultHasSignal(warning.result, "safety")).toBe(true); + expect(medicationResultHasSignal(warning.result, "monitoring")).toBe(true); + }); +}); diff --git a/tests/medication-prescribing-workspace.dom.test.tsx b/tests/medication-prescribing-workspace.dom.test.tsx index d9037e53e0..65ad55f4e5 100644 --- a/tests/medication-prescribing-workspace.dom.test.tsx +++ b/tests/medication-prescribing-workspace.dom.test.tsx @@ -1,15 +1,19 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { MedicationPrescribingWorkspace } from "@/components/clinical-dashboard/medication-prescribing-workspace"; import { PatientProfileProvider } from "@/components/clinical-dashboard/patient-profile-context"; -// The prescribing results view filters a medication catalogue through a -// best/indication/safety/monitoring lens strip. The catalogue hook fetches +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(window.location.search), +})); + +// The prescribing results view filters a medication catalogue through scope, +// match-quality, class and clinical-signal controls. The catalogue hook fetches // `/api/medications` (and reads the auth session), so it is mocked with a fixed -// set of results chosen to land in different filter buckets; the filter strip is -// the unit under test. Only usePatientProfile needs a real provider (the profile -// stays empty here, so no per-patient alert badges are computed). +// set of results chosen to land in different filter buckets. Only +// usePatientProfile needs a real provider (the profile stays empty here, so no +// per-patient alert badges are computed). type Result = { id: string; @@ -23,7 +27,7 @@ type Result = { tone: "teal" | "blue" | "slate"; }; -// Clozapine: danger + exact fit → best, indication, safety (not monitoring). +// Clozapine: danger + exact fit → Safety (not Monitoring). const clozapine: Result = { id: "clozapine", name: "Clozapine", @@ -35,7 +39,7 @@ const clozapine: Result = { actionTone: "danger", tone: "teal", }; -// Lithium: warning + monitor language → every filter. +// Lithium: warning + monitor language → both clinical signals. const lithium: Result = { id: "lithium", name: "Lithium", @@ -47,7 +51,7 @@ const lithium: Result = { actionTone: "warning", tone: "blue", }; -// Sertraline: neutral + related match → best only. +// Sertraline: neutral + related match → neither clinical signal. const sertraline: Result = { id: "sertraline", name: "Sertraline", @@ -118,14 +122,15 @@ function rowVisible(name: string): boolean { return screen.queryAllByText(name).length > 0; } -// The rail is a one-of-N lens, so its options are radios in a radiogroup, not -// pressed toggles. The accessible name carries the count as "Best (3)". +// Match quality is a one-of-N lens, so its options are radios in a radiogroup. function filterButton(label: string): HTMLElement { return screen.getByRole("radio", { name: new RegExp(`^${label}`, "i") }); } afterEach(() => { catalogInterpretation.current = undefined; + window.history.replaceState(null, "", "/"); + cleanup(); vi.restoreAllMocks(); }); @@ -190,44 +195,58 @@ describe("MedicationPrescribingWorkspace — query interpretation", () => { }); }); -describe("MedicationPrescribingWorkspace — result filter strip", () => { - it("labels each lens with the count of matching results", () => { +describe("MedicationPrescribingWorkspace — refined filters", () => { + function openFilters() { + fireEvent.click(screen.getByTestId("medication-filter-trigger-desktop")); + } + + it("separates match quality from overlapping clinical signals with projected counts", () => { renderWorkspace(); - // best = 3 (all), indication = 2 (exact-fit), safety = 2 (non-neutral), monitor = 1. - expect(filterButton("Best").textContent).toContain("3"); - expect(filterButton("Indication").textContent).toContain("2"); - expect(filterButton("Safety").textContent).toContain("2"); - expect(filterButton("Monitor").textContent).toContain("1"); + openFilters(); + + expect(filterButton("All qualities").textContent).toContain("3"); + expect(filterButton("Exact clinical fit").textContent).toContain("2"); + expect(filterButton("Related match").textContent).toContain("1"); + expect(screen.getByRole("button", { name: /^Safety \(2\)$/ })).toHaveAttribute("aria-pressed", "false"); + expect(screen.getByRole("button", { name: /^Monitoring \(1\)$/ })).toHaveAttribute("aria-pressed", "false"); }); - it("defaults to the Best lens with every result shown", () => { + it("defaults to Best matches and all match qualities with ranked rows unchanged", () => { renderWorkspace(); - expect(filterButton("Best")).toHaveAttribute("aria-checked", "true"); - expect(filterButton("Safety")).toHaveAttribute("aria-checked", "false"); + openFilters(); + + expect(screen.getByRole("radio", { name: /Best matches/ })).toBeChecked(); + expect(filterButton("All qualities")).toHaveAttribute("aria-checked", "true"); expect(rowVisible("Clozapine")).toBe(true); expect(rowVisible("Lithium")).toBe(true); expect(rowVisible("Sertraline")).toBe(true); }); - it("narrows to indication-relevant results and drops related-only matches", () => { + it("round-trips a match-quality URL refinement without changing patient safety chrome", () => { renderWorkspace(); - fireEvent.click(filterButton("Indication")); + openFilters(); + fireEvent.click(filterButton("Exact clinical fit")); + expect(new URLSearchParams(window.location.search).get("match")).toBe("exact"); - expect(filterButton("Indication")).toHaveAttribute("aria-checked", "true"); - expect(filterButton("Best")).toHaveAttribute("aria-checked", "false"); + cleanup(); + renderWorkspace(); expect(rowVisible("Clozapine")).toBe(true); expect(rowVisible("Lithium")).toBe(true); - // Sertraline is a "Related match", so it leaves the Indication lens. expect(rowVisible("Sertraline")).toBe(false); + expect(screen.getByText(/Patient details/i)).toBeInTheDocument(); }); - it("narrows the Monitor lens to results with monitoring signals only", () => { + it("keeps Safety and Monitoring as OR values in one clinical-signal facet", () => { renderWorkspace(); - fireEvent.click(filterButton("Monitor")); + openFilters(); + fireEvent.click(screen.getByRole("button", { name: /^Safety \(2\)$/ })); + fireEvent.click(screen.getByRole("button", { name: /^Monitoring \(1\)$/ })); + expect(new URLSearchParams(window.location.search).get("signal")).toBe("monitoring,safety"); - expect(filterButton("Monitor")).toHaveAttribute("aria-checked", "true"); + cleanup(); + renderWorkspace(); expect(rowVisible("Lithium")).toBe(true); - expect(rowVisible("Clozapine")).toBe(false); + expect(rowVisible("Clozapine")).toBe(true); expect(rowVisible("Sertraline")).toBe(false); }); }); diff --git a/tests/mobile-interaction-regressions.test.ts b/tests/mobile-interaction-regressions.test.ts index 80ca857271..50250bbe09 100644 --- a/tests/mobile-interaction-regressions.test.ts +++ b/tests/mobile-interaction-regressions.test.ts @@ -91,7 +91,8 @@ describe("mobile interaction regressions", () => { expect(tools).not.toContain("Sort by"); expect(tools).not.toContain("hasMenu"); expect(tools).toContain('label: "Saved", desktopLabel: "Favourites"'); - expect(tools).toMatch(/effectiveFilter === "more"\s*\?\s*app\.area === "coordination" \|\| app\.area === "saved"/); + expect(tools).toContain('if (filter === "more") return app.area === "coordination" || app.area === "saved";'); + expect(tools).toContain("launcherAppMatchesFilter(app, effectiveFilter)"); // Tools local search submit is an interactive control: both end tracks and // the submit face must read the tap knob (not a leftover h-10 / 2.75rem). expect(tools).toContain("grid-cols-[var(--spacing-tap)_minmax(0,1fr)_var(--spacing-tap)]"); diff --git a/tests/result-filter-url.test.ts b/tests/result-filter-url.test.ts new file mode 100644 index 0000000000..0283f72813 --- /dev/null +++ b/tests/result-filter-url.test.ts @@ -0,0 +1,70 @@ +/** @vitest-environment jsdom */ + +import { describe, expect, it, vi } from "vitest"; + +import { + readResultFilterValue, + readResultFilterValues, + replaceResultFilterUrl, + writeResultFilterValue, + writeResultFilterValues, +} from "@/lib/result-filter-url"; + +const allowed = new Set(["all", "high", "low"] as const); + +describe("result filter URL state", () => { + it("reads stable values, de-duplicates them and ignores invalid values", () => { + const params = new URLSearchParams("risk=low,unknown,high,low&q=mania&ids=a,b"); + expect(readResultFilterValues(params, "risk", allowed)).toEqual(["high", "low"]); + }); + + it("de-duplicates values after trimming whitespace", () => { + const params = new URLSearchParams("risk=high,%20high,low"); + expect(readResultFilterValues(params, "risk", allowed)).toEqual(["high", "low"]); + }); + + it("writes sorted facets while preserving unrelated parameters", () => { + const params = new URLSearchParams("q=mania&ids=a,b&focus=1"); + writeResultFilterValues(params, "risk", ["low", "high", "low"], allowed); + expect(params.toString()).toBe("q=mania&ids=a%2Cb&focus=1&risk=high%2Clow"); + }); + + it("de-duplicates written values after trimming whitespace", () => { + const params = new URLSearchParams(); + writeResultFilterValues(params, "risk", ["high", " high" as "high", "low"], allowed); + expect(params.get("risk")).toBe("high,low"); + }); + + it("omits empty facets and default lenses", () => { + const params = new URLSearchParams("q=mania&risk=high&scope=high"); + writeResultFilterValues(params, "risk", [], allowed); + writeResultFilterValue(params, "scope", "all", "all", allowed); + expect(params.toString()).toBe("q=mania"); + }); + + it("falls back for an invalid lens", () => { + expect(readResultFilterValue(new URLSearchParams("scope=unknown"), "scope", allowed, "all")).toBe("all"); + }); + + it("replaces the current URL without adding history or dropping unrelated state", () => { + window.history.replaceState(null, "", "/forms?q=mania&ids=a,b&focus=1#results"); + const pushState = vi.spyOn(window.history, "pushState"); + const replaceState = vi.spyOn(window.history, "replaceState"); + + replaceResultFilterUrl((params) => writeResultFilterValues(params, "risk", ["low", "high"], allowed)); + + expect(pushState).not.toHaveBeenCalled(); + expect(replaceState).toHaveBeenCalledWith(null, "", "/forms?q=mania&ids=a%2Cb&focus=1&risk=high%2Clow#results"); + expect(window.location.pathname).toBe("/forms"); + expect(window.location.hash).toBe("#results"); + }); + + it("composes rapid refinements from the current URL instead of a stale render snapshot", () => { + window.history.replaceState(null, "", "/services?q=crisis&group=community"); + + replaceResultFilterUrl((params) => writeResultFilterValues(params, "risk", ["high"], allowed)); + replaceResultFilterUrl((params) => writeResultFilterValues(params, "signal", ["low"], allowed)); + + expect(window.location.search).toBe("?q=crisis&group=community&risk=high&signal=low"); + }); +}); diff --git a/tests/search-results-header-band.dom.test.tsx b/tests/search-results-header-band.dom.test.tsx index 0d46996618..821d803000 100644 --- a/tests/search-results-header-band.dom.test.tsx +++ b/tests/search-results-header-band.dom.test.tsx @@ -88,6 +88,35 @@ describe("SearchResultsHeaderBand", () => { expect(within(region).queryByText(/\d/)).toBeNull(); }); + it("keeps removable filter state visible while a fault suppresses untrusted counts", () => { + const onRemove = vi.fn(); + + render( + Tables 0} + appliedFilters={[ + { + id: "risk-high", + valueLabel: "High", + groupLabel: "Risk", + accessibleLabel: "high-risk sources", + onRemove, + }, + ]} + />, + ); + + const region = screen.getByRole("region", { name: "Search results for lithium monitoring" }); + expect(screen.queryByText("Tables 0")).toBeNull(); + expect(within(region).queryByText(/\d/)).toBeNull(); + expect(screen.getByRole("group", { name: "Applied filters" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Remove high-risk sources filter" })).toBeVisible(); + }); + // Initial loading is the same untrue-zero risk: forms forces matches to [] until // the registry is ready, so a count-bearing page control would assert "0" under Searching…. it("drops count-bearing page controls while loading", () => { @@ -737,6 +766,42 @@ describe("ResultFilterSheet facet groups", () => { expect(screen.getByRole("radiogroup", { name: "Show" })).toBeInTheDocument(); expect(screen.getByRole("group", { name: "Domain" })).toBeInTheDocument(); }); + + it("organises one facet into visual sections without changing its OR group", () => { + render( + (), + options: [ + { value: "belief", label: "Belief" }, + { value: "threat", label: "Threat" }, + ], + optionSections: [ + { id: "meaning", label: "Meaning and belief", optionValues: ["belief"] }, + { id: "emotion", label: "Emotion and threat", optionValues: ["threat"] }, + ], + onToggle: vi.fn(), + }), + ]} + />, + ); + + expect(screen.getAllByRole("group", { name: "Domain" })).toHaveLength(1); + expect(screen.getByText("Select any relevant domains; selections combine with OR.")).toBeVisible(); + expect(screen.getByRole("heading", { name: "Meaning and belief" })).toBeVisible(); + expect(screen.getByRole("heading", { name: "Emotion and threat" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Belief" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Threat" })).toBeVisible(); + }); }); // docs/filter-contract.md section 5: more than three facet groups adds a @@ -896,7 +961,8 @@ describe("ResultFilterSheet dense facet groups", () => { expect(screen.getByText("No filter matches “zzz-no-match”.")).toBeInTheDocument(); }); - it("renders the scope segment slot above the groups when a mode supplies one", () => { + it("renders the typed scope selector above the groups when a mode supplies one", () => { + const onChange = vi.fn(); render( { testId="scope-filter-panel" title="Filter services" groups={[facetGroup("catchments", "Catchment", [{ value: "Peel", label: "Peel" }])]} - scopeControl={
These results 12 · All items 219
} + scope={{ + value: "results", + onChange, + options: [ + { value: "results", label: "Current results", count: 12 }, + { value: "all", label: "All items", count: 219 }, + ], + }} />, ); - expect(screen.getByTestId("scope-segment")).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: /current results/i })).toBeChecked(); + expect(screen.getByText("219")).toBeInTheDocument(); }); }); describe("ResultFilterSheet", () => { + it("uses typed staged actions, coverage, and one result action", async () => { + const user = userEvent.setup(); + const onApply = vi.fn(); + const onBrowseAll = vi.fn(); + + render( + , + ); + + const coverage = screen.getByRole("progressbar", { name: "Visible results" }); + expect(coverage).toHaveAttribute("aria-valuenow", "7"); + expect(coverage.parentElement).toHaveTextContent("7 of 12 retrieved matches visible"); + expect(screen.getByText(/Changes apply together/)).toHaveClass("sr-only"); + expect(screen.getAllByRole("button", { name: "Update search" })).toHaveLength(1); + + await user.click(screen.getByRole("button", { name: "Update search" })); + await user.click(screen.getByRole("button", { name: /Browse all sources/ })); + expect(onApply).toHaveBeenCalledTimes(1); + expect(onBrowseAll).toHaveBeenCalledTimes(1); + }); + it("exposes each dimension as a radio group and reports the selection back typed", async () => { const user = userEvent.setup(); const onFamilyChange = vi.fn(); @@ -1214,8 +1330,8 @@ describe("ResultFilterSheet", () => { describe("SearchResultsEmptyState", () => { const filters = [ - { id: "medication", label: "Lithium", onRemove: vi.fn() }, - { id: "action", label: "Discharge", onRemove: vi.fn() }, + { id: "medication", valueLabel: "Lithium", onRemove: vi.fn() }, + { id: "action", valueLabel: "Discharge", onRemove: vi.fn() }, ]; it("leads with relaxing a filter when the set is empty because of them", async () => { diff --git a/tests/services-navigator-scope-empty-state.dom.test.tsx b/tests/services-navigator-scope-empty-state.dom.test.tsx index 6036ff522b..e878c3ef62 100644 --- a/tests/services-navigator-scope-empty-state.dom.test.tsx +++ b/tests/services-navigator-scope-empty-state.dom.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; @@ -93,6 +93,12 @@ describe("services scope segment vs the query-empty state", () => { const results = screen.getByTestId("service-search-results"); expect(results).toBeInTheDocument(); expect(results.children.length).toBe(registryRecords.length); + + fireEvent.click(screen.getByTestId("service-filter-trigger-desktop")); + const scope = screen.getByRole("group", { name: "Search in" }); + expect(within(scope).getByRole("radio", { name: /^Current results/ })).not.toBeChecked(); + expect(within(scope).getByRole("radio", { name: /^All services/ })).toBeChecked(); + expect(screen.getByRole("button", { name: "Remove Search in: All services filter" })).toBeVisible(); }); it("marks a zero-count care lens as unavailable from the active facet set", () => { diff --git a/tests/sheet.dom.test.tsx b/tests/sheet.dom.test.tsx index 311caf86ce..197805101e 100644 --- a/tests/sheet.dom.test.tsx +++ b/tests/sheet.dom.test.tsx @@ -236,61 +236,22 @@ describe("Sheet stacked-overlay coordination", () => { expect(classes).not.toContain("sm:max-h-[88dvh]"); }); - it("protects fullscreen and opted-in near-full headers without padding short bottom sheets", () => { - const { rerender } = render( - -

Body

-
, - ); - - let dialog = screen.getByRole("dialog"); - let header = dialog.querySelector('[data-sheet-header="true"]'); - expect(dialog).toHaveAttribute("data-mobile-header-safe-area", "padding"); - expect(header).not.toBeNull(); - expect(header!.classList).toContain("pt-[max(1rem,var(--safe-area-top))]"); - - rerender( - -

Body

-
, - ); - dialog = screen.getByRole("dialog"); - header = dialog.querySelector('[data-sheet-header="true"]'); - expect(dialog).toHaveAttribute("data-mobile-header-safe-area", "none"); - expect(header!.classList).not.toContain("pt-[max(1rem,var(--safe-area-top))]"); - - rerender( - -

Body

-
, - ); - dialog = screen.getByRole("dialog"); - header = dialog.querySelector('[data-sheet-header="true"]'); - expect(dialog).toHaveAttribute("data-mobile-header-safe-area", "padding"); - expect(header!.classList).toContain("pt-[max(1rem,var(--safe-area-top))]"); - expect(header!.classList).toContain("sm:pt-5"); - }); - - it("offsets an absolutely positioned header below the phone safe area", () => { + it("uses a bottom sheet on phones and a restrained right drawer from small screens", () => { render( - -

Body

+ +

Filter body

, ); - const dialog = screen.getByRole("dialog"); - const header = dialog.querySelector('[data-sheet-header="true"]'); - expect(dialog).toHaveAttribute("data-mobile-header-safe-area", "offset"); - expect(header).not.toBeNull(); - expect(header!.classList).toContain("top-[max(0.75rem,var(--safe-area-top))]"); - expect(header!.classList).toContain("sm:top-4"); - expect(header!.classList).not.toContain("top-3"); + const dialog = screen.getByRole("dialog", { name: "Filters" }); + expect(dialog).toHaveClass( + "rounded-t-2xl", + "sm:h-full", + "sm:max-w-[32rem]", + "sm:rounded-l-2xl", + "sm:rounded-r-none", + ); + expect(dialog.parentElement).toHaveClass("items-end", "justify-center", "sm:items-stretch", "sm:justify-end"); }); it("keeps the dialog mounted in production when the title resolves empty", () => { diff --git a/tests/specifiers-search-filters.dom.test.tsx b/tests/specifiers-search-filters.dom.test.tsx new file mode 100644 index 0000000000..48fe521227 --- /dev/null +++ b/tests/specifiers-search-filters.dom.test.tsx @@ -0,0 +1,89 @@ +/** @vitest-environment jsdom */ + +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SpecifiersHomePage } from "@/components/specifiers/specifiers-home-page"; +import { searchSpecifierCatalog } from "@/lib/specifiers-search-index"; + +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(window.location.search), +})); + +vi.mock("next/link", () => ({ + default: ({ children, href, ...rest }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + +afterEach(() => { + cleanup(); + window.history.replaceState(null, "", "/"); +}); + +describe("SpecifiersHomePage filters", () => { + it("filters the catalogue before applying the 24-item display limit", () => { + const matches = searchSpecifierCatalog("disorder").filter(({ item }) => item.categoryId === "per"); + expect(matches.length).toBeGreaterThan(0); + expect(matches.length).toBeLessThan(24); + window.history.replaceState(null, "", "/specifiers?q=disorder&scope=catalogue&category=per"); + + render(); + + const catalogue = screen.getByRole("region", { name: "Full specifier catalogue matches" }); + expect(within(catalogue).getAllByRole("listitem")).toHaveLength(matches.length); + expect(screen.getByRole("status")).toHaveTextContent(`${matches.length} specifiers`); + expect(screen.queryByRole("button", { name: /show more/i })).toBeNull(); + }); + + it("reports the full total and progressively reveals catalogue matches", async () => { + const user = userEvent.setup(); + const matches = searchSpecifierCatalog("disorder"); + expect(matches.length).toBeGreaterThan(48); + window.history.replaceState(null, "", "/specifiers?q=disorder&scope=catalogue"); + + render(); + + const catalogue = screen.getByRole("region", { name: "Full specifier catalogue matches" }); + expect(within(catalogue).getAllByRole("listitem")).toHaveLength(24); + expect(screen.getByRole("status")).toHaveTextContent(`${matches.length} specifiers`); + + await user.click(screen.getByRole("button", { name: /show more/i })); + expect(within(catalogue).getAllByRole("listitem")).toHaveLength(48); + }); + + it("respects an explicit guide scope even when that scope is empty", () => { + expect(searchSpecifierCatalog("type").length).toBeGreaterThan(0); + window.history.replaceState(null, "", "/specifiers?q=type&scope=guides"); + + render(); + + expect(screen.getByRole("status")).toHaveTextContent("0 specifiers"); + expect(screen.getByRole("button", { name: "Remove Search in: Clinical guides filter" })).toBeVisible(); + expect(screen.queryByRole("region", { name: "Full specifier catalogue matches" })).toBeNull(); + }); + + it("writes a stable category URL and hydrates its contextual applied chip", async () => { + const user = userEvent.setup(); + const category = searchSpecifierCatalog("disorder").find(({ item }) => item.categoryId === "per")?.item.category; + expect(category).toBeTruthy(); + window.history.replaceState(null, "", "/specifiers?q=disorder&focus=1&scope=catalogue"); + + const view = render(); + await user.click(screen.getByTestId("specifier-filter-trigger-phone")); + const panel = screen.getByTestId("specifier-filter-panel"); + await user.click(within(panel).getByRole("button", { name: "Category" })); + await user.click(within(panel).getByRole("button", { name: new RegExp(`^${category} \\(`) })); + + const params = new URLSearchParams(window.location.search); + expect(params.get("category")).toBe("per"); + expect(params.get("q")).toBe("disorder"); + expect(params.get("focus")).toBe("1"); + view.rerender(); + expect(screen.getByRole("button", { name: `Remove Category: ${category} filter` })).toBeVisible(); + }); +}); diff --git a/tests/ui-route-coverage.spec.ts b/tests/ui-route-coverage.spec.ts index 509c2e0edb..891e659d42 100644 --- a/tests/ui-route-coverage.spec.ts +++ b/tests/ui-route-coverage.spec.ts @@ -3,11 +3,7 @@ import type { Route } from "playwright-core"; import { expect, test, type Page } from "playwright/test"; import { demoDocuments, getDemoDocument, getDemoDocumentPayload } from "../src/lib/demo-data"; -import { - differentialDiagnosesCards, - getDifferentialDetailContext, - getDifferentialRecord, -} from "../src/lib/differentials"; +import { getDifferentialDetailContext, getDifferentialRecord } from "../src/lib/differentials"; import { loadMedicationSnapshot } from "../src/lib/medication-snapshot"; import { visibleByTestId } from "./playwright-settlement"; @@ -482,8 +478,6 @@ test.describe("previously uncovered production routes", () => { browserName === "webkit" && text === 'Viewport argument key "interactive-widget" not recognized and ignored.'; if (message.type() === "error" && !isWebKitViewportDiagnostic) consoleErrors.push(text); }); - const action = - differentialDiagnosesCards.find((card) => !card.href.endsWith("/delirium")) ?? differentialDiagnosesCards[0]; await proveRenderedRoute( page, "/differentials/diagnoses?q=delirium", @@ -526,31 +520,37 @@ test.describe("previously uncovered production routes", () => { const filterPanel = visibleByTestId(currentPage, "differentials-stream-filter-panel"); await expect(filterPanel).toBeVisible(); - const allEntries = filterPanel.getByRole("radio", { name: "All entries" }); + const familyView = filterPanel.getByRole("radiogroup", { name: "Family view" }); + const allEntries = familyView.getByRole("radio", { name: "All entries", exact: true }); await allEntries.focus(); await currentPage.keyboard.press("ArrowRight"); - await expect(filterPanel.getByRole("radio", { name: /Focused family/ })).toHaveAttribute( - "aria-checked", - "true", - ); - await filterPanel.getByRole("button", { name: "Done" }).click(); + await expect(familyView.getByRole("radio", { name: /Focused family/ })).toHaveAttribute("aria-checked", "true"); + await filterPanel.getByTestId("differentials-stream-filter-panel-done").click(); await expect(filterTrigger).toBeFocused(); - const removeFamily = currentPage.getByRole("button", { name: /Remove .+ family filter/ }); + const removeFamily = currentPage.getByRole("button", { name: /Remove Family: .+ filter/ }); await expect(removeFamily).toBeVisible(); await removeFamily.click(); await expect(removeFamily).toHaveCount(0); - const entry = currentPage.locator(`a[href="${action.href}"]`).first(); + const entry = currentPage.locator('main a[href^="/differentials/diagnoses/"]:visible').first(); await expect(entry).toBeVisible(); - await Promise.all([currentPage.waitForURL(new RegExp(`${action.href}$`), { timeout: 30_000 }), entry.click()]); + const entryHref = await entry.getAttribute("href"); + expect(entryHref).toMatch(/^\/differentials\/diagnoses\/[a-z0-9-]+$/); + await Promise.all([ + currentPage.waitForURL((url) => url.pathname === entryHref, { timeout: 30_000 }), + entry.click(), + ]); }, ); await gotoApp(page, "/differentials/presentations"); await page.setViewportSize({ width: 1280, height: 900 }); await expect(page.getByRole("heading", { name: "Presentation pathways", level: 2 })).toBeVisible(); - await expect(page.getByRole("group", { name: "Presentation priority" })).toBeVisible(); + const presentationBrowseTrigger = visibleByTestId(page, "differentials-stream-filter-trigger-desktop"); + await presentationBrowseTrigger.click(); + await expect(page.getByRole("radiogroup", { name: "Clinical urgency" })).toBeVisible(); + await page.getByTestId("differentials-stream-filter-panel-done").click(); await expect(page.getByText("High-priority presentation pathways")).toBeVisible(); await expect(page.getByRole("link", { name: /Open pathway/ }).first()).toBeVisible(); await expect(page.getByRole("button", { name: "Show family" })).toHaveCount(0); @@ -580,9 +580,9 @@ test.describe("previously uncovered production routes", () => { "aria-checked", "true", ); - await presentationFilterPanel.getByRole("button", { name: "Done" }).click(); + await presentationFilterPanel.getByTestId("differentials-stream-filter-panel-done").click(); await expect(presentationFilterTrigger).toBeFocused(); - await expect(page.getByRole("button", { name: "Remove Emergent priority filter" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Remove Priority: Emergent filter" })).toBeVisible(); const visiblePresentationCards = page.locator('[data-testid^="differential-stream-card-"]:visible'); await expect(visiblePresentationCards.first()).toHaveAttribute("data-status", "emergent"); await expect.poll(async () => visiblePresentationCards.count()).toBeGreaterThan(0); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 60dd158652..880234ad55 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -3322,49 +3322,29 @@ test.describe("Clinical KB UI smoke coverage", () => { } }); - test("DSM category filter dropdown opens to the correct option by keyboard", async ({ page }) => { + test("DSM category facets support keyboard selection and restore focus", async ({ page }) => { await page.setViewportSize({ width: 1100, height: 850 }); await mockDemoApi(page); await gotoApp(page, "/dsm/search?q=depression"); await expect(page.getByTestId("dsm-search-page")).toBeVisible(); - const trigger = page.getByTestId("dsm-category-filter"); - const options = page.getByRole("menuitemradio"); - - // ArrowUp opens the menu with focus on the LAST option (reverse entry). This - // guards against a regression where a competing focus-on-open effect raced - // the key handler and stole focus back to the active item. + const trigger = page.getByTestId("dsm-category-filter-desktop"); await trigger.focus(); - await page.keyboard.press("ArrowUp"); - await expect(options.last()).toBeFocused(); - - // Escape closes the menu and restores focus to the trigger. - await page.keyboard.press("Escape"); - await expect(options.first()).toBeHidden(); - await expect(trigger).toBeFocused(); - - // ArrowDown opens the menu with focus on the active option ("All categories"). - await page.keyboard.press("ArrowDown"); - await expect(options.first()).toBeFocused(); - await expect(options.first()).toHaveAttribute("aria-checked", "true"); - - // Options sit outside the Tab sequence (tabIndex=-1), so one Tab press from a - // non-final option leaves the whole widget in a single step and closes the - // menu instead of stepping through every category link. - await page.keyboard.press("Tab"); - await expect(options.first()).toBeHidden(); - await expect(trigger).toHaveAttribute("aria-expanded", "false"); - await expect(options).toHaveCount(0); + await page.keyboard.press("Enter"); + const panel = page.getByTestId("dsm-category-filter-panel"); + await expect(panel).toBeVisible(); - // Space activates the focused option (announced as a menuitemradio) even - // though the underlying element is an anchor, applying the category filter. - await trigger.focus(); - await page.keyboard.press("ArrowDown"); - await expect(options.first()).toBeFocused(); - await page.keyboard.press("ArrowDown"); - await expect(options.nth(1)).toBeFocused(); + const categoryGroup = panel.getByRole("group", { name: "Category" }); + const category = categoryGroup.locator('button:not([aria-disabled="true"])').first(); + await expect(category).toHaveAttribute("aria-pressed", "false"); + await category.focus(); await page.keyboard.press("Space"); await expect(page).toHaveURL(/[?&]category=/); + await expect(category).toHaveAttribute("aria-pressed", "true"); + + await page.keyboard.press("Escape"); + await expect(panel).toHaveCount(0); + await expect(trigger).toBeFocused(); }); test("dashboard specifiers mode param redirects to the standalone specifiers route", async ({ page }) => { @@ -4012,7 +3992,8 @@ test.describe("Clinical KB UI smoke coverage", () => { await mobileFilterTrigger.click(); const filterPanel = page.getByTestId("document-filter-panel"); await expect(filterPanel).toBeVisible(); - await expect(filterPanel.getByRole("radiogroup", { name: "Source type" })).toBeVisible(); + await expect(filterPanel.getByRole("radiogroup", { name: "Source locality" })).toBeVisible(); + await expect(filterPanel.getByRole("radiogroup", { name: "Result type" })).toBeVisible(); // Library lives in the sheet footer now, under a rule and below the commit // action. It was first renamed from "Open source filters" — it browses, it // does not refine, and the old name made it read as a duplicate of Filter — @@ -4022,11 +4003,11 @@ test.describe("Clinical KB UI smoke coverage", () => { // overflow at all"; that was measured wrong. With Library gone the rail still // overflowed at every common phone width, because the inline utilities group // was `shrink` — see the rail-fit assertion above. - const libraryButton = filterPanel.getByTestId("document-filter-browse-library"); + const libraryButton = filterPanel.getByRole("button", { name: /Browse all sources/ }); await expect(libraryButton).toBeVisible(); await expect(libraryButton).toHaveText(/Browse all sources/); await expectMinTouchTarget(libraryButton); - await filterPanel.getByTestId("document-filter-done").click(); + await filterPanel.getByTestId("document-filter-panel-done").click(); await expect(filterPanel).toHaveCount(0); await expect(mobileFilterTrigger).toHaveAttribute("aria-expanded", "false"); // Asserted as an absence, not merely tolerated: putting Library back on the @@ -4129,17 +4110,20 @@ test.describe("Clinical KB UI smoke coverage", () => { await page.keyboard.press("Escape"); await expect(moreActions).toBeFocused(); - // Source type now lives inside the filter panel rather than in a native - // select in the ribbon, so reaching it goes through the trigger. + // Retrieval scope and result refinements now live in one staged panel + // rather than competing with a native select in the ribbon. await mobileFilterTrigger.click(); const phoneFilterPanel = page.getByTestId("document-filter-panel"); const phoneTablesFilter = phoneFilterPanel.getByRole("radio", { name: /Tables/ }); if ((await phoneTablesFilter.count()) > 0) { await phoneTablesFilter.click(); await expect(phoneTablesFilter).toHaveAttribute("aria-checked", "true"); - await phoneFilterPanel.getByRole("radio", { name: /^All/ }).click(); + await phoneFilterPanel + .getByRole("radiogroup", { name: "Result type" }) + .getByRole("radio", { name: /^All/ }) + .click(); } - await phoneFilterPanel.getByTestId("document-filter-done").click(); + await phoneFilterPanel.getByTestId("document-filter-panel-done").click(); await expect(phoneFilterPanel).toHaveCount(0); await expect(documentResults).toBeVisible(); @@ -4180,14 +4164,14 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(queryRibbon.getByTestId("document-filter-trigger-phone")).toBeHidden(); await wideFilterTrigger.click(); const wideFilterPanel = page.getByTestId("document-filter-panel"); - await expect(wideFilterPanel.getByRole("radiogroup", { name: "Source type" })).toBeVisible(); + await expect(wideFilterPanel.getByRole("radiogroup", { name: "Source locality" })).toBeVisible(); const dashboardMain = page.locator("main#main-content"); const scrollTopBeforeSources = await dashboardMain.evaluate((element) => element.scrollTop); // The corpus is now reached from the sheet's footer rather than the rail, // and reaching it dismisses the sheet: browsing is leaving this surface, so // the Sources drawer must not open underneath a filter panel still covering // the results both of them describe. - await wideFilterPanel.getByTestId("document-filter-browse-library").click(); + await wideFilterPanel.getByRole("button", { name: /Browse all sources/ }).click(); await expect(wideFilterPanel).toHaveCount(0); const resultsLibraryDialog = page.getByRole("dialog", { name: "Sources" }); await expect(resultsLibraryDialog).toBeVisible(); diff --git a/tests/ui-specifiers.spec.ts b/tests/ui-specifiers.spec.ts index c69abaf6de..aa4f75e72b 100644 --- a/tests/ui-specifiers.spec.ts +++ b/tests/ui-specifiers.spec.ts @@ -79,10 +79,14 @@ test("searches clinical language without provenance fields and carries a result await expect(queryRibbon.getByRole("group", { name: "Filter specifier results" })).toBeVisible(); await expect(page.getByText(/Results ranked by text relevance/i)).toHaveCount(0); await expect(page.getByText("Top match", { exact: true })).toBeVisible(); - // A lens, so the rail is a radiogroup rather than a toggle group — the sheet - // already said one-of-N and the desktop rail now agrees with it. - await expect(page.getByRole("radiogroup", { name: "Filter by specifier family" })).toBeVisible(); - await expect(page.getByRole("combobox", { name: "Filter by diagnosis" })).toBeVisible(); + const filterTrigger = page.getByTestId("specifier-filter-trigger-desktop"); + await filterTrigger.click(); + const filterPanel = page.getByTestId("specifier-filter-panel"); + await expect(filterPanel).toBeVisible(); + await expect(filterPanel.getByRole("group", { name: "Search in" })).toBeVisible(); + await expect(filterPanel.getByRole("radiogroup", { name: "Family" })).toBeVisible(); + await expect(filterPanel.getByRole("radiogroup", { name: "Diagnosis" })).toBeVisible(); + await filterPanel.getByTestId("specifier-filter-panel-done").click(); await expect(page.getByText("Best fit", { exact: true })).toHaveCount(0); await expect(page.getByText(/clinical fit/i)).toHaveCount(0); const topMatch = page.getByTestId("specifier-top-match"); diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts index 3326c926d8..843386feeb 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -474,13 +474,13 @@ test.describe("Medication responsive stress coverage", () => { const metrics = await page.evaluate((viewportWidth) => { const workspace = document.querySelector(".medication-results-workspace"); const patient = document.querySelector(".medication-patient-strip"); - const filters = document.querySelector(".medication-filter-strip"); const card = document.querySelector('[data-testid="medication-result-acamprosate-phone"]'); - const firstFilter = + const firstFilter = document.querySelector( viewportWidth < 640 - ? document.querySelector('[data-testid="medication-filter-trigger-phone"]') - : filters?.querySelector("button"); - if (!workspace || !patient || !filters || !card || !firstFilter) return null; + ? '[data-testid="medication-filter-trigger-phone"]' + : '[data-testid="medication-filter-trigger-desktop"]', + ); + if (!workspace || !patient || !card || !firstFilter) return null; const workspaceRect = workspace.getBoundingClientRect(); const patientRect = patient.getBoundingClientRect(); const cardRect = card.getBoundingClientRect(); @@ -501,7 +501,7 @@ test.describe("Medication responsive stress coverage", () => { }; }, viewport.width); expect(metrics).not.toBeNull(); - expect(metrics?.filterHeight ?? 0).toBeGreaterThanOrEqual(42); + expect(metrics?.filterHeight ?? 0).toBeGreaterThanOrEqual(viewport.width < 640 ? 48 : 40); if (viewport.width <= 639) { expect(metrics?.workspaceLeft ?? 0).toBeGreaterThanOrEqual(12); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 86ce537561..a83279613e 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -444,13 +444,7 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expect(page.getByTestId("tools-search-results-page")).toBeVisible(); await expect(page.getByRole("heading", { level: 1, name: "All tools" })).toBeVisible(); - if (viewport.name === "phone") { - await expect(page.getByTestId("tools-results-home-composer").getByTestId("global-search-input")).toHaveCount(0); - await expect(page.locator("form.answer-footer-search-dock")).toBeVisible(); - } else { - await expect(page.getByTestId("tools-results-home-composer").getByTestId("global-search-input")).toBeVisible(); - await expect(page.locator("form.answer-footer-search-dock")).toHaveCount(0); - } + await expect(page.getByTestId("tools-results-home-composer").getByTestId("global-search-input")).toBeVisible(); await expectNoPageHorizontalOverflow(page); }); } @@ -494,10 +488,9 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expect(page.getByLabel("Mode Tools")).toBeVisible(); await expect(visibleGlobalSearchInput(page)).toHaveCount(1); if (viewport.name === "mobile") { - // Tools delegates its phone composer to the same shared global footer - // used by submitted views; tablet and desktop retain the hero slot. - await expect(page.getByTestId("tools-home").getByTestId("global-search-input")).toHaveCount(0); - await expect(page.locator("form.answer-footer-search-dock")).toBeVisible(); + // Phones keep the compact shared search in the tools-home hero slot. + await expect(page.getByTestId("tools-home").getByTestId("global-search-input")).toBeVisible(); + await expect(page.locator("form.answer-footer-search-dock")).toHaveCount(0); } else { await expect(page.getByTestId("tools-home").getByTestId("global-search-input")).toBeVisible(); } @@ -583,7 +576,8 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expect(filterSheet).toBeVisible(); await expect(filterSheet.getByRole("radio", { name: /Assess/ })).toHaveAttribute("aria-checked", "false"); await expect(filterSheet.getByRole("radio", { name: /Treat/ })).toHaveAttribute("aria-disabled", "true"); - await filterSheet.getByRole("button", { name: "Done" }).click(); + await expect(filterSheet.getByTestId("tools-search-filter-sheet-done")).toHaveText(/View 1 tool/); + await filterSheet.getByTestId("tools-search-filter-sheet-done").click(); const details = results.getByRole("button", { name: "View details for Differentials" }); await details.click(); @@ -608,13 +602,7 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expect(page.getByRole("heading", { level: 1, name: "All tools" })).toBeVisible(); await expect(page.getByRole("region", { name: "Tool results" })).toBeVisible(); await expect(page.getByRole("heading", { level: 2, name: "Clinical KB Search" }).first()).toBeVisible(); - if (width < 640) { - await expect(page.getByTestId("tools-results-home-composer").getByTestId("global-search-input")).toHaveCount(0); - await expect(page.locator("form.answer-footer-search-dock")).toBeVisible(); - } else { - await expect(page.getByTestId("tools-results-home-composer").getByTestId("global-search-input")).toBeVisible(); - await expect(page.locator("form.answer-footer-search-dock")).toHaveCount(0); - } + await expect(page.getByTestId("tools-results-home-composer").getByTestId("global-search-input")).toBeVisible(); await expectNoPageHorizontalOverflow(page); } @@ -998,6 +986,7 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { { path: "/differentials", testId: "differentials-home" }, { path: "/factsheets", testId: "factsheets-home-main" }, { path: "/favourites", testId: "favourites-hub" }, + { path: "/tools", testId: "tools-search-results-page" }, ] as const) { await gotoLauncher(page, home.path); const homeSurface = page.getByTestId(home.testId); @@ -1165,18 +1154,15 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await page.setViewportSize({ width: 390, height: 820 }); await gotoLauncher(page, "/services?q=13YARN&focus=1&run=1"); await expect(page.getByRole("button", { name: "Mode Services" })).toBeVisible(); - await expect(page.getByTestId("service-quick-search-suggestions")).toHaveCount(0); const input = visibleGlobalSearchInput(page).first(); await expect(input).toBeVisible(); const quickFilter = page.getByTestId("service-filter-trigger-phone"); await expect(quickFilter).toBeVisible(); await expect(quickFilter).toHaveAccessibleName(/No filters active/); - await expect(page.getByRole("navigation", { name: "Service groups" })).toHaveCount(0); - await quickFilter.click(); - const phoneFilterPanel = page.getByTestId("service-filter-panel"); - await phoneFilterPanel.getByRole("button", { name: "Service category" }).click(); - await expect(phoneFilterPanel.getByRole("button", { name: /^Crisis & urgent/ })).toBeVisible(); - await phoneFilterPanel.getByRole("button", { name: "Close", exact: true }).click(); + await expect(page.getByTestId("service-quick-search-suggestions")).toHaveCount(0); + await input.fill("crisis"); + await input.press("Enter"); + await expect(page).toHaveURL(/\/services\?.*q=crisis/); // Phones keep the full search results in the page instead of opening a // command sheet over the small viewport. @@ -1372,7 +1358,6 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await gotoLauncher(page, "/services?focus=1"); await expect(page.getByTestId("services-home").getByTestId("global-search-input")).toBeVisible(); await expect(page.getByTestId("services-home").getByTestId("global-search-input")).toBeFocused(); - await expect(page.getByTestId("service-quick-search-suggestions")).toHaveCount(0); await gotoLauncher(page, "/forms?focus=1"); await expect(visibleByTestId(page, "forms-home").getByTestId("global-search-input")).toBeVisible(); @@ -1387,7 +1372,6 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expect(page.locator('input[placeholder="Search services..."]:visible').first()).toHaveValue("13YARN"); await expect(page.getByTestId("service-search-results")).toBeVisible(); await expect(page.getByTestId("service-search-result-13yarn")).toContainText("13YARN"); - await expect(page.getByTestId("service-quick-search-suggestions")).toHaveCount(0); await expect( page.getByTestId("service-search-result-13yarn").getByLabel("Review referral for 13YARN"), ).toHaveAttribute("href", "/services/13yarn"); @@ -1405,22 +1389,20 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expectNoPageHorizontalOverflow(page); }); - test("services results keep category filtering and compact referral progress without suggestion rows", async ({ - page, - }) => { + test("services results keep browse navigation and filters without a suggestion rail", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await gotoLauncher(page, "/services?q=13YARN&focus=1&run=1"); await expect(page.getByRole("heading", { level: 1, name: "13YARN" })).toBeVisible(); await expect(page.getByLabel("Referral workflow")).toHaveCount(0); - // The four-card numbered walkthrough stays gone (assertion above). A - // compact progress rail sits between the results heading and the list, - // under a different accessible name (ledger #163). + // The four-card numbered walkthrough stays gone (assertion above); what + // replaces it is a one-line dot rail under a DIFFERENT accessible name, + // so the check above cannot be satisfied by quietly renaming the old + // component back onto this route (ledger #163). const referralProgress = page.getByRole("navigation", { name: "Referral progress" }); await expect(referralProgress).toBeVisible(); await expect(referralProgress.locator('[aria-current="step"]')).toHaveText("Search"); - await expect(referralProgress).toContainText("Step 1 of 4"); - await expect(page.getByRole("navigation", { name: "Service groups" })).toHaveCount(0); + await expect(page.getByRole("navigation", { name: "Service groups" })).toBeVisible(); await expect(page.getByTestId("services-shortlist-bar")).toHaveCount(0); // The row is compact by contract: the Catchment/Eligibility/Cost strip @@ -1429,33 +1411,19 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { const firstResult = page.getByTestId("service-search-result-13yarn"); await expect(firstResult.getByText("Catchment", { exact: true })).toHaveCount(0); await expect(firstResult.getByRole("button", { name: "Save 13YARN to favourites" })).toBeVisible(); - const headingBox = await page.getByRole("heading", { level: 1, name: "13YARN" }).boundingBox(); - const progressBox = await referralProgress.boundingBox(); - const resultBox = await firstResult.boundingBox(); - expect(headingBox).not.toBeNull(); - expect(progressBox).not.toBeNull(); - expect(resultBox).not.toBeNull(); - expect(progressBox!.y).toBeGreaterThan(headingBox!.y); - expect(progressBox!.y).toBeLessThan(resultBox!.y); await expect(page.getByTestId("service-quick-search-suggestions")).toHaveCount(0); - // The former service-group strip is now an honest, overlapping facet in - // the shared filter sheet. Clearing it preserves the active query. + // Exercise a real facet, then clear only that facet while preserving q. await page.getByTestId("service-filter-trigger-desktop").click(); const filterPanel = page.getByTestId("service-filter-panel"); - await filterPanel.getByRole("button", { name: "Service category" }).click(); - await expect(filterPanel.getByRole("button", { name: /^Public mental health/ })).toBeVisible(); - await expect(filterPanel.getByRole("button", { name: /^Alcohol & other drugs/ })).toBeVisible(); - await expect(filterPanel.getByRole("button", { name: /^Community & specialist support/ })).toBeVisible(); - const crisisCategory = filterPanel.getByRole("button", { name: /^Crisis & urgent/ }); - await expect(crisisCategory).toBeVisible(); - await crisisCategory.click(); - await expect(page).toHaveURL(/group=urgent/); - await expect(page.getByRole("button", { name: "Remove Crisis & urgent filter" })).toBeVisible(); + await filterPanel.getByRole("button", { name: /^Acuity/ }).click(); + const crisisFacet = filterPanel.getByRole("button", { name: /^Crisis \/ urgent/ }); + await expect(crisisFacet).toBeVisible(); + await crisisFacet.click(); + await expect(page).toHaveURL(/acuity_flags=crisis_high/); await filterPanel.getByTestId("service-filter-panel-clear").click(); await expect(page).toHaveURL(/q=13YARN/); - await expect(page).not.toHaveURL(/group=/); await expect(page.getByTestId("service-search-result-13yarn")).toBeVisible(); await filterPanel.getByRole("button", { name: "Close", exact: true }).click(); @@ -3146,55 +3114,3 @@ test.describe("Responsive layout guards", () => { await expectNoPageHorizontalOverflow(page); }); }); - -test("low-confidence AccessibleTable keeps its full missing-value phrase readable at 320px @mockup", async ({ - page, -}, testInfo) => { - await page.setViewportSize({ width: 320, height: 700 }); - await page.goto("/mockups/accessible-table-browser-fixture", { waitUntil: "domcontentloaded" }); - - const fixture = visibleByTestId(page, "accessible-table-browser-fixture"); - await expect(fixture).toBeVisible({ timeout: 15_000 }); - await expect(fixture.getByTestId("table-low-confidence-note")).toContainText( - "verify values against the source document", - ); - - const table = fixture.getByRole("table", { name: "Clozapine ANC response" }); - await expect(table).toBeVisible(); - const missingValues = table.getByTestId("missing-value"); - await expect(missingValues).toHaveCount(2); - await expect(missingValues.first()).toHaveText("Not recorded"); - - const layout = await missingValues.first().evaluate((value) => { - const wrapper = value.parentElement; - if (!wrapper) throw new Error("Missing-value wrapper was not rendered"); - const valueRect = value.getBoundingClientRect(); - const wrapperRect = wrapper.getBoundingClientRect(); - const style = getComputedStyle(wrapper); - return { - valueLeft: valueRect.left, - valueRight: valueRect.right, - wrapperLeft: wrapperRect.left, - wrapperRight: wrapperRect.right, - wrapperClientWidth: wrapper.clientWidth, - wrapperScrollWidth: wrapper.scrollWidth, - whiteSpace: style.whiteSpace, - overflow: style.overflow, - textOverflow: style.textOverflow, - }; - }); - - expect(layout.whiteSpace).toBe("normal"); - expect(layout.textOverflow).not.toBe("ellipsis"); - expect(layout.wrapperScrollWidth - layout.wrapperClientWidth).toBeLessThanOrEqual(1); - expect(layout.valueLeft).toBeGreaterThanOrEqual(layout.wrapperLeft - 1); - expect(layout.valueRight).toBeLessThanOrEqual(layout.wrapperRight + 1); - await expectNoPageHorizontalOverflow(page); - - const screenshotPath = testInfo.outputPath("low-confidence-accessible-table-320px.png"); - await fixture.screenshot({ path: screenshotPath }); - await testInfo.attach("low-confidence-accessible-table-320px", { - path: screenshotPath, - contentType: "image/png", - }); -});