mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
refactor(labs): drop the sort pill from the filter rail
Settings > Labs already holds the list's sort order. The pill added a second, URL-only one that was lost on the next visit, and Reset cleared it while the rail counted it as no filter at all. The list now sorts the way Settings says, and a ?sort param is ignored.
This commit is contained in:
1 parent
a486c3c8a3
commit
2f4da62c46
11 files changed
+52
-585
No files matched your search
@@ -14,7 +14,6 @@ function props(): LabFilterRailProps {
|
||||
onCategoryChange: vi.fn(),
|
||||
onFromDayChange: vi.fn(),
|
||||
onToDayChange: vi.fn(),
|
||||
onSortChange: vi.fn(),
|
||||
onResetFilters: vi.fn(),
|
||||
isFiltered: false,
|
||||
categories: [
|
||||
@@ -27,7 +26,6 @@ function props(): LabFilterRailProps {
|
||||
},
|
||||
],
|
||||
total: 5,
|
||||
currentSortDir: "recentDesc",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,11 +78,6 @@ describe("<LabFilterRail> layout", () => {
|
||||
screen.getByRole("button", { name: "Date range" }).element(),
|
||||
screen.getByRole("combobox", { name: "Status: Out of range" }).element(),
|
||||
screen.getByRole("combobox", { name: "Category" }).element(),
|
||||
screen
|
||||
.getByRole("combobox", {
|
||||
name: "Sort order: Most recent reading first",
|
||||
})
|
||||
.element(),
|
||||
screen.getByRole("button", { name: "Reset" }).element(),
|
||||
];
|
||||
|
||||
@@ -95,7 +88,7 @@ describe("<LabFilterRail> layout", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps sort and reset visible without clipping in German at 1536px", async () => {
|
||||
it("keeps every control and reset visible without clipping in German at 1536px", async () => {
|
||||
await page.viewport(1536, 900);
|
||||
const { screen } = await renderWithApp(
|
||||
<LabFilterRail
|
||||
@@ -122,11 +115,6 @@ describe("<LabFilterRail> layout", () => {
|
||||
})
|
||||
.element(),
|
||||
screen.getByRole("combobox", { name: "Kategorie" }).element(),
|
||||
screen
|
||||
.getByRole("combobox", {
|
||||
name: "Sortierung: Neueste Messung zuerst",
|
||||
})
|
||||
.element(),
|
||||
screen.getByRole("button", { name: "Zurücksetzen" }).element(),
|
||||
];
|
||||
|
||||
@@ -145,11 +133,6 @@ describe("<LabFilterRail> layout", () => {
|
||||
screen.getByRole("button", { name: "Date range" }).element(),
|
||||
screen.getByRole("combobox", { name: "Status" }).element(),
|
||||
screen.getByRole("combobox", { name: "Category" }).element(),
|
||||
screen
|
||||
.getByRole("combobox", {
|
||||
name: "Sort order: Most recent reading first",
|
||||
})
|
||||
.element(),
|
||||
];
|
||||
const centers = controls.map(centerY);
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ describe("<LabFilterRail>", () => {
|
||||
onCategoryChange={vi.fn()}
|
||||
onFromDayChange={vi.fn()}
|
||||
onToDayChange={vi.fn()}
|
||||
onSortChange={vi.fn()}
|
||||
onResetFilters={vi.fn()}
|
||||
isFiltered={false}
|
||||
categories={[
|
||||
@@ -33,7 +32,6 @@ describe("<LabFilterRail>", () => {
|
||||
},
|
||||
]}
|
||||
total={5}
|
||||
currentSortDir="recentDesc"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
@@ -48,16 +46,13 @@ describe("<LabFilterRail>", () => {
|
||||
const dateRangeIdx = html.indexOf("Date range");
|
||||
const statusIdx = html.indexOf("Status");
|
||||
const categoryIdx = html.indexOf("Category");
|
||||
const sortIdx = html.indexOf("Sort order");
|
||||
|
||||
expect(searchIdx).toBeGreaterThan(-1);
|
||||
expect(dateRangeIdx).toBeGreaterThan(searchIdx);
|
||||
expect(statusIdx).toBeGreaterThan(dateRangeIdx);
|
||||
expect(categoryIdx).toBeGreaterThan(statusIdx);
|
||||
expect(sortIdx).toBeGreaterThan(categoryIdx);
|
||||
|
||||
// Active sort label is shown directly in the trigger
|
||||
expect(html).toContain("Sort order: Most recent reading first");
|
||||
// The saved sort in Settings > Labs is the only sort; the rail has none.
|
||||
expect(html).not.toContain("Sort order");
|
||||
expect(html).toContain("5 biomarkers");
|
||||
});
|
||||
|
||||
@@ -73,11 +68,9 @@ describe("<LabFilterRail>", () => {
|
||||
onCategoryChange={vi.fn()}
|
||||
onFromDayChange={vi.fn()}
|
||||
onToDayChange={vi.fn()}
|
||||
onSortChange={vi.fn()}
|
||||
onResetFilters={vi.fn()}
|
||||
isFiltered={true}
|
||||
total={1}
|
||||
currentSortDir="recentDesc"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
@@ -106,11 +99,9 @@ describe("<LabFilterRail>", () => {
|
||||
onCategoryChange={vi.fn()}
|
||||
onFromDayChange={vi.fn()}
|
||||
onToDayChange={vi.fn()}
|
||||
onSortChange={vi.fn()}
|
||||
onResetFilters={vi.fn()}
|
||||
onResetFilters={vi.fn()}
|
||||
isFiltered={false}
|
||||
total={total}
|
||||
currentSortDir="recentDesc"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
@@ -137,59 +128,13 @@ describe("<LabFilterRail>", () => {
|
||||
onCategoryChange={vi.fn()}
|
||||
onFromDayChange={vi.fn()}
|
||||
onToDayChange={vi.fn()}
|
||||
onSortChange={vi.fn()}
|
||||
onResetFilters={vi.fn()}
|
||||
isFiltered={true}
|
||||
total={2}
|
||||
currentSortDir="recentDesc"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Reset");
|
||||
});
|
||||
|
||||
it("renders active sort label in the trigger", () => {
|
||||
const htmlAlpha = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<LabFilterRail
|
||||
filters={{}}
|
||||
searchDraft=""
|
||||
onSearchChange={vi.fn()}
|
||||
searchInputRef={{ current: null }}
|
||||
onStatusChange={vi.fn()}
|
||||
onCategoryChange={vi.fn()}
|
||||
onFromDayChange={vi.fn()}
|
||||
onToDayChange={vi.fn()}
|
||||
onSortChange={vi.fn()}
|
||||
onResetFilters={vi.fn()}
|
||||
isFiltered={false}
|
||||
total={3}
|
||||
currentSortDir="alphaAsc"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(htmlAlpha).toContain("Sort order: Name (A–Z)");
|
||||
|
||||
const htmlRecent = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<LabFilterRail
|
||||
filters={{}}
|
||||
searchDraft=""
|
||||
onSearchChange={vi.fn()}
|
||||
searchInputRef={{ current: null }}
|
||||
onStatusChange={vi.fn()}
|
||||
onCategoryChange={vi.fn()}
|
||||
onFromDayChange={vi.fn()}
|
||||
onToDayChange={vi.fn()}
|
||||
onSortChange={vi.fn()}
|
||||
onResetFilters={vi.fn()}
|
||||
isFiltered={false}
|
||||
total={3}
|
||||
currentSortDir="recentDesc"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(htmlRecent).toContain("Sort order: Most recent reading first");
|
||||
});
|
||||
});
|
||||
@@ -71,7 +71,7 @@ describe("parseLabListSearchParams & labListFiltersToSearch", () => {
|
||||
|
||||
it("parses valid facets", () => {
|
||||
const params = new URLSearchParams(
|
||||
"q=iron&status=abnormal&category=cat_iron&from=2026-01-01&to=2026-12-31&sort=alphaAsc",
|
||||
"q=iron&status=abnormal&category=cat_iron&from=2026-01-01&to=2026-12-31",
|
||||
);
|
||||
expect(parseLabListSearchParams(params)).toEqual<LabListFilters>({
|
||||
q: "iron",
|
||||
@@ -79,13 +79,12 @@ describe("parseLabListSearchParams & labListFiltersToSearch", () => {
|
||||
category: "cat_iron",
|
||||
fromDay: "2026-01-01",
|
||||
toDay: "2026-12-31",
|
||||
sort: "alphaAsc",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops invalid status, invalid dates, and invalid sort", () => {
|
||||
it("drops invalid status and invalid dates", () => {
|
||||
const params = new URLSearchParams(
|
||||
"status=invalid&category=ALL&from=not-a-date&to=invalid&sort=random",
|
||||
"status=invalid&category=ALL&from=not-a-date&to=invalid",
|
||||
);
|
||||
expect(parseLabListSearchParams(params)).toEqual({});
|
||||
});
|
||||
@@ -106,14 +105,10 @@ describe("parseLabListSearchParams & labListFiltersToSearch", () => {
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("omits default sort from search string", () => {
|
||||
expect(labListFiltersToSearch({ sort: "recentDesc" }, "recentDesc")).toBe(
|
||||
"",
|
||||
);
|
||||
expect(labListFiltersToSearch({ sort: "alphaAsc" }, "recentDesc")).toBe(
|
||||
"sort=alphaAsc",
|
||||
);
|
||||
expect(labListFiltersToSearch({ sort: "alphaAsc" }, "alphaAsc")).toBe("");
|
||||
it("ignores a sort param: the list follows the saved sort only", () => {
|
||||
expect(
|
||||
parseLabListSearchParams(new URLSearchParams("sort=alphaAsc")),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("roundtrips valid filters to search string", () => {
|
||||
@@ -123,7 +118,6 @@ describe("parseLabListSearchParams & labListFiltersToSearch", () => {
|
||||
category: "lipids",
|
||||
fromDay: "2026-05-01",
|
||||
toDay: "2026-05-31",
|
||||
sort: "recentAsc",
|
||||
};
|
||||
const search = labListFiltersToSearch(filters);
|
||||
const parsed = parseLabListSearchParams(new URLSearchParams(search));
|
||||
|
||||
@@ -3,11 +3,6 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
legacyModuleListStorageKey,
|
||||
resetModuleListCarryOverForTests,
|
||||
} from "@/lib/module-list-prefs";
|
||||
import { stubFetch } from "../../../../tests/browser/fetch-stub";
|
||||
import { renderWithApp, testQueryClient } from "../../../../tests/browser/render";
|
||||
import {
|
||||
currentSearch,
|
||||
@@ -165,316 +160,6 @@ describe("LabList stale category and group header handling", () => {
|
||||
expect(history.replaces()).toEqual(["?status=abnormal"]);
|
||||
});
|
||||
|
||||
it("normalizes sort=manual without order in URL via replace after paint", async () => {
|
||||
setSearch("?sort=manual");
|
||||
const history = recordHistory();
|
||||
|
||||
const queryClient = labQueryClient();
|
||||
queryClient.setQueryData(queryKeys.moduleListLayout(), {
|
||||
version: 1,
|
||||
lists: {
|
||||
labs: {
|
||||
view: "list",
|
||||
order: [],
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
queryClient.setQueryData(
|
||||
queryKeys.labResultsOverview("en"),
|
||||
overviewWithCategories,
|
||||
);
|
||||
|
||||
await renderWithApp(<LabList />, { queryClient });
|
||||
|
||||
await expect.poll(currentSearch).toBe("");
|
||||
expect(history.pushes()).toEqual([]);
|
||||
expect(history.replaces()).toEqual([""]);
|
||||
});
|
||||
|
||||
it("preserves sort=manual while preferences load and keeps it when manual order arrives", async () => {
|
||||
setSearch("?sort=manual");
|
||||
const history = recordHistory();
|
||||
const api = stubFetch();
|
||||
const pendingLayout = api.deferred("GET", "/api/settings/module-list-layout");
|
||||
|
||||
const queryClient = labQueryClient();
|
||||
queryClient.setQueryData(
|
||||
queryKeys.labResultsOverview("en"),
|
||||
overviewWithCategories,
|
||||
);
|
||||
|
||||
const { screen } = await renderWithApp(<LabList />, { queryClient });
|
||||
|
||||
// Wait for double-rAF afterPaint to pass; without isLoaded gate, this would scrub prematurely
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
expect(currentSearch()).toBe("?sort=manual");
|
||||
expect(history.replaces()).toEqual([]);
|
||||
|
||||
// Server responds with persisted manual order
|
||||
pendingLayout.release({
|
||||
data: {
|
||||
version: 1,
|
||||
lists: {
|
||||
labs: {
|
||||
view: "list",
|
||||
order: ["bm:b1"],
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Wait until preference data has rendered and the manual sort option is visible/selected
|
||||
await expect
|
||||
.element(
|
||||
screen.getByRole("combobox", { name: "Sort order: Sort manually" }),
|
||||
)
|
||||
.toBeVisible();
|
||||
|
||||
// Ensure any afterPaint effects from the render have settled
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
|
||||
// Valid manual sort remains preserved in URL without triggering replace
|
||||
expect(currentSearch()).toBe("?sort=manual");
|
||||
expect(history.replaces()).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves sort=manual when preferences request fails", async () => {
|
||||
setSearch("?sort=manual");
|
||||
const history = recordHistory();
|
||||
const api = stubFetch();
|
||||
const pendingLayout = api.deferred("GET", "/api/settings/module-list-layout");
|
||||
|
||||
const queryClient = labQueryClient();
|
||||
queryClient.setQueryData(
|
||||
queryKeys.labResultsOverview("en"),
|
||||
overviewWithCategories,
|
||||
);
|
||||
|
||||
await renderWithApp(<LabList />, { queryClient });
|
||||
|
||||
// While preference query is still pending, sort=manual is preserved
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
expect(currentSearch()).toBe("?sort=manual");
|
||||
expect(history.replaces()).toEqual([]);
|
||||
|
||||
// Server request fails
|
||||
pendingLayout.release({
|
||||
status: 500,
|
||||
error: "Internal Server Error",
|
||||
});
|
||||
|
||||
// Wait for the query to transition to error state
|
||||
await expect
|
||||
.poll(() => queryClient.getQueryState(queryKeys.moduleListLayout())?.status)
|
||||
.toBe("error");
|
||||
|
||||
// Wait for double-rAF afterPaint to pass; if readiness was tied to !isLoading, this would scrub
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
|
||||
// sort=manual remains intact when preference state is unknown due to failure
|
||||
expect(currentSearch()).toBe("?sort=manual");
|
||||
expect(history.replaces()).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves sort=manual while preferences load and normalizes via replace when server confirms empty order", async () => {
|
||||
setSearch("?sort=manual");
|
||||
const history = recordHistory();
|
||||
const api = stubFetch();
|
||||
const pendingLayout = api.deferred("GET", "/api/settings/module-list-layout");
|
||||
|
||||
const queryClient = labQueryClient();
|
||||
queryClient.setQueryData(
|
||||
queryKeys.labResultsOverview("en"),
|
||||
overviewWithCategories,
|
||||
);
|
||||
|
||||
await renderWithApp(<LabList />, { queryClient });
|
||||
|
||||
// While preference query is still pending, sort=manual is preserved
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
expect(currentSearch()).toBe("?sort=manual");
|
||||
expect(history.replaces()).toEqual([]);
|
||||
|
||||
// Server responds with confirmed empty manual order
|
||||
pendingLayout.release({
|
||||
data: {
|
||||
version: 1,
|
||||
lists: {
|
||||
labs: {
|
||||
view: "list",
|
||||
order: [],
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Unsupported manual sort is normalized via replace after preferences load
|
||||
await expect.poll(currentSearch).toBe("");
|
||||
expect(history.pushes()).toEqual([]);
|
||||
expect(history.replaces()).toEqual([""]);
|
||||
});
|
||||
|
||||
it("preserves sort=manual while carry-over PUT is pending and keeps it when carry-over succeeds", async () => {
|
||||
const storageKey = legacyModuleListStorageKey("labs");
|
||||
window.localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({
|
||||
view: "list",
|
||||
order: ["bm:b1"],
|
||||
sortDir: "manual",
|
||||
}),
|
||||
);
|
||||
resetModuleListCarryOverForTests();
|
||||
|
||||
try {
|
||||
setSearch("?sort=manual");
|
||||
const history = recordHistory();
|
||||
const api = stubFetch();
|
||||
const pendingLayout = api.deferred("GET", "/api/settings/module-list-layout");
|
||||
const pendingPut = api.deferred("PUT", "/api/settings/module-list-layout");
|
||||
|
||||
const queryClient = labQueryClient();
|
||||
queryClient.setQueryData(
|
||||
queryKeys.labResultsOverview("en"),
|
||||
overviewWithCategories,
|
||||
);
|
||||
|
||||
const { screen } = await renderWithApp(<LabList />, { queryClient });
|
||||
|
||||
// Before layout GET arrives, sort=manual is preserved
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
expect(currentSearch()).toBe("?sort=manual");
|
||||
expect(history.replaces()).toEqual([]);
|
||||
|
||||
// Release layout GET with saved: false and empty order (unarranged server state)
|
||||
pendingLayout.release({
|
||||
data: {
|
||||
version: 1,
|
||||
lists: {
|
||||
labs: {
|
||||
view: "list",
|
||||
order: [],
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
saved: false,
|
||||
},
|
||||
illness: {
|
||||
view: "cards",
|
||||
order: [],
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
saved: false,
|
||||
},
|
||||
vorsorge: {
|
||||
view: "cards",
|
||||
order: [],
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
saved: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Wait until carry-over PUT is dispatched
|
||||
await expect
|
||||
.poll(() => api.calls("PUT", "/api/settings/module-list-layout").length)
|
||||
.toBe(1);
|
||||
|
||||
// Wait for double-rAF afterPaint to pass; while carry-over PUT is pending,
|
||||
// legacy order is preferred so sort=manual is not replaced
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
expect(currentSearch()).toBe("?sort=manual");
|
||||
expect(history.replaces()).toEqual([]);
|
||||
|
||||
// Release the carry-over PUT with saved manual order
|
||||
pendingPut.release({
|
||||
data: {
|
||||
version: 1,
|
||||
lists: {
|
||||
labs: {
|
||||
view: "list",
|
||||
order: ["bm:b1"],
|
||||
sortDir: "manual",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
saved: true,
|
||||
},
|
||||
illness: {
|
||||
view: "cards",
|
||||
order: [],
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
saved: false,
|
||||
},
|
||||
vorsorge: {
|
||||
view: "cards",
|
||||
order: [],
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
saved: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Wait until preference data has rendered and the manual sort option is visible/selected
|
||||
await expect
|
||||
.element(
|
||||
screen.getByRole("combobox", { name: "Sort order: Sort manually" }),
|
||||
)
|
||||
.toBeVisible();
|
||||
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
|
||||
// URL remains intact and no replace occurred throughout
|
||||
expect(currentSearch()).toBe("?sort=manual");
|
||||
expect(history.replaces()).toEqual([]);
|
||||
} finally {
|
||||
window.localStorage.removeItem(storageKey);
|
||||
resetModuleListCarryOverForTests();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves valid category in URL without triggering replace", async () => {
|
||||
setSearch("?category=cat_iron");
|
||||
const history = recordHistory();
|
||||
|
||||
@@ -167,40 +167,15 @@ describe("LabList URL filter sync — interactions write to URL", () => {
|
||||
expect(browser.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pushes sort override and does not set isFiltered", () => {
|
||||
render("");
|
||||
it("leaves a sort param out of the filter state", () => {
|
||||
render("sort=alphaAsc");
|
||||
expect(capturedRailProps!.isFiltered).toBe(false);
|
||||
capturedRailProps!.onSortChange("alphaAsc");
|
||||
expect(browser.push).toHaveBeenCalledWith("/labs?sort=alphaAsc");
|
||||
});
|
||||
|
||||
it("omits sort from URL when selecting the preference default", () => {
|
||||
render("sort=alphaAsc");
|
||||
capturedRailProps!.onSortChange("recentDesc");
|
||||
expect(browser.push).toHaveBeenCalledWith("/labs");
|
||||
});
|
||||
|
||||
it("treats recentDesc as effective default sort when prefs.sortDir is manual without manual order", () => {
|
||||
mockModulePrefs.sortDir = "manual";
|
||||
mockModulePrefs.order = [];
|
||||
render("");
|
||||
expect(capturedRailProps!.currentSortDir).toBe("recentDesc");
|
||||
capturedRailProps!.onSortChange("recentDesc");
|
||||
// Does not write redundant ?sort=recentDesc to URL
|
||||
expect(browser.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears sort override back to bare pathname when selecting recentDesc under manual prefs without manual order", () => {
|
||||
mockModulePrefs.sortDir = "manual";
|
||||
mockModulePrefs.order = [];
|
||||
render("sort=alphaAsc");
|
||||
expect(capturedRailProps!.currentSortDir).toBe("alphaAsc");
|
||||
capturedRailProps!.onSortChange("recentDesc");
|
||||
expect(browser.push).toHaveBeenCalledWith("/labs");
|
||||
capturedRailProps!.onStatusChange("abnormal");
|
||||
expect(browser.push).toHaveBeenCalledWith("/labs?status=abnormal");
|
||||
});
|
||||
|
||||
it("reset clears every facet in one push", () => {
|
||||
render("status=abnormal&category=cat_iron&from=2026-09-01&sort=alphaAsc");
|
||||
render("status=abnormal&category=cat_iron&from=2026-09-01");
|
||||
expect(capturedRailProps!.isFiltered).toBe(true);
|
||||
capturedRailProps!.onResetFilters();
|
||||
expect(browser.push).toHaveBeenCalledWith("/labs");
|
||||
@@ -210,23 +185,21 @@ describe("LabList URL filter sync — interactions write to URL", () => {
|
||||
describe("LabList URL filter sync — mounting restores filters from query", () => {
|
||||
it("restores every facet into the rail controls", () => {
|
||||
render(
|
||||
"q=ferritin&status=in-range&category=cat_iron&from=2026-09-01&to=2026-09-30&sort=alphaDesc",
|
||||
"q=ferritin&status=in-range&category=cat_iron&from=2026-09-01&to=2026-09-30",
|
||||
);
|
||||
expect(capturedRailProps!.filters.q).toBe("ferritin");
|
||||
expect(capturedRailProps!.filters.status).toBe("in-range");
|
||||
expect(capturedRailProps!.filters.category).toBe("cat_iron");
|
||||
expect(capturedRailProps!.filters.fromDay).toBe("2026-09-01");
|
||||
expect(capturedRailProps!.filters.toDay).toBe("2026-09-30");
|
||||
expect(capturedRailProps!.currentSortDir).toBe("alphaDesc");
|
||||
expect(capturedRailProps!.isFiltered).toBe(true);
|
||||
});
|
||||
|
||||
it("drops invalid status and invalid dates without breaking the page", () => {
|
||||
render("status=INVALID&from=2026-02-30&to=not-a-date&sort=UNKNOWN");
|
||||
render("status=INVALID&from=2026-02-30&to=not-a-date");
|
||||
expect(capturedRailProps!.filters.status).toBeUndefined();
|
||||
expect(capturedRailProps!.filters.fromDay).toBeUndefined();
|
||||
expect(capturedRailProps!.filters.toDay).toBeUndefined();
|
||||
expect(capturedRailProps!.currentSortDir).toBe("recentDesc");
|
||||
expect(capturedRailProps!.isFiltered).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -252,12 +252,12 @@ describe("<LabList>", () => {
|
||||
expect(html).toContain("1 of 1 outside range");
|
||||
});
|
||||
|
||||
it("handles sort=manual gracefully when manual order is empty", () => {
|
||||
mockSearch = "sort=manual";
|
||||
it("sorts by the saved preference and ignores a sort param in the URL", () => {
|
||||
mockSearch = "sort=recentDesc";
|
||||
mockModulePrefs = {
|
||||
view: "list",
|
||||
order: [], // no manual order
|
||||
sortDir: "recentDesc",
|
||||
order: [],
|
||||
sortDir: "alphaAsc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
@@ -268,6 +268,12 @@ describe("<LabList>", () => {
|
||||
});
|
||||
const overview: LabOverviewResponse = {
|
||||
markers: [
|
||||
{
|
||||
key: "bm:b2",
|
||||
count: 1,
|
||||
trend: [5],
|
||||
latest: mockReading({ id: "r2", biomarkerId: "b2", analyte: "Zinc" }),
|
||||
},
|
||||
{
|
||||
key: "bm:b1",
|
||||
count: 1,
|
||||
@@ -288,9 +294,9 @@ describe("<LabList>", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Should fall back to recentDesc instead of displaying raw "manual"
|
||||
expect(html).toContain("Sort order: Most recent reading first");
|
||||
expect(html).not.toContain("Sort order: manual");
|
||||
expect(html.indexOf("Ferritin")).toBeGreaterThan(-1);
|
||||
expect(html.indexOf("Zinc")).toBeGreaterThan(html.indexOf("Ferritin"));
|
||||
expect(html).not.toContain("Sort order");
|
||||
});
|
||||
|
||||
it("filters by local day bounds using display timezone (Europe/Warsaw)", () => {
|
||||
|
||||
@@ -7,21 +7,10 @@ import {
|
||||
FilterBar,
|
||||
FilterBarDateRange,
|
||||
FilterBarSelect,
|
||||
FILTER_BAR_PILL_CLASSES,
|
||||
type FilterBarSelectOption,
|
||||
} from "@/components/ui/filter-bar";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
isModuleSortDir,
|
||||
type ModuleSortDir,
|
||||
} from "@/lib/settings/module-list-layout";
|
||||
import {
|
||||
isLabStatusFilter,
|
||||
type LabListFilters,
|
||||
@@ -38,13 +27,10 @@ export interface LabFilterRailProps {
|
||||
onCategoryChange: (category: string) => void;
|
||||
onFromDayChange: (from: string) => void;
|
||||
onToDayChange: (to: string) => void;
|
||||
onSortChange: (sort: ModuleSortDir) => void;
|
||||
onResetFilters: () => void;
|
||||
isFiltered: boolean;
|
||||
categories?: LabOverviewCategoryDto[] | null;
|
||||
total?: number;
|
||||
currentSortDir: ModuleSortDir;
|
||||
hasManualOrder?: boolean;
|
||||
}
|
||||
|
||||
export function LabFilterRail({
|
||||
@@ -56,13 +42,10 @@ export function LabFilterRail({
|
||||
onCategoryChange,
|
||||
onFromDayChange,
|
||||
onToDayChange,
|
||||
onSortChange,
|
||||
onResetFilters,
|
||||
isFiltered,
|
||||
categories,
|
||||
total,
|
||||
currentSortDir,
|
||||
hasManualOrder,
|
||||
}: LabFilterRailProps) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -81,19 +64,6 @@ export function LabFilterRail({
|
||||
}),
|
||||
);
|
||||
|
||||
const sortOptions: FilterBarSelectOption[] = [
|
||||
{ value: "recentDesc", label: t("labs.sort.recentDesc") },
|
||||
{ value: "recentAsc", label: t("labs.sort.recentAsc") },
|
||||
{ value: "alphaAsc", label: t("labs.sort.alphaAsc") },
|
||||
{ value: "alphaDesc", label: t("labs.sort.alphaDesc") },
|
||||
...(hasManualOrder
|
||||
? [{ value: "manual", label: t("labs.sort.manual") }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const currentSortLabel =
|
||||
sortOptions.find((o) => o.value === currentSortDir)?.label ?? currentSortDir;
|
||||
|
||||
return (
|
||||
<div data-slot="lab-filter-rail" className="space-y-3">
|
||||
<FilterBar
|
||||
@@ -173,32 +143,6 @@ export function LabFilterRail({
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className="relative inline-flex shrink-0">
|
||||
<Select
|
||||
value={currentSortDir}
|
||||
onValueChange={(val) => {
|
||||
if (isModuleSortDir(val)) onSortChange(val);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={`${t("labs.sort.heading")}: ${currentSortLabel}`}
|
||||
data-slot="filter-bar-pill"
|
||||
className={FILTER_BAR_PILL_CLASSES}
|
||||
>
|
||||
<span className="truncate">
|
||||
<span className="text-muted-foreground">{t("labs.sort.heading")}: </span>
|
||||
{currentSortLabel}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="start">
|
||||
{sortOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</span>
|
||||
</FilterBar>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,17 +2,14 @@
|
||||
|
||||
/**
|
||||
* Pure URL-facet helpers for the labs overview list, following the
|
||||
* documents-vault and measurements patterns: the list's filter and sort state
|
||||
* lives in the page URL (?q&status&category&from&to&sort) so a filtered view is
|
||||
* documents-vault and measurements patterns: the list's filter state
|
||||
* lives in the page URL (?q&status&category&from&to) so a filtered view is
|
||||
* deep-linkable and survives navigation, reload and back/forward. Parsing is
|
||||
* lenient — an unknown facet drops silently — and serialisation omits defaults
|
||||
* so the unfiltered list keeps a clean URL.
|
||||
*/
|
||||
import { applyOrder } from "@/lib/module-list-prefs";
|
||||
import {
|
||||
isModuleSortDir,
|
||||
type ModuleSortDir,
|
||||
} from "@/lib/settings/module-list-layout";
|
||||
import type { ModuleSortDir } from "@/lib/settings/module-list-layout";
|
||||
import {
|
||||
dateOnlyKey,
|
||||
isCalendarDateKey,
|
||||
@@ -45,8 +42,6 @@ export interface LabListFilters {
|
||||
/** Inclusive day bounds, YYYY-MM-DD for newest reading (latest.takenAt). */
|
||||
fromDay?: string;
|
||||
toDay?: string;
|
||||
/** On-page sort override: recentDesc | recentAsc | alphaAsc | alphaDesc | manual. */
|
||||
sort?: ModuleSortDir;
|
||||
}
|
||||
|
||||
const STATUS_SET = new Set<string>(LAB_STATUS_FILTERS);
|
||||
@@ -99,11 +94,6 @@ export function parseLabListSearchParams(
|
||||
const toDay = dayOrUndefined(params.get("to"));
|
||||
if (toDay) filters.toDay = toDay;
|
||||
|
||||
const sort = params.get("sort")?.trim();
|
||||
if (sort && isModuleSortDir(sort)) {
|
||||
filters.sort = sort;
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
@@ -111,10 +101,7 @@ export function parseLabListSearchParams(
|
||||
* Serialise the filter object back into the page URL query string (no leading `?`).
|
||||
* Defaults/ALL values are omitted.
|
||||
*/
|
||||
export function labListFiltersToSearch(
|
||||
filters: LabListFilters,
|
||||
defaultSort: ModuleSortDir = "recentDesc",
|
||||
): string {
|
||||
export function labListFiltersToSearch(filters: LabListFilters): string {
|
||||
const sp = new URLSearchParams();
|
||||
if (filters.q?.trim()) sp.set("q", filters.q.trim());
|
||||
if (filters.status && filters.status !== "ALL")
|
||||
@@ -124,7 +111,6 @@ export function labListFiltersToSearch(
|
||||
}
|
||||
if (filters.fromDay) sp.set("from", filters.fromDay);
|
||||
if (filters.toDay) sp.set("to", filters.toDay);
|
||||
if (filters.sort && filters.sort !== defaultSort) sp.set("sort", filters.sort);
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
@@ -205,7 +191,12 @@ export function filterMarkerRows(
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort marker rows according to the chosen sort direction and optional manual order.
|
||||
* The person's Labs sort (Settings > Labs) over the server's
|
||||
* most-recent-first order: `recentAsc` reverses, the alpha options sort by
|
||||
* analyte name (#43), and `manual` applies the persisted biomarker order
|
||||
* (legacy un-linked rows, which carry no biomarkerId, sort after the ordered
|
||||
* block). Under categories the same order holds inside each category. The
|
||||
* list has no sort control of its own; the saved one is the only one.
|
||||
*/
|
||||
export function sortMarkerRows(
|
||||
rows: readonly MarkerRow[],
|
||||
|
||||
@@ -17,7 +17,6 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
useModuleListPrefs,
|
||||
type ModuleListView,
|
||||
type ModuleSortDir,
|
||||
} from "@/lib/module-list-prefs";
|
||||
|
||||
import { LabFilterRail } from "./lab-filter-rail";
|
||||
@@ -328,7 +327,7 @@ export function LabList({
|
||||
const { t, locale } = useTranslations();
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
const canAddLab = canWriteDomain("labs");
|
||||
const { prefs, isLoaded } = useModuleListPrefs("labs");
|
||||
const { prefs } = useModuleListPrefs("labs");
|
||||
const displayTz = useDisplayTimezone();
|
||||
|
||||
// v1.22 — a short, factual line under each marker heading describing what the
|
||||
@@ -343,12 +342,6 @@ export function LabList({
|
||||
queryFn: () => apiGet<LabOverviewResponse>("/api/labs/overview"),
|
||||
});
|
||||
|
||||
const hasManualOrder = prefs.order.length > 0;
|
||||
const effectiveDefaultSort: ModuleSortDir =
|
||||
prefs.sortDir === "manual" && !hasManualOrder
|
||||
? "recentDesc"
|
||||
: prefs.sortDir;
|
||||
|
||||
const {
|
||||
filters,
|
||||
searchDraft,
|
||||
@@ -358,15 +351,8 @@ export function LabList({
|
||||
setCategoryFilter,
|
||||
setFromDay,
|
||||
setToDay,
|
||||
setSortFilter,
|
||||
resetFilters,
|
||||
} = useLabListFilters({
|
||||
defaultSort: effectiveDefaultSort,
|
||||
});
|
||||
|
||||
const rawSort = filters.sort ?? effectiveDefaultSort;
|
||||
const effectiveSortDir =
|
||||
rawSort === "manual" && !hasManualOrder ? "recentDesc" : rawSort;
|
||||
} = useLabListFilters();
|
||||
|
||||
const rawRows = useMemo(
|
||||
() => toMarkerRows(data?.markers ?? [], t),
|
||||
@@ -395,14 +381,6 @@ export function LabList({
|
||||
|
||||
const groupsOpen = useGroupsOpen(prefs.groupsOpen);
|
||||
|
||||
// Scrub unsupported manual sort if preferences confirm no manual order exists.
|
||||
// afterPaint protects mount writes from router history initialization.
|
||||
useEffect(() => {
|
||||
if (isLoaded && filters.sort === "manual" && !hasManualOrder) {
|
||||
setSortFilter(effectiveDefaultSort, { mode: "replace", afterPaint: true });
|
||||
}
|
||||
}, [isLoaded, filters.sort, hasManualOrder, effectiveDefaultSort, setSortFilter]);
|
||||
|
||||
// Scrub stale category filter when categories load.
|
||||
// afterPaint protects mount writes from router history initialization.
|
||||
useEffect(() => {
|
||||
@@ -436,8 +414,8 @@ export function LabList({
|
||||
);
|
||||
|
||||
const rows = useMemo(
|
||||
() => sortMarkerRows(filteredRows, effectiveSortDir, prefs.order),
|
||||
[filteredRows, effectiveSortDir, prefs.order],
|
||||
() => sortMarkerRows(filteredRows, prefs.sortDir, prefs.order),
|
||||
[filteredRows, prefs.sortDir, prefs.order],
|
||||
);
|
||||
|
||||
// Categories only when the person asked for them and the server says they
|
||||
@@ -483,13 +461,10 @@ export function LabList({
|
||||
onCategoryChange={setCategoryFilter}
|
||||
onFromDayChange={setFromDay}
|
||||
onToDayChange={setToDay}
|
||||
onSortChange={setSortFilter}
|
||||
onResetFilters={resetFilters}
|
||||
isFiltered={effectiveIsFiltered}
|
||||
categories={categories}
|
||||
total={rows.length}
|
||||
currentSortDir={effectiveSortDir}
|
||||
hasManualOrder={hasManualOrder}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
useUrlFilterSync,
|
||||
type UrlFilterSyncOptions,
|
||||
} from "@/hooks/use-url-filter-sync";
|
||||
import type { ModuleSortDir } from "@/lib/settings/module-list-layout";
|
||||
import {
|
||||
labListFiltersToSearch,
|
||||
parseLabListSearchParams,
|
||||
@@ -13,20 +12,11 @@ import {
|
||||
type LabStatusFilter,
|
||||
} from "./lab-list-filters";
|
||||
|
||||
export function useLabListFilters({
|
||||
defaultSort = "recentDesc",
|
||||
}: {
|
||||
defaultSort?: ModuleSortDir;
|
||||
} = {}) {
|
||||
const serialise = useCallback(
|
||||
(f: LabListFilters) => labListFiltersToSearch(f, defaultSort),
|
||||
[defaultSort],
|
||||
);
|
||||
|
||||
export function useLabListFilters() {
|
||||
const { filters, applyFilters, patchFilters } =
|
||||
useUrlFilterSync<LabListFilters>({
|
||||
parse: parseLabListSearchParams,
|
||||
serialise,
|
||||
serialise: labListFiltersToSearch,
|
||||
});
|
||||
|
||||
// Debounced search draft
|
||||
@@ -115,21 +105,6 @@ export function useLabListFilters({
|
||||
[patchFilters],
|
||||
);
|
||||
|
||||
const setSortFilter = useCallback(
|
||||
(
|
||||
value: ModuleSortDir,
|
||||
modeOrOptions: "push" | "replace" | UrlFilterSyncOptions = "push",
|
||||
) => {
|
||||
patchFilters(
|
||||
{
|
||||
sort: value === defaultSort ? undefined : value,
|
||||
},
|
||||
modeOrOptions,
|
||||
);
|
||||
},
|
||||
[defaultSort, patchFilters],
|
||||
);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setSearchDraft("");
|
||||
applyFilters({});
|
||||
@@ -144,7 +119,6 @@ export function useLabListFilters({
|
||||
setCategoryFilter,
|
||||
setFromDay,
|
||||
setToDay,
|
||||
setSortFilter,
|
||||
resetFilters,
|
||||
};
|
||||
}
|
||||
@@ -57,11 +57,8 @@ import { cn } from "@/lib/utils";
|
||||
* ui-guidelines §5).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shared pill chrome for filter-bar triggers and active chips.
|
||||
* Exported for custom controls (such as select triggers) that participate in the rail.
|
||||
*/
|
||||
export const FILTER_BAR_PILL_CLASSES =
|
||||
/** Shared pill chrome — trigger and chip in one element. */
|
||||
const PILL_CLASSES =
|
||||
"border-border bg-card text-foreground inline-flex min-h-11 shrink-0 items-center gap-1.5 rounded-full border px-3 text-sm whitespace-nowrap shadow-xs transition-all duration-150 ease-out sm:min-h-9 h-auto py-1";
|
||||
const PILL_ACTIVE_CLASSES = "border-primary/40 bg-primary/10";
|
||||
|
||||
@@ -186,7 +183,7 @@ export function FilterBarSelect({
|
||||
data-slot="filter-bar-pill"
|
||||
data-active={active ? "true" : "false"}
|
||||
className={cn(
|
||||
FILTER_BAR_PILL_CLASSES,
|
||||
PILL_CLASSES,
|
||||
active && cn(PILL_ACTIVE_CLASSES, "pr-8"),
|
||||
)}
|
||||
>
|
||||
@@ -265,7 +262,7 @@ export function FilterBarDateRange({
|
||||
data-slot="filter-bar-pill"
|
||||
data-active={active ? "true" : "false"}
|
||||
className={cn(
|
||||
FILTER_BAR_PILL_CLASSES,
|
||||
PILL_CLASSES,
|
||||
"outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
active && cn(PILL_ACTIVE_CLASSES, "pr-8"),
|
||||
@@ -360,7 +357,7 @@ export function FilterBarNumberRange({
|
||||
data-slot="filter-bar-pill"
|
||||
data-active={active ? "true" : "false"}
|
||||
className={cn(
|
||||
FILTER_BAR_PILL_CLASSES,
|
||||
PILL_CLASSES,
|
||||
"outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
active && cn(PILL_ACTIVE_CLASSES, "pr-8"),
|
||||
|
||||
Reference in new issue
Block a user