From fdceb379b585ff740626617e04194e619c5b337f Mon Sep 17 00:00:00 2001 From: Alessandro Date: Mon, 23 Feb 2026 12:38:17 +0100 Subject: [PATCH 1/3] fix: confirm before leaving dirty job forms --- src/components/create-job/JobFormHeader.tsx | 9 +- src/components/create-job/JobFormWrapper.tsx | 15 +- .../edit-job/JobEditFormWrapper.tsx | 19 +- src/lib/hooks/useUnsavedChangesGuard.ts | 175 ++++++++++++++++++ 4 files changed, 209 insertions(+), 9 deletions(-) create mode 100644 src/lib/hooks/useUnsavedChangesGuard.ts diff --git a/src/components/create-job/JobFormHeader.tsx b/src/components/create-job/JobFormHeader.tsx index c2fea467..aeea24a2 100644 --- a/src/components/create-job/JobFormHeader.tsx +++ b/src/components/create-job/JobFormHeader.tsx @@ -11,7 +11,12 @@ import { useLiveQuery } from 'dexie-react-hooks'; import { useParams } from 'next/navigation'; import { useEffect, useState } from 'react'; -function JobFormHeader({ steps }: { steps: string[] }) { +interface Props { + steps: string[]; + onCancel?: () => void; +} + +function JobFormHeader({ steps, onCancel }: Props) { const { jobType, getProjectName } = useDeploymentContext() as DeploymentContextType; const { projectHash } = useParams<{ projectHash?: string }>(); @@ -41,7 +46,7 @@ function JobFormHeader({ steps }: { steps: string[] }) { } return ( - +
{projectName ? (
{projectName}
diff --git a/src/components/create-job/JobFormWrapper.tsx b/src/components/create-job/JobFormWrapper.tsx index 67e7d9a4..a040497b 100644 --- a/src/components/create-job/JobFormWrapper.tsx +++ b/src/components/create-job/JobFormWrapper.tsx @@ -13,6 +13,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { AuthenticationContextType, useAuthenticationContext } from '@lib/contexts/authentication'; import { DeploymentContextType, useDeploymentContext } from '@lib/contexts/deployment'; import { KYB_TAG } from '@lib/deeploy-utils'; +import { useUnsavedChangesGuard } from '@lib/hooks/useUnsavedChangesGuard'; import { MAIN_STEPS, Step, STEPS } from '@lib/steps/steps'; import db from '@lib/storage/db'; import { isValidProjectHash } from '@lib/utils'; @@ -156,6 +157,10 @@ function JobFormWrapper({ projectName, draftJobsCount }) { mode: 'onTouched', defaultValues: getDefaultSchemaValues(), }); + const { confirmNavigation } = useUnsavedChangesGuard({ + isDirty: form.formState.isDirty, + isSubmitting: form.formState.isSubmitting, + }); const mergeDefaults = (defaults: Record, prefillDefaults?: Record) => { if (!prefillDefaults) { @@ -290,19 +295,27 @@ function JobFormWrapper({ projectName, draftJobsCount }) { return STEPS[steps[step]].component; }, [step, steps]); + const handleCancel = () => { + void confirmNavigation(() => { + clearPendingRecoveredJobPrefill(); + setJobType(undefined); + }); + }; + return (
- STEPS[step].title)} /> + STEPS[step].title)} onCancel={handleCancel} /> STEPS[step])} cancelLabel="Project" + onCancel={handleCancel} disableNextStep={jobType === JobType.Service && step === 0 && !form.watch('serviceId')} />
diff --git a/src/components/edit-job/JobEditFormWrapper.tsx b/src/components/edit-job/JobEditFormWrapper.tsx index 10bb9619..05a18025 100644 --- a/src/components/edit-job/JobEditFormWrapper.tsx +++ b/src/components/edit-job/JobEditFormWrapper.tsx @@ -18,6 +18,7 @@ import { NATIVE_PLUGIN_DEFAULT_RESPONSE_KEYS, titlecase, } from '@lib/deeploy-utils'; +import { useUnsavedChangesGuard } from '@lib/hooks/useUnsavedChangesGuard'; import { Step, STEPS } from '@lib/steps/steps'; import { jobSchema } from '@schemas/index'; import JobFormHeaderInterface from '@shared/jobs/JobFormHeaderInterface'; @@ -326,6 +327,10 @@ export default function JobEditFormWrapper({ mode: 'onTouched', defaultValues, }); + const { confirmNavigation } = useUnsavedChangesGuard({ + isDirty: form.formState.isDirty, + isSubmitting: form.formState.isSubmitting || isLoading, + }); // Reset form useEffect(() => { @@ -391,6 +396,12 @@ export default function JobEditFormWrapper({ [activeStep, stepRenderers], ); + const handleCancel = () => { + void confirmNavigation(() => { + router.back(); + }); + }; + return ( @@ -399,9 +410,7 @@ export default function JobEditFormWrapper({
STEPS[step].title)} - onCancel={() => { - router.back(); - }} + onCancel={handleCancel} >
Edit Job
@@ -411,9 +420,7 @@ export default function JobEditFormWrapper({ STEPS[step])} cancelLabel="Job" - onCancel={() => { - router.back(); - }} + onCancel={handleCancel} customSubmitButton={
{ + if (!isNavigationBlocked || allowNavigationRef.current) { + return true; + } + + if (isPromptOpenRef.current) { + return false; + } + + isPromptOpenRef.current = true; + + try { + if (!interaction?.confirm) { + return window.confirm(message); + } + + return await interaction.confirm(message, { + confirmButtonClassNames: 'bg-slate-900', + }); + } finally { + isPromptOpenRef.current = false; + } + }, [interaction, isNavigationBlocked, message]); + + const confirmNavigation = useCallback( + async (action: () => void | Promise) => { + const confirmed = await requestConfirmation(); + + if (!confirmed) { + return false; + } + + allowNavigationRef.current = true; + + try { + await action(); + return true; + } finally { + window.setTimeout(() => { + allowNavigationRef.current = false; + }, 500); + } + }, + [requestConfirmation], + ); + + useEffect(() => { + if (!isNavigationBlocked) { + return; + } + + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (allowNavigationRef.current) { + return; + } + + event.preventDefault(); + event.returnValue = ''; + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [isNavigationBlocked]); + + useEffect(() => { + if (!isNavigationBlocked) { + return; + } + + const handleDocumentClick = (event: MouseEvent) => { + if (allowNavigationRef.current || event.defaultPrevented) { + return; + } + + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + + const target = event.target as HTMLElement | null; + const anchor = target?.closest('a[href]') as HTMLAnchorElement | null; + + if (!anchor || anchor.target === '_blank' || anchor.hasAttribute('download')) { + return; + } + + const destinationUrl = new URL(anchor.href, window.location.href); + const currentUrl = new URL(window.location.href); + + if (destinationUrl.href === currentUrl.href) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + void confirmNavigation(() => { + window.location.assign(destinationUrl.href); + }); + }; + + document.addEventListener('click', handleDocumentClick, true); + + return () => { + document.removeEventListener('click', handleDocumentClick, true); + }; + }, [confirmNavigation, isNavigationBlocked]); + + useEffect(() => { + if (!isNavigationBlocked) { + return; + } + + const handlePopState = () => { + if (allowNavigationRef.current) { + return; + } + + const confirmed = window.confirm(message); + + if (confirmed) { + allowNavigationRef.current = true; + + window.setTimeout(() => { + allowNavigationRef.current = false; + }, 500); + + return; + } + + allowNavigationRef.current = true; + window.history.go(1); + + window.setTimeout(() => { + allowNavigationRef.current = false; + }, 0); + }; + + window.addEventListener('popstate', handlePopState); + + return () => { + window.removeEventListener('popstate', handlePopState); + }; + }, [isNavigationBlocked, message]); + + return { + isNavigationBlocked, + confirmNavigation, + }; +} From f7d248a47c193e626116bc31cac8856cf3e2758c Mon Sep 17 00:00:00 2001 From: Alessandro Date: Mon, 23 Feb 2026 12:57:15 +0100 Subject: [PATCH 2/3] fix: simplify dirty form guard navigation handling --- src/lib/hooks/useUnsavedChangesGuard.ts | 80 ------------------------- 1 file changed, 80 deletions(-) diff --git a/src/lib/hooks/useUnsavedChangesGuard.ts b/src/lib/hooks/useUnsavedChangesGuard.ts index 82ab1c9b..01b9337d 100644 --- a/src/lib/hooks/useUnsavedChangesGuard.ts +++ b/src/lib/hooks/useUnsavedChangesGuard.ts @@ -88,86 +88,6 @@ export function useUnsavedChangesGuard({ }; }, [isNavigationBlocked]); - useEffect(() => { - if (!isNavigationBlocked) { - return; - } - - const handleDocumentClick = (event: MouseEvent) => { - if (allowNavigationRef.current || event.defaultPrevented) { - return; - } - - if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { - return; - } - - const target = event.target as HTMLElement | null; - const anchor = target?.closest('a[href]') as HTMLAnchorElement | null; - - if (!anchor || anchor.target === '_blank' || anchor.hasAttribute('download')) { - return; - } - - const destinationUrl = new URL(anchor.href, window.location.href); - const currentUrl = new URL(window.location.href); - - if (destinationUrl.href === currentUrl.href) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - void confirmNavigation(() => { - window.location.assign(destinationUrl.href); - }); - }; - - document.addEventListener('click', handleDocumentClick, true); - - return () => { - document.removeEventListener('click', handleDocumentClick, true); - }; - }, [confirmNavigation, isNavigationBlocked]); - - useEffect(() => { - if (!isNavigationBlocked) { - return; - } - - const handlePopState = () => { - if (allowNavigationRef.current) { - return; - } - - const confirmed = window.confirm(message); - - if (confirmed) { - allowNavigationRef.current = true; - - window.setTimeout(() => { - allowNavigationRef.current = false; - }, 500); - - return; - } - - allowNavigationRef.current = true; - window.history.go(1); - - window.setTimeout(() => { - allowNavigationRef.current = false; - }, 0); - }; - - window.addEventListener('popstate', handlePopState); - - return () => { - window.removeEventListener('popstate', handlePopState); - }; - }, [isNavigationBlocked, message]); - return { isNavigationBlocked, confirmNavigation, From b63ac01a965dfc5f65c1a5d055a121a7f41952b3 Mon Sep 17 00:00:00 2001 From: Alessandro Date: Mon, 23 Feb 2026 13:10:13 +0100 Subject: [PATCH 3/3] fix: restore unsaved changes confirmation on job forms --- .../deeploys/job/[jobId]/edit/page.tsx | 27 +++++++++++++++++-- src/components/create-job/JobFormWrapper.tsx | 18 ++++++++++--- .../edit-job/JobEditFormWrapper.tsx | 20 ++++++++++++-- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/app/(protected)/deeploys/job/[jobId]/edit/page.tsx b/app/(protected)/deeploys/job/[jobId]/edit/page.tsx index 32da2d8c..15d50aeb 100644 --- a/app/(protected)/deeploys/job/[jobId]/edit/page.tsx +++ b/app/(protected)/deeploys/job/[jobId]/edit/page.tsx @@ -11,6 +11,7 @@ import { scaleUpJobWorkers, updatePipeline } from '@lib/api/deeploy'; import { getDevAddress, isUsingDevAddress } from '@lib/config'; import { BlockchainContextType, useBlockchainContext } from '@lib/contexts/blockchain'; import { DeploymentContextType, useDeploymentContext } from '@lib/contexts/deployment'; +import { InteractionContextType, useInteractionContext } from '@lib/contexts/interaction'; import { buildDeeployMessage, formatContainerResources, @@ -50,6 +51,7 @@ export default function EditJob() { useDeploymentContext() as DeploymentContextType; const router = useRouter(); + const { confirm } = useInteractionContext() as InteractionContextType; const { jobId } = useParams<{ jobId?: string }>(); const { job, isLoading: isJobLoading } = useRunningJob(jobId, { onError: () => router.replace('/404'), @@ -68,6 +70,7 @@ export default function EditJob() { }>(null); const [isSubmitting, setSubmitting] = useState(false); + const [isFormDirty, setFormDirty] = useState(false); const [errors, setErrors] = useState<{ text: string; serverAlias: string }[]>([]); @@ -77,6 +80,20 @@ export default function EditJob() { 'callDeeployApi', ]); + const handleCancel = async () => { + if (isFormDirty && !isSubmitting) { + const confirmed = await confirm('You have unsaved changes. Are you sure you want to leave this page?', { + confirmButtonClassNames: 'bg-slate-900', + }); + + if (!confirmed) { + return; + } + } + + router.back(); + }; + // Init useEffect(() => { setStep(0); @@ -352,7 +369,7 @@ export default function EditJob() {
- router.back()}> + void handleCancel()}>
Cancel
@@ -366,7 +383,13 @@ export default function EditJob() { {/* Form */} - +
diff --git a/src/components/create-job/JobFormWrapper.tsx b/src/components/create-job/JobFormWrapper.tsx index a040497b..f903b010 100644 --- a/src/components/create-job/JobFormWrapper.tsx +++ b/src/components/create-job/JobFormWrapper.tsx @@ -23,8 +23,8 @@ import { RecoveredJobPrefill } from '@typedefs/recoveredDraft'; import { BasePluginType, PluginType } from '@typedefs/steps/deploymentStepTypes'; import _ from 'lodash'; import { useParams } from 'next/navigation'; -import { useEffect, useMemo, useRef } from 'react'; -import { FieldErrors, FormProvider, useForm } from 'react-hook-form'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { FieldErrors, FormProvider, useForm, useWatch } from 'react-hook-form'; import toast from 'react-hot-toast'; import { z } from 'zod'; @@ -58,6 +58,7 @@ function JobFormWrapper({ projectName, draftJobsCount }) { }); const getBaseSchemaDefaults = () => ({ + jobType, specifications: { // applicationType: APPLICATION_TYPES[0], targetNodesCount: jobType === JobType.Generic || jobType === JobType.Native ? 2 : 1, // Generic and Native jobs always have a minimal balancing of 2 nodes, Services are locked to 1 node @@ -157,8 +158,17 @@ function JobFormWrapper({ projectName, draftJobsCount }) { mode: 'onTouched', defaultValues: getDefaultSchemaValues(), }); + const watchedValues = useWatch({ control: form.control }); + const [baselineValues, setBaselineValues] = useState | null>(null); + const hasUnsavedChanges = useMemo(() => { + if (!baselineValues) { + return form.formState.isDirty; + } + + return !_.isEqual(watchedValues, baselineValues); + }, [baselineValues, form.formState.isDirty, watchedValues]); const { confirmNavigation } = useUnsavedChangesGuard({ - isDirty: form.formState.isDirty, + isDirty: hasUnsavedChanges, isSubmitting: form.formState.isSubmitting, }); @@ -225,6 +235,8 @@ function JobFormWrapper({ projectName, draftJobsCount }) { setDefaultJobAlias(jobType); } + setBaselineValues(form.getValues()); + if (recoveredPrefill) { clearPendingRecoveredJobPrefill(); } diff --git a/src/components/edit-job/JobEditFormWrapper.tsx b/src/components/edit-job/JobEditFormWrapper.tsx index 05a18025..fd11ffc0 100644 --- a/src/components/edit-job/JobEditFormWrapper.tsx +++ b/src/components/edit-job/JobEditFormWrapper.tsx @@ -28,7 +28,7 @@ import { JobType, RunningJobWithResources } from '@typedefs/deeploys'; import { BasePluginType, CustomParameterEntry, PluginType } from '@typedefs/steps/deploymentStepTypes'; import _ from 'lodash'; import { JSX, useEffect, useMemo, useRef, useState } from 'react'; -import { FieldErrors, FormProvider, useForm } from 'react-hook-form'; +import { FieldErrors, FormProvider, useForm, useWatch } from 'react-hook-form'; import { toast } from 'react-hot-toast'; import { useRouter } from 'next/navigation'; import z from 'zod'; @@ -47,11 +47,13 @@ export default function JobEditFormWrapper({ onSubmit, isLoading, setLoading, + onDirtyStateChange, }: { job: RunningJobWithResources; onSubmit: (data: z.infer) => Promise; isLoading: boolean; setLoading: (isLoading: boolean) => void; + onDirtyStateChange?: (isDirty: boolean) => void; }) { const { step } = useDeploymentContext() as DeploymentContextType; const router = useRouter(); @@ -327,8 +329,17 @@ export default function JobEditFormWrapper({ mode: 'onTouched', defaultValues, }); + const watchedValues = useWatch({ control: form.control }); + const [baselineValues, setBaselineValues] = useState | null>(null); + const hasUnsavedChanges = useMemo(() => { + if (!baselineValues) { + return form.formState.isDirty; + } + + return !_.isEqual(watchedValues, baselineValues); + }, [baselineValues, form.formState.isDirty, watchedValues]); const { confirmNavigation } = useUnsavedChangesGuard({ - isDirty: form.formState.isDirty, + isDirty: hasUnsavedChanges, isSubmitting: form.formState.isSubmitting || isLoading, }); @@ -337,11 +348,16 @@ export default function JobEditFormWrapper({ const defaults = getDefaultSchemaValues() as z.infer; setDefaultValues(defaults); form.reset(defaults); + setBaselineValues(defaults); setTargetNodesCountLower(false); setAdditionalCost(0n); }, [form]); + useEffect(() => { + onDirtyStateChange?.(hasUnsavedChanges); + }, [hasUnsavedChanges, onDirtyStateChange]); + useEffect(() => { if (step !== 0 && isTargetNodesCountLower) { setTargetNodesCountLower(false);