refactor: decompose frontend state and medication dialog flows

This commit is contained in:
Daniel Volz
2026-03-27 06:50:19 +01:00
committed by GitHub
parent b58c4fe5bb
commit f46043970f
28 changed files with 2450 additions and 1613 deletions
+32 -23
View File
@@ -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: {