feat: add medication enrichment lookup to the medication editor

* feat: add medication enrichment lookup

* fix: avoid double unescape in enrichment sanitization

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Volz
2026-03-20 20:39:38 +01:00
committed by GitHub
parent e1b47e82b2
commit b796e03bcb
16 changed files with 3510 additions and 2 deletions
@@ -0,0 +1,254 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
MedicationEnrichmentSection,
type MedicationEnrichmentViewModel,
} from "../../components/MedicationEnrichmentSection";
import type { MedicationEnrichmentSearchResult, MedicationEnrichmentStrengthOption } from "../../types";
function createResult(overrides: Partial<MedicationEnrichmentSearchResult> = {}): MedicationEnrichmentSearchResult {
return {
code: "EMA-ASPIRIN",
name: "Aspirin 500 mg tablets",
genericName: "Acetylsalicylic acid",
authorisationHolder: "Bayer",
therapeuticArea: "Pain",
matchType: "brand",
genericStatus: "original",
authorisationDate: "2024-02-01",
source: "ema",
...overrides,
};
}
function createStrengthOption(
overrides: Partial<MedicationEnrichmentStrengthOption> = {}
): MedicationEnrichmentStrengthOption {
return {
label: "500 mg",
pillWeightMg: 500,
doseUnit: "mg",
...overrides,
};
}
function createState(overrides: Partial<MedicationEnrichmentViewModel> = {}): MedicationEnrichmentViewModel {
return {
query: "",
results: [],
isSearching: false,
hasSearched: false,
searchError: null,
applyingCode: null,
activeResultCode: null,
appliedSelection: null,
enrichError: null,
meta: null,
strengthOptions: [],
appliedStrengthLabel: null,
...overrides,
};
}
describe("MedicationEnrichmentSection", () => {
it("starts collapsed so the lookup stays optional by default", () => {
render(
<MedicationEnrichmentSection
state={createState()}
onQueryChange={vi.fn()}
onSearch={vi.fn()}
onApplyResult={vi.fn()}
onApplyStrength={vi.fn()}
/>
);
expect(screen.getByText("form.enrichment.title")).toBeInTheDocument();
expect(screen.getByText("form.enrichment.collapsedHint")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "form.enrichment.toggleShow" })).toBeInTheDocument();
expect(screen.queryByPlaceholderText("form.enrichment.searchPlaceholder")).not.toBeInTheDocument();
});
it("supports explicit show and hide toggles for the lookup and source guidance", () => {
render(
<MedicationEnrichmentSection
state={createState()}
onQueryChange={vi.fn()}
onSearch={vi.fn()}
onApplyResult={vi.fn()}
onApplyStrength={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.toggleShow" }));
expect(screen.getByPlaceholderText("form.enrichment.searchPlaceholder")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "form.enrichment.toggleHide" })).toBeInTheDocument();
expect(screen.queryByText("form.enrichment.infoTitle")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.infoShow" }));
expect(screen.getByText("form.enrichment.infoTitle")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "form.enrichment.infoHide" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.infoHide" }));
expect(screen.queryByText("form.enrichment.infoTitle")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "form.enrichment.infoShow" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.toggleHide" }));
expect(screen.queryByPlaceholderText("form.enrichment.searchPlaceholder")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "form.enrichment.toggleShow" })).toBeInTheDocument();
});
it("reveals guidance only when requested and wires search/apply actions", () => {
const onQueryChange = vi.fn();
const onSearch = vi.fn();
const onApplyResult = vi.fn();
const result = createResult();
render(
<MedicationEnrichmentSection
state={createState({ query: "Aspirin", results: [result] })}
onQueryChange={onQueryChange}
onSearch={onSearch}
onApplyResult={onApplyResult}
onApplyStrength={vi.fn()}
/>
);
expect(screen.queryByText("form.enrichment.details.authorisationHolder")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.infoShow" }));
expect(screen.getByText("form.enrichment.infoTitle")).toBeInTheDocument();
expect(screen.getByText("form.enrichment.description")).toBeInTheDocument();
expect(screen.getByText("form.enrichment.manualEntryHint")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("form.enrichment.searchPlaceholder"), {
target: { value: "Ibuprofen" },
});
fireEvent.keyDown(screen.getByPlaceholderText("form.enrichment.searchPlaceholder"), { key: "Enter" });
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.details.showAction" }));
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.applyAction" }));
expect(onQueryChange).toHaveBeenCalledWith("Ibuprofen");
expect(onSearch).toHaveBeenCalledTimes(1);
expect(onApplyResult).toHaveBeenCalledWith(result);
expect(screen.getByText("form.enrichment.details.authorisationHolder")).toBeInTheDocument();
expect(screen.getByText("form.enrichment.details.therapeuticArea")).toBeInTheDocument();
expect(screen.getByText("form.enrichment.genericStatus.original")).toBeInTheDocument();
});
it("labels RxNorm and openFDA results with their source badges", () => {
render(
<MedicationEnrichmentSection
state={createState({
query: "Semaglutide",
results: [
createResult({
code: "RX-123",
name: "Wegovy",
genericName: "Semaglutide",
source: "rxnorm",
}),
createResult({
code: "NDC-123",
name: "Ozempic",
genericName: "Semaglutide",
source: "openfda",
}),
],
})}
onQueryChange={vi.fn()}
onSearch={vi.fn()}
onApplyResult={vi.fn()}
onApplyStrength={vi.fn()}
/>
);
expect(screen.getByText("form.enrichment.sources.rxnorm")).toBeInTheDocument();
const openFdaBadge = screen.getByText("form.enrichment.sources.openfda");
expect(openFdaBadge).toBeInTheDocument();
expect(openFdaBadge).toHaveClass("warning");
expect(screen.queryByText("form.enrichment.genericStatus.unknown")).not.toBeInTheDocument();
});
it("shows a load-more action when the backend reports more results", () => {
const onLoadMoreResults = vi.fn();
render(
<MedicationEnrichmentSection
state={createState({
query: "Aspirin",
results: [createResult({ source: "rxnorm", code: "RX-123", name: "Aspirin" })],
hasMoreResults: true,
})}
onQueryChange={vi.fn()}
onSearch={vi.fn()}
onLoadMoreResults={onLoadMoreResults}
onApplyResult={vi.fn()}
onApplyStrength={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.showMoreAction" }));
expect(onLoadMoreResults).toHaveBeenCalledTimes(1);
});
it("can expand automatically when follow-up feedback exists", () => {
render(
<MedicationEnrichmentSection
state={createState({
hasSearched: true,
searchError: "Lookup unavailable",
})}
onQueryChange={vi.fn()}
onSearch={vi.fn()}
onApplyResult={vi.fn()}
onApplyStrength={vi.fn()}
/>
);
expect(screen.getByPlaceholderText("form.enrichment.searchPlaceholder")).toBeInTheDocument();
expect(screen.getByText("Lookup unavailable")).toBeInTheDocument();
});
it("shows partial coverage feedback and optional strength suggestions", () => {
const onApplyStrength = vi.fn();
const strengthOption = createStrengthOption();
render(
<MedicationEnrichmentSection
state={createState({
hasSearched: true,
appliedSelection: {
name: "Aspirin 500 mg tablets",
genericName: "Acetylsalicylic acid",
therapeuticArea: "Pain",
indication: "Pain relief",
atcCode: "N02BA01",
source: "ema",
},
meta: {
rxNormMatched: false,
openFdaMatched: false,
partial: true,
note: "Returned EMA enrichment without RxNorm suggestions.",
},
strengthOptions: [strengthOption],
appliedStrengthLabel: "500 mg",
})}
onQueryChange={vi.fn()}
onSearch={vi.fn()}
onApplyResult={vi.fn()}
onApplyStrength={onApplyStrength}
/>
);
expect(screen.getByText("form.enrichment.partialNote")).toBeInTheDocument();
expect(screen.getByText("form.enrichment.applied")).toBeInTheDocument();
expect(screen.getByText("form.enrichment.strengthTitle")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "500 mg" }));
expect(onApplyStrength).toHaveBeenCalledWith(strengthOption);
expect(screen.getByText("form.enrichment.appliedStrength")).toBeInTheDocument();
});
});
@@ -1,6 +1,7 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { FormEvent } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MedicationEnrichmentViewModel } from "../../components/MedicationEnrichmentSection";
import { MobileEditModal } from "../../components/MobileEditModal";
import type { FormState, WeekdayCode } from "../../types";
@@ -92,6 +93,26 @@ const defaultProps = {
onSaveMedication: vi.fn(),
};
function createMedicationEnrichmentState(
overrides: Partial<MedicationEnrichmentViewModel> = {}
): MedicationEnrichmentViewModel {
return {
query: "",
results: [],
isSearching: false,
hasSearched: false,
searchError: null,
applyingCode: null,
activeResultCode: null,
appliedSelection: null,
enrichError: null,
meta: null,
strengthOptions: [],
appliedStrengthLabel: null,
...overrides,
};
}
describe("MobileEditModal", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -161,6 +182,64 @@ describe("MobileEditModal", () => {
expect(screen.getByText(/form\.genericName/i)).toBeInTheDocument();
});
it("renders the shared medication enrichment section after generic name", () => {
render(<MobileEditModal {...defaultProps} />);
const genericNameLabel = screen.getByText("form.genericName");
const enrichmentTitle = screen.getByText("form.enrichment.title");
expect(genericNameLabel.compareDocumentPosition(enrichmentTitle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getByText("form.enrichment.collapsedHint")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "form.enrichment.toggleShow" })).toBeInTheDocument();
});
it("wires medication enrichment search and apply actions inside the mobile editor", () => {
const onMedicationEnrichmentQueryChange = vi.fn();
const onMedicationEnrichmentSearch = vi.fn();
const onMedicationEnrichmentApply = vi.fn();
const onMedicationEnrichmentStrengthApply = vi.fn();
const result = {
code: "RX-123",
name: "Wegovy",
genericName: "Semaglutide",
authorisationHolder: null,
therapeuticArea: null,
matchType: "brand" as const,
genericStatus: "unknown" as const,
authorisationDate: null,
source: "rxnorm" as const,
};
const strengthOption = { label: "0.25 mg", pillWeightMg: 0.25, doseUnit: "mg" as const };
render(
<MobileEditModal
{...defaultProps}
medicationEnrichment={createMedicationEnrichmentState({
query: "Wegovy",
results: [result],
strengthOptions: [strengthOption],
})}
onMedicationEnrichmentQueryChange={onMedicationEnrichmentQueryChange}
onMedicationEnrichmentSearch={onMedicationEnrichmentSearch}
onMedicationEnrichmentApply={onMedicationEnrichmentApply}
onMedicationEnrichmentStrengthApply={onMedicationEnrichmentStrengthApply}
/>
);
expect(screen.getByRole("button", { name: "form.enrichment.toggleHide" })).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("form.enrichment.searchPlaceholder"), {
target: { value: "Ozempic" },
});
fireEvent.keyDown(screen.getByPlaceholderText("form.enrichment.searchPlaceholder"), { key: "Enter" });
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.applyAction" }));
fireEvent.click(screen.getByRole("button", { name: "0.25 mg" }));
expect(onMedicationEnrichmentQueryChange).toHaveBeenCalledWith("Ozempic");
expect(onMedicationEnrichmentSearch).toHaveBeenCalledTimes(1);
expect(onMedicationEnrichmentApply).toHaveBeenCalledWith(result);
expect(onMedicationEnrichmentStrengthApply).toHaveBeenCalledWith(strengthOption);
});
it("groups medication start and end date fields in one stacked date pair", () => {
render(<MobileEditModal {...defaultProps} />);
@@ -492,4 +492,158 @@ describe("MedicationsPage form interactions", () => {
expect(resetForm).toHaveBeenCalledTimes(1);
expect(pushStateSpy).toHaveBeenCalledWith({ modal: "edit" }, "");
});
it("renders the shared medication enrichment section after generic name on desktop", () => {
renderPage();
openNewMedicationForm();
const genericNameLabel = screen.getByText("form.genericName");
const enrichmentTitle = screen.getByText("form.enrichment.title");
expect(genericNameLabel.compareDocumentPosition(enrichmentTitle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getByText("form.enrichment.collapsedHint")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "form.enrichment.toggleShow" })).toBeInTheDocument();
});
it("searches and applies medication enrichment suggestions through the desktop form", async () => {
const setForm = vi.fn();
mockFormHookValue = createMockFormHook({ setForm });
fetchMock.mockImplementation((url: string) => {
if (url.startsWith("/api/medication-enrichment/search?")) {
return Promise.resolve({
ok: true,
json: async () => ({
query: "Aspirin",
normalizedQuery: "aspirin",
hasMore: url.includes("limit=6"),
results: [
{
code: "RX-ASPIRIN",
name: "Aspirin",
genericName: "Acetylsalicylic acid",
authorisationHolder: null,
therapeuticArea: null,
matchType: "ingredient",
genericStatus: "unknown",
authorisationDate: null,
source: "rxnorm",
},
{
code: "EMA-ASPIRIN",
name: "Aspirin 500 mg tablets",
genericName: "Acetylsalicylic acid",
authorisationHolder: "Bayer",
therapeuticArea: "Pain",
matchType: "brand",
genericStatus: "original",
authorisationDate: "2024-02-01",
source: "ema",
},
...(url.includes("limit=12")
? [
{
code: "NDC-ASPIRIN",
name: "Bayer Aspirin",
genericName: "Acetylsalicylic acid",
authorisationHolder: null,
therapeuticArea: null,
matchType: "brand",
genericStatus: "unknown",
authorisationDate: null,
source: "openfda",
},
]
: []),
],
}),
});
}
if (url === "/api/medication-enrichment/enrich") {
return Promise.resolve({
ok: true,
json: async () => ({
selection: {
name: "Aspirin",
genericName: "Acetylsalicylic acid",
therapeuticArea: "Pain",
indication: "Pain relief",
atcCode: "N02BA01",
source: "rxnorm",
},
suggestions: {
name: "Aspirin",
genericName: "Acetylsalicylic acid",
medicationForm: "tablet",
strengthOptions: [{ label: "500 mg", pillWeightMg: 500, doseUnit: "mg" }],
},
meta: {
rxNormMatched: true,
openFdaMatched: false,
partial: false,
note: null,
},
}),
});
}
return Promise.resolve({ ok: true, json: async () => [] });
});
renderPage();
openNewMedicationForm();
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.toggleShow" }));
fireEvent.change(screen.getByPlaceholderText("form.enrichment.searchPlaceholder"), {
target: { value: " Aspirin " },
});
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.searchAction" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=6", {
credentials: "include",
});
});
await screen.findByText("Aspirin 500 mg tablets");
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.showMoreAction" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=12", {
credentials: "include",
});
});
await screen.findByText("Bayer Aspirin");
expect(screen.queryByRole("button", { name: "form.enrichment.showMoreAction" })).not.toBeInTheDocument();
fireEvent.click(screen.getAllByRole("button", { name: "form.enrichment.applyAction" })[0]);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: "Aspirin",
name: "Aspirin",
genericName: "Acetylsalicylic acid",
code: "RX-ASPIRIN",
source: "rxnorm",
}),
credentials: "include",
});
expect(setForm).toHaveBeenCalledWith(
expect.objectContaining({
name: "Aspirin",
genericName: "Acetylsalicylic acid",
medicationForm: "tablet",
pillForm: "tablet",
pillWeightMg: "500",
doseUnit: "mg",
})
);
});
expect(screen.getAllByText("form.enrichment.applied").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("form.enrichment.appliedStrength")).toBeInTheDocument();
});
});