From eba77c9520096d7dc371576eb3bb934daf52e872 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sun, 10 May 2026 20:25:14 +0200 Subject: [PATCH] fix: preserve frontend medication deep links --- frontend/src/App.tsx | 2 +- frontend/src/pages/MedicationsPage.tsx | 75 +++++++++++++------ frontend/src/test/App.test.tsx | 25 ++++++- frontend/src/test/components/Auth.test.tsx | 4 + .../src/test/pages/MedicationsPage.test.tsx | 15 ++++ 5 files changed, 95 insertions(+), 26 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1dc775f..b50c10c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -506,7 +506,7 @@ function AppContent() { - } /> + } /> } /> } /> diff --git a/frontend/src/pages/MedicationsPage.tsx b/frontend/src/pages/MedicationsPage.tsx index 14a206e..584faa0 100644 --- a/frontend/src/pages/MedicationsPage.tsx +++ b/frontend/src/pages/MedicationsPage.tsx @@ -257,8 +257,10 @@ export function MedicationsPage() { useUnsavedChangesWarning(formChanged); // View mode: grid (default) or form (edit/new) - // If navigating in with editMedId, suppress rendering until the edit form is ready - const [pendingEditTransition, setPendingEditTransition] = useState(() => searchParams.has("editMedId")); + // If navigating in with a medication deep-link, suppress rendering until the target form is ready + const [pendingEditTransition, setPendingEditTransition] = useState( + () => searchParams.has("editMedId") || searchParams.has("viewMedId") + ); const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid"); const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null); const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general"); @@ -269,9 +271,23 @@ export function MedicationsPage() { useEffect(() => { showEditModalRef.current = showEditModal; }, [showEditModal]); - const processedEditMedIdRef = useRef(null); + const processedMedicationLinkRef = useRef(null); const hasDesktopFormHistoryState = useRef(false); + const getMedicationLinkState = useCallback((params: URLSearchParams) => { + const viewMedId = params.get("viewMedId"); + if (viewMedId) { + return { mode: "view" as const, linkedMedId: viewMedId }; + } + + const editMedId = params.get("editMedId"); + if (editMedId) { + return { mode: "edit" as const, linkedMedId: editMedId }; + } + + return { mode: null, linkedMedId: null }; + }, []); + // Sync formChanged state to the global context for navigation blocking const { setHasUnsavedChanges } = useUnsavedChanges(); useEffect(() => { @@ -819,12 +835,13 @@ export function MedicationsPage() { [t] ); - const clearEditMedIdParam = useCallback(() => { + const clearMedicationLinkParams = useCallback(() => { setSearchParams( (prevParams) => { - if (!prevParams.has("editMedId")) return prevParams; + if (!prevParams.has("editMedId") && !prevParams.has("viewMedId")) return prevParams; const nextParams = new URLSearchParams(prevParams); nextParams.delete("editMedId"); + nextParams.delete("viewMedId"); return nextParams; }, { replace: true } @@ -848,7 +865,7 @@ export function MedicationsPage() { setShowUnsavedConfirm(true); return; } - clearEditMedIdParam(); + clearMedicationLinkParams(); // Mark as confirmed to avoid double confirmation in popstate handler closeConfirmedRef.current = true; window.history.back(); @@ -1159,7 +1176,7 @@ export function MedicationsPage() { if (shouldCloseMobileModal) { // Treat post-save close as confirmed so popstate does not trigger unsaved guards. closeConfirmedRef.current = true; - clearEditMedIdParam(); + clearMedicationLinkParams(); setShowEditModal(false); setReadOnlyView(false); setActiveTab("general"); @@ -1188,7 +1205,8 @@ export function MedicationsPage() { // Handle browser back button for modals and unsaved changes useEffect(() => { const handlePopState = () => { - const currentEditMedId = new URLSearchParams(window.location.search).get("editMedId"); + const currentParams = new URLSearchParams(window.location.search); + const { mode: currentLinkMode, linkedMedId: currentMedicationLinkId } = getMedicationLinkState(currentParams); // Obsolete confirmation is open — dismiss it and stay where we are if (showObsoleteConfirm) { @@ -1207,10 +1225,10 @@ export function MedicationsPage() { // If close was already confirmed programmatically, allow navigation if (closeConfirmedRef.current) { closeConfirmedRef.current = false; - if (currentEditMedId) { + if (currentMedicationLinkId && currentLinkMode) { // Prevent URL popstate from immediately reopening mobile edit for the same id. - processedEditMedIdRef.current = currentEditMedId; - clearEditMedIdParam(); + processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`; + clearMedicationLinkParams(); } if (showEditModal) { setShowEditModal(false); @@ -1231,11 +1249,11 @@ export function MedicationsPage() { setShowUnsavedConfirm(true); return; } - if (currentEditMedId) { + if (currentMedicationLinkId && currentLinkMode) { // Mark as handled before URL cleanup to avoid same-tick re-open races. - processedEditMedIdRef.current = currentEditMedId; + processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`; } - clearEditMedIdParam(); + clearMedicationLinkParams(); setShowEditModal(false); resetForm(); resetMedicationEnrichment(); @@ -1271,7 +1289,16 @@ export function MedicationsPage() { }; window.addEventListener("popstate", handlePopState); return () => window.removeEventListener("popstate", handlePopState); - }, [showObsoleteConfirm, showDeleteConfirm, showEditModal, viewMode, formChanged, resetForm, clearEditMedIdParam]); + }, [ + showObsoleteConfirm, + showDeleteConfirm, + showEditModal, + viewMode, + formChanged, + resetForm, + clearMedicationLinkParams, + getMedicationLinkState, + ]); // Close modal on Escape key useEffect(() => { @@ -1389,22 +1416,23 @@ export function MedicationsPage() { }, [activeMeds, editingId]); useEffect(() => { - const editMedId = searchParams.get("editMedId"); - if (!editMedId) { - processedEditMedIdRef.current = null; + const { mode: linkMode, linkedMedId } = getMedicationLinkState(searchParams); + if (!linkedMedId || !linkMode) { + processedMedicationLinkRef.current = null; return; } - if (processedEditMedIdRef.current === editMedId) return; - const parsedMedId = Number.parseInt(editMedId, 10); + const linkKey = `${linkMode}:${linkedMedId}`; + if (processedMedicationLinkRef.current === linkKey) return; + const parsedMedId = Number.parseInt(linkedMedId, 10); if (Number.isNaN(parsedMedId)) return; const medicationToEdit = meds.find((med) => med.id === parsedMedId) ?? allMeds.find((med) => med.id === parsedMedId); if (!medicationToEdit) return; - processedEditMedIdRef.current = editMedId; + processedMedicationLinkRef.current = linkKey; setShowNameValidation(false); - setReadOnlyView(false); + setReadOnlyView(linkMode === "view"); setActiveTab("general"); resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || ""); startEdit(medicationToEdit, openEditModal); @@ -1415,8 +1443,9 @@ export function MedicationsPage() { const nextParams = new URLSearchParams(searchParams); nextParams.delete("editMedId"); + nextParams.delete("viewMedId"); setSearchParams(nextParams, { replace: true }); - }, [allMeds, meds, openEditModal, searchParams, setSearchParams, startEdit]); + }, [allMeds, getMedicationLinkState, meds, openEditModal, searchParams, setSearchParams, startEdit]); const selectedMedication = useMemo(() => { if (!editingId) return null; diff --git a/frontend/src/test/App.test.tsx b/frontend/src/test/App.test.tsx index 5f0c693..0302f6e 100644 --- a/frontend/src/test/App.test.tsx +++ b/frontend/src/test/App.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; +import { MemoryRouter, useLocation } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import App from "../App"; @@ -59,7 +59,15 @@ vi.mock("../context", async () => { }); vi.mock("../pages", () => ({ - DashboardPage: () =>
dashboard-page
, + DashboardPage: () => { + const location = useLocation(); + return ( +
+ dashboard-page + {location.search} +
+ ); + }, MedicationsPage: () =>
medications-page
, PlannerPage: () =>
planner-page
, SchedulePage: () =>
schedule-page
, @@ -265,6 +273,19 @@ describe("App", () => { expect(screen.getByText("dashboard-page")).toBeInTheDocument(); }); + it("preserves notification query params when redirecting root to dashboard", () => { + const search = "?date=2026-05-06&medId=4332&doseId=4332-0-1778104500000"; + + render( + + + + ); + + expect(screen.getByText("dashboard-page")).toBeInTheDocument(); + expect(screen.getByTestId("dashboard-location-search")).toHaveTextContent(search); + }); + it("renders initializing state when auth state is missing", () => { authMock = { user: null, diff --git a/frontend/src/test/components/Auth.test.tsx b/frontend/src/test/components/Auth.test.tsx index 45c3a3b..6682a81 100644 --- a/frontend/src/test/components/Auth.test.tsx +++ b/frontend/src/test/components/Auth.test.tsx @@ -175,6 +175,10 @@ describe("LoginForm", () => { oidcProviderName: "", }; + afterEach(() => { + window.history.replaceState({}, "", "/"); + }); + beforeEach(() => { vi.clearAllMocks(); (global.fetch as ReturnType) diff --git a/frontend/src/test/pages/MedicationsPage.test.tsx b/frontend/src/test/pages/MedicationsPage.test.tsx index d322d9d..c67583b 100644 --- a/frontend/src/test/pages/MedicationsPage.test.tsx +++ b/frontend/src/test/pages/MedicationsPage.test.tsx @@ -475,6 +475,21 @@ describe("MedicationsPage with items", () => { }); }); + it("opens read-only view from viewMedId query parameter", async () => { + const startEdit = vi.fn(); + mockFormHookValue = createMockFormHook({ startEdit }); + fetchMock.mockResolvedValue({ ok: true, json: async () => mockMeds }); + + renderPage("/medications?viewMedId=1"); + + await waitFor(() => { + expect(startEdit).toHaveBeenCalledTimes(1); + }); + + expect(screen.getByText("common.close")).toBeInTheDocument(); + expect(screen.queryByText("common.save")).not.toBeInTheDocument(); + }); + it("opens unsaved confirm and continues edit after confirmation", async () => { const startEdit = vi.fn(); const resetForm = vi.fn();