diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index b03bdbddb8..879976a14b 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 | 8 | +| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 9 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | | `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 27 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 72c7073568..3b0db9464e 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1400,6 +1400,7 @@ "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" ], "productImportFiles": [ @@ -1410,6 +1411,7 @@ "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" ], "designSync": { diff --git a/docs/filter-contract.md b/docs/filter-contract.md index 7addee2bc3..4b6e27e327 100644 --- a/docs/filter-contract.md +++ b/docs/filter-contract.md @@ -90,7 +90,12 @@ which choice did it. Where a mode has a catalogue meaningfully larger than the current result set, the sheet offers a scope segment — `These results N | All items N`, counts on both — built from the shared `SegmentedControl` (`src/components/ui/segmented-control.tsx`), which already has the roving -tabindex and radio semantics. +tabindex and radio semantics. `ResultFilterSheet` reserves the slot (`scopeControl`) but does not +build the segment itself: "meaningfully larger" and what the two counts mean are per-mode +judgements the shared renderer cannot make. Services is the first mode to use it — see +`services-navigator-page.tsx`: the segment is gated on the catalogue exceeding the query/group +scoped result set (not the facet-narrowed one, so the segment does not flicker away as facets are +applied), and both counts reflect the current facet/lens selection. It earns its place because it is the only escape from a filtered-to-zero state that does not discard the query: the commit becomes "Show N in all items" instead of a dead end. @@ -110,16 +115,30 @@ corpus of that size, and it stays. Facet groups only. Thresholds match the rule documents already uses (`dense = groups.length > 3`). -| Options | Renderer | -| ------------------- | ----------------------------------------------------------------------- | -| ≤ 5 | chips, single row where they fit | -| 6 – 20 | dense list: full-width rows, right-aligned count column, group headings | -| > 20, or > 3 groups | dense list plus find-a-filter and collapse-by-default | +| Options | Renderer | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| ≤ 5 | chips, single row where they fit | +| 6 – 20 | chips, same rendering as above — the group is still small enough on its own; it is the >3-groups tier below that adds the chrome | +| > 20, or > 3 groups | chips plus find-a-filter and collapse-by-default, per group | + +The `> 3 groups` tier lives directly in `ResultFilterSheet` (`result-filter-control.tsx`), not as a +second hand-rolled panel: a "Find a filter…" field appears once, above the groups, and every facet +group becomes a disclosure. Below the threshold every group renders exactly as it did before this +was added — formulation (one facet group) never crosses it, so its sheet is byte-for-byte +unchanged. Services (five facet groups) is the first mode to cross it. + +This intentionally does not yet build the "full-width rows, right-aligned count column" list +layout an earlier draft of this section described for the 6–20 band — a group that size still +renders as wrapping chips, just collapsible. `document-search-results.tsx` keeps its own, +separate dense-list implementation (needle + collapse, chip rendering) for now; porting it up as +the shared `> 20` tier and converging both call sites is PR F's job, not this one's — see +"Rollout" below and `docs/outstanding-issues.md` `#170`. Collapse rules, when they apply: groups start collapsed; a group holding a selection opens itself; an explicit user collapse beats that; an active needle forces every matched group open and owns openness. A selected option always survives the needle, so an active constraint can -never become unreachable. +never become unreachable. A group whose options are all filtered out by the needle disappears +rather than showing an empty heading. ## 6. Invariants @@ -153,8 +172,17 @@ Contract first, then one PR per mode: so this PR converges its desktop rail onto `SegmentedControl` sharing one counted option array with the phone sheet, rather than evicting anything — there was no query-replacing preset to evict. -3. **Services** — evict the six query-replacing quick filters to the composer, alongside its own - facet/scope work (open as a separate PR). + applications, specifiers (all `lens`, PR A) and formulation (`facet`, PR B). Also done for + factsheets: its category dimension was already a real `lens` (see the corrected note above), + so this PR converges its desktop rail onto `SegmentedControl` sharing one counted option array + with the phone sheet, rather than evicting anything — there was no query-replacing preset to + evict. +3. **Services** — evict the six query-replacing quick filters to the composer. Done for services: + five facets (catchments, age_groups, setting_flags, acuity_flags, housing_flags), + substance_flags as a lens (an exact partition, not an accumulating constraint — see + `src/lib/service-facets.ts`), a URL round-trip alongside `q`/`group`, and the scope segment + (section 4). Services is also the first mode dense enough (5 facet groups) to exercise the + `> 3 groups` chrome added to the shared sheet for this — see section 5. 4. **Documents last** — port its needle and collapse up into the shared component as the `> 20` tier, then converge. It is the largest surface and should move once the contract is proven elsewhere. diff --git a/src/components/clinical-dashboard/result-filter-control.tsx b/src/components/clinical-dashboard/result-filter-control.tsx index 6c64ac567e..cd9a88468e 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, Funnel, X } from "lucide-react"; -import { type ReactNode, useCallback, useRef } from "react"; +import { Check, ChevronDown, Funnel, Search, X } from "lucide-react"; +import { type ReactNode, useCallback, useRef, useState } from "react"; import { Sheet } from "@/components/ui/sheet"; import { cn } from "@/components/ui-primitives"; @@ -422,9 +422,31 @@ function FilterRadioGroup({ group, panelId }: { group: ResultFilterLensGroup; pa * option can only appear as a consequence of the current selection, never as a * permanent fixture of the catalogue. */ -export function ResultFilterFacetChips({ group, idPrefix }: { group: ResultFilterFacetGroup; idPrefix: string }) { +/** + * Present only when the sheet is dense (see `ResultFilterSheet`) and the + * reader is not mid-search: the group becomes a disclosure, collapsed unless + * it holds a selection or has been explicitly opened. A live needle owns + * openness instead (every matched group is forced open) — see + * `ResultFilterSheet` for why a control that never gets to act is worse than + * none at all. + */ +type FacetGroupDisclosure = { open: boolean; onToggle: () => void; contentId: string }; + +export function ResultFilterFacetChips({ + group, + idPrefix, + options, + disclosure, +}: { + group: ResultFilterFacetGroup; + idPrefix: string; + /** Needle-filtered subset; defaults to every option in the group. */ + options?: ReadonlyArray>; + disclosure?: FacetGroupDisclosure; +}) { const panelId = idPrefix; const groupLabelId = `${panelId}-${group.id}-label`; + const visibleOptions = options ?? group.options; return (
@@ -433,16 +455,54 @@ export function ResultFilterFacetChips({ group, idPrefix }: { group: ResultFilte inside the labelled element the group's accessible name became "Domain 1" — the selection count leaking into the dimension's name, and changing it on every toggle. Caught by the DOM test. */} - {group.label} - {group.selected.size > 0 ? ( - - {group.selected.size} selected - {group.selected.size} - - ) : null} + {disclosure ? ( + + ) : ( + <> + {group.label} + {group.selected.size > 0 ? ( + + {group.selected.size} selected + {group.selected.size} + + ) : null} + + )} -
- {group.options.map((option) => { + } + />, + ); + + expect(screen.getByTestId("scope-segment")).toBeInTheDocument(); + }); +}); + describe("ResultFilterSheet", () => { it("exposes each dimension as a radio group and reports the selection back typed", async () => { const user = userEvent.setup(); diff --git a/tests/service-facets.test.ts b/tests/service-facets.test.ts new file mode 100644 index 0000000000..4111148475 --- /dev/null +++ b/tests/service-facets.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; + +import { loadServicesSnapshot } from "@/lib/service-catalog"; +import { mapCatalogToServiceRecords } from "@/lib/service-catalog-mapper"; +import { + deriveServiceFacetOptions, + deriveSubstanceLensOptions, + emptyServiceFacetSelection, + filterServicesByFacets, + matchesServiceFacets, + matchesSubstanceLens, + serviceCatalogTags, + serviceFacetDimensions, + serviceFacetOptionCount, + serviceFacetSelectionFromParams, + serviceFacetSelectionSize, + serviceFacetValueLabel, + serviceSubstanceLensOptionCount, + serviceSubstanceLensValueLabel, + writeServiceFacetSelectionToParams, +} from "@/lib/service-facets"; + +const records = mapCatalogToServiceRecords(loadServicesSnapshot().services); + +describe("service facets", () => { + it("carries the full typed tag payload onto every ServiceRecord (the PR C runtime spike)", () => { + expect(records).toHaveLength(219); + for (const record of records) { + expect(record.catalogPayload).toBeTruthy(); + const tags = serviceCatalogTags(record); + const total = + serviceFacetDimensions.reduce((sum, dimension) => sum + tags[dimension].length, 0) + + tags.substance_flags.length; + expect(total).toBeGreaterThan(0); + } + }); + + it("finds substance_flags an exact partition — every service carries exactly one value", () => { + const counts = records.map((record) => serviceCatalogTags(record).substance_flags.length); + expect(counts.every((count) => count === 1)).toBe(true); + const general = records.filter((record) => serviceCatalogTags(record).substance_flags.includes("general")).length; + const aod = records.filter((record) => serviceCatalogTags(record).substance_flags.includes("aod")).length; + expect(general + aod).toBe(records.length); + }); + + it("finds housing_flags a near-partition, not an exact one — 4 services carry two values", () => { + const counts = records.map((record) => serviceCatalogTags(record).housing_flags.length); + const multi = counts.filter((count) => count > 1).length; + expect(multi).toBe(4); + expect(counts.every((count) => count >= 1)).toBe(true); + }); + + it("derives facet options from the data — never a permanently empty option", () => { + for (const dimension of serviceFacetDimensions) { + const options = deriveServiceFacetOptions(records, dimension); + expect(options.length).toBeGreaterThan(0); + for (const value of options) { + expect(records.some((record) => serviceCatalogTags(record)[dimension].includes(value))).toBe(true); + } + } + const substanceOptions = deriveSubstanceLensOptions(records); + expect(substanceOptions.sort()).toEqual(["aod", "general"]); + }); + + it("matches OR-within-dimension, AND-across-dimensions", () => { + const empty = emptyServiceFacetSelection(); + const selection = { + ...empty, + age_groups: new Set(["youth", "young_adult"]), + acuity_flags: new Set(["crisis_high"]), + }; + const matches = filterServicesByFacets(records, selection, "all"); + for (const record of matches) { + const tags = serviceCatalogTags(record); + expect(tags.age_groups.some((value) => value === "youth" || value === "young_adult")).toBe(true); + expect(tags.acuity_flags).toContain("crisis_high"); + } + // Sanity: narrower than either dimension alone. + const ageOnly = filterServicesByFacets(records, { ...empty, age_groups: selection.age_groups }, "all"); + const acuityOnly = filterServicesByFacets(records, { ...empty, acuity_flags: selection.acuity_flags }, "all"); + expect(matches.length).toBeLessThanOrEqual(ageOnly.length); + expect(matches.length).toBeLessThanOrEqual(acuityOnly.length); + }); + + it("computes non-additive union counts — widening a dimension is not a sum", () => { + const empty = emptyServiceFacetSelection(); + const crisisCount = serviceFacetOptionCount(records, empty, "all", "acuity_flags", "crisis_high"); + const highCount = serviceFacetOptionCount(records, empty, "all", "acuity_flags", "high"); + const withCrisis = { ...empty, acuity_flags: new Set(["crisis_high"]) }; + const bothCount = serviceFacetOptionCount(records, withCrisis, "all", "acuity_flags", "high"); + // The union (crisis_high OR high) must be at least as large as either + // alone and can only be smaller than the naive sum when any service + // carries both — exercising the same "not simply additive" property PR B + // verified for formulation's Affect/Risk domains. + expect(bothCount).toBeGreaterThanOrEqual(crisisCount); + expect(bothCount).toBeGreaterThanOrEqual(highCount); + expect(bothCount).toBeLessThanOrEqual(crisisCount + highCount); + }); + + it("reports the current count, not zero, for an already-selected value", () => { + const empty = emptyServiceFacetSelection(); + const selection = { ...empty, housing_flags: new Set(["home_based"]) }; + const direct = filterServicesByFacets(records, selection, "all").length; + expect(serviceFacetOptionCount(records, selection, "all", "housing_flags", "home_based")).toBe(direct); + }); + + it("never disables an already-selected option even when its union count is zero", () => { + // Deliberately contradictory selection: no service can satisfy both, so + // the count is genuinely zero — but the already-selected value must stay + // selectable so the reader can back out of the dead end they created. + const empty = emptyServiceFacetSelection(); + const contradiction = { + ...empty, + catchments: new Set(["__no-such-catchment__"]), + age_groups: new Set(["youth"]), + }; + expect(filterServicesByFacets(records, contradiction, "all")).toHaveLength(0); + expect(serviceFacetOptionCount(records, contradiction, "all", "age_groups", "youth")).toBe(0); + // The page layer treats `withCandidate === 0 && !selection[dim].has(value)` + // as the disabled predicate — confirm the already-selected half of that + // guard holds for a genuinely zero-yield combination. + expect(contradiction.age_groups.has("youth")).toBe(true); + }); + + it("treats the substance lens as narrowing, never as a union", () => { + const empty = emptyServiceFacetSelection(); + expect(matchesSubstanceLens(records[0], "all")).toBe(true); + const aodCount = serviceSubstanceLensOptionCount(records, empty, "aod"); + const generalCount = serviceSubstanceLensOptionCount(records, empty, "general"); + const allCount = serviceSubstanceLensOptionCount(records, empty, "all"); + expect(aodCount + generalCount).toBe(allCount); + expect(allCount).toBe(records.length); + }); + + it("labels every measured tag value without falling through to a raw token", () => { + for (const dimension of serviceFacetDimensions) { + for (const value of deriveServiceFacetOptions(records, dimension)) { + const label = serviceFacetValueLabel(dimension, value); + expect(label.length).toBeGreaterThan(0); + if (dimension !== "catchments") expect(label).not.toBe(value); + } + } + expect(serviceSubstanceLensValueLabel("all")).toBe("All programs"); + expect(serviceSubstanceLensValueLabel("aod")).not.toBe("aod"); + }); + + it("round-trips facet selection through URL search params", () => { + const selection = emptyServiceFacetSelection(); + const withValues = { + ...selection, + catchments: new Set(["Metro-wide", "Peel"]), + acuity_flags: new Set(["crisis_high"]), + }; + const params = new URLSearchParams(); + writeServiceFacetSelectionToParams(params, withValues); + expect(params.get("catchments")).toBe("Metro-wide,Peel"); + expect(params.get("acuity_flags")).toBe("crisis_high"); + + const commaBearing = { ...selection, catchments: new Set(["Wanneroo, north metro"]) }; + const commaParams = new URLSearchParams(); + writeServiceFacetSelectionToParams(commaParams, commaBearing); + expect([...serviceFacetSelectionFromParams(commaParams).catchments]).toEqual(["Wanneroo, north metro"]); + expect(params.has("age_groups")).toBe(false); + + const parsed = serviceFacetSelectionFromParams(params); + expect([...parsed.catchments].sort()).toEqual(["Metro-wide", "Peel"]); + expect([...parsed.acuity_flags]).toEqual(["crisis_high"]); + expect(parsed.age_groups.size).toBe(0); + expect(serviceFacetSelectionSize(parsed)).toBe(3); + }); + + it("clears a dimension from the URL once its selection empties", () => { + const params = new URLSearchParams({ catchments: "Peel" }); + writeServiceFacetSelectionToParams(params, emptyServiceFacetSelection()); + expect(params.has("catchments")).toBe(false); + }); + + it("reads malformed or missing catalogPayload as no tags rather than throwing", () => { + const bareRecord = { slug: "x", title: "X" } as (typeof records)[number]; + expect(() => serviceCatalogTags(bareRecord)).not.toThrow(); + const tags = serviceCatalogTags(bareRecord); + for (const dimension of serviceFacetDimensions) expect(tags[dimension]).toEqual([]); + expect(matchesServiceFacets(bareRecord, emptyServiceFacetSelection())).toBe(true); + + const activeSelection = { ...emptyServiceFacetSelection(), age_groups: new Set(["youth"]) }; + expect(matchesServiceFacets(bareRecord, activeSelection)).toBe(true); + expect(matchesSubstanceLens(bareRecord, "aod")).toBe(true); + expect(filterServicesByFacets([bareRecord], activeSelection, "aod")).toHaveLength(1); + }); +}); diff --git a/tests/services-navigator-scope-empty-state.dom.test.tsx b/tests/services-navigator-scope-empty-state.dom.test.tsx new file mode 100644 index 0000000000..6da21b2fd8 --- /dev/null +++ b/tests/services-navigator-scope-empty-state.dom.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { loadServicesSnapshot } from "@/lib/service-catalog"; +import { mapCatalogToServiceRecords } from "@/lib/service-catalog-mapper"; +import { rankServiceRecords } from "@/lib/service-ranker"; + +const registryRecords = mapCatalogToServiceRecords(loadServicesSnapshot().services); + +// A string no service title, subtitle, tag, or catchment contains — asserted +// below rather than assumed, so this test fails loudly instead of silently +// passing if the fixture ever grows a real match for it. +const noMatchQuery = "zzzznonexistentservicequeryxyz123"; + +const paramsState = vi.hoisted(() => ({ search: "" })); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(paramsState.search), +})); + +vi.mock("next/link", () => ({ + default: ({ children, href, ...rest }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + +vi.mock("@/lib/use-registry-records", () => ({ + useRegistryRecords: () => ({ + status: "ready", + records: registryRecords, + total: registryRecords.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, +})); + +import { ServicesNavigatorPage } from "@/components/services/services-navigator-page"; + +describe("services scope segment vs the query-empty state", () => { + it("confirms the fixture query has zero whole-catalogue matches (test premise)", () => { + expect(rankServiceRecords(registryRecords, noMatchQuery)).toHaveLength(0); + }); + + it("shows the query-empty state in the default (results-scoped) view", () => { + paramsState.search = `q=${encodeURIComponent(noMatchQuery)}`; + render(); + + expect(screen.getByText(/no matches for/i)).toBeInTheDocument(); + expect(screen.queryByTestId("service-search-results")).not.toBeInTheDocument(); + }); + + it("falls through to the facet-driven results once scope is widened to All items", () => { + // `rankedMatches` (query-only, ignores scope) is empty for this query, but + // `resultScope=all` tells the page to bypass the query for faceting and show + // the whole catalogue instead — the scope segment's whole purpose (see + // docs/filter-contract.md section 4). Before the fix, the query-empty branch + // fired unconditionally and this state was unreachable. + paramsState.search = `q=${encodeURIComponent(noMatchQuery)}&scope=all`; + render(); + + expect(screen.queryByText(/no matches for/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/no services match/i)).not.toBeInTheDocument(); + const results = screen.getByTestId("service-search-results"); + expect(results).toBeInTheDocument(); + expect(results.children.length).toBe(registryRecords.length); + }); +}); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 4e9befa40f..9043a3803f 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1049,14 +1049,9 @@ test.describe("Clinical KB tools launcher", () => { const quickFilter = page.getByTestId("service-filter-trigger-phone"); await expect(quickFilter).toBeVisible(); await expect(quickFilter).toHaveAccessibleName(/No filters active/); - await quickFilter.click(); - await page.getByRole("radiogroup", { name: "Quick filters" }).getByRole("radio", { name: "Crisis" }).click(); + await page.getByTestId("service-quick-search-suggestions").getByRole("button", { name: "Crisis" }).click(); await expect(page).toHaveURL(/\/services\?.*q=crisis/); - await quickFilter.click(); - await page.getByRole("button", { name: "Clear filters" }).click(); - await expect(page).toHaveURL(/\/services\?run=1$/); - // Phones keep the full search results in the page instead of opening a // command sheet over the small viewport. await input.click(); @@ -1292,19 +1287,28 @@ test.describe("Clinical KB tools launcher", () => { await expect(page.getByRole("navigation", { name: "Service groups" })).toBeVisible(); await expect(page.getByTestId("services-shortlist-bar")).toHaveCount(0); - await page.getByTestId("service-filter-trigger-desktop").click(); - const culturallySafe = page.getByRole("radio", { name: "Culturally safe" }); + const culturallySafe = page + .getByTestId("service-quick-search-suggestions") + .getByRole("button", { name: "Culturally safe" }); await expect(culturallySafe).toBeVisible(); await waitForReactEventHandler(culturallySafe); await culturallySafe.click(); await expect(page).toHaveURL(/q=Aboriginal\+Torres\+Strait\+Islander/); await expect(page.getByTestId("service-search-result-13yarn")).toBeVisible(); + // Quick search suggestions and facet clearing are separate contracts. + // Exercise a real facet, then clear only that facet while preserving q. await page.getByTestId("service-filter-trigger-desktop").click(); - await page.getByTestId("service-filter-panel-clear").click(); - await expect(page).toHaveURL(/\/services\?run=1$/); - await expect(page.getByRole("heading", { level: 1, name: "Browse services" })).toBeVisible(); + const filterPanel = page.getByTestId("service-filter-panel"); + 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=Aboriginal\+Torres\+Strait\+Islander/); await expect(page.getByTestId("service-search-result-13yarn")).toBeVisible(); + await filterPanel.getByRole("button", { name: "Close", exact: true }).click(); await page .getByTestId("service-search-result-13yarn")