fix: preserve frontend medication deep links

This commit is contained in:
Daniel Volz
2026-05-10 20:25:14 +02:00
committed by GitHub
parent d4b8ddc590
commit eba77c9520
5 changed files with 95 additions and 26 deletions
+1 -1
View File
@@ -506,7 +506,7 @@ function AppContent() {
<AboutModal isOpen={showAbout} onClose={closeAbout} />
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/" element={<Navigate to={{ pathname: "/dashboard", search: location.search }} replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/medications" element={<MedicationsPage />} />
+52 -23
View File
@@ -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<string | null>(null);
const processedMedicationLinkRef = useRef<string | null>(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;
+23 -2
View File
@@ -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: () => <div>dashboard-page</div>,
DashboardPage: () => {
const location = useLocation();
return (
<div>
<span>dashboard-page</span>
<span data-testid="dashboard-location-search">{location.search}</span>
</div>
);
},
MedicationsPage: () => <div>medications-page</div>,
PlannerPage: () => <div>planner-page</div>,
SchedulePage: () => <div>schedule-page</div>,
@@ -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(
<MemoryRouter initialEntries={[`/${search}`]}>
<App />
</MemoryRouter>
);
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,
@@ -175,6 +175,10 @@ describe("LoginForm", () => {
oidcProviderName: "",
};
afterEach(() => {
window.history.replaceState({}, "", "/");
});
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
@@ -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();