mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(labs): let a person order the lab categories (#1349)
The labs list layout gains categoryOrder, a bounded allow-list of category keys (200 entries, 64 characters each, deduplicated, malformed values dropped on read). GET /api/labs/overview reads the caller's own layout and answers categories already in that order, so every client renders them as given. Listed categories come first; keys the data does not have are ignored; a category the order does not name follows in the default order (catalog panels, then the person's own by label, then other). Other left at the end of the order stays last, behind any category added later; moved anywhere else, it stays there. Settings > Arrangement > Labs shows a category order card while categories are on. It lists the overview's categories under their localised labels, sends the whole order after every move, moves the cached overview at once and refetches it. The card says how the default order works. The OpenAPI document carries the new field.
This commit is contained in:
1 parent
157809c24f
commit
928ade5109
28 files changed
+660
-34
No files matched your search
@@ -23,6 +23,17 @@
|
||||
prevention line 109, which answers around the clock. Before, Korean
|
||||
fell through to the international set with 112, which reaches the
|
||||
police there, not an ambulance.
|
||||
- **Set the order of the lab categories.** With the labs list grouped
|
||||
by category you could reorder the markers but not the categories, and
|
||||
nothing said how they were ordered. Settings > Arrangement > Labs now
|
||||
has a "Category order" card while categories are on: move a category
|
||||
with the handle or the arrows, and the card explains the default
|
||||
(built-in categories in a fixed order, your own panels alphabetically,
|
||||
Other last). A category that turns up later joins the end of your
|
||||
order, and Other stays last if you left it there. The order is saved
|
||||
with your account and applied by the server, so the app shows the same
|
||||
order. The labs list layout accepts `categoryOrder`, and
|
||||
`GET /api/labs/overview` answers `categories` in that order. (#1349)
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
+21
-2
@@ -34362,6 +34362,13 @@ components:
|
||||
- expanded
|
||||
- flagged
|
||||
- collapsed
|
||||
categoryOrder:
|
||||
maxItems: 200
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
required:
|
||||
- version
|
||||
- list
|
||||
@@ -75520,6 +75527,15 @@ components:
|
||||
- collapsed
|
||||
description: 'Which labs categories start open: every one (`expanded`), only those whose `status` is `above` or `below`
|
||||
(`flagged`), or none (`collapsed`). Default "expanded". The other lists ignore it.'
|
||||
categoryOrder:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: The person's order of the labs categories, as `categories[].key` values of `GET /api/labs/overview`. The
|
||||
server applies it there, so a client renders `categories` as given and never sorts them itself. Keys missing
|
||||
from the live data are ignored; a category missing from the list follows the listed ones in the default
|
||||
order, and "other" left at the end of the list stays last. Default empty (the default order). The other
|
||||
lists ignore it.
|
||||
saved:
|
||||
type: boolean
|
||||
description: True when the server holds a choice for this list; false means every field is a default.
|
||||
@@ -75529,6 +75545,7 @@ components:
|
||||
- sortDir
|
||||
- groupBy
|
||||
- groupsOpen
|
||||
- categoryOrder
|
||||
- saved
|
||||
additionalProperties: false
|
||||
ModuleListLayoutSavedEnvelope:
|
||||
@@ -77121,8 +77138,10 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/LabOverviewCategory"
|
||||
description: "The list's categories in display order: catalog panels in catalog order, then the person's own panels by
|
||||
label, then `other`."
|
||||
description: "The list's categories in display order, already arranged for the caller: the default order is catalog
|
||||
panels in catalog order, then the person's own panels by label, then `other`, and the caller's
|
||||
`categoryOrder` (`PUT /api/settings/module-list-layout`, labs list) is applied over it. Render them in this
|
||||
order."
|
||||
grouped:
|
||||
type: boolean
|
||||
description: "Whether grouping adds anything: true when there are two categories or more and at least one is not
|
||||
|
||||
+3
-1
@@ -10596,7 +10596,9 @@
|
||||
"openExpanded": "Alle",
|
||||
"openFlagged": "Nur außerhalb des Bereichs",
|
||||
"openCollapsed": "Keine",
|
||||
"orderWithinGroups": "Die Reihenfolge gilt innerhalb jeder Kategorie."
|
||||
"orderWithinGroups": "Die Reihenfolge gilt innerhalb jeder Kategorie.",
|
||||
"orderHeading": "Reihenfolge der Kategorien",
|
||||
"orderDefault": "Bis du eine verschiebst, stehen eingebaute Kategorien in fester Folge, deine eigenen alphabetisch und {other} zuletzt."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "Befundbereich {range}",
|
||||
|
||||
+3
-1
@@ -10596,7 +10596,9 @@
|
||||
"openExpanded": "All",
|
||||
"openFlagged": "Outside range only",
|
||||
"openCollapsed": "None",
|
||||
"orderWithinGroups": "The order applies within each category."
|
||||
"orderWithinGroups": "The order applies within each category.",
|
||||
"orderHeading": "Category order",
|
||||
"orderDefault": "Until you move one, built-in categories keep a fixed order, your own follow alphabetically and {other} comes last."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "Report range {range}",
|
||||
|
||||
+3
-1
@@ -10596,7 +10596,9 @@
|
||||
"openExpanded": "Todas",
|
||||
"openFlagged": "Solo fuera de rango",
|
||||
"openCollapsed": "Ninguna",
|
||||
"orderWithinGroups": "El orden se aplica dentro de cada categoría."
|
||||
"orderWithinGroups": "El orden se aplica dentro de cada categoría.",
|
||||
"orderHeading": "Orden de las categorías",
|
||||
"orderDefault": "Hasta que muevas una, las categorías integradas siguen un orden fijo, las tuyas van por orden alfabético y {other} al final."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "Rango del informe {range}",
|
||||
|
||||
+3
-1
@@ -10596,7 +10596,9 @@
|
||||
"openExpanded": "Toutes",
|
||||
"openFlagged": "Hors norme seulement",
|
||||
"openCollapsed": "Aucune",
|
||||
"orderWithinGroups": "L’ordre s’applique au sein de chaque catégorie."
|
||||
"orderWithinGroups": "L’ordre s’applique au sein de chaque catégorie.",
|
||||
"orderHeading": "Ordre des catégories",
|
||||
"orderDefault": "Tant que vous n’en déplacez aucune, les catégories intégrées suivent un ordre fixe, les vôtres l’ordre alphabétique et {other} vient en dernier."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "Plage du compte rendu {range}",
|
||||
|
||||
+3
-1
@@ -10596,7 +10596,9 @@
|
||||
"openExpanded": "Tutte",
|
||||
"openFlagged": "Solo fuori norma",
|
||||
"openCollapsed": "Nessuna",
|
||||
"orderWithinGroups": "L’ordine vale all’interno di ogni categoria."
|
||||
"orderWithinGroups": "L’ordine vale all’interno di ogni categoria.",
|
||||
"orderHeading": "Ordine delle categorie",
|
||||
"orderDefault": "Finché non ne sposti una, le categorie predefinite seguono un ordine fisso, le tue l’ordine alfabetico e {other} viene per ultima."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "Intervallo del referto {range}",
|
||||
|
||||
+3
-1
@@ -10596,7 +10596,9 @@
|
||||
"openExpanded": "모두",
|
||||
"openFlagged": "범위 밖만",
|
||||
"openCollapsed": "없음",
|
||||
"orderWithinGroups": "순서는 각 카테고리 안에서 적용돼요."
|
||||
"orderWithinGroups": "순서는 각 카테고리 안에서 적용돼요.",
|
||||
"orderHeading": "카테고리 순서",
|
||||
"orderDefault": "직접 옮기기 전에는 기본 카테고리가 정해진 순서로, 직접 만든 카테고리가 가나다순으로 이어지고 {other}가 마지막에 와요."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "결과지 범위 {range}",
|
||||
|
||||
+3
-1
@@ -10596,7 +10596,9 @@
|
||||
"openExpanded": "Alle",
|
||||
"openFlagged": "Alleen buiten bereik",
|
||||
"openCollapsed": "Geen",
|
||||
"orderWithinGroups": "De volgorde geldt binnen elke categorie."
|
||||
"orderWithinGroups": "De volgorde geldt binnen elke categorie.",
|
||||
"orderHeading": "Volgorde van de categorieën",
|
||||
"orderDefault": "Tot je er een verplaatst, staan de ingebouwde categorieën in een vaste volgorde, je eigen alfabetisch daarna en {other} als laatste."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "Bereik volgens rapport: {range}",
|
||||
|
||||
+3
-1
@@ -10596,7 +10596,9 @@
|
||||
"openExpanded": "Wszystkie",
|
||||
"openFlagged": "Tylko poza normą",
|
||||
"openCollapsed": "Żadne",
|
||||
"orderWithinGroups": "Kolejność obowiązuje w obrębie każdej kategorii."
|
||||
"orderWithinGroups": "Kolejność obowiązuje w obrębie każdej kategorii.",
|
||||
"orderHeading": "Kolejność kategorii",
|
||||
"orderDefault": "Dopóki żadnej nie przesuniesz, wbudowane kategorie mają stałą kolejność, twoje własne idą alfabetycznie, a {other} na końcu."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "Zakres z wyniku {range}",
|
||||
|
||||
+3
-1
@@ -10596,7 +10596,9 @@
|
||||
"openExpanded": "Todas",
|
||||
"openFlagged": "Só fora da faixa",
|
||||
"openCollapsed": "Nenhuma",
|
||||
"orderWithinGroups": "A ordem vale dentro de cada categoria."
|
||||
"orderWithinGroups": "A ordem vale dentro de cada categoria.",
|
||||
"orderHeading": "Ordem das categorias",
|
||||
"orderDefault": "Até você mover uma, as categorias integradas seguem uma ordem fixa, as suas vêm em ordem alfabética e {other} fica por último."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "Faixa do laudo {range}",
|
||||
|
||||
+3
-1
@@ -10596,7 +10596,9 @@
|
||||
"openExpanded": "Все",
|
||||
"openFlagged": "Только вне диапазона",
|
||||
"openCollapsed": "Ни одной",
|
||||
"orderWithinGroups": "Порядок действует внутри каждой категории."
|
||||
"orderWithinGroups": "Порядок действует внутри каждой категории.",
|
||||
"orderHeading": "Порядок категорий",
|
||||
"orderDefault": "Пока вы ничего не переместили, встроенные категории идут в постоянном порядке, ваши собственные по алфавиту, а {other} в конце."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "Диапазон из отчёта {range}",
|
||||
|
||||
@@ -930,7 +930,7 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
},
|
||||
"app/api/labs/overview/route.ts": {
|
||||
domain: "labs",
|
||||
why: "The Labs list's per-marker overview of the record: the newest reading of each biomarker, its count and a short trend. The same rows and the same biomarker include as `GET /api/labs`, grouped server-side; read only.",
|
||||
why: "The Labs list's per-marker overview of the record: the newest reading of each biomarker, its count and a short trend. The same rows and the same biomarker include as `GET /api/labs`, grouped server-side; read only. The category order it applies comes from the caller's own list layout, never the owner's, so it reveals nothing about the record.",
|
||||
},
|
||||
"app/api/biomarkers/route.ts": {
|
||||
domain: "labs",
|
||||
|
||||
@@ -2,10 +2,12 @@ import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { categoriseOverview } from "@/lib/labs/marker-category";
|
||||
import { loadLabOverview } from "@/lib/labs/overview";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveModuleListLayout } from "@/lib/settings/module-list-layout";
|
||||
import { labOverviewQuerySchema } from "@/lib/validations/labs";
|
||||
|
||||
/**
|
||||
@@ -18,7 +20,11 @@ import { labOverviewQuerySchema } from "@/lib/validations/labs";
|
||||
* `categories` and `grouped` are the list's categories, resolved here once
|
||||
* (`src/lib/labs/marker-category.ts`): order, label, members, counts and a
|
||||
* combined status. Labels follow the viewer's locale, so on a managed record
|
||||
* the caller's own language wins, not the record owner's.
|
||||
* the caller's own language wins, not the record owner's. The order is the
|
||||
* default one with the caller's `categoryOrder` (the labs list layout,
|
||||
* `PUT /api/settings/module-list-layout`) applied, for the same reason: it is
|
||||
* the viewer's presentation, so a person acting for another record sees the
|
||||
* categories in their own order. A client renders the order as given.
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user, actor } = await requireRecordAuth("read", "labs");
|
||||
@@ -34,15 +40,33 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const markers = await loadLabOverview(user.id);
|
||||
const [markers, layoutRow] = await Promise.all([
|
||||
loadLabOverview(user.id),
|
||||
prisma.user.findUnique({
|
||||
where: { id: actor.id },
|
||||
select: { moduleListLayoutJson: true },
|
||||
}),
|
||||
]);
|
||||
const { categoryOrder } = resolveModuleListLayout(
|
||||
layoutRow?.moduleListLayoutJson,
|
||||
).lists.labs;
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: actor.locale ?? null,
|
||||
});
|
||||
const { categories, grouped } = categoriseOverview(markers, locale);
|
||||
const { categories, grouped } = categoriseOverview(
|
||||
markers,
|
||||
locale,
|
||||
categoryOrder,
|
||||
);
|
||||
annotate({
|
||||
action: { name: "labs.overview" },
|
||||
meta: { markers: markers.length, categories: categories.length, grouped },
|
||||
meta: {
|
||||
markers: markers.length,
|
||||
categories: categories.length,
|
||||
grouped,
|
||||
categoryOrderLength: categoryOrder.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess({ markers, categories, grouped });
|
||||
});
|
||||
@@ -58,14 +58,15 @@ export const GET = apiHandler(async () => {
|
||||
export const PUT = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireActorAuth();
|
||||
|
||||
// 500 ids of up to 64 characters plus the envelope.
|
||||
// 500 ids plus 200 category keys of up to 64 characters, plus the envelope.
|
||||
const parsed = await parseJsonBody(request, moduleListLayoutPutSchema, {
|
||||
maxBytes: 48 * 1024,
|
||||
maxBytes: 64 * 1024,
|
||||
action: "settings.module-list-layout.validation-failed",
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const { list, view, order, sortDir, groupBy, groupsOpen } = parsed.data;
|
||||
const { list, view, order, sortDir, groupBy, groupsOpen, categoryOrder } =
|
||||
parsed.data;
|
||||
// Preserve-when-absent: one stored read fills whichever field the body
|
||||
// left out, and every other list stays as stored. Only fields a person has
|
||||
// chosen are written, so a field never picked keeps following the default.
|
||||
@@ -79,6 +80,7 @@ export const PUT = apiHandler(async (request: Request) => {
|
||||
...(sortDir ? { sortDir } : {}),
|
||||
...(groupBy ? { groupBy } : {}),
|
||||
...(groupsOpen ? { groupsOpen } : {}),
|
||||
...(categoryOrder ? { categoryOrder: [...new Set(categoryOrder)] } : {}),
|
||||
};
|
||||
const next = { ...stored, [list]: nextChoices };
|
||||
await tx.user.update({
|
||||
@@ -98,6 +100,7 @@ export const PUT = apiHandler(async (request: Request) => {
|
||||
groupBy: layout.lists[list].groupBy,
|
||||
groupsOpen: layout.lists[list].groupsOpen,
|
||||
orderLength: layout.lists[list].order.length,
|
||||
categoryOrderLength: layout.lists[list].categoryOrder.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(layout);
|
||||
|
||||
@@ -28,6 +28,21 @@ function render(prefs: Partial<ModuleListPrefs>) {
|
||||
biomarkers: [],
|
||||
customPanels: [],
|
||||
});
|
||||
// The server's answer, already in the person's order.
|
||||
queryClient.setQueryData(queryKeys.labResultsOverview("en"), {
|
||||
markers: [],
|
||||
grouped: true,
|
||||
categories: [
|
||||
{ key: "thyroid", label: "Thyroid" },
|
||||
{ key: "other", label: "Other" },
|
||||
{ key: "lipids", label: "Lipids" },
|
||||
].map((category) => ({
|
||||
...category,
|
||||
markerKeys: [],
|
||||
counts: { inRange: 0, below: 0, above: 0, unknown: 0 },
|
||||
status: "unknown",
|
||||
})),
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
@@ -60,4 +75,26 @@ describe("<LabsSettings> categories", () => {
|
||||
"The order applies within each category.",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers the category order only while categories are on", () => {
|
||||
expect(render({})).not.toContain('id="labs-category-order"');
|
||||
expect(render({ groupBy: "category" })).toContain(
|
||||
'id="labs-category-order"',
|
||||
);
|
||||
});
|
||||
|
||||
it("lists the categories as the server ordered them, under their labels", () => {
|
||||
const html = render({ groupBy: "category" });
|
||||
const card = html.slice(html.indexOf('id="labs-category-order"'));
|
||||
const at = (label: string) => card.indexOf(`title="${label}"`);
|
||||
expect(at("Thyroid")).toBeGreaterThan(-1);
|
||||
expect(at("Thyroid")).toBeLessThan(at("Other"));
|
||||
expect(at("Other")).toBeLessThan(at("Lipids"));
|
||||
});
|
||||
|
||||
it("says how the categories are ordered until the person moves one", () => {
|
||||
expect(render({ groupBy: "category" })).toContain(
|
||||
"Until you move one, built-in categories keep a fixed order, your own follow alphabetically and Other comes last.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,8 @@
|
||||
*
|
||||
* `LabsSettings` — Settings > Arrangement > Labs: the card-vs-list view
|
||||
* (MOD-03), the sort (recency, alphabetical or manual, MOD-04) and the manual
|
||||
* order of the biomarkers, and whether the list shows its categories and
|
||||
* which of them start open. Persisted per account through
|
||||
* order of the biomarkers, and whether the list shows its categories, which
|
||||
* of them start open and their order. Persisted per account through
|
||||
* `useModuleListPrefs("labs")`.
|
||||
*
|
||||
* `LabsModuleSettings` — Settings > Modules > Labs: the on-device text
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ArrowDownUp,
|
||||
FlaskConical,
|
||||
FolderTree,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
ListOrdered,
|
||||
ScanText,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Segmented } from "@/components/ui/segmented";
|
||||
@@ -49,14 +51,25 @@ import {
|
||||
} from "@/lib/module-list-prefs";
|
||||
|
||||
import { BiomarkerManager } from "./biomarker-manager";
|
||||
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
|
||||
import type {
|
||||
BiomarkerDto,
|
||||
BiomarkerListResponse,
|
||||
LabOverviewResponse,
|
||||
} from "./types";
|
||||
import { useLabsLocalOcr, useUpdateLabsLocalOcr } from "./use-ocr-extract";
|
||||
import { labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
|
||||
export function LabsSettings() {
|
||||
const { t } = useTranslations();
|
||||
const { prefs, setView, setOrder, setSortDir, setGroupBy, setGroupsOpen } =
|
||||
useModuleListPrefs("labs");
|
||||
const {
|
||||
prefs,
|
||||
setView,
|
||||
setOrder,
|
||||
setSortDir,
|
||||
setGroupBy,
|
||||
setGroupsOpen,
|
||||
setCategoryOrder,
|
||||
} = useModuleListPrefs("labs");
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.biomarkers(),
|
||||
@@ -120,6 +133,10 @@ export function LabsSettings() {
|
||||
onGroupsOpen={setGroupsOpen}
|
||||
/>
|
||||
|
||||
{prefs.groupBy === "category" ? (
|
||||
<LabsCategoryOrderCard onChange={setCategoryOrder} />
|
||||
) : null}
|
||||
|
||||
{manual ? (
|
||||
<SettingsCard id="labs-order" className="scroll-mt-28">
|
||||
<SettingsCardHeader
|
||||
@@ -204,6 +221,56 @@ function LabsGroupingCard({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The order of the categories. The server answers them already in the
|
||||
* person's order (`GET /api/labs/overview` applies `categoryOrder`), so the
|
||||
* editor lists them as given, under the labels the overview carries, and
|
||||
* sends the whole order after every move.
|
||||
*/
|
||||
function LabsCategoryOrderCard({
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (categoryOrder: string[]) => void;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.labResultsOverview(locale),
|
||||
queryFn: () => apiGet<LabOverviewResponse>("/api/labs/overview"),
|
||||
});
|
||||
const items: ReorderItem[] = (data?.categories ?? []).map((category) => ({
|
||||
id: category.key,
|
||||
name: category.label,
|
||||
}));
|
||||
|
||||
return (
|
||||
<SettingsCard id="labs-category-order" className="scroll-mt-28">
|
||||
<SettingsCardHeader
|
||||
icon={Layers}
|
||||
title={t("labs.groups.orderHeading")}
|
||||
description={t("labs.groups.orderDefault", {
|
||||
other: t("labs.groups.other"),
|
||||
})}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
inset
|
||||
slot="labs-category-order-error"
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : (
|
||||
<ModuleOrderEditor items={items} onChange={onChange} />
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function LabsModuleSettings() {
|
||||
const { t } = useTranslations();
|
||||
// v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider
|
||||
|
||||
@@ -68,6 +68,7 @@ describe("parseModuleListPrefs", () => {
|
||||
order: ["a", "b"],
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,6 +205,85 @@ describe("runSetModuleListPrefs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runSetModuleListPrefs category order", () => {
|
||||
const t = (key: string) => key;
|
||||
const overview = (keys: string[]) => ({
|
||||
markers: [],
|
||||
grouped: true,
|
||||
categories: keys.map((key) => ({ key, label: key })),
|
||||
});
|
||||
|
||||
it("moves the cached overview's categories at once and refetches it", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const cell = queryKeys.labResultsOverview("en");
|
||||
queryClient.setQueryData(cell, overview(["lipids", "thyroid", "other"]));
|
||||
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
|
||||
let cachedDuringWrite: unknown;
|
||||
vi.mocked(apiPut).mockImplementation(async () => {
|
||||
cachedDuringWrite = queryClient.getQueryData(cell);
|
||||
return resolveModuleListLayout({
|
||||
labs: { categoryOrder: ["thyroid", "other", "lipids"] },
|
||||
});
|
||||
});
|
||||
|
||||
await runSetModuleListPrefs({
|
||||
list: "labs",
|
||||
patch: { categoryOrder: ["thyroid", "other", "lipids"] },
|
||||
queryClient,
|
||||
t,
|
||||
});
|
||||
|
||||
expect(apiPut).toHaveBeenCalledWith("/api/settings/module-list-layout", {
|
||||
version: 1,
|
||||
list: "labs",
|
||||
categoryOrder: ["thyroid", "other", "lipids"],
|
||||
});
|
||||
expect(
|
||||
(cachedDuringWrite as ReturnType<typeof overview>).categories.map(
|
||||
(c) => c.key,
|
||||
),
|
||||
).toEqual(["thyroid", "other", "lipids"]);
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.labResultsOverview(),
|
||||
});
|
||||
// An order move is its own feedback, like the marker order.
|
||||
expect(toastWrittenOutcome).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refetches the overview after a failed write too, so the server order returns", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData(
|
||||
queryKeys.labResultsOverview("en"),
|
||||
overview(["lipids", "thyroid"]),
|
||||
);
|
||||
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
|
||||
vi.mocked(apiPut).mockRejectedValue(new Error("offline"));
|
||||
await runSetModuleListPrefs({
|
||||
list: "labs",
|
||||
patch: { categoryOrder: ["thyroid", "lipids"] },
|
||||
queryClient,
|
||||
t,
|
||||
});
|
||||
expect(showErrorToast).toHaveBeenCalled();
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.labResultsOverview(),
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the overview alone for any other field", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
|
||||
vi.mocked(apiPut).mockResolvedValue(resolveModuleListLayout(null));
|
||||
await runSetModuleListPrefs({
|
||||
list: "labs",
|
||||
patch: { order: ["m1"] },
|
||||
queryClient,
|
||||
t,
|
||||
});
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOrder", () => {
|
||||
const items = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
||||
const idOf = (i: { id: string }) => i.id;
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ReferenceRangeStatus } from "@/lib/labs/reference-range";
|
||||
|
||||
import { ALIAS_PANEL, BIOMARKER_PANELS } from "../biomarker-catalog";
|
||||
import {
|
||||
applyCategoryOrder,
|
||||
categoriseOverview,
|
||||
customCategoryLabel,
|
||||
customPanelSuggestions,
|
||||
@@ -222,3 +223,110 @@ describe("categoriseOverview", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCategoryOrder", () => {
|
||||
const cats = (...keys: string[]) => keys.map((key) => ({ key }));
|
||||
const keysOf = (list: { key: string }[]) => list.map((c) => c.key);
|
||||
|
||||
it("keeps the default order without a saved order", () => {
|
||||
expect(
|
||||
keysOf(applyCategoryOrder(cats("lipids", "thyroid", "other"), [])),
|
||||
).toEqual(["lipids", "thyroid", "other"]);
|
||||
});
|
||||
|
||||
it("puts the listed categories first, in the listed order", () => {
|
||||
expect(
|
||||
keysOf(
|
||||
applyCategoryOrder(cats("lipids", "thyroid", "liver", "other"), [
|
||||
"liver",
|
||||
"lipids",
|
||||
"thyroid",
|
||||
"other",
|
||||
]),
|
||||
),
|
||||
).toEqual(["liver", "lipids", "thyroid", "other"]);
|
||||
});
|
||||
|
||||
it("ignores keys the data does not have", () => {
|
||||
expect(
|
||||
keysOf(
|
||||
applyCategoryOrder(cats("lipids", "thyroid"), [
|
||||
"custom:gone",
|
||||
"thyroid",
|
||||
"iron",
|
||||
"lipids",
|
||||
]),
|
||||
),
|
||||
).toEqual(["thyroid", "lipids"]);
|
||||
});
|
||||
|
||||
it("adds a category the order does not name after the listed ones, in the default order", () => {
|
||||
expect(
|
||||
keysOf(
|
||||
applyCategoryOrder(cats("lipids", "glucose", "thyroid", "liver"), [
|
||||
"thyroid",
|
||||
"lipids",
|
||||
]),
|
||||
),
|
||||
).toEqual(["thyroid", "lipids", "glucose", "liver"]);
|
||||
});
|
||||
|
||||
it("keeps other last when the person left it at the end", () => {
|
||||
expect(
|
||||
keysOf(
|
||||
applyCategoryOrder(cats("lipids", "iron", "custom:abc", "other"), [
|
||||
"lipids",
|
||||
"other",
|
||||
]),
|
||||
),
|
||||
).toEqual(["lipids", "iron", "custom:abc", "other"]);
|
||||
});
|
||||
|
||||
it("keeps other where the person moved it", () => {
|
||||
expect(
|
||||
keysOf(
|
||||
applyCategoryOrder(cats("lipids", "iron", "thyroid", "other"), [
|
||||
"other",
|
||||
"lipids",
|
||||
"thyroid",
|
||||
]),
|
||||
),
|
||||
).toEqual(["other", "lipids", "thyroid", "iron"]);
|
||||
});
|
||||
|
||||
it("judges other's place among the present categories only", () => {
|
||||
// "thyroid" is gone from the data, so "other" ends the listed block.
|
||||
expect(
|
||||
keysOf(
|
||||
applyCategoryOrder(cats("lipids", "iron", "other"), [
|
||||
"lipids",
|
||||
"other",
|
||||
"thyroid",
|
||||
]),
|
||||
),
|
||||
).toEqual(["lipids", "iron", "other"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("categoriseOverview with a saved category order", () => {
|
||||
it("answers the categories in the person's order", () => {
|
||||
const { categories, grouped } = categoriseOverview(
|
||||
[
|
||||
marker("bm:1", "Mystery", null),
|
||||
marker("bm:2", "TSH", null),
|
||||
marker("bm:3", "LDL", null),
|
||||
marker("bm:4", "Cortisol", "Hormones"),
|
||||
],
|
||||
"en",
|
||||
["thyroid", "other", "lipids"],
|
||||
);
|
||||
expect(grouped).toBe(true);
|
||||
// Hormones is not in the order: it follows the listed categories.
|
||||
expect(categories.map((c) => c.label)).toEqual([
|
||||
"Thyroid",
|
||||
"Other",
|
||||
"Lipids",
|
||||
"Hormones",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -197,14 +197,47 @@ export interface CategorisableMarker {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every category of an overview, in display order (catalog panels in catalog
|
||||
* order, then the person's own by label, then "other"), and whether the list
|
||||
* The person's category order (`categoryOrder` in the labs list layout)
|
||||
* over categories already in the default order.
|
||||
*
|
||||
* Listed keys come first, in the listed order; a key the data does not have
|
||||
* is skipped. A category the list does not name (a panel added since the
|
||||
* person arranged the list) follows the listed ones in the default order.
|
||||
* "other" is the one exception: left at the end of the listed categories it
|
||||
* stays last, after any category added later, because a catch-all belongs
|
||||
* at the bottom unless the person moved it up. An empty order is the
|
||||
* default order.
|
||||
*/
|
||||
export function applyCategoryOrder<T extends { key: string }>(
|
||||
categories: readonly T[],
|
||||
order: readonly string[],
|
||||
): T[] {
|
||||
if (order.length === 0) return [...categories];
|
||||
const present = new Set(categories.map((category) => category.key));
|
||||
const listed = order.filter((key) => present.has(key));
|
||||
const rank = new Map(listed.map((key, index) => [key, index]));
|
||||
const ordered = categories
|
||||
.filter((category) => rank.has(category.key))
|
||||
.sort((a, b) => rank.get(a.key)! - rank.get(b.key)!);
|
||||
const rest = categories.filter((category) => !rank.has(category.key));
|
||||
const otherLast = ordered.at(-1)?.key === OTHER_CATEGORY;
|
||||
if (!otherLast || rest.length === 0) return [...ordered, ...rest];
|
||||
return [...ordered.slice(0, -1), ...rest, ordered.at(-1)!];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every category of an overview, in display order, and whether the list
|
||||
* groups at all: it does when there are two categories or more and at least
|
||||
* one is not "other". One category, or only "other", stays a flat list.
|
||||
*
|
||||
* The default order is catalog panels in catalog order (`BIOMARKER_PANELS`),
|
||||
* then the person's own by label, then "other"; `categoryOrder`, the
|
||||
* person's own arrangement, is applied over it (`applyCategoryOrder`).
|
||||
*/
|
||||
export function categoriseOverview(
|
||||
markers: readonly CategorisableMarker[],
|
||||
locale: Locale,
|
||||
categoryOrder: readonly string[] = [],
|
||||
): { categories: LabOverviewCategory[]; grouped: boolean } {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const buckets = new Map<
|
||||
@@ -270,5 +303,5 @@ export function categoriseOverview(
|
||||
const grouped =
|
||||
categories.length >= 2 &&
|
||||
categories.some((category) => category.key !== OTHER_CATEGORY);
|
||||
return { categories, grouped };
|
||||
return { categories: applyCategoryOrder(categories, categoryOrder), grouped };
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
/**
|
||||
* Per-list presentation for the labs, illness and checkups lists: card or
|
||||
* list view, a manual order, and (labs only) the sort and the categories.
|
||||
* list view, a manual order, and (labs only) the sort, the categories and
|
||||
* their order.
|
||||
*
|
||||
* The choices live on the server (`GET/PUT /api/settings/module-list-layout`,
|
||||
* `User.moduleListLayoutJson`) so they follow a person to every device. Until
|
||||
@@ -198,6 +199,11 @@ type Translator = (
|
||||
* Apply one field optimistically (the control has to feel instant), then
|
||||
* persist it. On failure the cache rolls back and a toast says so; a view or
|
||||
* sort change confirms with a toast, an order change is its own feedback.
|
||||
*
|
||||
* The category order is applied by the server (`GET /api/labs/overview`
|
||||
* answers `categories` already arranged), so a change to it moves the
|
||||
* cached overview's categories to the full order the editor sent, at once,
|
||||
* and then refetches the overview, whose answer is the one that stays.
|
||||
*/
|
||||
export async function runSetModuleListPrefs(deps: {
|
||||
list: ModuleListKey;
|
||||
@@ -210,6 +216,25 @@ export async function runSetModuleListPrefs(deps: {
|
||||
if (queryClient.isFetching({ queryKey: key }) > 0) {
|
||||
await queryClient.cancelQueries({ queryKey: key });
|
||||
}
|
||||
const overviewKey = queryKeys.labResultsOverview();
|
||||
const categoryOrder = patch.categoryOrder;
|
||||
if (categoryOrder !== undefined) {
|
||||
await queryClient.cancelQueries({ queryKey: overviewKey });
|
||||
queryClient.setQueriesData<{ categories?: { key: string }[] }>(
|
||||
{ queryKey: overviewKey },
|
||||
(cached) =>
|
||||
cached?.categories
|
||||
? {
|
||||
...cached,
|
||||
categories: applyOrder(
|
||||
cached.categories,
|
||||
categoryOrder,
|
||||
(category) => category.key,
|
||||
),
|
||||
}
|
||||
: cached,
|
||||
);
|
||||
}
|
||||
const previous = queryClient.getQueryData<ModuleListLayout>(key);
|
||||
const base = previous ?? resolveModuleListLayout(null);
|
||||
queryClient.setQueryData<ModuleListLayout>(key, {
|
||||
@@ -237,6 +262,10 @@ export async function runSetModuleListPrefs(deps: {
|
||||
} catch (failure) {
|
||||
queryClient.setQueryData(key, previous);
|
||||
showErrorToast(t("settings.savingError"), { cause: failure });
|
||||
} finally {
|
||||
if (categoryOrder !== undefined) {
|
||||
void queryClient.invalidateQueries({ queryKey: overviewKey });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +280,7 @@ export function useModuleListPrefs(list: ModuleListKey): {
|
||||
setSortDir: (sortDir: ModuleSortDir) => void;
|
||||
setGroupBy: (groupBy: ModuleGroupBy) => void;
|
||||
setGroupsOpen: (groupsOpen: ModuleGroupsOpen) => void;
|
||||
setCategoryOrder: (categoryOrder: string[]) => void;
|
||||
} {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
@@ -278,6 +308,7 @@ export function useModuleListPrefs(list: ModuleListKey): {
|
||||
sortDir: entry.sortDir,
|
||||
groupBy: entry.groupBy,
|
||||
groupsOpen: entry.groupsOpen,
|
||||
categoryOrder: entry.categoryOrder,
|
||||
};
|
||||
}
|
||||
const defaults = moduleListDefaults(list);
|
||||
@@ -310,5 +341,18 @@ export function useModuleListPrefs(list: ModuleListKey): {
|
||||
[write],
|
||||
);
|
||||
|
||||
return { prefs, setView, setOrder, setSortDir, setGroupBy, setGroupsOpen };
|
||||
const setCategoryOrder = useCallback(
|
||||
(categoryOrder: string[]) => write({ categoryOrder }),
|
||||
[write],
|
||||
);
|
||||
|
||||
return {
|
||||
prefs,
|
||||
setView,
|
||||
setOrder,
|
||||
setSortDir,
|
||||
setGroupBy,
|
||||
setGroupsOpen,
|
||||
setCategoryOrder,
|
||||
};
|
||||
}
|
||||
@@ -171,7 +171,7 @@ export const labOverviewResponse = z
|
||||
markers: z.array(labOverviewMarker),
|
||||
categories: z.array(labOverviewCategory).meta({
|
||||
description:
|
||||
"The list's categories in display order: catalog panels in catalog order, then the person's own panels by label, then `other`.",
|
||||
"The list's categories in display order, already arranged for the caller: the default order is catalog panels in catalog order, then the person's own panels by label, then `other`, and the caller's `categoryOrder` (`PUT /api/settings/module-list-layout`, labs list) is applied over it. Render them in this order.",
|
||||
}),
|
||||
grouped: z.boolean().meta({
|
||||
description:
|
||||
|
||||
@@ -59,6 +59,11 @@ const moduleListPrefsEntry = z
|
||||
.describe(
|
||||
'Which labs categories start open: every one (`expanded`), only those whose `status` is `above` or `below` (`flagged`), or none (`collapsed`). Default "expanded". The other lists ignore it.',
|
||||
),
|
||||
categoryOrder: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
'The person\'s order of the labs categories, as `categories[].key` values of `GET /api/labs/overview`. The server applies it there, so a client renders `categories` as given and never sorts them itself. Keys missing from the live data are ignored; a category missing from the list follows the listed ones in the default order, and "other" left at the end of the list stays last. Default empty (the default order). The other lists ignore it.',
|
||||
),
|
||||
saved: z
|
||||
.boolean()
|
||||
.describe(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MODULE_LIST_CATEGORY_ORDER_MAX,
|
||||
MODULE_LIST_ORDER_MAX,
|
||||
normaliseModuleListOrder,
|
||||
resolveModuleListLayout,
|
||||
@@ -16,6 +17,7 @@ describe("resolveModuleListLayout", () => {
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
saved: false,
|
||||
});
|
||||
expect(layout.lists.illness.view).toBe("cards");
|
||||
@@ -34,6 +36,7 @@ describe("resolveModuleListLayout", () => {
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
saved: true,
|
||||
});
|
||||
expect(layout.lists.labs.saved).toBe(false);
|
||||
@@ -50,6 +53,7 @@ describe("resolveModuleListLayout", () => {
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
saved: false,
|
||||
});
|
||||
expect(layout.lists.vorsorge.saved).toBe(false);
|
||||
@@ -70,11 +74,49 @@ describe("category choices", () => {
|
||||
expect(layout.lists.illness).toMatchObject({
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
saved: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("category order", () => {
|
||||
it("keeps a stored category order, deduplicated, and marks the list saved", () => {
|
||||
const layout = resolveModuleListLayout({
|
||||
labs: { categoryOrder: ["thyroid", "lipids", "thyroid", 7, "other"] },
|
||||
});
|
||||
expect(layout.lists.labs).toMatchObject({
|
||||
categoryOrder: ["thyroid", "lipids", "other"],
|
||||
saved: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a malformed category order and leaves the default", () => {
|
||||
const layout = resolveModuleListLayout({
|
||||
labs: { categoryOrder: "lipids" },
|
||||
});
|
||||
expect(layout.lists.labs.categoryOrder).toEqual([]);
|
||||
expect(layout.lists.labs.saved).toBe(false);
|
||||
});
|
||||
|
||||
it("caps the category order on its own limit", () => {
|
||||
const keys = Array.from(
|
||||
{ length: MODULE_LIST_CATEGORY_ORDER_MAX + 5 },
|
||||
(_, i) => `custom:${i}`,
|
||||
);
|
||||
const layout = resolveModuleListLayout({ labs: { categoryOrder: keys } });
|
||||
expect(layout.lists.labs.categoryOrder).toHaveLength(
|
||||
MODULE_LIST_CATEGORY_ORDER_MAX,
|
||||
);
|
||||
});
|
||||
|
||||
it("hands every list its own copy of the default", () => {
|
||||
const layout = resolveModuleListLayout(null);
|
||||
layout.lists.labs.categoryOrder.push("lipids");
|
||||
expect(resolveModuleListLayout(null).lists.labs.categoryOrder).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normaliseModuleListOrder", () => {
|
||||
it("keeps strings, drops duplicates, empties and over-long ids", () => {
|
||||
expect(
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
* the list under its server-resolved categories (`"category"`) or flat
|
||||
* (`"none"`, the default); `groupsOpen` says which categories start open:
|
||||
* all of them, only those with a value outside its range, or none.
|
||||
* - `categoryOrder`: only the labs list reads it, and only on the server: a
|
||||
* category-key allow-list `GET /api/labs/overview` applies before it
|
||||
* answers `categories` (`applyCategoryOrder` in
|
||||
* `src/lib/labs/marker-category.ts`), so every client receives the
|
||||
* categories already in the person's order. Same tolerance as `order`.
|
||||
*
|
||||
* Null / missing column = the defaults below. The GET never lazy-writes, and
|
||||
* the PUT stores only the fields a person has actually chosen, so a default
|
||||
@@ -51,6 +56,11 @@ export type ModuleGroupsOpen = (typeof MODULE_GROUPS_OPEN)[number];
|
||||
export const MODULE_LIST_ORDER_MAX = 500;
|
||||
/** Longest id an order entry may carry (cuid and uuid fit comfortably). */
|
||||
export const MODULE_LIST_ID_MAX = 64;
|
||||
/**
|
||||
* Longest category order a list keeps. Ten catalog panels plus "other" plus
|
||||
* the person's own panels; a longer one is cut, never refused.
|
||||
*/
|
||||
export const MODULE_LIST_CATEGORY_ORDER_MAX = 200;
|
||||
|
||||
export interface ModuleListPrefs {
|
||||
view: ModuleListView;
|
||||
@@ -58,6 +68,7 @@ export interface ModuleListPrefs {
|
||||
sortDir: ModuleSortDir;
|
||||
groupBy: ModuleGroupBy;
|
||||
groupsOpen: ModuleGroupsOpen;
|
||||
categoryOrder: string[];
|
||||
}
|
||||
|
||||
export interface ModuleListPrefsEntry extends ModuleListPrefs {
|
||||
@@ -76,6 +87,7 @@ const BASE_DEFAULTS: ModuleListPrefs = {
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
};
|
||||
|
||||
/** Labs opens on the compact list, the dense reading table (#40). */
|
||||
@@ -87,7 +99,11 @@ const LIST_DEFAULTS: Record<ModuleListKey, ModuleListPrefs> = {
|
||||
|
||||
export function moduleListDefaults(list: ModuleListKey): ModuleListPrefs {
|
||||
const defaults = LIST_DEFAULTS[list];
|
||||
return { ...defaults, order: [...defaults.order] };
|
||||
return {
|
||||
...defaults,
|
||||
order: [...defaults.order],
|
||||
categoryOrder: [...defaults.categoryOrder],
|
||||
};
|
||||
}
|
||||
|
||||
export function isModuleListView(value: unknown): value is ModuleListView {
|
||||
@@ -119,7 +135,10 @@ export function isModuleGroupsOpen(value: unknown): value is ModuleGroupsOpen {
|
||||
}
|
||||
|
||||
/** Keep string ids of a sane length, first occurrence wins, capped. */
|
||||
export function normaliseModuleListOrder(raw: unknown): string[] | null {
|
||||
export function normaliseModuleListOrder(
|
||||
raw: unknown,
|
||||
max: number = MODULE_LIST_ORDER_MAX,
|
||||
): string[] | null {
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
@@ -129,7 +148,7 @@ export function normaliseModuleListOrder(raw: unknown): string[] | null {
|
||||
if (seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
out.push(value);
|
||||
if (out.length >= MODULE_LIST_ORDER_MAX) break;
|
||||
if (out.length >= max) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -148,8 +167,13 @@ export function storedModuleListChoices(
|
||||
sortDir?: unknown;
|
||||
groupBy?: unknown;
|
||||
groupsOpen?: unknown;
|
||||
categoryOrder?: unknown;
|
||||
};
|
||||
const order = normaliseModuleListOrder(blob.order);
|
||||
const categoryOrder = normaliseModuleListOrder(
|
||||
blob.categoryOrder,
|
||||
MODULE_LIST_CATEGORY_ORDER_MAX,
|
||||
);
|
||||
return {
|
||||
...(isModuleListView(blob.view) ? { view: blob.view } : {}),
|
||||
...(order !== null ? { order } : {}),
|
||||
@@ -158,6 +182,7 @@ export function storedModuleListChoices(
|
||||
...(isModuleGroupsOpen(blob.groupsOpen)
|
||||
? { groupsOpen: blob.groupsOpen }
|
||||
: {}),
|
||||
...(categoryOrder !== null ? { categoryOrder } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { z } from "zod/v4";
|
||||
import {
|
||||
MODULE_GROUPS_OPEN,
|
||||
MODULE_GROUP_BYS,
|
||||
MODULE_LIST_CATEGORY_ORDER_MAX,
|
||||
MODULE_LIST_ID_MAX,
|
||||
MODULE_LIST_KEYS,
|
||||
MODULE_LIST_ORDER_MAX,
|
||||
@@ -28,5 +29,9 @@ export const moduleListLayoutPutSchema = z
|
||||
sortDir: z.enum(MODULE_SORT_DIRS).optional(),
|
||||
groupBy: z.enum(MODULE_GROUP_BYS).optional(),
|
||||
groupsOpen: z.enum(MODULE_GROUPS_OPEN).optional(),
|
||||
categoryOrder: z
|
||||
.array(z.string().min(1).max(MODULE_LIST_ID_MAX))
|
||||
.max(MODULE_LIST_CATEGORY_ORDER_MAX)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -401,6 +401,53 @@ describe("GET /api/labs/overview categories", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers the categories in the caller's saved order", async () => {
|
||||
await seedCategories();
|
||||
cookieJar.set("healthlog-locale", "en");
|
||||
const { PUT } = await import("@/app/api/settings/module-list-layout/route");
|
||||
const saved = await PUT(
|
||||
new Request("http://localhost/api/settings/module-list-layout", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
// Lipids is left out (it follows the listed ones), "iron" has no
|
||||
// values here (ignored), and "other" sits first.
|
||||
body: JSON.stringify({
|
||||
version: 1,
|
||||
list: "labs",
|
||||
categoryOrder: ["other", "iron", "thyroid"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(saved.status).toBe(200);
|
||||
const { categories, grouped } = (
|
||||
(await (await getOverview()).json()) as CategoryBody
|
||||
).data;
|
||||
expect(grouped).toBe(true);
|
||||
expect(categories.map((c) => c.label)).toEqual([
|
||||
"Other",
|
||||
"Thyroid",
|
||||
"Lipids",
|
||||
"trace ELEMENTS",
|
||||
]);
|
||||
|
||||
// Left at the end, "other" stays last behind the categories not named.
|
||||
await PUT(
|
||||
new Request("http://localhost/api/settings/module-list-layout", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
version: 1,
|
||||
list: "labs",
|
||||
categoryOrder: ["thyroid", "other"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const again = (
|
||||
(await (await getOverview()).json()) as CategoryBody
|
||||
).data.categories.map((c) => c.label);
|
||||
expect(again).toEqual(["Thyroid", "Lipids", "trace ELEMENTS", "Other"]);
|
||||
});
|
||||
|
||||
it("stays flat when grouping adds nothing", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.labResult.create({
|
||||
|
||||
@@ -126,6 +126,7 @@ describe("/api/settings/module-list-layout — integration", () => {
|
||||
sortDir: "manual",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
saved: true,
|
||||
});
|
||||
expect(body.data.lists.illness).toMatchObject({
|
||||
@@ -190,6 +191,61 @@ describe("/api/settings/module-list-layout — integration", () => {
|
||||
expect(refused.status).toBe(422);
|
||||
});
|
||||
|
||||
it("stores the category order deduplicated and keeps it through other writes", async () => {
|
||||
const { userId } = await seedUser();
|
||||
const { GET, PUT } =
|
||||
await import("@/app/api/settings/module-list-layout/route");
|
||||
const put = await (PUT as Handler)(
|
||||
json("PUT", URL, {
|
||||
version: 1,
|
||||
list: "labs",
|
||||
categoryOrder: ["thyroid", "other", "thyroid", "lipids"],
|
||||
}),
|
||||
);
|
||||
expect(put.status).toBe(200);
|
||||
await (PUT as Handler)(
|
||||
json("PUT", URL, { version: 1, list: "labs", groupBy: "category" }),
|
||||
);
|
||||
const res = await (GET as Handler)(new Request(URL));
|
||||
const body = (await res.json()) as {
|
||||
data: { lists: Record<string, Record<string, unknown>> };
|
||||
};
|
||||
expect(body.data.lists.labs).toMatchObject({
|
||||
categoryOrder: ["thyroid", "other", "lipids"],
|
||||
groupBy: "category",
|
||||
saved: true,
|
||||
});
|
||||
const row = await getPrismaClient().user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { moduleListLayoutJson: true },
|
||||
});
|
||||
expect(row?.moduleListLayoutJson).toEqual({
|
||||
version: 1,
|
||||
labs: {
|
||||
categoryOrder: ["thyroid", "other", "lipids"],
|
||||
groupBy: "category",
|
||||
},
|
||||
});
|
||||
|
||||
// An empty order is a choice too: back to the default order.
|
||||
await (PUT as Handler)(
|
||||
json("PUT", URL, { version: 1, list: "labs", categoryOrder: [] }),
|
||||
);
|
||||
const reset = (await (await (GET as Handler)(new Request(URL))).json()) as {
|
||||
data: { lists: { labs: { categoryOrder: string[] } } };
|
||||
};
|
||||
expect(reset.data.lists.labs.categoryOrder).toEqual([]);
|
||||
|
||||
const refused = await (PUT as Handler)(
|
||||
json("PUT", URL, {
|
||||
version: 1,
|
||||
list: "labs",
|
||||
categoryOrder: ["x".repeat(65), ""],
|
||||
}),
|
||||
);
|
||||
expect(refused.status).toBe(422);
|
||||
});
|
||||
|
||||
it("refuses unknown lists, values and keys with every issue named", async () => {
|
||||
await seedUser();
|
||||
const { PUT } = await import("@/app/api/settings/module-list-layout/route");
|
||||
|
||||
Reference in new issue
Block a user