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/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..f903b010 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'; @@ -22,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'; @@ -57,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 @@ -156,6 +158,19 @@ 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: hasUnsavedChanges, + isSubmitting: form.formState.isSubmitting, + }); const mergeDefaults = (defaults: Record, prefillDefaults?: Record) => { if (!prefillDefaults) { @@ -220,6 +235,8 @@ function JobFormWrapper({ projectName, draftJobsCount }) { setDefaultJobAlias(jobType); } + setBaselineValues(form.getValues()); + if (recoveredPrefill) { clearPendingRecoveredJobPrefill(); } @@ -290,19 +307,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..fd11ffc0 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'; @@ -27,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'; @@ -46,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(); @@ -326,17 +329,35 @@ 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: hasUnsavedChanges, + isSubmitting: form.formState.isSubmitting || isLoading, + }); // Reset form useEffect(() => { 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); @@ -391,6 +412,12 @@ export default function JobEditFormWrapper({ [activeStep, stepRenderers], ); + const handleCancel = () => { + void confirmNavigation(() => { + router.back(); + }); + }; + return ( @@ -399,9 +426,7 @@ export default function JobEditFormWrapper({
STEPS[step].title)} - onCancel={() => { - router.back(); - }} + onCancel={handleCancel} >
Edit Job
@@ -411,9 +436,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]); + + return { + isNavigationBlocked, + confirmNavigation, + }; +}