form.markAsSaved(); applied across the application

This commit is contained in:
Colin Dawson 2026-02-12 17:48:28 +00:00
parent 046869510a
commit 5f03b1cccc
20 changed files with 260 additions and 21 deletions

View File

@ -19,6 +19,7 @@ import { renderCustomField } from "./formHelpers";
import { CustomFieldValue } from "../../modules/manager/glossary/services/glossaryService";
import { useTranslation } from "react-i18next";
import { Namespaces } from "../../i18n/i18n";
import { useFormWithGuard } from "./useFormRouter";
interface TemplateFillerProps {
templateId?: GeneralIdRef;
@ -40,7 +41,7 @@ interface TemplateState {
const TemplateFiller = forwardRef<TemplateFillerHandle, TemplateFillerProps>(
({ templateId, formInstanceId, onValidationChanged }, ref) => {
const form = useForm({
const form = useFormWithGuard({
loaded: false,
data: {},
errors: {},

View File

@ -91,6 +91,9 @@ interface UseFormReturn {
hasUnsavedChanges: () => boolean;
markAsSaved: () => void;
setupNavigationGuard: (onBlock?: (location: Location) => void) => () => void;
enableReactRouterGuard: () => void;
disableReactRouterGuard: () => void;
routerGuardEnabledRef: React.MutableRefObject<boolean>;
}
export const useForm = (initialState: FormState): UseFormReturn => {
@ -581,6 +584,18 @@ export const useForm = (initialState: FormState): UseFormReturn => {
initialDataRef.current = JSON.parse(JSON.stringify(state.data));
}, [state.data]);
// React Router navigation guard state
const routerGuardEnabledRef = useRef<boolean>(true);
// Setup React Router blocker for in-app navigation
const enableReactRouterGuard = useCallback((): void => {
routerGuardEnabledRef.current = true;
}, []);
const disableReactRouterGuard = useCallback((): void => {
routerGuardEnabledRef.current = false;
}, []);
const setupNavigationGuard = useCallback(
(onBlock?: (location: Location) => void): (() => void) => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
@ -633,6 +648,9 @@ export const useForm = (initialState: FormState): UseFormReturn => {
hasUnsavedChanges,
markAsSaved,
setupNavigationGuard,
enableReactRouterGuard,
disableReactRouterGuard,
routerGuardEnabledRef,
};
Object.defineProperty(api, "schema", {
get: () => schemaRef.current,
@ -643,3 +661,34 @@ export const useForm = (initialState: FormState): UseFormReturn => {
return api as UseFormReturn;
};
/**
* Hook to enable React Router data router navigation blocking.
*
* Call this hook in your component to enable navigation guards for React Router's data router.
* This is optional and only needed if you're using RouterProvider (not BrowserRouter).
* The browserunload guard from useForm still works regardless.
*
* @param formApi The return value from useForm
*
* @example
* // In your component that is inside a data router:
* import { useBlocker } from "react-router-dom";
*
* const MyEditForm = () => {
* const form = useForm(initialState);
*
* // Only call this if inside RouterProvider (data router)
* const blocker = useBlocker(({ currentLocation, nextLocation }) =>
* form.routerGuardEnabledRef.current &&
* form.hasUnsavedChanges() &&
* currentLocation.pathname !== nextLocation.pathname
* );
*
* return (...);
* }
*/
export const useFormRouterBlocker = (formApi: UseFormReturn): void => {
// This is a documentation function - actual implementation is done in the component
// See the example above for how to properly set up router blocking with useBlocker
};

View File

@ -0,0 +1,159 @@
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { UseFormReturn, useForm, FormState } from "./useForm";
/**
* Hook for BrowserRouter navigation blocking with unsaved changes.
*
* Works with BrowserRouter (standard routing, not data router).
* Shows a confirmation dialog when user attempts to navigate away with unsaved changes.
*
* @param formApi - The return value from useForm()
*
* @example
* const form = useForm(initialState);
* useBrowserRouterFormGuard(form);
*/
export const useBrowserRouterFormGuard = (formApi: UseFormReturn): void => {
const location = useLocation();
const { hasUnsavedChanges, routerGuardEnabledRef, disableReactRouterGuard } =
formApi;
const previousLocationRef = useRef(location);
const pendingNavigationRef = useRef<string | null>(null);
useEffect(() => {
// If navigation was attempted and we're at a new location
if (
location.pathname !== previousLocationRef.current.pathname &&
pendingNavigationRef.current
) {
// Navigation succeeded, reset
previousLocationRef.current = location;
pendingNavigationRef.current = null;
return;
}
// Check if we need to guard against navigation
if (
hasUnsavedChanges() &&
routerGuardEnabledRef.current &&
pendingNavigationRef.current === null
) {
// Store the current location for comparison
previousLocationRef.current = location;
}
}, [
location,
hasUnsavedChanges,
routerGuardEnabledRef,
disableReactRouterGuard,
]);
// Intercept navigation attempts by wrapping navigate
useEffect(() => {
// Override the window's history behavior to catch navigation attempts
const originalPushState = window.history.pushState;
const originalReplaceState = window.history.replaceState;
const handleNavigation = (
method: (state: any, title: string, url?: string | null) => void,
state: any,
title: string,
url?: string | null,
) => {
if (
hasUnsavedChanges() &&
routerGuardEnabledRef.current &&
url &&
url !== window.location.pathname + window.location.search
) {
// Prompt user
const confirmed = window.confirm(
"You have unsaved changes. Do you want to leave without saving?",
);
if (confirmed) {
disableReactRouterGuard();
method.call(window.history, state, title, url);
setTimeout(() => {
formApi.enableReactRouterGuard();
}, 0);
}
} else {
method.call(window.history, state, title, url);
}
};
window.history.pushState = function (state, title, url) {
handleNavigation(originalPushState, state, title, url);
};
window.history.replaceState = function (state, title, url) {
handleNavigation(originalReplaceState, state, title, url);
};
return () => {
window.history.pushState = originalPushState;
window.history.replaceState = originalReplaceState;
};
}, [
hasUnsavedChanges,
routerGuardEnabledRef,
disableReactRouterGuard,
formApi,
]);
};
/**
* Hook to enable React Router data router navigation blocking.
*
* Call this hook in your component to enable navigation guards for React Router's data router.
* This is optional and only needed if you're using RouterProvider (not BrowserRouter).
* The beforeunload guard from useForm still works regardless.
*
* @param formApi The return value from useForm
*
* @example
* // In your component that is inside a data router:
* import { useBlocker } from "react-router-dom";
*
* const MyEditForm = () => {
* const form = useForm(initialState);
*
* // Only call this if inside RouterProvider (data router)
* const blocker = useBlocker(({ currentLocation, nextLocation }) =>
* form.routerGuardEnabledRef.current &&
* form.hasUnsavedChanges() &&
* currentLocation.pathname !== nextLocation.pathname
* );
*
* return (...);
* }
*/
export const useFormRouterBlocker = (formApi: UseFormReturn): void => {
// This is a documentation function - actual implementation is done in the component
// See the example above for how to properly set up router blocking with useBlocker
};
/**
* Combined hook that creates a form with automatic unsaved changes navigation guard.
*
* This is the recommended way to create forms with navigation protection in BrowserRouter apps.
* It combines useForm() and useBrowserRouterFormGuard() into a single call.
*
* @param initialState - The initial form state
* @returns The form API with navigation guard already set up
*
* @example
* // Instead of:
* const form = useForm(initialState);
* useBrowserRouterFormGuard(form);
*
* // Just use:
* const form = useFormWithGuard(initialState);
*/
export const useFormWithGuard = (initialState: FormState): UseFormReturn => {
const form = useForm(initialState);
useBrowserRouterFormGuard(form);
return form;
};

View File

@ -4,7 +4,6 @@ import Joi from "joi";
import authentication from "../services/authenticationService";
import { InputType } from "../../../components/common/Input";
import { ButtonType } from "../../../components/common/Button";
import { useForm } from "../../../components/common/useForm";
import {
renderButton,
renderError,
@ -12,6 +11,7 @@ import {
} from "../../../components/common/formHelpers";
import { useTranslation } from "react-i18next";
import { Namespaces } from "../../../i18n/i18n";
import { useFormWithGuard } from "../../../components/common/useFormRouter";
const InternalLoginForm: React.FC = () => {
const { t } = useTranslation(Namespaces.Common);
@ -19,7 +19,7 @@ const InternalLoginForm: React.FC = () => {
const [emailSent, setEmailSent] = useState(false);
const passwordMaxLength = 255;
const form = useForm({
const form = useFormWithGuard({
loaded: true,
data: {
username: "",

View File

@ -28,6 +28,7 @@ import {
SystemGlossaries,
} from "../glossary/services/glossaryService";
import Loading from "../../../components/common/Loading";
import { useFormWithGuard } from "../../../components/common/useFormRouter";
interface CustomFieldDetailsProps {
editMode?: boolean;
@ -54,7 +55,7 @@ const CustomFieldDetails: React.FC<CustomFieldDetailsProps> = ({
const labelApply = t("Save");
const labelSave = t("SaveAndClose");
const form = useForm({
const form = useFormWithGuard({
loaded: false,
data: {
name: "",
@ -318,6 +319,8 @@ const CustomFieldDetails: React.FC<CustomFieldDetailsProps> = ({
}
if (buttonName === "save") form.setState({ redirect: "/customfields" });
form.markAsSaved();
} catch (ex: any) {
form.handleGeneralError(ex);
}

View File

@ -13,6 +13,7 @@ import {
renderButton,
renderUserPicker,
} from "../../../../components/common/formHelpers";
import { useFormWithGuard } from "../../../../components/common/useFormRouter";
interface LocAddUserToRoleProps {
isEditMode: boolean;
@ -29,7 +30,7 @@ const AddUserToRole: React.FC<LocAddUserToRoleProps> = ({ isEditMode }) => {
const labelApply = t("Save");
const labelSave = t("SaveAndClose");
const form = useForm({
const form = useFormWithGuard({
loaded: true,
data: {
userId: undefined,
@ -57,6 +58,8 @@ const AddUserToRole: React.FC<LocAddUserToRoleProps> = ({ isEditMode }) => {
form.setState({
redirect: `/domains/edit/${domainId}#securityRoles/${roleId}/users`,
});
form.markAsSaved();
}
} catch (ex: any) {
form.handleGeneralError(ex);

View File

@ -14,6 +14,7 @@ import {
renderInput,
renderTemplateEditor,
} from "../../../../components/common/formHelpers";
import { useFormWithGuard } from "../../../../components/common/useFormRouter";
interface EmailTemplateEditorProps {
domainId?: string;
@ -33,7 +34,7 @@ const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
const labelApply = t("Save");
const labelSave = t("SaveAndClose");
const form = useForm({
const form = useFormWithGuard({
loaded: false,
data: {
currentMailType: undefined,

View File

@ -15,6 +15,7 @@ import {
renderInput,
renderSsoProviderPicker,
} from "../../../../components/common/formHelpers";
import { useFormWithGuard } from "../../../../components/common/useFormRouter";
interface GeneralTabProps {
isEditMode: boolean;
@ -34,7 +35,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ isEditMode }) => {
const labelApply = t("Save");
const labelSave = t("SaveAndClose");
const form = useForm({
const form = useFormWithGuard({
loaded: false,
data: {
name: "",
@ -117,6 +118,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ isEditMode }) => {
}
if (buttonName === "save") form.setState({ redirect: "/domains" });
form.markAsSaved();
} catch (ex: any) {
form.handleGeneralError(ex);
}

View File

@ -14,6 +14,7 @@ import {
renderButton,
renderInput,
} from "../../../../components/common/formHelpers";
import { useFormWithGuard } from "../../../../components/common/useFormRouter";
interface RolesDetailsProps {
isEditMode: boolean;
@ -30,7 +31,7 @@ const RolesDetails: React.FC<RolesDetailsProps> = ({ isEditMode }) => {
const labelApply = t("Save");
const labelSave = t("SaveAndClose");
const form = useForm({
const form = useFormWithGuard({
loaded: false,
data: {
name: "",

View File

@ -15,6 +15,7 @@ import {
renderInput,
renderTemplateEditor,
} from "../../../components/common/formHelpers";
import { useFormWithGuard } from "../../../components/common/useFormRouter";
const FormsDetails: React.FC<{ editMode?: boolean }> = ({
editMode = false,
@ -28,7 +29,7 @@ const FormsDetails: React.FC<{ editMode?: boolean }> = ({
const labelApply = t("Save");
const labelSave = t("SaveAndClose");
const form = useForm({
const form = useFormWithGuard({
loaded: false,
data: {
name: "",
@ -93,6 +94,7 @@ const FormsDetails: React.FC<{ editMode?: boolean }> = ({
}
if (buttonName === "save") form.setState({ redirect: "/forms" });
form.markAsSaved();
} catch (ex: any) {
form.handleGeneralError(ex);
}

View File

@ -21,6 +21,7 @@ import {
renderCustomFields,
renderCustomFieldsEditor,
} from "../../../components/common/formHelpers";
import { useFormWithGuard } from "../../../components/common/useFormRouter";
interface GlossariesDetailsProps {
editMode?: boolean;
@ -38,7 +39,7 @@ const GlossariesDetails: React.FC<GlossariesDetailsProps> = ({
const labelApply = t("Save");
const labelSave = t("SaveAndClose");
const form = useForm({
const form = useFormWithGuard({
loaded: false,
data: {
id: undefined,
@ -182,6 +183,7 @@ const GlossariesDetails: React.FC<GlossariesDetailsProps> = ({
const navigateId = parentGlossary ? parentGlossary.id.toString() : "";
if (buttonName === "save")
form.setState({ redirect: "/glossaries/" + navigateId });
form.markAsSaved();
} catch (ex: any) {
form.handleGeneralError(ex);
}

View File

@ -16,6 +16,7 @@ import {
renderInput,
renderSelect,
} from "../../../components/common/formHelpers";
import { useFormWithGuard } from "../../../components/common/useFormRouter";
const OrganisationsDetails: React.FC<{ editMode?: boolean }> = ({
editMode = false,
@ -36,7 +37,7 @@ const OrganisationsDetails: React.FC<{ editMode?: boolean }> = ({
{ _id: "Blocked", name: t("Blocked") },
];
const form = useForm({
const form = useFormWithGuard({
loaded: false,
data: {
name: "",
@ -112,6 +113,7 @@ const OrganisationsDetails: React.FC<{ editMode?: boolean }> = ({
}
if (buttonName === "save") form.setState({ redirect: "/organisations" });
form.markAsSaved();
} catch (ex: any) {
form.handleGeneralError(ex);
}

View File

@ -17,6 +17,7 @@ import sequenceService from "./services/sequenceService";
import Option from "../../../components/common/option";
import { MakeGeneralIdRef } from "../../../utils/GeneralIdRef";
import Loading from "../../../components/common/Loading";
import { useFormWithGuard } from "../../../components/common/useFormRouter";
interface SequenceDetailsProps {
editMode?: boolean;
@ -37,7 +38,7 @@ const SequenceDetails: React.FC<SequenceDetailsProps> = ({
const labelApply = t("Save");
const labelSave = t("SaveAndClose");
const form = useForm({
const form = useFormWithGuard({
loaded: false,
data: {
name: "",
@ -131,6 +132,7 @@ const SequenceDetails: React.FC<SequenceDetailsProps> = ({
if (buttonName === "save") {
form.setState({ redirect: "/sequence" });
}
form.markAsSaved();
} catch (ex: any) {
form.handleGeneralError(ex);
}

View File

@ -16,6 +16,7 @@ import { MakeGeneralIdRef } from "../../../utils/GeneralIdRef";
import Option from "../../../components/common/option";
import siteService from "./services/sitessService";
import Loading from "../../../components/common/Loading";
import { useFormWithGuard } from "../../../components/common/useFormRouter";
interface SiteDetailsProps {
editMode?: boolean;
@ -35,7 +36,7 @@ const SiteDetails: React.FC<SiteDetailsProps> = ({ editMode = false }) => {
const labelApply = t("Save");
const labelSave = t("SaveAndClose");
const form = useForm({
const form = useFormWithGuard({
loaded: false,
data: {
name: "",
@ -118,6 +119,7 @@ const SiteDetails: React.FC<SiteDetailsProps> = ({ editMode = false }) => {
if (buttonName === "save") {
form.setState({ redirect: "/site/" + organisationId });
}
form.markAsSaved();
} catch (ex: any) {
form.handleGeneralError(ex);
}

View File

@ -22,6 +22,7 @@ import {
renderError,
renderGlossaryPicker,
} from "../../../components/common/formHelpers";
import { useFormWithGuard } from "../../../components/common/useFormRouter";
interface SpecificationsDetailsProps {
editMode?: boolean;
@ -44,7 +45,7 @@ const SpecificationsDetails: React.FC<SpecificationsDetailsProps> = ({
const labelApply = t("Save");
const labelSave = t("SaveAndClose");
const form = useForm({
const form = useFormWithGuard({
loaded: false,
data: {
name: "",
@ -193,6 +194,7 @@ const SpecificationsDetails: React.FC<SpecificationsDetailsProps> = ({
redirect: "/Specifications/" + organisationId + "/" + siteId,
});
}
form.markAsSaved();
} catch (ex: any) {
form.handleGeneralError(ex);
}

View File

@ -4,7 +4,7 @@ import { Navigate, useParams, useLocation } from "react-router-dom";
import { toast } from "react-toastify";
import { useTranslation } from "react-i18next";
import { Namespaces } from "../../../i18n/i18n";
import { useForm } from "../../../components/common/useForm";
import { useFormWithGuard } from "../../../components/common/useFormRouter";
import { InputType } from "../../../components/common/Input";
import {
renderInput,
@ -37,7 +37,7 @@ const SsoProviderDetails: React.FC<SsoProviderDetailsProps> = ({
const labelApply = t("Save");
const labelSave = t("SaveAndClose");
const form = useForm({
const form = useFormWithGuard({
loaded: false,
data: {
name: "",
@ -155,6 +155,8 @@ const SsoProviderDetails: React.FC<SsoProviderDetailsProps> = ({
if (buttonName === "save") {
form.setState({ redirect: "/ssoManager" });
}
form.markAsSaved();
} catch (ex: any) {
form.handleGeneralError(ex);
}
@ -286,7 +288,7 @@ const SsoProviderDetails: React.FC<SsoProviderDetailsProps> = ({
{editMode && <div>Redirect URL: {redirectUrl}</div>}
{editMode && renderButton(labelApply, form.state.errors, "save")}
{editMode && renderButton(labelApply, form.state.errors, "apply")}
{renderButton(labelSave, form.state.errors, "save")}
</form>
</Loading>

View File

@ -17,6 +17,7 @@ import {
renderError,
renderDomainPicker,
} from "../../../../components/common/formHelpers";
import { useFormWithGuard } from "../../../../components/common/useFormRouter";
interface GeneralTabProps {
isEditMode: boolean;
@ -35,7 +36,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ isEditMode }) => {
const labelApply = t("Save");
const labelSave = t("SaveAndClose");
const form = useForm({
const form = useFormWithGuard({
loaded: false,
data: {
firstName: "",
@ -131,6 +132,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ isEditMode }) => {
if (buttonName === "save") {
form.setState({ redirect: "/users" });
}
form.markAsSaved();
} catch (ex: any) {
form.handleGeneralError(ex);
}

View File

@ -16,12 +16,12 @@ import {
renderButton,
renderError,
} from "../../../components/common/formHelpers";
import { useForm } from "../../../components/common/useForm";
import ErrorBlock from "../../../components/common/ErrorBlock";
import authentication from "../../frame/services/authenticationService";
import { MakeGeneralIdRef } from "../../../utils/GeneralIdRef";
import { CustomFieldValue } from "../glossary/services/glossaryService";
import TasksTab from "./components/TasksTab";
import { useFormWithGuard } from "../../../components/common/useFormRouter";
const WorkflowTemplateDetails: React.FC<{ editMode: boolean }> = ({
editMode,
@ -31,7 +31,7 @@ const WorkflowTemplateDetails: React.FC<{ editMode: boolean }> = ({
const [activeTab, setActiveTab] = React.useState("general");
// useForm promoted to the parent
const form = useForm({
const form = useFormWithGuard({
loaded: false,
data: {
name: "",
@ -112,6 +112,8 @@ const WorkflowTemplateDetails: React.FC<{ editMode: boolean }> = ({
if (buttonName === "save") {
form.setState({ ...form.state, redirect: "/workflowTemplates" });
}
form.markAsSaved();
} catch (ex: any) {
form.handleGeneralError(ex);
}

View File

@ -5,6 +5,7 @@ import {
} from "../services/WorkflowTemplateService";
import AddTaskButton from "./AddTaskButton";
import { Namespaces } from "../../../../i18n/i18n";
import { useTranslation } from "react-i18next";
interface TaskListProps {
tasks: TaskDefinition[];

View File

@ -15,6 +15,7 @@ import {
renderToggle,
renderDropSection,
} from "../../../components/common/formHelpers";
import { useFormWithGuard } from "../../../components/common/useFormRouter";
const InternalProfile: React.FC = () => {
const { t } = useTranslation(Namespaces.Common);
@ -35,7 +36,7 @@ const InternalProfile: React.FC = () => {
qrCodeImageUrl: "",
});
const form = useForm({
const form = useFormWithGuard({
loaded: false,
data: {
firstName: "",