chore: consolidate platform split checks
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
Module Matrix / module-matrix (push) Has been cancelled

This commit is contained in:
2026-07-10 12:51:19 +02:00
parent 150b720f12
commit 635d25c74c
216 changed files with 23336 additions and 4077 deletions

View File

@@ -1,5 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, type NavigateFunction, type NavigateOptions, type To } from "react-router-dom";
import Button from "./Button";
import Dialog from "./Dialog";
import DismissibleAlert from "./DismissibleAlert";
@@ -13,6 +13,15 @@ export type UnsavedChangesRegistration = {
onDiscard?: () => void;
};
export type UnsavedDraftGuardOptions = {
dirty: boolean;
title?: string;
message?: string;
onSave: () => boolean | Promise<boolean>;
onDiscard?: () => void;
enabled?: boolean;
};
type UnsavedChangesContextValue = {
hasUnsavedChanges: boolean;
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
@@ -21,13 +30,15 @@ type UnsavedChangesContextValue = {
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
const navigate = useNavigate();
const [registration, setRegistration] = useState<UnsavedChangesRegistration | null>(null);
const [registrations, setRegistrations] = useState<Array<{id: number;registration: UnsavedChangesRegistration;}>>([]);
const [pendingAction, setPendingAction] = useState<UnsavedNavigationAction | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const registrationRef = useRef<UnsavedChangesRegistration | null>(null);
const nextRegistrationId = useRef(1);
const registration = registrations.length ? registrations[registrations.length - 1].registration : null;
useEffect(() => {
registrationRef.current = registration;
@@ -36,9 +47,12 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
const hasUnsavedChanges = Boolean(registration);
const registerUnsavedChanges = useCallback((next: UnsavedChangesRegistration | null) => {
setRegistration(next);
if (!next) return () => undefined;
const id = nextRegistrationId.current;
nextRegistrationId.current += 1;
setRegistrations((current) => [...current, { id, registration: next }]);
return () => {
setRegistration((current) => current === next ? null : current);
setRegistrations((current) => current.filter((item) => item.id !== id));
};
}, []);
@@ -60,9 +74,12 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
useEffect(() => {
function onBeforeUnload(event: BeforeUnloadEvent) {
if (!registrationRef.current) return;
const active = registrationRef.current;
if (!active) return;
const message = active.message || "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b";
event.preventDefault();
event.returnValue = "";
event.returnValue = message;
return message;
}
window.addEventListener("beforeunload", onBeforeUnload);
@@ -111,7 +128,7 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
try {
const ok = await active.onSave();
if (!ok) {
setSaveError("The changes could not be saved. Please review the page message and try again.");
setSaveError("i18n:govoplan-core.the_changes_could_not_be_saved_please_review_the.4615a3c6");
return;
}
proceed(action);
@@ -139,30 +156,30 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
return (
<UnsavedChangesContext.Provider value={value}>
{children}
{pendingAction && registration && (
<Dialog
open
role="alertdialog"
title={registration.title ?? "Unsaved changes"}
className="unsaved-changes-dialog"
footerClassName="unsaved-changes-actions"
closeOnBackdrop={!saving}
closeDisabled={saving}
onClose={() => setPendingAction(null)}
footer={(
<>
<Button onClick={() => setPendingAction(null)} disabled={saving}>Cancel</Button>
<Button onClick={handleDiscardAndLeave} disabled={saving}>Discard</Button>
<Button variant="primary" onClick={() => void handleSaveAndLeave()} disabled={saving}>{saving ? "Saving..." : "Save and leave"}</Button>
{pendingAction && registration &&
<Dialog
open
role="alertdialog"
title={registration.title ?? "i18n:govoplan-core.unsaved_changes.29267269"}
className="unsaved-changes-dialog"
footerClassName="unsaved-changes-actions"
closeOnBackdrop={!saving}
closeDisabled={saving}
onClose={() => setPendingAction(null)}
footer={
<>
<Button onClick={() => setPendingAction(null)} disabled={saving}>i18n:govoplan-core.cancel.77dfd213</Button>
<Button onClick={handleDiscardAndLeave} disabled={saving}>i18n:govoplan-core.discard.36fff63c</Button>
<Button variant="primary" onClick={() => void handleSaveAndLeave()} disabled={saving}>{saving ? "i18n:govoplan-core.saving.ae7e8875" : "i18n:govoplan-core.save_and_leave.0507824a"}</Button>
</>
)}
>
<p>{registration.message ?? "This page has unsaved changes. Save them before leaving, or discard the changes and continue."}</p>
}>
<p>{registration.message ?? "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b"}</p>
{saveError && <DismissibleAlert tone="danger" resetKey={saveError}>{saveError}</DismissibleAlert>}
</Dialog>
)}
</UnsavedChangesContext.Provider>
);
}
</UnsavedChangesContext.Provider>);
}
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
@@ -182,3 +199,44 @@ export function useRegisterUnsavedChanges(registration: UnsavedChangesRegistrati
return registerUnsavedChanges(registration);
}, [registerUnsavedChanges, registration]);
}
export function useUnsavedDraftGuard({
dirty,
title = "i18n:govoplan-core.unsaved_changes.29267269",
message = "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b",
onSave,
onDiscard,
enabled = true
}: UnsavedDraftGuardOptions) {
const saveRef = useRef(onSave);
const discardRef = useRef(onDiscard);
useEffect(() => {
saveRef.current = onSave;
discardRef.current = onDiscard;
}, [onDiscard, onSave]);
const registration = useMemo<UnsavedChangesRegistration | null>(() => enabled && dirty ? {
title,
message,
onSave: () => saveRef.current(),
onDiscard: () => discardRef.current?.()
} : null, [dirty, enabled, message, title]);
useRegisterUnsavedChanges(registration);
}
export function useGuardedNavigate(): NavigateFunction {
const navigate = useNavigate();
const { requestNavigation } = useUnsavedChanges();
return useCallback(((to: To | number, options?: NavigateOptions) => {
requestNavigation(() => {
if (typeof to === "number") {
navigate(to);
} else {
navigate(to, options);
}
});
}) as NavigateFunction, [navigate, requestNavigation]);
}