From 910f28dcfe6a68cb9f4c8381194d1340817564f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 6 Nov 2026 17:51:34 +0100 Subject: [PATCH] feat(labs): suggest panels in the marker form and let an edit clear fields The panel field offers the catalog panels in the page language and the person's own panels, which GET /api/biomarkers now lists as customPanels (one spelling per folded form). The field stays free text; a catalog panel typed in any language lands in the same category. An edit used to omit an emptied panel, context or bound, so the stored value survived the save. The form now sends null for a field that held a value and was emptied. A new marker still omits empty fields, and a field that was empty when the edit began stays out of the body, so a context the list could not decrypt is never wiped by an unrelated save. Refs #1349 --- docs/api/openapi.yaml | 8 +++ messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/nl.json | 1 + messages/pl.json | 1 + messages/pt-BR.json | 1 + messages/ru.json | 1 + .../baselines/source-function-lines.json | 4 +- src/app/api/biomarkers/route.ts | 4 ++ .../__tests__/biomarker-form-body.test.ts | 66 +++++++++++++++++++ .../labs/__tests__/biomarker-form.test.tsx | 32 ++++++++- .../labs/__tests__/lab-unit-input.test.tsx | 9 ++- .../__tests__/labs-settings-groups.test.tsx | 5 +- src/components/labs/biomarker-form-body.ts | 57 ++++++++++++++++ src/components/labs/biomarker-form.tsx | 27 ++------ src/components/labs/biomarker-panel-field.tsx | 65 ++++++++++++++++++ src/components/labs/types.ts | 5 ++ src/lib/openapi/routes/biomarkers.ts | 16 ++++- 22 files changed, 279 insertions(+), 29 deletions(-) create mode 100644 src/components/labs/__tests__/biomarker-form-body.test.ts create mode 100644 src/components/labs/biomarker-form-body.ts create mode 100644 src/components/labs/biomarker-panel-field.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 63591a0c4..615a13b0b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -77763,8 +77763,16 @@ components: type: array items: $ref: "#/components/schemas/Biomarker" + customPanels: + type: array + items: + type: string + description: The caller's panels that are no catalog panel in any language, one spelling per folded form (the most + frequent, ties to the alphabetically first), sorted. Suggestions for the panel field; catalog panels are not + repeated here. required: - biomarkers + - customPanels additionalProperties: false Biomarker: type: object diff --git a/messages/de.json b/messages/de.json index 62b9f18f8..ce094622f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10322,6 +10322,7 @@ "unitLockedHint": "Sobald dieser Marker Werte hat, bleibt seine Einheit, wie sie ist. Sonst würden die gespeicherten Werte in einer neuen Einheit angezeigt, ohne umgerechnet zu werden.", "panel": "Panel", "panelPlaceholder": "z. B. Lipide", + "panelHint": "Wähle einen Vorschlag oder gib ein eigenes ein. Panels aus dem Katalog werden in jeder Sprache erkannt.", "lowerBound": "Referenz unten", "upperBound": "Referenz oben", "lowerPlaceholder": "Untere Grenze", diff --git a/messages/en.json b/messages/en.json index d19458fd1..4691baf80 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10322,6 +10322,7 @@ "unitLockedHint": "Once this marker has readings, its unit stays as it is. The stored values would otherwise be shown in a new unit without being converted.", "panel": "Panel", "panelPlaceholder": "e.g. Lipids", + "panelHint": "Pick a suggestion or type your own. Catalog panels are recognised in every language.", "lowerBound": "Reference low", "upperBound": "Reference high", "lowerPlaceholder": "Lower bound", diff --git a/messages/es.json b/messages/es.json index bbafebc0f..3df9d94d7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10322,6 +10322,7 @@ "unitLockedHint": "Cuando este marcador ya tiene valores, su unidad no cambia. De lo contrario, los valores guardados se mostrarían en una unidad nueva sin convertirse.", "panel": "Panel", "panelPlaceholder": "p. ej. Lípidos", + "panelHint": "Elige una sugerencia o escribe la tuya. Los paneles del catálogo se reconocen en todos los idiomas.", "lowerBound": "Referencia mínima", "upperBound": "Referencia máxima", "lowerPlaceholder": "Límite inferior", diff --git a/messages/fr.json b/messages/fr.json index 723697b62..a61b5944f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10322,6 +10322,7 @@ "unitLockedHint": "Dès que ce marqueur a des valeurs, son unité reste la même. Sinon, les valeurs enregistrées s'afficheraient dans une nouvelle unité sans être converties.", "panel": "Panel", "panelPlaceholder": "ex. Lipides", + "panelHint": "Choisissez une suggestion ou saisissez la vôtre. Les panels du catalogue sont reconnus dans toutes les langues.", "lowerBound": "Référence basse", "upperBound": "Référence haute", "lowerPlaceholder": "Borne inférieure", diff --git a/messages/it.json b/messages/it.json index df8ff7e41..b1c7e66af 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10322,6 +10322,7 @@ "unitLockedHint": "Quando questo marcatore ha già dei valori, la sua unità resta invariata. Altrimenti i valori salvati verrebbero mostrati in una nuova unità senza essere convertiti.", "panel": "Panel", "panelPlaceholder": "es. Lipidi", + "panelHint": "Scegli un suggerimento o scrivine uno tuo. I panel del catalogo vengono riconosciuti in ogni lingua.", "lowerBound": "Riferimento minimo", "upperBound": "Riferimento massimo", "lowerPlaceholder": "Limite inferiore", diff --git a/messages/ko.json b/messages/ko.json index 5cbc33397..3834a66ac 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10322,6 +10322,7 @@ "unitLockedHint": "이 마커에 기록된 값이 있으면 단위는 그대로 유지돼요. 그렇지 않으면 저장된 값이 변환되지 않은 채 새 단위로 표시돼요.", "panel": "패널", "panelPlaceholder": "예: 지질", + "panelHint": "제안에서 고르거나 직접 입력하세요. 카탈로그 패널은 모든 언어에서 인식돼요.", "lowerBound": "참고 하한", "upperBound": "참고 상한", "lowerPlaceholder": "하한값", diff --git a/messages/nl.json b/messages/nl.json index d1d713304..c0c3eeda5 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -10322,6 +10322,7 @@ "unitLockedHint": "Zodra deze marker metingen heeft, blijft de eenheid zoals die is. Anders zouden de opgeslagen waarden zonder omrekening in een nieuwe eenheid worden getoond.", "panel": "Groep", "panelPlaceholder": "bijv. Lipiden", + "panelHint": "Kies een suggestie of typ je eigen groep. Groepen uit de catalogus worden in elke taal herkend.", "lowerBound": "Referentie ondergrens", "upperBound": "Referentie bovengrens", "lowerPlaceholder": "Ondergrens", diff --git a/messages/pl.json b/messages/pl.json index 377c44cb9..5d10dd0f0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10322,6 +10322,7 @@ "unitLockedHint": "Gdy ten marker ma już wyniki, jego jednostka pozostaje bez zmian. W przeciwnym razie zapisane wartości byłyby pokazywane w nowej jednostce bez przeliczenia.", "panel": "Panel", "panelPlaceholder": "np. Lipidy", + "panelHint": "Wybierz podpowiedź lub wpisz własny. Panele z katalogu są rozpoznawane w każdym języku.", "lowerBound": "Dolna granica", "upperBound": "Górna granica", "lowerPlaceholder": "Dolna granica", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index cb9f463a2..ddd915acd 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -10322,6 +10322,7 @@ "unitLockedHint": "Depois que este marcador tiver leituras, sua unidade permanece como está. Caso contrário, os valores armazenados seriam exibidos em uma nova unidade sem serem convertidos.", "panel": "Painel", "panelPlaceholder": "ex.: Lipídios", + "panelHint": "Escolha uma sugestão ou digite o seu. Os painéis do catálogo são reconhecidos em todos os idiomas.", "lowerBound": "Referência baixa", "upperBound": "Referência alta", "lowerPlaceholder": "Limite inferior", diff --git a/messages/ru.json b/messages/ru.json index b2bcb4b9a..7be6be626 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -10322,6 +10322,7 @@ "unitLockedHint": "Когда у показателя есть измерения, его единица остаётся прежней. Иначе сохранённые значения показывались бы в новой единице без пересчёта.", "panel": "Панель", "panelPlaceholder": "например Липиды", + "panelHint": "Выберите подсказку или введите свою. Панели из каталога распознаются на любом языке.", "lowerBound": "Нижняя граница", "upperBound": "Верхняя граница", "lowerPlaceholder": "Нижняя граница", diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index ad1e29f61..4fa80a682 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 344, - "total": 111568, + "total": 111553, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -130,7 +130,7 @@ "components/insights/sleep-stage-stacked-bar.tsx :: SleepStageStackedBar": 354, "components/insights/sub-page-shell.tsx :: SubPageShell": 266, "components/insights/trends-row.tsx :: TrendsRow": 215, - "components/labs/biomarker-form.tsx :: BiomarkerForm": 244, + "components/labs/biomarker-form.tsx :: BiomarkerForm": 229, "components/labs/biomarker-manager.tsx :: BiomarkerManager": 242, "components/labs/biomarker-merge-sheet.tsx :: BiomarkerMergeSheet": 250, "components/labs/lab-biomarker-chart.tsx :: LabBiomarkerChart": 370, diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index 3dca738f1..8b1a75562 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -15,6 +15,7 @@ import { aliasNamesByMarker } from "@/lib/labs/biomarker-aliases"; import { serialiseBiomarker } from "@/lib/labs/biomarker-dto"; import { encryptContextToBytes } from "@/lib/labs/biomarker-store"; import { matchAnalyteKey } from "@/lib/labs/analyte-match"; +import { customPanelSuggestions } from "@/lib/labs/marker-category"; import { annotate } from "@/lib/logging/context"; import { createBiomarkerSchema } from "@/lib/validations/biomarkers"; @@ -64,6 +65,9 @@ export const GET = apiHandler(async () => { biomarkers: rows.map((row) => serialiseBiomarker(row, aliases.get(row.id) ?? [], { soft: true }), ), + // The person's own panel spellings, for the panel suggestions in the + // marker form; catalog panels are offered there from the bundle. + customPanels: customPanelSuggestions(rows.map((row) => row.panel)), }); }); diff --git a/src/components/labs/__tests__/biomarker-form-body.test.ts b/src/components/labs/__tests__/biomarker-form-body.test.ts new file mode 100644 index 000000000..8b71aefad --- /dev/null +++ b/src/components/labs/__tests__/biomarker-form-body.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { buildBiomarkerBody } from "../biomarker-form-body"; + +const empty = { + name: " LDL ", + unit: "mg/dL", + lower: null, + upper: null, + panel: " ", + context: "", +}; + +describe("buildBiomarkerBody", () => { + it("omits every empty field on a new marker", () => { + expect(buildBiomarkerBody(empty, null)).toEqual({ + name: "LDL", + unit: "mg/dL", + }); + }); + + it("sends the filled fields trimmed", () => { + expect( + buildBiomarkerBody( + { ...empty, lower: 0, upper: 116, panel: " Lipids ", context: " x " }, + null, + ), + ).toEqual({ + name: "LDL", + unit: "mg/dL", + lowerBound: 0, + upperBound: 116, + panel: "Lipids", + context: "x", + }); + }); + + it("sends null for every field an edit emptied", () => { + expect( + buildBiomarkerBody(empty, { + lowerBound: 10, + upperBound: 0, + panel: "Lipids", + context: "note", + }), + ).toEqual({ + name: "LDL", + unit: "mg/dL", + lowerBound: null, + upperBound: null, + panel: null, + context: null, + }); + }); + + it("leaves a field out that was empty when the edit began", () => { + expect( + buildBiomarkerBody(empty, { + lowerBound: null, + upperBound: null, + panel: null, + context: null, + }), + ).toEqual({ name: "LDL", unit: "mg/dL" }); + }); +}); diff --git a/src/components/labs/__tests__/biomarker-form.test.tsx b/src/components/labs/__tests__/biomarker-form.test.tsx index 50333598e..1eb91a737 100644 --- a/src/components/labs/__tests__/biomarker-form.test.tsx +++ b/src/components/labs/__tests__/biomarker-form.test.tsx @@ -1,9 +1,11 @@ import { describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; import { ApiError } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; +import { queryKeys } from "@/lib/query-keys"; import en from "../../../../messages/en.json"; /** @@ -36,9 +38,18 @@ const MARKER: BiomarkerDto = { updatedAt: "2026-01-01T00:00:00.000Z", }; -function render(node: React.ReactNode) { +function render(node: React.ReactNode, customPanels: string[] = []) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + queryClient.setQueryData(queryKeys.biomarkers(), { + biomarkers: [], + customPanels, + }); return renderToStaticMarkup( - {node}, + + {node} + , ); } @@ -79,3 +90,20 @@ describe(" unit lock", () => { ); }); }); + +describe(" panel suggestions", () => { + it("suggests every catalog panel and the person's own panels", () => { + const html = render(, ["Annual check"]); + const list = html.match( + /]*data-slot="biomarker-panel-suggestions"[^>]*>(.*?)<\/datalist>/, + ); + expect(list).not.toBeNull(); + const values = [...list![1].matchAll(/value="([^"]*)"/g)].map((m) => m[1]); + expect(values).toContain("Lipids"); + expect(values).toContain("Blood count"); + expect(values).toContain("Annual check"); + expect(values).toHaveLength(11); + const id = /]*id="([^"]+)"/.exec(html)![1]; + expect(html).toContain(`list="${id}"`); + }); +}); diff --git a/src/components/labs/__tests__/lab-unit-input.test.tsx b/src/components/labs/__tests__/lab-unit-input.test.tsx index ebc06b31c..b54122518 100644 --- a/src/components/labs/__tests__/lab-unit-input.test.tsx +++ b/src/components/labs/__tests__/lab-unit-input.test.tsx @@ -1,4 +1,5 @@ import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { describe, expect, it, vi } from "vitest"; import { I18nProvider } from "@/lib/i18n/context"; @@ -22,8 +23,14 @@ import { OcrRowEditor } from "../ocr-row-editor"; */ function render(node: React.ReactNode): string { + // The marker form reads the biomarker list for its panel suggestions. + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, enabled: false } }, + }); return renderToStaticMarkup( - {node}, + + {node} + , ); } diff --git a/src/components/labs/__tests__/labs-settings-groups.test.tsx b/src/components/labs/__tests__/labs-settings-groups.test.tsx index b578d0793..b08f243e8 100644 --- a/src/components/labs/__tests__/labs-settings-groups.test.tsx +++ b/src/components/labs/__tests__/labs-settings-groups.test.tsx @@ -24,7 +24,10 @@ function render(prefs: Partial) { queryKeys.moduleListLayout(), resolveModuleListLayout({ version: 1, labs: prefs }), ); - queryClient.setQueryData(queryKeys.biomarkers(), { biomarkers: [] }); + queryClient.setQueryData(queryKeys.biomarkers(), { + biomarkers: [], + customPanels: [], + }); return renderToStaticMarkup( diff --git a/src/components/labs/biomarker-form-body.ts b/src/components/labs/biomarker-form-body.ts new file mode 100644 index 000000000..fa3203921 --- /dev/null +++ b/src/components/labs/biomarker-form-body.ts @@ -0,0 +1,57 @@ +/** + * The request body the marker form sends. + * + * A new marker omits every empty field: the create schema has no "clear", + * and an omitted field simply stays unset. An edit omits a field that was + * empty and still is, so it stays as stored, and sends `null` for a field the + * person emptied: panel, context or a bound that held a value and now holds + * none is cleared, which an omitted key never did. Only a field that held a + * value when the form opened can be emptied, so a context the list could not + * decrypt (shown empty) is never wiped by saving another field. + */ + +export interface BiomarkerFormFields { + name: string; + unit: string; + lower: number | null; + upper: number | null; + panel: string; + context: string; +} + +export interface BiomarkerFormInitial { + lowerBound: number | null; + upperBound: number | null; + panel: string | null; + context: string | null; +} + +export type BiomarkerBody = { + name: string; + unit: string; + lowerBound?: number | null; + upperBound?: number | null; + panel?: string | null; + context?: string | null; +}; + +export function buildBiomarkerBody( + fields: BiomarkerFormFields, + initial: BiomarkerFormInitial | null, +): BiomarkerBody { + const panel = fields.panel.trim(); + const context = fields.context.trim(); + const body: BiomarkerBody = { + name: fields.name.trim(), + unit: fields.unit.trim(), + }; + if (fields.lower !== null) body.lowerBound = fields.lower; + else if (initial?.lowerBound != null) body.lowerBound = null; + if (fields.upper !== null) body.upperBound = fields.upper; + else if (initial?.upperBound != null) body.upperBound = null; + if (panel) body.panel = panel; + else if (initial?.panel) body.panel = null; + if (context) body.context = context; + else if (initial?.context) body.context = null; + return body; +} diff --git a/src/components/labs/biomarker-form.tsx b/src/components/labs/biomarker-form.tsx index 168e68bad..7a7d07c8e 100644 --- a/src/components/labs/biomarker-form.tsx +++ b/src/components/labs/biomarker-form.tsx @@ -26,6 +26,8 @@ import { } from "@/lib/labs/biomarker-catalog"; import { useTranslations } from "@/lib/i18n/context"; +import { buildBiomarkerBody } from "./biomarker-form-body"; +import { BiomarkerPanelField } from "./biomarker-panel-field"; import { LabUnitInput } from "./lab-unit-input"; import type { BiomarkerDto } from "./types"; @@ -113,14 +115,10 @@ export function BiomarkerForm({ return; } - const body = { - name: name.trim(), - unit: unit.trim(), - ...(low !== null ? { lowerBound: low } : {}), - ...(high !== null ? { upperBound: high } : {}), - ...(panel.trim() ? { panel: panel.trim() } : {}), - ...(context.trim() ? { context: context.trim() } : {}), - }; + const body = buildBiomarkerBody( + { name, unit, lower: low, upper: high, panel, context }, + existing ?? null, + ); setSubmitting(true); try { @@ -238,18 +236,7 @@ export function BiomarkerForm({

) : null} -
- - setPanel(e.target.value)} - placeholder={t("labs.biomarker.form.panelPlaceholder")} - maxLength={120} - /> -
+