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,283 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type {
MedicationEnrichmentEnrichResponse,
MedicationEnrichmentSearchResult,
MedicationEnrichmentStrengthOption,
} from "../types";
import { formatDate } from "../utils/formatters";
export interface MedicationEnrichmentViewModel {
query: string;
results: MedicationEnrichmentSearchResult[];
hasMoreResults?: boolean;
isSearching: boolean;
hasSearched: boolean;
searchError: string | null;
applyingCode: string | null;
activeResultCode: string | null;
appliedSelection: MedicationEnrichmentEnrichResponse["selection"] | null;
enrichError: string | null;
meta: MedicationEnrichmentEnrichResponse["meta"] | null;
strengthOptions: MedicationEnrichmentStrengthOption[];
appliedStrengthLabel: string | null;
}
export interface MedicationEnrichmentSectionProps {
state: MedicationEnrichmentViewModel;
onQueryChange: (value: string) => void;
onSearch: () => void;
onLoadMoreResults?: () => void;
onApplyResult: (result: MedicationEnrichmentSearchResult) => void;
onApplyStrength: (option: MedicationEnrichmentStrengthOption) => void;
}
export function MedicationEnrichmentSection({
state,
onQueryChange,
onSearch,
onLoadMoreResults,
onApplyResult,
onApplyStrength,
}: MedicationEnrichmentSectionProps) {
const { t } = useTranslation();
const canSearch = state.query.trim().length > 0 && !state.isSearching && !state.applyingCode;
const shouldAutoExpand =
state.isSearching ||
state.hasSearched ||
state.searchError !== null ||
state.enrichError !== null ||
state.results.length > 0 ||
state.appliedSelection !== null ||
state.strengthOptions.length > 0 ||
state.appliedStrengthLabel !== null ||
Boolean(state.meta?.partial);
const [isExpanded, setIsExpanded] = useState(shouldAutoExpand);
const [showInfo, setShowInfo] = useState(false);
const [expandedResultCode, setExpandedResultCode] = useState<string | null>(null);
const autoExpandStateRef = useRef(shouldAutoExpand);
useEffect(() => {
if (shouldAutoExpand && !autoExpandStateRef.current) {
setIsExpanded(true);
}
autoExpandStateRef.current = shouldAutoExpand;
}, [shouldAutoExpand]);
return (
<div className="full medication-enrichment-section">
<div className="medication-enrichment-header">
<div>
<h5 className="form-category-title medication-enrichment-title">{t("form.enrichment.title")}</h5>
<p className="sub medication-enrichment-collapsed-hint">{t("form.enrichment.collapsedHint")}</p>
</div>
<button
type="button"
className="secondary small"
aria-expanded={isExpanded}
onClick={() => setIsExpanded((current) => !current)}
>
{isExpanded ? t("form.enrichment.toggleHide") : t("form.enrichment.toggleShow")}
</button>
</div>
{isExpanded ? (
<div className="medication-enrichment-body">
<div className="medication-enrichment-helper-row">
<span className="status-chip small warning">{t("form.enrichment.coverageLabel")}</span>
<button
type="button"
className="ghost small"
aria-expanded={showInfo}
onClick={() => setShowInfo((current) => !current)}
>
{showInfo ? t("form.enrichment.infoHide") : t("form.enrichment.infoShow")}
</button>
</div>
{showInfo ? (
<div className="medication-enrichment-info">
<p className="medication-enrichment-info-title">{t("form.enrichment.infoTitle")}</p>
<p className="sub medication-enrichment-description">{t("form.enrichment.description")}</p>
<p className="sub medication-enrichment-manual-hint">{t("form.enrichment.manualEntryHint")}</p>
</div>
) : null}
<label className="full">
{t("form.enrichment.searchLabel")}
<div className="medication-enrichment-search-row">
<input
type="search"
value={state.query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
if (!canSearch) return;
onSearch();
}}
placeholder={t("form.enrichment.searchPlaceholder")}
/>
<button type="button" className="secondary small" onClick={onSearch} disabled={!canSearch}>
{state.isSearching ? t("form.enrichment.searching") : t("form.enrichment.searchAction")}
</button>
</div>
</label>
{state.searchError ? <p className="danger-text">{state.searchError}</p> : null}
{state.enrichError ? <p className="danger-text">{state.enrichError}</p> : null}
{state.meta?.partial ? <p className="info-text">{t("form.enrichment.partialNote")}</p> : null}
{state.hasSearched && !state.isSearching && state.results.length === 0 ? (
<p className="info-text">{t("form.enrichment.noResults")}</p>
) : null}
{state.results.length > 0 ? (
<div className="medication-enrichment-results">
{state.results.map((result) => {
const isActive = state.activeResultCode === result.code;
const hasDetails = Boolean(
result.authorisationHolder || result.therapeuticArea || result.authorisationDate
);
const isDetailsExpanded = expandedResultCode === result.code;
const genericStatusClass = result.genericStatus === "generic" ? "success" : "neutral";
const sourceClass = result.source === "openfda" ? "warning" : "neutral";
let applyLabel = t("form.enrichment.applyAction");
if (state.applyingCode === result.code) {
applyLabel = t("form.enrichment.applying");
} else if (isActive && state.appliedSelection) {
applyLabel = t("form.enrichment.applied");
}
return (
<article key={result.code} className={`medication-enrichment-result${isActive ? " active" : ""}`}>
<div className="medication-enrichment-result-header">
<div className="medication-enrichment-result-names">
<strong>{result.name}</strong>
{result.genericName ? (
<span className="medication-enrichment-result-generic">{result.genericName}</span>
) : null}
</div>
<div className="medication-enrichment-result-actions">
<span className={`pill ${sourceClass}`}>{t(`form.enrichment.sources.${result.source}`)}</span>
{result.source === "ema" ? (
<span className={`pill ${genericStatusClass}`}>
{t(`form.enrichment.genericStatus.${result.genericStatus}`)}
</span>
) : null}
{hasDetails ? (
<button
type="button"
className="ghost small"
aria-expanded={isDetailsExpanded}
onClick={() =>
setExpandedResultCode((current) => (current === result.code ? null : result.code))
}
>
{isDetailsExpanded
? t("form.enrichment.details.hideAction")
: t("form.enrichment.details.showAction")}
</button>
) : null}
<button
type="button"
className={isActive ? "secondary small" : "primary small"}
onClick={() => {
setExpandedResultCode(result.code);
onApplyResult(result);
}}
disabled={state.applyingCode === result.code}
>
{applyLabel}
</button>
</div>
</div>
{hasDetails && isDetailsExpanded ? (
<dl className="medication-enrichment-result-meta">
{result.authorisationHolder ? (
<div>
<dt>{t("form.enrichment.details.authorisationHolder")}</dt>
<dd>{result.authorisationHolder}</dd>
</div>
) : null}
{result.therapeuticArea ? (
<div>
<dt>{t("form.enrichment.details.therapeuticArea")}</dt>
<dd>{result.therapeuticArea}</dd>
</div>
) : null}
{result.authorisationDate ? (
<div>
<dt>{t("form.enrichment.details.authorisationDate")}</dt>
<dd>{formatDate(result.authorisationDate)}</dd>
</div>
) : null}
</dl>
) : null}
</article>
);
})}
</div>
) : null}
{state.results.length > 0 && state.hasMoreResults && onLoadMoreResults ? (
<div className="medication-enrichment-results-footer">
<button
type="button"
className="secondary small"
onClick={onLoadMoreResults}
disabled={state.isSearching || Boolean(state.applyingCode)}
>
{t("form.enrichment.showMoreAction")}
</button>
</div>
) : null}
{state.appliedSelection || state.strengthOptions.length > 0 || state.appliedStrengthLabel ? (
<div className="medication-enrichment-followup">
{state.appliedSelection ? (
<div>
<p className="success-text">{t("form.enrichment.applied")}</p>
<p className="sub medication-enrichment-selection-summary">
<strong>{state.appliedSelection.name}</strong>
{state.appliedSelection.genericName ? `${state.appliedSelection.genericName}` : ""}
</p>
</div>
) : null}
{state.strengthOptions.length > 0 ? (
<div className="medication-enrichment-strengths">
<p className="medication-enrichment-strength-title">{t("form.enrichment.strengthTitle")}</p>
<p className="sub">{t("form.enrichment.strengthHint")}</p>
<div className="medication-enrichment-strength-list">
{state.strengthOptions.map((option) => {
const isSelected = state.appliedStrengthLabel === option.label;
return (
<button
key={option.label}
type="button"
className={isSelected ? "primary small" : "secondary small"}
onClick={() => onApplyStrength(option)}
>
{option.label}
</button>
);
})}
</div>
</div>
) : null}
{state.appliedStrengthLabel ? (
<p className="success-text">
{t("form.enrichment.appliedStrength", { label: state.appliedStrengthLabel })}
</p>
) : null}
</div>
) : null}
</div>
) : null}
</div>
);
}
+48 -1
View File
@@ -9,7 +9,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useEscapeKey } from "../hooks/useEscapeKey";
import { useScrollLock } from "../hooks/useScrollLock";
import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
import type {
DoseUnit,
FieldErrors,
FormBlister,
FormIntake,
FormState,
Medication,
MedicationEnrichmentSearchResult,
MedicationEnrichmentStrengthOption,
} from "../types";
import {
allowsPillFormSelection,
DOSE_UNITS,
@@ -28,6 +37,8 @@ import {
} from "../utils/intake-schedule";
import { DateInput } from "./DateInput";
import { FormNumberStepper } from "./FormNumberStepper";
import type { MedicationEnrichmentViewModel } from "./MedicationEnrichmentSection";
import { MedicationEnrichmentSection } from "./MedicationEnrichmentSection";
// Field limits for validation
const FIELD_LIMITS = {
@@ -40,11 +51,33 @@ const FIELD_LIMITS = {
const MOBILE_TAB_ORDER = ["general", "stock", "schedule", "prescription"] as const;
type MobileTab = (typeof MOBILE_TAB_ORDER)[number];
const EMPTY_MEDICATION_ENRICHMENT: MedicationEnrichmentViewModel = {
query: "",
results: [],
hasMoreResults: false,
isSearching: false,
hasSearched: false,
searchError: null,
applyingCode: null,
activeResultCode: null,
appliedSelection: null,
enrichError: null,
meta: null,
strengthOptions: [],
appliedStrengthLabel: null,
};
export interface MobileEditModalProps {
show: boolean;
editingId: number | null;
form: FormState;
onFormChange: (form: FormState) => void;
medicationEnrichment?: MedicationEnrichmentViewModel;
onMedicationEnrichmentQueryChange?: (value: string) => void;
onMedicationEnrichmentSearch?: () => void;
onMedicationEnrichmentLoadMore?: () => void;
onMedicationEnrichmentApply?: (result: MedicationEnrichmentSearchResult) => void;
onMedicationEnrichmentStrengthApply?: (option: MedicationEnrichmentStrengthOption) => void;
fieldErrors: FieldErrors;
saving: boolean;
formSaved: boolean;
@@ -97,6 +130,12 @@ export function MobileEditModal({
editingId,
form,
onFormChange,
medicationEnrichment = EMPTY_MEDICATION_ENRICHMENT,
onMedicationEnrichmentQueryChange = () => {},
onMedicationEnrichmentSearch = () => {},
onMedicationEnrichmentLoadMore = () => {},
onMedicationEnrichmentApply = () => {},
onMedicationEnrichmentStrengthApply = () => {},
fieldErrors,
saving,
formSaved,
@@ -446,6 +485,14 @@ export function MobileEditModal({
<span className="field-error">{fieldErrors.genericName}</span>
)}
</label>
<MedicationEnrichmentSection
state={medicationEnrichment}
onQueryChange={onMedicationEnrichmentQueryChange}
onSearch={onMedicationEnrichmentSearch}
onLoadMoreResults={onMedicationEnrichmentLoadMore}
onApplyResult={onMedicationEnrichmentApply}
onApplyStrength={onMedicationEnrichmentStrengthApply}
/>
<div className="full date-pair-group">
<label className="date-pair-field">
{t("form.medicationStartDate")}
+2
View File
@@ -14,6 +14,8 @@ export type { MedDetailModalProps } from "./MedDetailModal";
export { MedDetailModal } from "./MedDetailModal";
export type { MedicationAvatarProps } from "./MedicationAvatar";
export { MedicationAvatar } from "./MedicationAvatar";
export type { MedicationEnrichmentViewModel } from "./MedicationEnrichmentSection";
export { MedicationEnrichmentSection } from "./MedicationEnrichmentSection";
export type { MobileEditModalProps } from "./MobileEditModal";
export { MobileEditModal } from "./MobileEditModal";
export { PasswordInput } from "./PasswordInput";
+44
View File
@@ -225,6 +225,50 @@
"weight": "z.B. 240",
"notes": "z.B. Mit Essen einnehmen, Alkohol vermeiden... (optional)"
},
"enrichment": {
"title": "Optionale Medikamentensuche",
"coverageLabel": "Unvollständige freie Abdeckung",
"collapsedHint": "Öffne das nur, wenn du Suchvorschläge nutzen möchtest.",
"toggleShow": "Suche anzeigen",
"toggleHide": "Suche ausblenden",
"infoShow": "Infos zu den Quellen",
"infoHide": "Quellenhinweise ausblenden",
"infoTitle": "Was du erwarten kannst",
"description": "Durchsuche zuerst RxNorm und openFDA, nutze EMA nur als letzten Fallback und prüfe jeden Treffer, bevor du etwas ins Formular übernimmst.",
"searchLabel": "Medikamentenquellen durchsuchen",
"searchPlaceholder": "Nach Marke oder Wirkstoff suchen",
"searchAction": "Suchen",
"searching": "Suche läuft...",
"showMoreAction": "Mehr Treffer anzeigen",
"noResults": "Es wurden in der aktuellen Freiquellen-Suche keine Treffer gefunden. Du kannst das Medikament manuell weiter erfassen.",
"manualEntryHint": "Diese Hilfe ist optional und kann Medikamente, Stärken oder lokale Marktvarianten übersehen.",
"searchError": "Die Medikamentensuche ist momentan nicht verfügbar. Bitte fahre mit der manuellen Eingabe fort.",
"applyAction": "Übernehmen",
"applying": "Wird übernommen...",
"applied": "Ins Formular übernommen",
"applyError": "Das Autofill konnte nicht übernommen werden. Bitte bearbeite das Medikament manuell weiter.",
"partialNote": "Es waren nur teilweise Autofill-Vorschläge verfügbar. Prüfe die Felder vor dem Speichern.",
"strengthTitle": "Stärke-Vorschläge",
"strengthHint": "Wähle eine Stärke aus, um Dosis pro Tablette und Einheit zu aktualisieren.",
"appliedStrength": "Übernommene Stärke: {{label}}",
"details": {
"showAction": "Mehr Details",
"hideAction": "Weniger Details",
"authorisationHolder": "Zulassungsinhaber",
"therapeuticArea": "Therapiebereich",
"authorisationDate": "Zulassungsdatum"
},
"genericStatus": {
"generic": "Generikum",
"original": "Original",
"unknown": "Status unbekannt"
},
"sources": {
"ema": "EMA",
"rxnorm": "RxNorm",
"openfda": "openFDA (USA)"
}
},
"validation": {
"startDateAfterIntake": "Das Medikations-Startdatum ({{medicationStartDate}}) darf nicht nach dem Einnahmedatum ({{intakeDate}}) liegen.",
"endDateBeforeStart": "Das Medikations-Enddatum ({{medicationEndDate}}) darf nicht vor dem Startdatum ({{medicationStartDate}}) liegen."
+44
View File
@@ -225,6 +225,50 @@
"weight": "e.g. 240",
"notes": "e.g. Take with food, avoid alcohol... (optional)"
},
"enrichment": {
"title": "Optional medication lookup",
"coverageLabel": "Incomplete free-source coverage",
"collapsedHint": "Open this only if you want lookup suggestions.",
"toggleShow": "Show lookup",
"toggleHide": "Hide lookup",
"infoShow": "About sources",
"infoHide": "Hide source notes",
"infoTitle": "What to expect",
"description": "Search RxNorm and openFDA first, use EMA as a last fallback, and review each result before applying anything to the form.",
"searchLabel": "Search medication sources",
"searchPlaceholder": "Search by brand or ingredient",
"searchAction": "Search",
"searching": "Searching...",
"showMoreAction": "Show more results",
"noResults": "No matches were found in the current free-source search. You can continue entering the medication manually.",
"manualEntryHint": "This helper is optional and may miss medications, strengths, or local market variants.",
"searchError": "Medication lookup is currently unavailable. Please continue with manual entry.",
"applyAction": "Apply",
"applying": "Applying...",
"applied": "Applied to form",
"applyError": "Autofill could not be applied. Please keep editing the medication manually.",
"partialNote": "Only partial autofill suggestions were available. Review the fields before saving.",
"strengthTitle": "Strength suggestions",
"strengthHint": "Choose a strength to update dose per pill and unit.",
"appliedStrength": "Applied strength: {{label}}",
"details": {
"showAction": "More details",
"hideAction": "Less details",
"authorisationHolder": "Authorisation holder",
"therapeuticArea": "Therapeutic area",
"authorisationDate": "Authorisation date"
},
"genericStatus": {
"generic": "Generic",
"original": "Original",
"unknown": "Status unknown"
},
"sources": {
"ema": "EMA",
"rxnorm": "RxNorm",
"openfda": "openFDA (US)"
}
},
"validation": {
"startDateAfterIntake": "Medication start date ({{medicationStartDate}}) cannot be after intake date ({{intakeDate}}).",
"endDateBeforeStart": "Medication end date ({{medicationEndDate}}) cannot be before medication start date ({{medicationStartDate}})."
+349 -1
View File
@@ -11,13 +11,23 @@ import {
FormNumberStepper,
Lightbox,
MedicationAvatar,
MedicationEnrichmentSection,
MobileEditModal,
ReportModal,
} from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext, useUnsavedChanges } from "../context";
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
import type { DoseUnit, FormState, Medication, PackageType } from "../types";
import type {
DoseUnit,
FormState,
Medication,
MedicationEnrichmentEnrichResponse,
MedicationEnrichmentSearchResponse,
MedicationEnrichmentSearchResult,
MedicationEnrichmentStrengthOption,
PackageType,
} from "../types";
import {
allowsPillFormSelection,
DOSE_UNITS,
@@ -49,6 +59,113 @@ function userStorageKey(userId: number | undefined, key: string): string {
}
const OBSOLETE_SECTION_STORAGE_KEY = "medicationsShowObsolete";
const MEDICATION_ENRICHMENT_INITIAL_LIMIT = 6;
const MEDICATION_ENRICHMENT_LIMIT_STEP = 6;
const MEDICATION_ENRICHMENT_MAX_LIMIT = 20;
type MedicationEnrichmentState = {
query: string;
results: MedicationEnrichmentSearchResult[];
hasMoreResults: boolean;
resultLimit: number;
isSearching: boolean;
hasSearched: boolean;
searchError: string | null;
applyingCode: string | null;
activeResultCode: string | null;
appliedSelection: MedicationEnrichmentEnrichResponse["selection"] | null;
enrichError: string | null;
meta: MedicationEnrichmentEnrichResponse["meta"] | null;
strengthOptions: MedicationEnrichmentStrengthOption[];
appliedStrengthLabel: string | null;
};
function createMedicationEnrichmentState(
query = "",
resultLimit = MEDICATION_ENRICHMENT_INITIAL_LIMIT
): MedicationEnrichmentState {
return {
query,
results: [],
hasMoreResults: false,
resultLimit,
isSearching: false,
hasSearched: false,
searchError: null,
applyingCode: null,
activeResultCode: null,
appliedSelection: null,
enrichError: null,
meta: null,
strengthOptions: [],
appliedStrengthLabel: null,
};
}
function normalizeMedicationEnrichmentDoseUnit(unit: MedicationEnrichmentStrengthOption["doseUnit"]): DoseUnit | null {
if (unit === "IU") return "units";
if (unit === "mg" || unit === "g" || unit === "mcg" || unit === "ml" || unit === "units") return unit;
return null;
}
function applyMedicationEnrichmentSuggestions(
form: FormState,
suggestions: MedicationEnrichmentEnrichResponse["suggestions"]
): FormState {
const nextForm: FormState = {
...form,
name: suggestions.name,
genericName: suggestions.genericName ?? "",
};
if (suggestions.medicationForm === "tablet" || suggestions.medicationForm === "capsule") {
return {
...nextForm,
medicationForm: suggestions.medicationForm,
pillForm: suggestions.medicationForm,
};
}
if (suggestions.medicationForm === "liquid" || suggestions.medicationForm === "topical") {
return {
...nextForm,
medicationForm: suggestions.medicationForm,
};
}
return nextForm;
}
function applyMedicationEnrichmentStrength(
form: FormState,
option: MedicationEnrichmentStrengthOption
): FormState | null {
if (option.pillWeightMg === null) return null;
const doseUnit = normalizeMedicationEnrichmentDoseUnit(option.doseUnit);
if (!doseUnit) return null;
return {
...form,
pillWeightMg: `${option.pillWeightMg}`,
doseUnit,
};
}
async function getMedicationEnrichmentErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const errorBody = (await response.json()) as { error?: string; message?: string };
if (typeof errorBody?.error === "string" && errorBody.error.trim().length > 0) {
return errorBody.error;
}
if (typeof errorBody?.message === "string" && errorBody.message.trim().length > 0) {
return errorBody.message;
}
} catch {
// keep translated fallback
}
return fallback;
}
export function MedicationsPage() {
const [searchParams, setSearchParams] = useSearchParams();
@@ -162,6 +279,88 @@ export function MedicationsPage() {
const [obsoleteCandidate, setObsoleteCandidate] = useState<Medication | null>(null);
const [allMeds, setAllMeds] = useState<Medication[]>(meds);
const [imageUploadError, setImageUploadError] = useState<string | null>(null);
const [medicationEnrichment, setMedicationEnrichment] = useState<MedicationEnrichmentState>(() =>
createMedicationEnrichmentState()
);
const resetMedicationEnrichment = useCallback((query = "") => {
setMedicationEnrichment(createMedicationEnrichmentState(query));
}, []);
const handleMedicationEnrichmentQueryChange = useCallback((value: string) => {
setMedicationEnrichment((previous) => ({
...previous,
query: value,
}));
}, []);
const performMedicationEnrichmentSearch = useCallback(
async (requestedLimit: number, preserveExistingResults = false) => {
const trimmedQuery = medicationEnrichment.query.trim();
if (!trimmedQuery) return;
const limit = Math.min(requestedLimit, MEDICATION_ENRICHMENT_MAX_LIMIT);
setMedicationEnrichment((previous) => ({
...previous,
query: trimmedQuery,
results: preserveExistingResults ? previous.results : [],
hasMoreResults: false,
resultLimit: limit,
isSearching: true,
hasSearched: preserveExistingResults ? previous.hasSearched : false,
searchError: null,
applyingCode: null,
...(preserveExistingResults
? {}
: {
activeResultCode: null,
appliedSelection: null,
enrichError: null,
meta: null,
strengthOptions: [],
appliedStrengthLabel: null,
}),
}));
try {
const params = new URLSearchParams({ q: trimmedQuery, limit: String(limit) });
const response = await fetch(`/api/medication-enrichment/search?${params.toString()}`, {
credentials: "include",
});
if (!response.ok) {
throw new Error(await getMedicationEnrichmentErrorMessage(response, t("form.enrichment.searchError")));
}
const data = (await response.json()) as MedicationEnrichmentSearchResponse;
setMedicationEnrichment((previous) => ({
...previous,
query: data.query,
results: Array.isArray(data.results) ? data.results : [],
hasMoreResults: Boolean(data.hasMore),
resultLimit: limit,
isSearching: false,
hasSearched: true,
searchError: null,
}));
} catch (error) {
const message =
error instanceof Error && error.message.trim().length > 0 ? error.message : t("form.enrichment.searchError");
setMedicationEnrichment((previous) => ({
...previous,
results: preserveExistingResults ? previous.results : [],
hasMoreResults: false,
resultLimit: limit,
isSearching: false,
hasSearched: true,
searchError: message,
}));
}
},
[medicationEnrichment.query, t]
);
const handlePendingMedicationImageSelection = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
@@ -258,6 +457,126 @@ export function MedicationsPage() {
[deleteMedImage, loadAllMeds]
);
const applicableMedicationEnrichmentStrengthOptions = useMemo(() => {
if (!allowsPillFormSelection(form.packageType)) return [];
return medicationEnrichment.strengthOptions.filter(
(option) => option.pillWeightMg !== null && normalizeMedicationEnrichmentDoseUnit(option.doseUnit) !== null
);
}, [form.packageType, medicationEnrichment.strengthOptions]);
const handleMedicationEnrichmentSearch = useCallback(async () => {
await performMedicationEnrichmentSearch(MEDICATION_ENRICHMENT_INITIAL_LIMIT);
}, [performMedicationEnrichmentSearch]);
const handleMedicationEnrichmentLoadMore = useCallback(async () => {
if (medicationEnrichment.isSearching || !medicationEnrichment.hasMoreResults) return;
await performMedicationEnrichmentSearch(
Math.min(medicationEnrichment.resultLimit + MEDICATION_ENRICHMENT_LIMIT_STEP, MEDICATION_ENRICHMENT_MAX_LIMIT),
true
);
}, [
medicationEnrichment.hasMoreResults,
medicationEnrichment.isSearching,
medicationEnrichment.resultLimit,
performMedicationEnrichmentSearch,
]);
const handleMedicationEnrichmentApply = useCallback(
async (result: MedicationEnrichmentSearchResult) => {
setMedicationEnrichment((previous) => ({
...previous,
applyingCode: result.code,
activeResultCode: result.code,
enrichError: null,
appliedSelection: null,
meta: null,
strengthOptions: [],
appliedStrengthLabel: null,
}));
try {
const response = await fetch("/api/medication-enrichment/enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: medicationEnrichment.query.trim() || result.name,
name: result.name,
genericName: result.genericName ?? null,
code: result.code,
source: result.source,
}),
credentials: "include",
});
if (!response.ok) {
throw new Error(await getMedicationEnrichmentErrorMessage(response, t("form.enrichment.applyError")));
}
const data = (await response.json()) as MedicationEnrichmentEnrichResponse;
let nextForm = applyMedicationEnrichmentSuggestions(form, data.suggestions);
let appliedStrengthLabel: string | null = null;
if (allowsPillFormSelection(nextForm.packageType) && data.suggestions.strengthOptions.length === 1) {
const autoAppliedForm = applyMedicationEnrichmentStrength(nextForm, data.suggestions.strengthOptions[0]);
if (autoAppliedForm) {
nextForm = autoAppliedForm;
appliedStrengthLabel = data.suggestions.strengthOptions[0].label;
}
}
setForm(nextForm);
setMedicationEnrichment((previous) => ({
...previous,
applyingCode: null,
activeResultCode: result.code,
appliedSelection: data.selection,
enrichError: null,
meta: data.meta,
strengthOptions: data.suggestions.strengthOptions,
appliedStrengthLabel,
}));
} catch (error) {
const message =
error instanceof Error && error.message.trim().length > 0 ? error.message : t("form.enrichment.applyError");
setMedicationEnrichment((previous) => ({
...previous,
applyingCode: null,
activeResultCode: null,
appliedSelection: null,
enrichError: message,
meta: null,
strengthOptions: [],
appliedStrengthLabel: null,
}));
}
},
[form, medicationEnrichment.query, setForm, t]
);
const handleMedicationEnrichmentStrengthApply = useCallback(
(option: MedicationEnrichmentStrengthOption) => {
setForm((currentForm) => {
const nextForm = applyMedicationEnrichmentStrength(currentForm, option);
return nextForm ?? currentForm;
});
setMedicationEnrichment((previous) => ({
...previous,
appliedStrengthLabel: option.label,
}));
},
[setForm]
);
const medicationEnrichmentViewModel = useMemo(
() => ({
...medicationEnrichment,
strengthOptions: applicableMedicationEnrichmentStrengthOptions,
}),
[applicableMedicationEnrichmentStrengthOptions, medicationEnrichment]
);
// Calculate total tablets
const totalTablets = useMemo(() => {
if (isAmountBasedPackageType(form.packageType)) {
@@ -416,12 +735,14 @@ export function MedicationsPage() {
if (pendingAction) {
// There's a pending action (e.g. switching to another medication) — reset and run it
resetForm();
resetMedicationEnrichment();
setReadOnlyView(false);
pendingAction();
} else if (source === "mobile-edit" && showEditModal) {
clearEditMedIdParam();
setShowEditModal(false);
resetForm();
resetMedicationEnrichment();
setReadOnlyView(false);
window.history.back();
} else {
@@ -449,6 +770,7 @@ export function MedicationsPage() {
window.history.back();
}
resetForm();
resetMedicationEnrichment();
setShowNameValidation(false);
setActiveTab("general");
setReadOnlyView(false);
@@ -710,11 +1032,13 @@ export function MedicationsPage() {
setActiveTab("general");
setViewMode("grid");
resetForm();
resetMedicationEnrichment();
window.history.back();
setSaving(false);
return;
}
resetForm();
resetMedicationEnrichment();
setViewMode("grid");
} else {
// Update originalForm so formChanged becomes false
@@ -758,6 +1082,7 @@ export function MedicationsPage() {
if (showEditModal) {
setShowEditModal(false);
resetForm();
resetMedicationEnrichment();
}
return;
}
@@ -780,6 +1105,7 @@ export function MedicationsPage() {
clearEditMedIdParam();
setShowEditModal(false);
resetForm();
resetMedicationEnrichment();
return;
}
@@ -793,6 +1119,7 @@ export function MedicationsPage() {
}
hasDesktopFormHistoryState.current = false;
resetForm();
resetMedicationEnrichment();
setShowNameValidation(false);
setActiveTab("general");
setReadOnlyView(false);
@@ -836,6 +1163,7 @@ export function MedicationsPage() {
pendingActionRef.current = () => {
setShowNameValidation(false);
setReadOnlyView(false);
resetMedicationEnrichment(med.name || med.genericName || "");
startEdit(med, openEditModal);
setViewMode("form");
scrollToTopForDesktopEdit();
@@ -847,6 +1175,7 @@ export function MedicationsPage() {
setShowNameValidation(false);
setReadOnlyView(false);
setActiveTab("general");
resetMedicationEnrichment(med.name || med.genericName || "");
startEdit(med, openEditModal);
setViewMode("form");
scrollToTopForDesktopEdit();
@@ -857,6 +1186,7 @@ export function MedicationsPage() {
pendingActionRef.current = () => {
setShowNameValidation(false);
setReadOnlyView(true);
resetMedicationEnrichment(med.name || med.genericName || "");
startEdit(med, openEditModal);
setViewMode("form");
scrollToTopForDesktopEdit();
@@ -868,6 +1198,7 @@ export function MedicationsPage() {
setShowNameValidation(false);
setReadOnlyView(true);
setActiveTab("general");
resetMedicationEnrichment(med.name || med.genericName || "");
startEdit(med, openEditModal);
setViewMode("form");
scrollToTopForDesktopEdit();
@@ -877,6 +1208,7 @@ export function MedicationsPage() {
if (formChanged) {
pendingActionRef.current = () => {
resetForm();
resetMedicationEnrichment();
setShowNameValidation(false);
setReadOnlyView(false);
if (window.innerWidth <= 768) {
@@ -890,6 +1222,7 @@ export function MedicationsPage() {
return;
}
resetForm();
resetMedicationEnrichment();
setShowNameValidation(false);
setReadOnlyView(false);
if (window.innerWidth <= 768) {
@@ -932,6 +1265,7 @@ export function MedicationsPage() {
setShowNameValidation(false);
setReadOnlyView(false);
setActiveTab("general");
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
startEdit(medicationToEdit, openEditModal);
setViewMode("form");
scrollToTopForDesktopEdit();
@@ -1280,6 +1614,14 @@ export function MedicationsPage() {
<span className="field-error">{fieldErrors.genericName}</span>
)}
</label>
<MedicationEnrichmentSection
state={medicationEnrichmentViewModel}
onQueryChange={handleMedicationEnrichmentQueryChange}
onSearch={handleMedicationEnrichmentSearch}
onLoadMoreResults={handleMedicationEnrichmentLoadMore}
onApplyResult={handleMedicationEnrichmentApply}
onApplyStrength={handleMedicationEnrichmentStrengthApply}
/>
<div className="full date-pair-group">
<label className="date-pair-field">
{t("form.medicationStartDate")}
@@ -1938,6 +2280,12 @@ export function MedicationsPage() {
editingId={editingId}
form={form}
onFormChange={setForm}
medicationEnrichment={medicationEnrichmentViewModel}
onMedicationEnrichmentQueryChange={handleMedicationEnrichmentQueryChange}
onMedicationEnrichmentSearch={handleMedicationEnrichmentSearch}
onMedicationEnrichmentLoadMore={handleMedicationEnrichmentLoadMore}
onMedicationEnrichmentApply={handleMedicationEnrichmentApply}
onMedicationEnrichmentStrengthApply={handleMedicationEnrichmentStrengthApply}
fieldErrors={fieldErrors}
saving={saving}
formSaved={formSaved}
+205
View File
@@ -2068,6 +2068,211 @@ button.has-validation-error {
border-color: var(--accent);
}
.medication-enrichment-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.medication-enrichment-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.85rem;
border: 1px solid var(--border-primary);
border-radius: 12px;
background: color-mix(in srgb, var(--bg-secondary) 62%, var(--bg-tertiary));
}
.medication-enrichment-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.medication-enrichment-title {
margin-bottom: 0.2rem;
}
.medication-enrichment-collapsed-hint,
.medication-enrichment-description,
.medication-enrichment-manual-hint,
.medication-enrichment-selection-summary {
margin: 0;
}
.medication-enrichment-helper-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.medication-enrichment-info {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.75rem;
border: 1px solid color-mix(in srgb, var(--info) 28%, var(--border-primary));
border-radius: 10px;
background: color-mix(in srgb, var(--accent-bg) 55%, transparent);
}
.medication-enrichment-info-title {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-primary);
}
.medication-enrichment-search-row {
display: flex;
align-items: stretch;
gap: 0.75rem;
}
.medication-enrichment-search-row button {
flex-shrink: 0;
white-space: nowrap;
}
.medication-enrichment-results {
display: grid;
gap: 0.75rem;
}
.medication-enrichment-results-footer {
display: flex;
justify-content: flex-start;
}
.medication-enrichment-result {
display: grid;
gap: 0.6rem;
padding: 0.85rem;
border: 1px solid var(--border-primary);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-secondary) 68%, var(--bg-tertiary));
}
.medication-enrichment-result.active {
border-color: color-mix(in srgb, var(--accent) 55%, var(--border-primary));
box-shadow: inset 0 0 0 1px rgba(47, 134, 246, 0.18);
}
.medication-enrichment-result-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.medication-enrichment-result-names {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.medication-enrichment-result-names strong {
font-size: 0.95rem;
word-break: break-word;
}
.medication-enrichment-result-generic {
font-size: 0.85rem;
color: var(--text-secondary);
word-break: break-word;
}
.medication-enrichment-result-actions {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: flex-end;
gap: 0.5rem;
}
.medication-enrichment-result-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.5rem 1rem;
margin: 0;
}
.medication-enrichment-result-meta div {
min-width: 0;
}
.medication-enrichment-result-meta dt {
margin-bottom: 0.15rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
}
.medication-enrichment-result-meta dd {
margin: 0;
font-size: 0.85rem;
color: var(--text-primary);
word-break: break-word;
}
.medication-enrichment-followup {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border: 1px dashed var(--border-secondary);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-secondary) 55%, transparent);
}
.medication-enrichment-strengths {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.medication-enrichment-strength-title {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-primary);
}
.medication-enrichment-strength-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.medication-enrichment-strength-list button {
white-space: nowrap;
}
@media (max-width: 760px) {
.medication-enrichment-header,
.medication-enrichment-result-header,
.medication-enrichment-search-row,
.medication-enrichment-helper-row {
flex-direction: column;
align-items: stretch;
}
.medication-enrichment-result-actions {
justify-content: flex-start;
}
.medication-enrichment-search-row button {
width: 100%;
}
}
.select-field.dose-unit-select:hover,
.dose-unit-select:hover {
border-color: var(--accent);
@@ -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();
});
});
+58
View File
@@ -26,6 +26,64 @@ export type MedicationForm = "tablet" | "capsule" | "topical" | "liquid";
export type PillForm = "tablet" | "capsule";
export type LifecycleCategory = "refill_when_empty" | "treatment_period";
export type PackageAmountUnit = "ml" | "g";
export type MedicationEnrichmentDoseUnit = DoseUnit | "IU" | "drops" | "puffs";
export type MedicationEnrichmentMatchType = "brand" | "ingredient";
export type MedicationEnrichmentGenericStatus = "generic" | "original" | "unknown";
export type MedicationEnrichmentSearchSource = "ema" | "rxnorm" | "openfda";
export type MedicationEnrichmentSource =
| MedicationEnrichmentSearchSource
| "ema+rxnorm"
| "ema+openfda"
| "rxnorm+openfda"
| "ema+rxnorm+openfda";
export type MedicationEnrichmentSearchResult = {
code: string;
name: string;
genericName: string | null;
authorisationHolder: string | null;
therapeuticArea: string | null;
matchType: MedicationEnrichmentMatchType;
genericStatus: MedicationEnrichmentGenericStatus;
authorisationDate: string | null;
source: MedicationEnrichmentSearchSource;
};
export type MedicationEnrichmentSearchResponse = {
query: string;
normalizedQuery: string;
hasMore: boolean;
results: MedicationEnrichmentSearchResult[];
};
export type MedicationEnrichmentStrengthOption = {
label: string;
pillWeightMg: number | null;
doseUnit: MedicationEnrichmentDoseUnit | null;
};
export type MedicationEnrichmentEnrichResponse = {
selection: {
name: string;
genericName: string | null;
therapeuticArea: string | null;
indication: string | null;
atcCode: string | null;
source: MedicationEnrichmentSource;
};
suggestions: {
name: string;
genericName: string | null;
medicationForm: MedicationForm | null;
strengthOptions: MedicationEnrichmentStrengthOption[];
};
meta: {
rxNormMatched: boolean;
openFdaMatched: boolean;
partial: boolean;
note: string | null;
};
};
export const DOSE_UNITS: { value: DoseUnit; label: string }[] = [
{ value: "mg", label: "mg" },