mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0 * test: stabilize water capture re-entry
This commit is contained in:
1 parent
b01822be48
commit
62eb431d66
372 files changed
+25178
-8561
No files matched your search
@@ -0,0 +1,353 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createSeededFormDismissalController,
|
||||
createSeededFormHistoryTraversalGuard,
|
||||
safeInternalNavigationTarget,
|
||||
} from "../use-seeded-form-dismissal";
|
||||
|
||||
describe("seeded form dismissal", () => {
|
||||
const measurementSeed = {
|
||||
value: "72.4",
|
||||
measuredAt: "2026-07-21T08:30",
|
||||
notes: "before breakfast",
|
||||
};
|
||||
|
||||
it("closes a pristine edit immediately", () => {
|
||||
const close = vi.fn();
|
||||
const controller = createSeededFormDismissalController({
|
||||
seed: measurementSeed,
|
||||
value: measurementSeed,
|
||||
});
|
||||
|
||||
controller.requestClose(close);
|
||||
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(controller.getState()).toEqual({
|
||||
isDirty: false,
|
||||
discardDialogOpen: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("asks before closing an edit changed from its seeded values", () => {
|
||||
const close = vi.fn();
|
||||
const controller = createSeededFormDismissalController({
|
||||
seed: measurementSeed,
|
||||
value: { ...measurementSeed, value: "73.1" },
|
||||
});
|
||||
|
||||
controller.requestClose(close);
|
||||
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(controller.getState()).toEqual({
|
||||
isDirty: true,
|
||||
discardDialogOpen: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the changed draft when discard is cancelled", () => {
|
||||
const close = vi.fn();
|
||||
const changed = { ...measurementSeed, notes: "after breakfast" };
|
||||
const controller = createSeededFormDismissalController({
|
||||
seed: measurementSeed,
|
||||
value: changed,
|
||||
});
|
||||
|
||||
controller.requestClose(close);
|
||||
controller.cancelDiscard();
|
||||
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(controller.getState()).toEqual({
|
||||
isDirty: true,
|
||||
discardDialogOpen: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes and discards only after explicit confirmation", () => {
|
||||
const close = vi.fn();
|
||||
const controller = createSeededFormDismissalController({
|
||||
seed: measurementSeed,
|
||||
value: { ...measurementSeed, measuredAt: "2026-07-21T09:15" },
|
||||
});
|
||||
|
||||
controller.requestClose(close);
|
||||
controller.confirmDiscard();
|
||||
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(controller.getState().discardDialogOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a successfully saved profile value as the new clean seed", () => {
|
||||
const close = vi.fn();
|
||||
const profileSeed = {
|
||||
email: "person@example.test",
|
||||
heightCm: "175",
|
||||
timezone: "Europe/Berlin",
|
||||
};
|
||||
const savedProfile = { ...profileSeed, timezone: "Europe/London" };
|
||||
const controller = createSeededFormDismissalController({
|
||||
seed: profileSeed,
|
||||
value: savedProfile,
|
||||
});
|
||||
|
||||
expect(controller.getState().isDirty).toBe(true);
|
||||
controller.sync({ seed: savedProfile, value: savedProfile });
|
||||
controller.requestClose(close);
|
||||
|
||||
expect(controller.getState().isDirty).toBe(false);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not dismiss while a write is pending", () => {
|
||||
const close = vi.fn();
|
||||
const controller = createSeededFormDismissalController({
|
||||
seed: measurementSeed,
|
||||
value: { ...measurementSeed, value: "73.1" },
|
||||
blocked: true,
|
||||
});
|
||||
|
||||
controller.requestClose(close);
|
||||
controller.confirmDiscard();
|
||||
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(controller.getState().discardDialogOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seeded form Back navigation", () => {
|
||||
function createHistoryHarness() {
|
||||
let index = 1;
|
||||
let entries: unknown[] = [{}, {}];
|
||||
let onPopState:
|
||||
((event: { stopImmediatePropagation(): void }) => void) | null = null;
|
||||
const events: Array<{ stopImmediatePropagation(): void }> = [];
|
||||
const history = {
|
||||
get state() {
|
||||
return entries[index];
|
||||
},
|
||||
go(delta: number) {
|
||||
const nextIndex = index + delta;
|
||||
if (nextIndex < 0 || nextIndex >= entries.length) return;
|
||||
index = nextIndex;
|
||||
const event = { stopImmediatePropagation: vi.fn() };
|
||||
events.push(event);
|
||||
onPopState?.(event);
|
||||
},
|
||||
pushState(state: unknown, unused?: string, url?: string) {
|
||||
void unused;
|
||||
void url;
|
||||
entries = entries.slice(0, index + 1);
|
||||
entries.push(state);
|
||||
index += 1;
|
||||
},
|
||||
replaceState(state: unknown, unused?: string, url?: string) {
|
||||
void unused;
|
||||
void url;
|
||||
entries[index] = state;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
history,
|
||||
events,
|
||||
get index() {
|
||||
return index;
|
||||
},
|
||||
getCurrentIndex: () => index,
|
||||
listen(listener: typeof onPopState) {
|
||||
onPopState = listener;
|
||||
},
|
||||
back() {
|
||||
history.go(-1);
|
||||
},
|
||||
forward() {
|
||||
history.go(1);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDirtyBackNavigation() {
|
||||
const seed = { value: "72.4" };
|
||||
const draft = { value: "73.1" };
|
||||
const dismissal = createSeededFormDismissalController({
|
||||
seed,
|
||||
value: draft,
|
||||
});
|
||||
const historyHarness = createHistoryHarness();
|
||||
const requestClose = vi.fn((close: () => void) => {
|
||||
dismissal.requestClose(close);
|
||||
return dismissal.getState().discardDialogOpen;
|
||||
});
|
||||
const historyGuard = createSeededFormHistoryTraversalGuard({
|
||||
history: historyHarness.history,
|
||||
getCurrentIndex: historyHarness.getCurrentIndex,
|
||||
shouldBlock: () => dismissal.getState().isDirty,
|
||||
requestClose,
|
||||
});
|
||||
historyHarness.listen((event) => historyGuard.handlePopState(event));
|
||||
|
||||
return {
|
||||
dismissal,
|
||||
draft,
|
||||
historyGuard,
|
||||
historyHarness,
|
||||
requestClose,
|
||||
};
|
||||
}
|
||||
|
||||
it("restores the form route and dirty draft when Back is cancelled", () => {
|
||||
const { dismissal, draft, historyGuard, historyHarness, requestClose } =
|
||||
createDirtyBackNavigation();
|
||||
|
||||
historyHarness.back();
|
||||
|
||||
expect(historyHarness.index).toBe(1);
|
||||
expect(dismissal.getState().discardDialogOpen).toBe(true);
|
||||
expect(requestClose).toHaveBeenCalledOnce();
|
||||
|
||||
historyGuard.cancelPendingTraversal();
|
||||
dismissal.cancelDiscard();
|
||||
|
||||
expect(historyHarness.index).toBe(1);
|
||||
expect(draft).toEqual({ value: "73.1" });
|
||||
expect(dismissal.getState()).toEqual({
|
||||
isDirty: true,
|
||||
discardDialogOpen: false,
|
||||
});
|
||||
|
||||
historyHarness.back();
|
||||
|
||||
expect(historyHarness.index).toBe(1);
|
||||
expect(requestClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not prompt twice while the same Back dismissal is pending", () => {
|
||||
const { dismissal, historyHarness, requestClose } =
|
||||
createDirtyBackNavigation();
|
||||
|
||||
historyHarness.back();
|
||||
historyHarness.back();
|
||||
|
||||
expect(historyHarness.index).toBe(1);
|
||||
expect(historyHarness.events).toHaveLength(4);
|
||||
expect(requestClose).toHaveBeenCalledOnce();
|
||||
expect(dismissal.getState().discardDialogOpen).toBe(true);
|
||||
});
|
||||
|
||||
it("allows the original Back traversal after discard is confirmed", () => {
|
||||
const { dismissal, historyHarness } = createDirtyBackNavigation();
|
||||
|
||||
historyHarness.back();
|
||||
dismissal.confirmDiscard();
|
||||
|
||||
expect(historyHarness.index).toBe(0);
|
||||
expect(dismissal.getState().discardDialogOpen).toBe(false);
|
||||
expect(
|
||||
historyHarness.events.at(-1)?.stopImmediatePropagation,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the form route when Forward follows a Back return and edit without the Navigation API", () => {
|
||||
let dirty = false;
|
||||
const historyHarness = createHistoryHarness();
|
||||
const requestClose = vi.fn(() => true);
|
||||
const historyGuard = createSeededFormHistoryTraversalGuard({
|
||||
history: historyHarness.history,
|
||||
shouldBlock: () => dirty,
|
||||
requestClose,
|
||||
});
|
||||
historyHarness.listen((event) => historyGuard.handlePopState(event));
|
||||
|
||||
historyHarness.history.replaceState(
|
||||
{ section: "profile" },
|
||||
"",
|
||||
"/settings?section=profile",
|
||||
);
|
||||
historyHarness.history.pushState({}, "", "/next");
|
||||
historyHarness.back();
|
||||
dirty = true;
|
||||
historyHarness.forward();
|
||||
|
||||
expect(historyHarness.index).toBe(1);
|
||||
expect(requestClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("leaves clean-form Back traversal untouched", () => {
|
||||
const historyHarness = createHistoryHarness();
|
||||
const requestClose = vi.fn(() => true);
|
||||
const historyGuard = createSeededFormHistoryTraversalGuard({
|
||||
history: historyHarness.history,
|
||||
getCurrentIndex: historyHarness.getCurrentIndex,
|
||||
shouldBlock: () => false,
|
||||
requestClose,
|
||||
});
|
||||
historyHarness.listen((event) => historyGuard.handlePopState(event));
|
||||
|
||||
historyHarness.back();
|
||||
|
||||
expect(historyHarness.index).toBe(0);
|
||||
expect(requestClose).not.toHaveBeenCalled();
|
||||
expect(
|
||||
historyHarness.events[0]?.stopImmediatePropagation,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeInternalNavigationTarget", () => {
|
||||
it("keeps only same-origin path, query, and hash state", () => {
|
||||
expect(
|
||||
safeInternalNavigationTarget(
|
||||
"https://healthlog.test/settings/security?from=account#passkeys",
|
||||
"https://healthlog.test/settings/account",
|
||||
),
|
||||
).toBe("/settings/security?from=account#passkeys");
|
||||
});
|
||||
|
||||
it("rejects external and credential-bearing navigation targets", () => {
|
||||
expect(
|
||||
safeInternalNavigationTarget(
|
||||
"https://example.test/settings/security",
|
||||
"https://healthlog.test/settings/account",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
safeInternalNavigationTarget(
|
||||
"https://user:secret@healthlog.test/settings/security",
|
||||
"https://healthlog.test/settings/account",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("seeded dismissal migrations", () => {
|
||||
it.each([
|
||||
"measurements/measurement-list.tsx",
|
||||
"mood/mood-list.tsx",
|
||||
"labs/lab-history-list.tsx",
|
||||
])(
|
||||
"routes every %s edit exit through the shared contract",
|
||||
(relativePath) => {
|
||||
const source = readFileSync(
|
||||
new URL(`../../${relativePath}`, import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).toContain("useSeededFormDismissal({");
|
||||
expect(source).toContain("editDismissal.requestClose(dismissEdit)");
|
||||
expect(source).toContain("<SeededFormDiscardDialog");
|
||||
},
|
||||
);
|
||||
|
||||
it("guards profile navigation and refreshes its seed after a successful save", () => {
|
||||
const source = readFileSync(
|
||||
new URL("../../settings/account-section/index.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).toContain("guardNavigation: true");
|
||||
expect(source).toContain("setProfileSeed(savedProfile)");
|
||||
expect(source).toContain("<SeededFormDiscardDialog");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
type CloseRequest = () => void;
|
||||
|
||||
type ControllerOptions<T> = {
|
||||
seed: T;
|
||||
value: T;
|
||||
blocked?: boolean;
|
||||
equals?: (seed: T, value: T) => boolean;
|
||||
onStateChange?: () => void;
|
||||
};
|
||||
|
||||
type ControllerSync<T> = Pick<
|
||||
ControllerOptions<T>,
|
||||
"seed" | "value" | "blocked"
|
||||
>;
|
||||
|
||||
export type SeededFormDismissalController<T> = {
|
||||
sync(options: ControllerSync<T>): void;
|
||||
getState(): { isDirty: boolean; discardDialogOpen: boolean };
|
||||
requestClose(close: CloseRequest): void;
|
||||
confirmDiscard(): void;
|
||||
cancelDiscard(): void;
|
||||
};
|
||||
|
||||
function seededValuesEqual<T>(seed: T, value: T): boolean {
|
||||
return JSON.stringify(seed) === JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function createSeededFormDismissalController<T>(
|
||||
initial: ControllerOptions<T>,
|
||||
): SeededFormDismissalController<T> {
|
||||
let seed = initial.seed;
|
||||
let value = initial.value;
|
||||
let blocked = initial.blocked ?? false;
|
||||
let discardDialogOpen = false;
|
||||
let pendingClose: CloseRequest | null = null;
|
||||
const equals = initial.equals ?? seededValuesEqual;
|
||||
const notify = initial.onStateChange ?? (() => undefined);
|
||||
|
||||
return {
|
||||
sync(next) {
|
||||
seed = next.seed;
|
||||
value = next.value;
|
||||
blocked = next.blocked ?? false;
|
||||
},
|
||||
getState() {
|
||||
return {
|
||||
isDirty: !equals(seed, value),
|
||||
discardDialogOpen,
|
||||
};
|
||||
},
|
||||
requestClose(close) {
|
||||
if (blocked) return;
|
||||
if (equals(seed, value)) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
pendingClose = close;
|
||||
discardDialogOpen = true;
|
||||
notify();
|
||||
},
|
||||
confirmDiscard() {
|
||||
if (blocked || !discardDialogOpen) return;
|
||||
const close = pendingClose;
|
||||
pendingClose = null;
|
||||
discardDialogOpen = false;
|
||||
notify();
|
||||
close?.();
|
||||
},
|
||||
cancelDiscard() {
|
||||
if (!discardDialogOpen) return;
|
||||
pendingClose = null;
|
||||
discardDialogOpen = false;
|
||||
notify();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type HistoryTraversal = {
|
||||
readonly state?: unknown;
|
||||
go(delta: number): void;
|
||||
pushState?(data: unknown, unused: string, url?: string | URL | null): void;
|
||||
replaceState?(data: unknown, unused: string, url?: string | URL | null): void;
|
||||
};
|
||||
|
||||
type HistoryTraversalGuardOptions = {
|
||||
history: HistoryTraversal;
|
||||
getCurrentIndex?: () => number | undefined;
|
||||
shouldBlock: () => boolean;
|
||||
requestClose: (close: CloseRequest) => boolean;
|
||||
};
|
||||
|
||||
type InterceptablePopStateEvent = {
|
||||
stopImmediatePropagation(): void;
|
||||
};
|
||||
|
||||
export type SeededFormHistoryTraversalGuard = {
|
||||
handlePopState(event: InterceptablePopStateEvent): void;
|
||||
cancelPendingTraversal(): void;
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
const SEEDED_FORM_HISTORY_INDEX = "__healthlogSeededFormHistoryIndex";
|
||||
|
||||
function createHistoryEntryIndexTracker(history: HistoryTraversal) {
|
||||
const getCurrentIndex = () => {
|
||||
if (
|
||||
history.state === null ||
|
||||
typeof history.state !== "object" ||
|
||||
Array.isArray(history.state)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const index = (history.state as Record<string, unknown>)[
|
||||
SEEDED_FORM_HISTORY_INDEX
|
||||
];
|
||||
return typeof index === "number" ? index : undefined;
|
||||
};
|
||||
const originalPushState = history.pushState;
|
||||
const originalReplaceState = history.replaceState;
|
||||
if (originalPushState === undefined || originalReplaceState === undefined) {
|
||||
return { getCurrentIndex, dispose: () => undefined };
|
||||
}
|
||||
|
||||
const withIndex = (state: unknown, index: number) => ({
|
||||
...(state !== null && typeof state === "object" && !Array.isArray(state)
|
||||
? state
|
||||
: {}),
|
||||
[SEEDED_FORM_HISTORY_INDEX]: index,
|
||||
});
|
||||
if (getCurrentIndex() === undefined) {
|
||||
originalReplaceState.call(history, withIndex(history.state, 0), "");
|
||||
}
|
||||
|
||||
const trackedPushState: NonNullable<HistoryTraversal["pushState"]> = (
|
||||
data,
|
||||
unused,
|
||||
url,
|
||||
) => {
|
||||
const nextIndex = (getCurrentIndex() ?? 0) + 1;
|
||||
originalPushState.call(history, withIndex(data, nextIndex), unused, url);
|
||||
};
|
||||
const trackedReplaceState: NonNullable<HistoryTraversal["replaceState"]> = (
|
||||
data,
|
||||
unused,
|
||||
url,
|
||||
) => {
|
||||
originalReplaceState.call(
|
||||
history,
|
||||
withIndex(data, getCurrentIndex() ?? 0),
|
||||
unused,
|
||||
url,
|
||||
);
|
||||
};
|
||||
history.pushState = trackedPushState;
|
||||
history.replaceState = trackedReplaceState;
|
||||
|
||||
return {
|
||||
getCurrentIndex,
|
||||
dispose() {
|
||||
if (history.pushState === trackedPushState) {
|
||||
history.pushState = originalPushState;
|
||||
}
|
||||
if (history.replaceState === trackedReplaceState) {
|
||||
history.replaceState = originalReplaceState;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createSeededFormHistoryTraversalGuard({
|
||||
history,
|
||||
getCurrentIndex,
|
||||
shouldBlock,
|
||||
requestClose,
|
||||
}: HistoryTraversalGuardOptions): SeededFormHistoryTraversalGuard {
|
||||
const nativeIndex = getCurrentIndex?.();
|
||||
const indexTracker =
|
||||
nativeIndex === undefined
|
||||
? createHistoryEntryIndexTracker(history)
|
||||
: undefined;
|
||||
const currentIndex =
|
||||
indexTracker === undefined
|
||||
? () => getCurrentIndex?.()
|
||||
: indexTracker.getCurrentIndex;
|
||||
const guardedIndex = currentIndex();
|
||||
let allowNextTraversal = false;
|
||||
let restoringRoute = false;
|
||||
let promptOpen = false;
|
||||
let confirmedWhileRestoring = false;
|
||||
let confirmedTraversalDelta = -1;
|
||||
|
||||
const continueTraversal = () => {
|
||||
promptOpen = false;
|
||||
if (restoringRoute) {
|
||||
confirmedWhileRestoring = true;
|
||||
return;
|
||||
}
|
||||
|
||||
allowNextTraversal = true;
|
||||
history.go(confirmedTraversalDelta);
|
||||
};
|
||||
|
||||
return {
|
||||
handlePopState(event) {
|
||||
if (allowNextTraversal) {
|
||||
allowNextTraversal = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoringRoute) {
|
||||
event.stopImmediatePropagation();
|
||||
restoringRoute = false;
|
||||
if (confirmedWhileRestoring) {
|
||||
confirmedWhileRestoring = false;
|
||||
allowNextTraversal = true;
|
||||
history.go(confirmedTraversalDelta);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldBlock()) return;
|
||||
|
||||
event.stopImmediatePropagation();
|
||||
const traversedIndex = currentIndex();
|
||||
const restorationDelta =
|
||||
guardedIndex === undefined ||
|
||||
traversedIndex === undefined ||
|
||||
guardedIndex === traversedIndex
|
||||
? 1
|
||||
: guardedIndex - traversedIndex;
|
||||
confirmedTraversalDelta = -restorationDelta;
|
||||
restoringRoute = true;
|
||||
history.go(restorationDelta);
|
||||
|
||||
if (promptOpen) return;
|
||||
promptOpen = true;
|
||||
if (!requestClose(continueTraversal)) {
|
||||
promptOpen = false;
|
||||
}
|
||||
},
|
||||
cancelPendingTraversal() {
|
||||
promptOpen = false;
|
||||
confirmedWhileRestoring = false;
|
||||
},
|
||||
dispose() {
|
||||
indexTracker?.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function safeInternalNavigationTarget(
|
||||
href: string,
|
||||
currentHref: string,
|
||||
): string | null {
|
||||
try {
|
||||
const current = new URL(currentHref);
|
||||
const target = new URL(href, current);
|
||||
if (
|
||||
target.protocol !== current.protocol ||
|
||||
target.origin !== current.origin ||
|
||||
target.username !== "" ||
|
||||
target.password !== ""
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return `${target.pathname}${target.search}${target.hash}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type UseSeededFormDismissalOptions<T> = {
|
||||
seed: T;
|
||||
value: T;
|
||||
blocked?: boolean;
|
||||
guardNavigation?: boolean;
|
||||
navigate?: (target: string) => void;
|
||||
};
|
||||
|
||||
export function useSeededFormDismissal<T>({
|
||||
seed,
|
||||
value,
|
||||
blocked = false,
|
||||
guardNavigation = false,
|
||||
navigate,
|
||||
}: UseSeededFormDismissalOptions<T>) {
|
||||
const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
|
||||
const pendingCloseRef = useRef<CloseRequest | null>(null);
|
||||
const historyTraversalGuardRef =
|
||||
useRef<SeededFormHistoryTraversalGuard | null>(null);
|
||||
const isDirty = !seededValuesEqual(seed, value);
|
||||
const requestClose = useCallback(
|
||||
(close: CloseRequest) => {
|
||||
if (blocked) return false;
|
||||
if (!isDirty) {
|
||||
close();
|
||||
return true;
|
||||
}
|
||||
pendingCloseRef.current = close;
|
||||
setDiscardDialogOpen(true);
|
||||
return true;
|
||||
},
|
||||
[blocked, isDirty],
|
||||
);
|
||||
const confirmDiscard = useCallback(() => {
|
||||
if (blocked || !discardDialogOpen) return;
|
||||
const close = pendingCloseRef.current;
|
||||
pendingCloseRef.current = null;
|
||||
setDiscardDialogOpen(false);
|
||||
close?.();
|
||||
}, [blocked, discardDialogOpen]);
|
||||
const cancelDiscard = useCallback(() => {
|
||||
historyTraversalGuardRef.current?.cancelPendingTraversal();
|
||||
pendingCloseRef.current = null;
|
||||
setDiscardDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!guardNavigation) return;
|
||||
|
||||
const navigation = (
|
||||
window as Window & {
|
||||
navigation?: { currentEntry?: { index: number } };
|
||||
}
|
||||
).navigation;
|
||||
const historyTraversalGuard = createSeededFormHistoryTraversalGuard({
|
||||
history: window.history,
|
||||
getCurrentIndex: () => navigation?.currentEntry?.index,
|
||||
shouldBlock: () => isDirty,
|
||||
requestClose,
|
||||
});
|
||||
historyTraversalGuardRef.current = historyTraversalGuard;
|
||||
const handlePopState = (event: PopStateEvent) => {
|
||||
historyTraversalGuard.handlePopState(event);
|
||||
};
|
||||
|
||||
window.addEventListener("popstate", handlePopState, true);
|
||||
return () => {
|
||||
window.removeEventListener("popstate", handlePopState, true);
|
||||
historyTraversalGuard.dispose();
|
||||
if (historyTraversalGuardRef.current === historyTraversalGuard) {
|
||||
historyTraversalGuardRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [guardNavigation, isDirty, requestClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!guardNavigation || !isDirty) return;
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
const handleDocumentClick = (event: MouseEvent) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey ||
|
||||
!(event.target instanceof Element)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = event.target.closest<HTMLAnchorElement>("a[href]");
|
||||
if (
|
||||
!anchor ||
|
||||
anchor.hasAttribute("download") ||
|
||||
(anchor.target !== "" && anchor.target !== "_self")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = safeInternalNavigationTarget(
|
||||
anchor.href,
|
||||
window.location.href,
|
||||
);
|
||||
if (target === null || navigate === undefined) return;
|
||||
|
||||
const targetUrl = new URL(target, window.location.origin);
|
||||
const currentDocument = `${window.location.pathname}${window.location.search}`;
|
||||
if (`${targetUrl.pathname}${targetUrl.search}` === currentDocument)
|
||||
return;
|
||||
event.preventDefault();
|
||||
requestClose(() => navigate(target));
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
document.addEventListener("click", handleDocumentClick, true);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
document.removeEventListener("click", handleDocumentClick, true);
|
||||
};
|
||||
}, [guardNavigation, isDirty, navigate, requestClose]);
|
||||
|
||||
return {
|
||||
isDirty,
|
||||
discardDialogOpen,
|
||||
requestClose,
|
||||
confirmDiscard,
|
||||
cancelDiscard,
|
||||
};
|
||||
}
|
||||
|
||||
export function SeededFormDiscardDialog({
|
||||
open,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={(next) => !next && onCancel()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("dashboard.quickEntryDiscard.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("dashboard.quickEntryDiscard.description")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel}>
|
||||
{t("dashboard.quickEntryDiscard.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>
|
||||
{t("dashboard.quickEntryDiscard.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user