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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user