refactor: decompose frontend state and medication dialog flows
This commit is contained in:
@@ -18,6 +18,7 @@ let authMock: AuthStateMock = {
|
||||
};
|
||||
|
||||
let appContextMock: Record<string, unknown>;
|
||||
let shareContextMock: Record<string, unknown>;
|
||||
|
||||
vi.mock("../components", () => ({
|
||||
AboutModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ? <div>about-modal-open</div> : null),
|
||||
@@ -45,11 +46,17 @@ vi.mock("../components/Auth", () => ({
|
||||
useAuth: () => authMock,
|
||||
}));
|
||||
|
||||
vi.mock("../context", () => ({
|
||||
AppProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
UnsavedChangesProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
useAppContext: () => appContextMock,
|
||||
}));
|
||||
vi.mock("../context", async () => {
|
||||
const actual = await vi.importActual<typeof import("../context")>("../context");
|
||||
return {
|
||||
...actual,
|
||||
AppProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
ShareContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
UnsavedChangesProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
useAppContext: () => appContextMock,
|
||||
useShareContext: () => shareContextMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../pages", () => ({
|
||||
DashboardPage: () => <div>dashboard-page</div>,
|
||||
@@ -93,21 +100,6 @@ describe("App", () => {
|
||||
closeRefillModal: vi.fn(),
|
||||
openEditStockModal: vi.fn(),
|
||||
closeEditStockModal: vi.fn(),
|
||||
showShareDialog: false,
|
||||
sharePeople: [],
|
||||
shareSelectedPerson: "",
|
||||
setShareSelectedPerson: vi.fn(),
|
||||
shareSelectedDays: 7,
|
||||
setShareSelectedDays: vi.fn(),
|
||||
shareGenerating: false,
|
||||
shareLink: null,
|
||||
setShareLink: vi.fn(),
|
||||
shareCopied: false,
|
||||
setShareCopied: vi.fn(),
|
||||
generateShareLink: vi.fn(),
|
||||
copyShareLink: vi.fn(),
|
||||
closeShareDialog: vi.fn(),
|
||||
resetShareDialogState: vi.fn(),
|
||||
coverage: { all: [], low: [] },
|
||||
selectedMed: null,
|
||||
setSelectedMed: vi.fn(),
|
||||
@@ -134,6 +126,23 @@ describe("App", () => {
|
||||
expiryWarningDays: 30,
|
||||
},
|
||||
};
|
||||
shareContextMock = {
|
||||
showShareDialog: false,
|
||||
sharePeople: [],
|
||||
shareSelectedPerson: "",
|
||||
setShareSelectedPerson: vi.fn(),
|
||||
shareSelectedDays: 7,
|
||||
setShareSelectedDays: vi.fn(),
|
||||
shareGenerating: false,
|
||||
shareLink: null,
|
||||
setShareLink: vi.fn(),
|
||||
shareCopied: false,
|
||||
setShareCopied: vi.fn(),
|
||||
generateShareLink: vi.fn(),
|
||||
copyShareLink: vi.fn(),
|
||||
closeShareDialog: vi.fn(),
|
||||
resetShareDialogState: vi.fn(),
|
||||
};
|
||||
document.documentElement.classList.remove("modal-open");
|
||||
document.body.classList.remove("modal-open");
|
||||
vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||
@@ -300,7 +309,7 @@ describe("App", () => {
|
||||
});
|
||||
|
||||
it("adds modal-open class when modal state is active", () => {
|
||||
appContextMock.showShareDialog = true;
|
||||
shareContextMock.showShareDialog = true;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
@@ -328,7 +337,7 @@ describe("App", () => {
|
||||
});
|
||||
|
||||
it("handles popstate by resetting share dialog state", () => {
|
||||
appContextMock.showShareDialog = true;
|
||||
shareContextMock.showShareDialog = true;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
@@ -337,7 +346,7 @@ describe("App", () => {
|
||||
);
|
||||
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
expect(appContextMock.resetShareDialogState).toHaveBeenCalled();
|
||||
expect(shareContextMock.resetShareDialogState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redirects unknown routes to dashboard", () => {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MedicationDialogs } from "../../components/medications/MedicationDialogs";
|
||||
import type { Medication } from "../../types";
|
||||
|
||||
vi.mock("../../components/ConfirmModal", () => ({
|
||||
ConfirmModal: ({
|
||||
title,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
overlayClassName,
|
||||
}: {
|
||||
title: string;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
overlayClassName?: string;
|
||||
}) => (
|
||||
<div data-testid={`confirm-${title}`} data-overlay-class={overlayClassName ?? ""}>
|
||||
<button type="button" onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../components/Lightbox", () => ({
|
||||
Lightbox: ({ src, alt }: { src: string; alt: string }) => <div data-testid="lightbox">{`${src}|${alt}`}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/ReportModal", () => ({
|
||||
default: ({ isOpen }: { isOpen: boolean }) => (isOpen ? <div data-testid="report-modal">report</div> : null),
|
||||
}));
|
||||
|
||||
const baseMedication: Medication = {
|
||||
id: 1,
|
||||
name: "Aspirin",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
takenBy: ["John"],
|
||||
packageType: "blister",
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
totalPills: null,
|
||||
packageAmountValue: null,
|
||||
packageAmountUnit: null,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
lastStockCorrectionAt: null,
|
||||
pillWeightMg: 500,
|
||||
doseUnit: "mg",
|
||||
medicationForm: "tablet",
|
||||
pillForm: "tablet",
|
||||
lifecycleCategory: "refill_when_empty",
|
||||
blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00Z" }],
|
||||
intakes: [
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2024-01-01T09:00:00Z",
|
||||
takenBy: "",
|
||||
intakeRemindersEnabled: false,
|
||||
scheduleMode: "interval",
|
||||
weekdays: [],
|
||||
},
|
||||
],
|
||||
imageUrl: null,
|
||||
expiryDate: null,
|
||||
notes: null,
|
||||
intakeRemindersEnabled: false,
|
||||
medicationStartDate: "",
|
||||
medicationEndDate: null,
|
||||
autoMarkObsoleteAfterEndDate: true,
|
||||
isObsolete: false,
|
||||
obsoleteAt: null,
|
||||
prescriptionEnabled: false,
|
||||
prescriptionAuthorizedRefills: null,
|
||||
prescriptionRemainingRefills: null,
|
||||
prescriptionLowRefillThreshold: 1,
|
||||
prescriptionExpiryDate: null,
|
||||
dismissedUntil: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
function createProps(overrides: Partial<React.ComponentProps<typeof MedicationDialogs>> = {}) {
|
||||
return {
|
||||
mobileEditModal: <div data-testid="mobile-edit">mobile</div>,
|
||||
showUnsavedConfirm: false,
|
||||
unsavedCancelLabel: "cancel-unsaved",
|
||||
unsavedConfirmLabel: "confirm-unsaved",
|
||||
unsavedMessage: "unsaved-message",
|
||||
unsavedTitle: "unsaved-title",
|
||||
onConfirmClose: vi.fn(),
|
||||
onCancelClose: vi.fn(),
|
||||
showObsoleteConfirm: false,
|
||||
obsoleteCandidate: null,
|
||||
obsoleteTitle: "obsolete-title",
|
||||
obsoleteMessage: "obsolete-message",
|
||||
obsoleteConfirmLabel: "confirm-obsolete",
|
||||
obsoleteCancelLabel: "cancel-obsolete",
|
||||
onConfirmMarkObsolete: vi.fn(),
|
||||
onCancelMarkObsolete: vi.fn(),
|
||||
showDeleteConfirm: false,
|
||||
deleteCandidate: null,
|
||||
deleteTitle: "delete-title",
|
||||
deleteMessage: "delete-message",
|
||||
deleteConfirmLabel: "confirm-delete",
|
||||
deleteCancelLabel: "cancel-delete",
|
||||
onConfirmDelete: vi.fn(),
|
||||
onCancelDelete: vi.fn(),
|
||||
showEditModal: false,
|
||||
lightboxImage: null,
|
||||
onCloseLightbox: vi.fn(),
|
||||
showReportModal: false,
|
||||
onCloseReportModal: vi.fn(),
|
||||
medications: [baseMedication],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MedicationDialogs", () => {
|
||||
it("always renders mobile edit container and report modal state", () => {
|
||||
render(<MedicationDialogs {...createProps({ showReportModal: true })} />);
|
||||
|
||||
expect(screen.getByTestId("mobile-edit")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("report-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nested unsaved confirm when edit modal is open and triggers callbacks", () => {
|
||||
const onConfirmClose = vi.fn();
|
||||
const onCancelClose = vi.fn();
|
||||
|
||||
render(
|
||||
<MedicationDialogs
|
||||
{...createProps({
|
||||
showUnsavedConfirm: true,
|
||||
showEditModal: true,
|
||||
onConfirmClose,
|
||||
onCancelClose,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
const modal = screen.getByTestId("confirm-unsaved-title");
|
||||
expect(modal).toHaveAttribute("data-overlay-class", "nested-confirm");
|
||||
|
||||
fireEvent.click(screen.getByText("confirm-unsaved"));
|
||||
fireEvent.click(screen.getByText("cancel-unsaved"));
|
||||
|
||||
expect(onConfirmClose).toHaveBeenCalledTimes(1);
|
||||
expect(onCancelClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders obsolete and delete confirms only when a candidate exists", () => {
|
||||
const { rerender } = render(
|
||||
<MedicationDialogs
|
||||
{...createProps({
|
||||
showObsoleteConfirm: true,
|
||||
showDeleteConfirm: true,
|
||||
obsoleteCandidate: baseMedication,
|
||||
deleteCandidate: baseMedication,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("confirm-obsolete-title")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("confirm-delete-title")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<MedicationDialogs
|
||||
{...createProps({
|
||||
showObsoleteConfirm: true,
|
||||
showDeleteConfirm: true,
|
||||
obsoleteCandidate: null,
|
||||
deleteCandidate: null,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("confirm-obsolete-title")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("confirm-delete-title")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders lightbox when image data is present", () => {
|
||||
render(
|
||||
<MedicationDialogs
|
||||
{...createProps({
|
||||
lightboxImage: { src: "https://example.com/a.jpg", alt: "Medication image" },
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("lightbox")).toHaveTextContent("https://example.com/a.jpg|Medication image");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { FormEvent } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MedicationEditCoordinator } from "../../components/medications/MedicationEditCoordinator";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
describe("MedicationEditCoordinator", () => {
|
||||
it("renders new-entry header and closes via back action", () => {
|
||||
const onBack = vi.fn();
|
||||
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => event.preventDefault());
|
||||
|
||||
render(
|
||||
<MedicationEditCoordinator
|
||||
viewMode="grid"
|
||||
editingId={null}
|
||||
readOnlyView={false}
|
||||
onBack={onBack}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<div>content</div>
|
||||
</MedicationEditCoordinator>
|
||||
);
|
||||
|
||||
expect(screen.getByText("form.newEntry")).toBeInTheDocument();
|
||||
expect(document.querySelector(".edit-sidebar.open")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "<- common.back" }));
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.submit(document.querySelector("form") as HTMLFormElement);
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders edit header for editable and read-only flows", () => {
|
||||
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => event.preventDefault());
|
||||
|
||||
const { rerender } = render(
|
||||
<MedicationEditCoordinator
|
||||
viewMode="form"
|
||||
editingId={42}
|
||||
readOnlyView={false}
|
||||
selectedMedicationName="Aspirin"
|
||||
onBack={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<div>content</div>
|
||||
</MedicationEditCoordinator>
|
||||
);
|
||||
|
||||
expect(document.querySelector(".edit-sidebar.open")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "form.editEntry: Aspirin" })).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<MedicationEditCoordinator
|
||||
viewMode="form"
|
||||
editingId={42}
|
||||
readOnlyView={true}
|
||||
selectedMedicationName="Aspirin"
|
||||
onBack={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<div>content</div>
|
||||
</MedicationEditCoordinator>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "form.viewEntry: Aspirin" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -120,11 +120,15 @@ let mockContextValue = createMockContext();
|
||||
let mockFormHookValue = createMockFormHook();
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
vi.mock("../../hooks", () => ({
|
||||
useMedicationForm: () => mockFormHookValue,
|
||||
useUnsavedChangesWarning: () => ({}),
|
||||
useModalHistory: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../hooks", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../hooks")>("../../hooks");
|
||||
return {
|
||||
...actual,
|
||||
useMedicationForm: () => mockFormHookValue,
|
||||
useUnsavedChangesWarning: () => ({}),
|
||||
useModalHistory: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../context", () => ({
|
||||
useAppContext: () => mockContextValue,
|
||||
@@ -180,6 +184,57 @@ vi.mock("../../components", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../components/medications/MedicationDialogs", () => ({
|
||||
MedicationDialogs: ({
|
||||
showUnsavedConfirm,
|
||||
unsavedConfirmLabel,
|
||||
onConfirmClose,
|
||||
showObsoleteConfirm,
|
||||
obsoleteConfirmLabel,
|
||||
onConfirmMarkObsolete,
|
||||
showDeleteConfirm,
|
||||
deleteConfirmLabel,
|
||||
onConfirmDelete,
|
||||
showReportModal,
|
||||
}: {
|
||||
showUnsavedConfirm: boolean;
|
||||
unsavedConfirmLabel: string;
|
||||
onConfirmClose: () => void;
|
||||
showObsoleteConfirm: boolean;
|
||||
obsoleteConfirmLabel: string;
|
||||
onConfirmMarkObsolete: () => void;
|
||||
showDeleteConfirm: boolean;
|
||||
deleteConfirmLabel: string;
|
||||
onConfirmDelete: () => void;
|
||||
showReportModal: boolean;
|
||||
}) => (
|
||||
<>
|
||||
{showUnsavedConfirm ? (
|
||||
<div data-testid="confirm-modal">
|
||||
<button type="button" onClick={onConfirmClose}>
|
||||
{unsavedConfirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{showObsoleteConfirm ? (
|
||||
<div data-testid="confirm-modal">
|
||||
<button type="button" onClick={onConfirmMarkObsolete}>
|
||||
{obsoleteConfirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{showDeleteConfirm ? (
|
||||
<div data-testid="confirm-modal">
|
||||
<button type="button" onClick={onConfirmDelete}>
|
||||
{deleteConfirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{showReportModal ? <div data-testid="report-modal-open">Report Modal</div> : null}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
function renderPage(initialEntry = "/medications") {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
@@ -829,17 +884,15 @@ describe("MedicationsPage form interactions", () => {
|
||||
});
|
||||
|
||||
it("keeps a visible loading state while more lookup results are being fetched", async () => {
|
||||
let resolveLoadMore:
|
||||
| ((value: {
|
||||
ok: boolean;
|
||||
json: () => Promise<{
|
||||
query: string;
|
||||
normalizedQuery: string;
|
||||
hasMore: boolean;
|
||||
results: ReturnType<typeof createMedicationEnrichmentSearchResults>;
|
||||
}>;
|
||||
}) => void)
|
||||
| null = null;
|
||||
let resolveLoadMore!: (value: {
|
||||
ok: boolean;
|
||||
json: () => Promise<{
|
||||
query: string;
|
||||
normalizedQuery: string;
|
||||
hasMore: boolean;
|
||||
results: ReturnType<typeof createMedicationEnrichmentSearchResults>;
|
||||
}>;
|
||||
}) => void;
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url === "/api/medication-enrichment/search?q=Aspirin&limit=6") {
|
||||
@@ -884,7 +937,7 @@ describe("MedicationsPage form interactions", () => {
|
||||
expect(screen.getByRole("button", { name: "form.enrichment.loadingMoreResults" })).toBeDisabled();
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
|
||||
resolveLoadMore?.({
|
||||
resolveLoadMore({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
query: "Aspirin",
|
||||
@@ -1539,7 +1592,7 @@ describe("MedicationsPage form interactions", () => {
|
||||
|
||||
it("shows the selected package as pending while enrichment details are still loading", async () => {
|
||||
const setForm = vi.fn();
|
||||
let resolveEnrichment: ((value: { ok: boolean; json: () => Promise<unknown> }) => void) | null = null;
|
||||
let resolveEnrichment!: (value: { ok: boolean; json: () => Promise<unknown> }) => void;
|
||||
mockFormHookValue = createMockFormHook({ setForm });
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.startsWith("/api/medication-enrichment/search?")) {
|
||||
@@ -1638,7 +1691,7 @@ describe("MedicationsPage form interactions", () => {
|
||||
expect(pendingPackageButton.querySelector(".medication-enrichment-spinner")).not.toBeNull();
|
||||
expect(screen.getByText("form.enrichment.applying")).toBeInTheDocument();
|
||||
|
||||
resolveEnrichment?.({
|
||||
resolveEnrichment({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
selection: {
|
||||
|
||||
Reference in New Issue
Block a user