From 2e2a0ac4a17adb28632d931fe81258a1e8375d2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 00:09:54 +0100 Subject: [PATCH] fix(labs): keep groups with a match open while filtering With groups opening only when flagged, the open state was worked out from the filtered rows. Filtering to in-range markers, or searching for an unremarkable one, closed the header while the counter reported matches. A group's default now follows the server's status for the whole category again, and while any filter is active every group with a match is open. A group closed by hand during filtering stays closed while the filter changes, is not stored, and the saved state returns once the filter is cleared. --- .../lab-list-url-sync.browser.test.tsx | 57 +++++++++++++-- src/components/labs/lab-list-groups.tsx | 69 +++++++++++-------- src/components/labs/lab-list.tsx | 9 +-- 3 files changed, 93 insertions(+), 42 deletions(-) diff --git a/src/components/labs/__tests__/lab-list-url-sync.browser.test.tsx b/src/components/labs/__tests__/lab-list-url-sync.browser.test.tsx index 0caa69e3d..028a4648a 100644 --- a/src/components/labs/__tests__/lab-list-url-sync.browser.test.tsx +++ b/src/components/labs/__tests__/lab-list-url-sync.browser.test.tsx @@ -10,6 +10,7 @@ import { setSearch, } from "../../../../tests/browser/url"; import { LabList } from "../lab-list"; +import { readStoredOpen } from "../lab-list-groups"; import type { LabOverviewResponse, LabResultDto } from "../types"; import { useLabListFilters } from "../use-lab-list-filters"; @@ -212,11 +213,11 @@ describe("LabList stale category and group header handling", () => { await expect.element(groupButton.getByText("1")).toBeVisible(); }); - it("keeps flagged-mode default-open behavior consistent with visible filtered rows", async () => { - // overviewWithAbnormalMarker has 2 markers in Iron Panel: - // Iron (below) and Ferritin (in-range). Category server status is "below". - // When filtered to ?status=in-range, Iron is excluded and only Ferritin (in-range) remains. - // In flagged mode, the group should NOT be open by default because visible rows are all in-range. + it("keeps a flagged group open while a filter leaves only in-range rows", async () => { + // Iron (below) and Ferritin (in-range) share the Iron Panel, whose server + // status is "below". Filtering to in-range leaves Ferritin alone: the + // header now reads "All in range", but the group must stay open, since + // the counter says there is a match. setSearch("?status=in-range"); const queryClient = labQueryClient(); queryClient.setQueryData(queryKeys.moduleListLayout(), { @@ -239,11 +240,53 @@ describe("LabList stale category and group header handling", () => { const { screen } = await renderWithApp(, { queryClient }); - // Group button reflects in-range status ("All in range") and is collapsed (aria-expanded="false") const groupButton = screen.getByRole("button", { name: /Iron Panel/i }); - await expect.element(groupButton).toBeVisible(); await expect.element(groupButton.getByText("All in range")).toBeVisible(); + await expect.element(groupButton).toHaveAttribute("aria-expanded", "true"); + await expect.element(screen.getByText("Ferritin")).toBeVisible(); + }); + + it("opens every matching group while filtering and restores the closed state after reset", async () => { + setSearch("?q=ferr"); + window.localStorage.clear(); + const queryClient = labQueryClient(); + queryClient.setQueryData(queryKeys.moduleListLayout(), { + version: 1, + lists: { + labs: { + view: "list", + order: [], + sortDir: "recentDesc", + groupBy: "category", + groupsOpen: "collapsed", + categoryOrder: [], + }, + }, + }); + queryClient.setQueryData( + queryKeys.labResultsOverview("en"), + overviewWithAbnormalMarker, + ); + + const { screen } = await renderWithApp(, { queryClient }); + + const groupButton = screen.getByRole("button", { name: /Iron Panel/i }); + await expect.element(groupButton).toHaveAttribute("aria-expanded", "true"); + await expect.element(screen.getByText("Ferritin")).toBeVisible(); + + // A toggle made while filtering holds while the filter changes... + await groupButton.click(); await expect.element(groupButton).toHaveAttribute("aria-expanded", "false"); + await screen.getByRole("combobox", { name: "Status" }).click(); + await screen.getByRole("option", { name: "In range" }).click(); + await expect.poll(currentSearch).toBe("?q=ferr&status=in-range"); + await expect.element(groupButton).toHaveAttribute("aria-expanded", "false"); + + // ...and is dropped with the filter: "collapsed" applies again. + await screen.getByRole("button", { name: "Reset" }).click(); + await expect.poll(currentSearch).toBe(""); + await expect.element(groupButton).toHaveAttribute("aria-expanded", "false"); + expect(readStoredOpen("collapsed")).toEqual({}); }); it("memoizes setCategoryFilter callback identity across re-renders", async () => { diff --git a/src/components/labs/lab-list-groups.tsx b/src/components/labs/lab-list-groups.tsx index ca6e93121..79d41d350 100644 --- a/src/components/labs/lab-list-groups.tsx +++ b/src/components/labs/lab-list-groups.tsx @@ -100,14 +100,14 @@ export function writeStoredOpen( } function openByDefault( + category: LabOverviewCategoryDto, mode: ModuleGroupsOpen, - status: LabOverviewCategoryDto["status"], ): boolean { if (mode === "expanded") return true; if (mode === "collapsed") return false; - // Consumers supply category.status for unfiltered categories, or the - // summarised status for filtered groups. - return status === "above" || status === "below"; + // The server's status for the whole category, never a filtered summary: + // what a filter hides must not open or close a group. + return category.status === "above" || category.status === "below"; } /** @@ -115,16 +115,19 @@ function openByDefault( * category the person opened or closed by hand stays so in this browser * (keyed by the opaque category key, never by its label) until the choice * itself changes. Storage is read only after hydration and may be missing. + * + * While a filter is active every category that still has a match is open, + * whatever the choice or the stored toggles say, so a match is never hidden + * behind a closed header. A category opened or closed by hand during + * filtering stays so while the filter changes, is not written to storage, + * and the stored state returns once the filter is cleared. */ -export function useGroupsOpen(mode: ModuleGroupsOpen): { - isOpen: ( - category: LabOverviewCategoryDto, - status: LabOverviewCategoryDto["status"], - ) => boolean; - toggle: ( - category: LabOverviewCategoryDto, - status: LabOverviewCategoryDto["status"], - ) => void; +export function useGroupsOpen( + mode: ModuleGroupsOpen, + filtering = false, +): { + isOpen: (category: LabOverviewCategoryDto) => boolean; + toggle: (category: LabOverviewCategoryDto) => void; } { const mounted = useMounted(); const stored = useMemo( @@ -134,23 +137,31 @@ export function useGroupsOpen(mode: ModuleGroupsOpen): { const [toggled, setToggled] = useState({ mode, open: {} }); const own = toggled.mode === mode ? toggled.open : {}; - const isOpen = ( - category: LabOverviewCategoryDto, - status: LabOverviewCategoryDto["status"], - ) => - own[category.key] ?? - stored[category.key] ?? - openByDefault(mode, status); + const [filtered, setFiltered] = useState<{ + filtering: boolean; + open: Record; + }>({ filtering, open: {} }); + if (filtered.filtering !== filtering) { + setFiltered({ filtering, open: {} }); + } + const whileFiltering = filtered.filtering === filtering ? filtered.open : {}; - const toggle = ( - category: LabOverviewCategoryDto, - status: LabOverviewCategoryDto["status"], - ) => { - const next = { - ...stored, - ...own, - [category.key]: !isOpen(category, status), - }; + const isOpen = (category: LabOverviewCategoryDto) => + filtering + ? (whileFiltering[category.key] ?? true) + : (own[category.key] ?? + stored[category.key] ?? + openByDefault(category, mode)); + + const toggle = (category: LabOverviewCategoryDto) => { + if (filtering) { + setFiltered({ + filtering, + open: { ...whileFiltering, [category.key]: !isOpen(category) }, + }); + return; + } + const next = { ...stored, ...own, [category.key]: !isOpen(category) }; setToggled({ mode, open: next }); writeStoredOpen(mode, next); }; diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 67680ca2f..1de1a0ecf 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -87,12 +87,11 @@ function LabTable({ function useGroupToggle( category: LabOverviewCategoryDto, groupsOpen: GroupsOpen, - status: LabOverviewCategoryDto["status"], ) { const panelId = `${useId()}-panel`; return { - open: groupsOpen.isOpen(category, status), - onToggle: () => groupsOpen.toggle(category, status), + open: groupsOpen.isOpen(category), + onToggle: () => groupsOpen.toggle(category), panelId, }; } @@ -118,7 +117,6 @@ function TableGroup({ const { open, onToggle, panelId } = useGroupToggle( group.category, groupsOpen, - status, ); return (
@@ -186,7 +184,6 @@ function CardGroup({ const { open, onToggle, panelId } = useGroupToggle( group.category, groupsOpen, - status, ); return (
@@ -379,7 +376,7 @@ export function LabList({ effectiveFilters.toDay, ); - const groupsOpen = useGroupsOpen(prefs.groupsOpen); + const groupsOpen = useGroupsOpen(prefs.groupsOpen, effectiveIsFiltered); // Scrub stale category filter when categories load. // afterPaint protects mount writes from router history initialization.