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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 00:09:54 +01:00
1 parent b3e22269cb
commit 2e2a0ac4a1
3 files changed
+93 -42

No files matched your search

@@ -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(<LabList />, { 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(<LabList />, { 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 () => {
+40 -29
View File
@@ -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<StoredOpen>({ 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<string, boolean>;
}>({ 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);
};
+3 -6
View File
@@ -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 (
<div className={cn(LIST_SUBGRID_CLASS, "divide-y-0")}>
@@ -186,7 +184,6 @@ function CardGroup({
const { open, onToggle, panelId } = useGroupToggle(
group.category,
groupsOpen,
status,
);
return (
<section className="space-y-4">
@@ -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.