mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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 () => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user