Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions app/(protected)/deeploys/job/[jobId]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'),
Expand All @@ -68,6 +70,7 @@ export default function EditJob() {
}>(null);

const [isSubmitting, setSubmitting] = useState<boolean>(false);
const [isFormDirty, setFormDirty] = useState<boolean>(false);

const [errors, setErrors] = useState<{ text: string; serverAlias: string }[]>([]);

Expand All @@ -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);
Expand Down Expand Up @@ -352,7 +369,7 @@ export default function EditJob() {
<JobBreadcrumbs job={job} jobTypeOption={jobTypeOption} />

<div className="row gap-2">
<ActionButton className="slate-button" color="default" onPress={() => router.back()}>
<ActionButton className="slate-button" color="default" onPress={() => void handleCancel()}>
<div className="row gap-1.5">
<RiArrowLeftLine className="text-lg" />
<div className="compact">Cancel</div>
Expand All @@ -366,7 +383,13 @@ export default function EditJob() {
<DeeployErrors type="update" errors={errors} />

{/* Form */}
<JobEditFormWrapper job={job} onSubmit={onSubmit} isLoading={isSubmitting} setLoading={setSubmitting} />
<JobEditFormWrapper
job={job}
onSubmit={onSubmit}
isLoading={isSubmitting}
setLoading={setSubmitting}
onDirtyStateChange={setFormDirty}
/>
</div>
</div>

Expand Down
9 changes: 7 additions & 2 deletions src/components/create-job/JobFormHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>();
Expand Down Expand Up @@ -41,7 +46,7 @@ function JobFormHeader({ steps }: { steps: string[] }) {
}

return (
<JobFormHeaderInterface steps={steps}>
<JobFormHeaderInterface steps={steps} onCancel={onCancel}>
<div className="row justify-between">
{projectName ? (
<div className="big-title max-w-[280px] truncate">{projectName}</div>
Expand Down
31 changes: 28 additions & 3 deletions src/components/create-job/JobFormWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -156,6 +158,19 @@ function JobFormWrapper({ projectName, draftJobsCount }) {
mode: 'onTouched',
defaultValues: getDefaultSchemaValues(),
});
const watchedValues = useWatch({ control: form.control });
const [baselineValues, setBaselineValues] = useState<z.infer<typeof jobSchema> | 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<string, any>, prefillDefaults?: Record<string, any>) => {
if (!prefillDefaults) {
Expand Down Expand Up @@ -220,6 +235,8 @@ function JobFormWrapper({ projectName, draftJobsCount }) {
setDefaultJobAlias(jobType);
}

setBaselineValues(form.getValues());

if (recoveredPrefill) {
clearPendingRecoveredJobPrefill();
}
Expand Down Expand Up @@ -290,19 +307,27 @@ function JobFormWrapper({ projectName, draftJobsCount }) {
return STEPS[steps[step]].component;
}, [step, steps]);

const handleCancel = () => {
void confirmNavigation(() => {
clearPendingRecoveredJobPrefill();
setJobType(undefined);
});
};

return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit, onError)} key={jobType || 'no-type'}>
<div className="w-full flex-1">
<div className="mx-auto max-w-[626px]">
<div className="col gap-6">
<JobFormHeader steps={steps.map((step) => STEPS[step].title)} />
<JobFormHeader steps={steps.map((step) => STEPS[step].title)} onCancel={handleCancel} />

<ActiveStep />

<JobFormButtons
steps={steps.map((step) => STEPS[step])}
cancelLabel="Project"
onCancel={handleCancel}
disableNextStep={jobType === JobType.Service && step === 0 && !form.watch('serviceId')}
/>
</div>
Expand Down
37 changes: 30 additions & 7 deletions src/components/edit-job/JobEditFormWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -46,11 +47,13 @@ export default function JobEditFormWrapper({
onSubmit,
isLoading,
setLoading,
onDirtyStateChange,
}: {
job: RunningJobWithResources;
onSubmit: (data: z.infer<typeof jobSchema>) => Promise<void>;
isLoading: boolean;
setLoading: (isLoading: boolean) => void;
onDirtyStateChange?: (isDirty: boolean) => void;
}) {
const { step } = useDeploymentContext() as DeploymentContextType;
const router = useRouter();
Expand Down Expand Up @@ -326,17 +329,35 @@ export default function JobEditFormWrapper({
mode: 'onTouched',
defaultValues,
});
const watchedValues = useWatch({ control: form.control });
const [baselineValues, setBaselineValues] = useState<z.infer<typeof jobSchema> | 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<typeof jobSchema>;
setDefaultValues(defaults);
form.reset(defaults);
setBaselineValues(defaults);

setTargetNodesCountLower(false);
setAdditionalCost(0n);
}, [form]);
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useEffect has an incomplete dependency array. The effect calls getDefaultSchemaValues() which depends on various props and state (job, jobConfig, etc.), and setTargetNodesCountLower and setAdditionalCost which are state setters. However, only form is listed in the dependency array. This will cause the effect to run on every render since form is recreated each time. Either add all necessary dependencies (which would cause the form to reset unexpectedly), or consider running this effect only once on mount if that's the intended behavior. If it should only run on mount, use an empty dependency array [] instead.

Suggested change
}, [form]);
}, []);

Copilot uses AI. Check for mistakes.

useEffect(() => {
onDirtyStateChange?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onDirtyStateChange]);

useEffect(() => {
if (step !== 0 && isTargetNodesCountLower) {
setTargetNodesCountLower(false);
Expand Down Expand Up @@ -391,6 +412,12 @@ export default function JobEditFormWrapper({
[activeStep, stepRenderers],
);

const handleCancel = () => {
void confirmNavigation(() => {
router.back();
});
};

return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(handleSubmit, onError)} key={`${job.resources.jobType}-edit`}>
Expand All @@ -399,9 +426,7 @@ export default function JobEditFormWrapper({
<div className="col gap-6">
<JobFormHeaderInterface
steps={steps.map((step) => STEPS[step].title)}
onCancel={() => {
router.back();
}}
onCancel={handleCancel}
>
<div className="big-title">Edit Job</div>
</JobFormHeaderInterface>
Expand All @@ -411,9 +436,7 @@ export default function JobEditFormWrapper({
<JobFormButtons
steps={steps.map((step) => STEPS[step])}
cancelLabel="Job"
onCancel={() => {
router.back();
}}
onCancel={handleCancel}
customSubmitButton={
<div className="center-all gap-2">
<PayButtonWithAllowance
Expand Down
95 changes: 95 additions & 0 deletions src/lib/hooks/useUnsavedChangesGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
'use client';

import { InteractionContextType, useInteractionContext } from '@lib/contexts/interaction';
import { useCallback, useEffect, useRef } from 'react';

const DEFAULT_MESSAGE = 'You have unsaved changes. Are you sure you want to leave this page?';

interface UseUnsavedChangesGuardOptions {
isDirty: boolean;
isSubmitting?: boolean;
message?: string;
}

export function useUnsavedChangesGuard({
isDirty,
isSubmitting = false,
message = DEFAULT_MESSAGE,
}: UseUnsavedChangesGuardOptions) {
const interaction = useInteractionContext() as InteractionContextType | null;
const isNavigationBlocked = isDirty && !isSubmitting;
const allowNavigationRef = useRef(false);
const isPromptOpenRef = useRef(false);

const requestConfirmation = useCallback(async () => {
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<void>) => {
const confirmed = await requestConfirmation();

if (!confirmed) {
return false;
}

allowNavigationRef.current = true;

try {
await action();
return true;
} finally {
window.setTimeout(() => {
allowNavigationRef.current = false;
}, 500);
Comment on lines +62 to +64
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 500ms timeout for resetting allowNavigationRef after successful navigation seems arbitrary and could cause issues. If the navigation action completes quickly but the component unmounts or re-renders before the timeout fires, the flag will remain true. Consider resetting the flag immediately after navigation or using a cleanup function to handle unmounting scenarios.

Suggested change
window.setTimeout(() => {
allowNavigationRef.current = false;
}, 500);
allowNavigationRef.current = false;

Copilot uses AI. Check for mistakes.
}
},
[requestConfirmation],
);
Comment on lines +48 to +68
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The window.setTimeout calls in the confirmNavigation callback and handlePopState handler are not cleaned up if the component unmounts before they fire. This could lead to memory leaks and attempts to update unmounted component state. Store the timeout IDs and clear them in a cleanup function or useEffect return.

Copilot uses AI. Check for mistakes.

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,
};
}