diff --git a/src/components/dsm/dsm-compare-chrome.tsx b/src/components/dsm/dsm-compare-chrome.tsx index ff8dab4397..3cc02c5d06 100644 --- a/src/components/dsm/dsm-compare-chrome.tsx +++ b/src/components/dsm/dsm-compare-chrome.tsx @@ -37,7 +37,14 @@ export function DsmCompareChrome({ changeLabel="Change diagnoses" slotPlaceholder="Choose diagnosis" icon={BookOpenCheck} - onCommit={(ids) => router.push(idsCompareHref("/dsm/compare", ids))} + onCommit={(ids) => + router.push( + idsCompareHref( + "/dsm/compare", + ids.filter((id): id is string => Boolean(id)), + ), + ) + } /> ); } diff --git a/src/components/dsm/dsm-comparison-page.tsx b/src/components/dsm/dsm-comparison-page.tsx index b6eb5d752e..5b6026ba9a 100644 --- a/src/components/dsm/dsm-comparison-page.tsx +++ b/src/components/dsm/dsm-comparison-page.tsx @@ -1,23 +1,22 @@ import Link from "next/link"; -import { ChevronRight, GitCompareArrows, ListChecks, ShieldCheck, X } from "lucide-react"; +import { ArrowRight, HelpCircle } from "lucide-react"; -import { idsCompareHref, type CompareCatalogItem, type CompareStarterChip } from "@/components/compare"; +import { type CompareCatalogItem, type CompareStarterChip } from "@/components/compare"; import { DsmCompareChrome } from "@/components/dsm/dsm-compare-chrome"; -import { DsmCompareRemoveLink } from "@/components/dsm/dsm-compare-remove-link"; import { DsmPageHeader } from "@/components/dsm/dsm-page-header"; -import { cn, codeText, metadataPill, pageContainer } from "@/components/ui-primitives"; +import { cn, codeText, eyebrowText, pageContainer } from "@/components/ui-primitives"; import { dsmCriteria, type DsmDiagnosis } from "@/lib/dsm"; -function compareHref(diagnoses: DsmDiagnosis[]) { - return idsCompareHref( - "/dsm/compare", - diagnoses.map((diagnosis) => diagnosis.slug), - ); +const BADGE_LETTERS = ["A", "B", "C"] as const; + +function diagnosisHeaderGridClass(count: number) { + return count >= 3 ? "grid sm:grid-cols-3" : "grid sm:grid-cols-2"; } -function removeDiagnosisHref(diagnoses: DsmDiagnosis[], slug: string) { - const remaining = diagnoses.filter((diagnosis) => diagnosis.slug !== slug); - return remaining.length ? compareHref(remaining) : "/dsm/compare"; +function comparisonRowGridClass(count: number) { + return count >= 3 + ? "grid border-b border-[color:var(--border)] last:border-b-0 sm:grid-cols-[10rem_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)]" + : "grid border-b border-[color:var(--border)] last:border-b-0 sm:grid-cols-[10rem_minmax(0,1fr)_minmax(0,1fr)]"; } type ComparisonRow = { @@ -87,163 +86,133 @@ export function DsmComparisonPage({ }) { const rows = comparisonRows(diagnoses); const chromeIds = selectedIds ?? diagnoses.map((diagnosis) => diagnosis.slug); + const comparisonLabel = diagnoses.map((diagnosis) => diagnosis.title).join(" compared with "); + const summaryById = new Map(catalog.map((item) => [item.id, item.snippet])); return (
- {diagnoses.length > 0 ? ( -
- {diagnoses.map((diagnosis, index) => ( -
-
- - {index + 1} - -
-

- {diagnosis.category.label} -

-

- {diagnosis.title} -

- {diagnosis.icd_code} -
-
- - - -
- ))} -
- ) : null} - {diagnoses.length >= 2 ? ( <> -
-
- - - - - - {diagnoses.map((diagnosis) => ( - - ))} - - - - {rows.map((row) => ( - - - {row.values.map((value, index) => ( - - ))} - - ))} - -
DSM diagnosis comparison
- Compare - - {diagnosis.title} -
- {row.label} - - {value} -
+
+
+ +
+

Ask this

+

+ Which diagnosis best fits duration, episodicity, and exclusions? +

+
-
- {rows.map((row) => ( -
-

- {row.label} -

-
+
+
+ {diagnoses.map((diagnosis, index) => ( +
0 && "border-t border-[color:var(--border)] sm:border-l sm:border-t-0", + )} + > +
+ + {BADGE_LETTERS[index]} + +

{diagnosis.title}

+
+

+ {summaryById.get(diagnosis.slug) ?? + diagnosis.key_features[0]?.text ?? + "Review the complete diagnostic record."} +

+ + Open record + + +
+ ))} +
+ +
+ {rows.map((row) => ( +
+
+ {row.label} +
{row.values.map((value, index) => ( -
-
- {diagnoses[index]?.title} -
-
+
0 && "border-t border-[color:var(--border)] sm:border-l sm:border-t-0", + row.label === "ICD-10 code" && codeText, + )} + > + + {BADGE_LETTERS[index]} + + {value} -
+
))} -
-
- ))} -
- -
- - - -
-

- Use the comparison to identify what still needs clarification -

-

- Review duration, episodicity, exclusions, substance or medication effects, medical causes, and - functional impact in the complete records. -

+
+ ))}
- - Differential review - - + {diagnoses.map((diagnosis, index) => ( +
0 && "border-t border-[color:var(--border)] sm:border-l sm:border-t-0", + )} + > + + Differential review + + +
+ ))} +
) : null} -
diff --git a/src/components/dsm/dsm-page-header.tsx b/src/components/dsm/dsm-page-header.tsx index 863b5feee5..1fe0bc2cbb 100644 --- a/src/components/dsm/dsm-page-header.tsx +++ b/src/components/dsm/dsm-page-header.tsx @@ -1,4 +1,4 @@ -import { BookOpenCheck } from "lucide-react"; +import { BookOpenCheck, type LucideIcon } from "lucide-react"; import type { ReactNode } from "react"; import { InformationPageBreadcrumbs } from "@/components/information-page-shell"; @@ -14,6 +14,8 @@ export function DsmPageHeader({ actions, className, breadcrumb = true, + homeIcon, + icon = BookOpenCheck, }: { eyebrow?: string; title: string; @@ -29,6 +31,8 @@ export function DsmPageHeader({ * header's title is a ``. */ breadcrumb?: boolean; + homeIcon?: LucideIcon | false; + icon?: LucideIcon | false; }) { return (
@@ -37,13 +41,17 @@ export function DsmPageHeader({ `InformationPageBreadcrumbs` (now itself a `Breadcrumb`), so the `PageHeader` below is not given a second `breadcrumb` of its own. */} {breadcrumb ? ( - + ) : null} ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }), +})); + +describe("DsmComparisonPage", () => { + const mdd = getDsmDiagnosis("major-depressive-disorder"); + const bp2 = getDsmDiagnosis("bipolar-ii-disorder"); + + it("renders unified comparison without duplicate diagnosis cards when two slots are filled", () => { + if (!mdd || !bp2) { + throw new Error("Expected catalogue diagnoses for comparison test"); + } + + const summaries = listDsmDiagnosisSummaries(); + const mddSummary = summaries.find((entry) => entry.slug === mdd.slug); + const bp2Summary = summaries.find((entry) => entry.slug === bp2.slug); + if (!mddSummary || !bp2Summary) { + throw new Error("Expected diagnosis summaries for comparison test"); + } + + render( + , + ); + + expect(screen.getByRole("heading", { name: "Compare diagnoses", level: 1 })).toBeInTheDocument(); + expect(screen.getByTestId("dsm-comparison-unified")).toBeInTheDocument(); + expect(screen.getByTestId("dsm-comparison-ask-this")).toBeInTheDocument(); + expect(screen.queryByLabelText("Selected diagnoses")).not.toBeInTheDocument(); + expect(screen.getAllByRole("link", { name: /Open record/i })).toHaveLength(2); + expect(screen.getAllByRole("link", { name: /Differential review/i })).toHaveLength(2); + expect( + screen.getByText("Structured review aid — not a diagnostic score. Open each record for complete criteria."), + ).toBeInTheDocument(); + }); +}); diff --git a/tests/information-page-shell.dom.test.tsx b/tests/information-page-shell.dom.test.tsx index e5aab2d62f..32e1e575a2 100644 --- a/tests/information-page-shell.dom.test.tsx +++ b/tests/information-page-shell.dom.test.tsx @@ -76,6 +76,20 @@ describe("InformationPageShell", () => { expect(screen.getByText("Transport")).toHaveAttribute("aria-current", "page"); }); + it("renders a text-only home crumb when homeIcon is false", () => { + render( + , + ); + + const home = screen.getByRole("link", { name: "DSM-5 Diagnosis home" }); + expect(home).toHaveAttribute("href", "/dsm"); + expect(home.querySelector("svg")).toBeNull(); + }); + it("keeps a linked intermediate crumb a link after the fold onto Breadcrumb", () => { // The DS `Breadcrumb` decides link-vs-text from `href`, not from position. // Deciding on position would turn this middle crumb into dead text the diff --git a/tests/ui-route-coverage.spec.ts b/tests/ui-route-coverage.spec.ts index 1109050049..cf6847bcd2 100644 --- a/tests/ui-route-coverage.spec.ts +++ b/tests/ui-route-coverage.spec.ts @@ -440,7 +440,7 @@ test.describe("previously uncovered production routes", () => { await expect(compare).toBeVisible(); await compare.click(); await expect(currentPage).toHaveURL(/\/dsm\/compare$/); - await expect(currentPage.getByRole("heading", { name: "Compare DSM diagnoses", level: 1 })).toBeVisible(); + await expect(currentPage.getByRole("heading", { name: "Compare diagnoses", level: 1 })).toBeVisible(); }, ); }); @@ -451,31 +451,21 @@ test.describe("previously uncovered production routes", () => { "/dsm/compare?ids=major-depressive-disorder,bipolar-ii-disorder", async (currentPage) => { await expect(visibleByTestId(currentPage, "dsm-comparison-page")).toBeVisible(); - await expect(currentPage.getByRole("heading", { name: "Compare DSM diagnoses", level: 1 })).toBeVisible(); + await expect(currentPage.getByRole("heading", { name: "Compare diagnoses", level: 1 })).toBeVisible(); }, async (currentPage) => { // Scope to the visible comparison owner (#093): under Production UI load, - // Next streaming can leave a hidden duplicate root, and same-route - // search-param soft-nav has been observed to click without updating the - // URL. Pin the visible tree and wait for navigation with the click - // (DsmCompareRemoveLink uses location.assign for this hop). + // Next streaming can leave a hidden duplicate root. Removal lives on the + // shared compare slot strip (`Remove ${title}`), not the old + // `DsmCompareRemoveLink` row. const pageRoot = visibleByTestId(currentPage, "dsm-comparison-page"); - const remove = pageRoot.getByRole("link", { - name: "Remove Major depressive disorder from comparison", + const remove = pageRoot.getByRole("button", { + name: "Remove Major depressive disorder", }); await expect(remove).toBeEnabled(); - // `DsmCompareRemoveLink` is a `` whose `onClick` calls - // `preventDefault()` and then `window.location.assign(href)`. Before - // hydration the anchor is a bare ``, so a click there races two - // different navigations — the browser's native one, or React capturing - // the discrete event for replay once the root hydrates — and neither is - // guaranteed to leave the URL where this step asserts it. Waiting for - // the handler makes the assign hop the only path the click can take. await waitForReactEventHandler(remove); - // Same-route `?ids=` assign can update the URL without a new document - // load. `waitUntil: "domcontentloaded"` then hangs for 30s after the - // hop already happened (Production UI on #2299: waitForURL timeout - // while the heading assertion never ran). Wait for the URL only. + // Slot clear commits through `router.push` with compacted ids. Wait for + // the URL only — same-route `?ids=` soft-nav may not fire a document load. await Promise.all([ currentPage.waitForURL(/\/dsm\/compare\?ids=bipolar-ii-disorder$/, { timeout: 30_000,