Feature: Create a new useFormBlocker hook to block the user from closing a dialog or navigating away from a page if the form is dirty (#2654)

* feat: add useFormBlocker hook for dialog dirty state protection

* feat: add useFormBlocker hook for dialog dirty state protection

- Create useFormBlocker hook that blocks dialog closing when forms have unsaved changes
- Use onPointerDownOutside and onEscapeKeyDown to intercept close attempts
- Show confirmation dialog with Leave/Stay options using useConfirm
- Integrate with CreateResumeDialog, UpdateResumeDialog, and DuplicateResumeDialog
- All strings are translatable via Lingui

* Feature: Create a new `useFormBlocker` hook to block the user from closing a dialog or navigating away from a page if the form is dirty.
This commit is contained in:
Amruth Pillai
2026-01-31 01:13:38 +01:00
committed by GitHub
parent f6fcbdcad0
commit 3d1c2d1fb6
81 changed files with 1800 additions and 1032 deletions
+4 -1
View File
@@ -14,6 +14,7 @@ import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTit
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { authClient } from "@/integrations/auth/client";
import { type DialogProps, useDialogStore } from "../store";
@@ -45,6 +46,8 @@ const CreateApiKeyForm = ({ setApiKey }: CreateApiKeyFormProps) => {
},
});
const { blockEvents } = useFormBlocker(form);
const onSubmit = async (values: FormValues) => {
const toastId = toast.loading(t`Creating your API key...`);
@@ -63,7 +66,7 @@ const CreateApiKeyForm = ({ setApiKey }: CreateApiKeyFormProps) => {
};
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
+4 -1
View File
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { authClient } from "@/integrations/auth/client";
import { type DialogProps, useDialogStore } from "../store";
@@ -41,6 +42,8 @@ export function ChangePasswordDialog(_: DialogProps<"auth.change-password">) {
},
});
const { blockEvents } = useFormBlocker(form);
const onSubmit = async (data: FormValues) => {
const toastId = toast.loading(t`Updating your password...`);
@@ -60,7 +63,7 @@ export function ChangePasswordDialog(_: DialogProps<"auth.change-password">) {
};
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PasswordIcon />
+4 -1
View File
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { authClient } from "@/integrations/auth/client";
import { type DialogProps, useDialogStore } from "../store";
@@ -32,6 +33,8 @@ export function DisableTwoFactorDialog(_: DialogProps<"auth.two-factor.disable">
},
});
const { blockEvents } = useFormBlocker(form);
const onSubmit = async (data: FormValues) => {
const toastId = toast.loading(t`Disabling two-factor authentication...`);
@@ -49,7 +52,7 @@ export function DisableTwoFactorDialog(_: DialogProps<"auth.two-factor.disable">
};
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<LockOpenIcon />
+14 -2
View File
@@ -16,6 +16,7 @@ import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTit
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { authClient } from "@/integrations/auth/client";
import { type DialogProps, useDialogStore } from "../store";
@@ -55,6 +56,17 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
},
});
const enableFormState = enableForm.formState;
const verifyFormState = verifyForm.formState;
const { blockEvents, requestClose } = useFormBlocker(enableForm, {
shouldBlock: () => {
if (step === "enable") return enableFormState.isDirty && !enableFormState.isSubmitting;
if (step === "verify") return verifyFormState.isDirty && !verifyFormState.isSubmitting;
return false;
},
});
const onEnableSubmit = async (values: EnableFormValues) => {
const toastId = toast.loading(t`Enabling two-factor authentication...`);
@@ -136,7 +148,7 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
};
return (
<DialogContent className="max-w-md">
<DialogContent className="max-w-md" {...blockEvents}>
<DialogHeader>
<DialogTitle>
{match(step)
@@ -257,7 +269,7 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
/>
<DialogFooter className="gap-x-2">
<Button type="button" variant="outline" onClick={closeDialog}>
<Button type="button" variant="outline" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit">
+4 -1
View File
@@ -14,6 +14,7 @@ import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTit
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { useAIStore } from "@/integrations/ai/store";
import { JSONResumeImporter } from "@/integrations/import/json-resume";
import { ReactiveResumeJSONImporter } from "@/integrations/import/reactive-resume-json";
@@ -98,6 +99,8 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
form.setValue("file", file, { shouldDirty: true });
};
const { blockEvents } = useFormBlocker(form);
const onSubmit = async (values: FormValues) => {
if (values.type === "") return;
@@ -182,7 +185,7 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
};
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<DownloadSimpleIcon />
+10 -3
View File
@@ -21,6 +21,7 @@ import {
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { authClient } from "@/integrations/auth/client";
import { orpc, type RouterInput } from "@/integrations/orpc/client";
import { generateId, generateRandomName, slugify } from "@/utils/string";
@@ -56,6 +57,8 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
form.setValue("slug", slugify(name), { shouldDirty: true });
}, [form, name]);
const { blockEvents } = useFormBlocker(form);
const onSubmit = (data: FormValues) => {
const toastId = toast.loading(t`Creating your resume...`);
@@ -100,7 +103,7 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
};
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
@@ -165,6 +168,8 @@ export function UpdateResumeDialog({ data }: DialogProps<"resume.update">) {
form.setValue("slug", slugify(name), { shouldDirty: true });
}, [form, name]);
const { blockEvents } = useFormBlocker(form);
const onSubmit = (data: FormValues) => {
const toastId = toast.loading(t`Updating your resume...`);
@@ -185,7 +190,7 @@ export function UpdateResumeDialog({ data }: DialogProps<"resume.update">) {
};
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
@@ -234,6 +239,8 @@ export function DuplicateResumeDialog({ data }: DialogProps<"resume.duplicate">)
form.setValue("slug", slugify(name), { shouldDirty: true });
}, [form, name]);
const { blockEvents } = useFormBlocker(form);
const onSubmit = (values: FormValues) => {
const toastId = toast.loading(t`Duplicating your resume...`);
@@ -253,7 +260,7 @@ export function DuplicateResumeDialog({ data }: DialogProps<"resume.duplicate">)
};
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
+9 -4
View File
@@ -12,6 +12,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { awardItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
@@ -48,8 +49,10 @@ export function CreateAwardDialog({ data }: DialogProps<"resume.sections.awards.
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
@@ -63,7 +66,7 @@ export function CreateAwardDialog({ data }: DialogProps<"resume.sections.awards.
<AwardForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
@@ -109,8 +112,10 @@ export function UpdateAwardDialog({ data }: DialogProps<"resume.sections.awards.
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
@@ -124,7 +129,7 @@ export function UpdateAwardDialog({ data }: DialogProps<"resume.sections.awards.
<AwardForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
@@ -12,6 +12,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { certificationItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
@@ -48,8 +49,10 @@ export function CreateCertificationDialog({ data }: DialogProps<"resume.sections
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
@@ -63,7 +66,7 @@ export function CreateCertificationDialog({ data }: DialogProps<"resume.sections
<CertificationForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
@@ -109,8 +112,10 @@ export function UpdateCertificationDialog({ data }: DialogProps<"resume.sections
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
@@ -124,7 +129,7 @@ export function UpdateCertificationDialog({ data }: DialogProps<"resume.sections
<CertificationForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
+9 -4
View File
@@ -10,6 +10,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { customSectionSchema, type SectionType } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
@@ -59,8 +60,10 @@ export function CreateCustomSectionDialog({ data }: DialogProps<"resume.sections
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
@@ -74,7 +77,7 @@ export function CreateCustomSectionDialog({ data }: DialogProps<"resume.sections
<CustomSectionForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
@@ -113,8 +116,10 @@ export function UpdateCustomSectionDialog({ data }: DialogProps<"resume.sections
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
@@ -128,7 +133,7 @@ export function UpdateCustomSectionDialog({ data }: DialogProps<"resume.sections
<CustomSectionForm isUpdate />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
+9 -4
View File
@@ -12,6 +12,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { educationItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
@@ -51,8 +52,10 @@ export function CreateEducationDialog({ data }: DialogProps<"resume.sections.edu
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
@@ -66,7 +69,7 @@ export function CreateEducationDialog({ data }: DialogProps<"resume.sections.edu
<EducationForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
@@ -115,8 +118,10 @@ export function UpdateEducationDialog({ data }: DialogProps<"resume.sections.edu
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
@@ -130,7 +135,7 @@ export function UpdateEducationDialog({ data }: DialogProps<"resume.sections.edu
<EducationForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
+9 -4
View File
@@ -12,6 +12,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { experienceItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
@@ -49,8 +50,10 @@ export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.ex
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
@@ -64,7 +67,7 @@ export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.ex
<ExperienceForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
@@ -111,8 +114,10 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
@@ -126,7 +131,7 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
<ExperienceForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
+9 -4
View File
@@ -13,6 +13,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { interestItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
import { cn } from "@/utils/style";
@@ -48,8 +49,10 @@ export function CreateInterestDialog({ data }: DialogProps<"resume.sections.inte
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
@@ -63,7 +66,7 @@ export function CreateInterestDialog({ data }: DialogProps<"resume.sections.inte
<InterestForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
@@ -107,8 +110,10 @@ export function UpdateInterestDialog({ data }: DialogProps<"resume.sections.inte
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
@@ -122,7 +127,7 @@ export function UpdateInterestDialog({ data }: DialogProps<"resume.sections.inte
<InterestForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
+9 -4
View File
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { Slider } from "@/components/ui/slider";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { languageItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
@@ -46,8 +47,10 @@ export function CreateLanguageDialog({ data }: DialogProps<"resume.sections.lang
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
@@ -61,7 +64,7 @@ export function CreateLanguageDialog({ data }: DialogProps<"resume.sections.lang
<LanguageForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
@@ -105,8 +108,10 @@ export function UpdateLanguageDialog({ data }: DialogProps<"resume.sections.lang
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
@@ -120,7 +125,7 @@ export function UpdateLanguageDialog({ data }: DialogProps<"resume.sections.lang
<LanguageForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
+9 -4
View File
@@ -13,6 +13,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
import { type DialogProps, useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { profileItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
import { cn } from "@/utils/style";
@@ -49,8 +50,10 @@ export function CreateProfileDialog({ data }: DialogProps<"resume.sections.profi
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
@@ -64,7 +67,7 @@ export function CreateProfileDialog({ data }: DialogProps<"resume.sections.profi
<ProfileForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
@@ -109,8 +112,10 @@ export function UpdateProfileDialog({ data }: DialogProps<"resume.sections.profi
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
@@ -124,7 +129,7 @@ export function UpdateProfileDialog({ data }: DialogProps<"resume.sections.profi
<ProfileForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
+9 -4
View File
@@ -12,6 +12,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { projectItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
@@ -47,8 +48,10 @@ export function CreateProjectDialog({ data }: DialogProps<"resume.sections.proje
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
@@ -62,7 +65,7 @@ export function CreateProjectDialog({ data }: DialogProps<"resume.sections.proje
<ProjectForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
@@ -107,8 +110,10 @@ export function UpdateProjectDialog({ data }: DialogProps<"resume.sections.proje
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
@@ -122,7 +127,7 @@ export function UpdateProjectDialog({ data }: DialogProps<"resume.sections.proje
<ProjectForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
+9 -4
View File
@@ -12,6 +12,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { publicationItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
@@ -48,8 +49,10 @@ export function CreatePublicationDialog({ data }: DialogProps<"resume.sections.p
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
@@ -63,7 +66,7 @@ export function CreatePublicationDialog({ data }: DialogProps<"resume.sections.p
<PublicationForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
@@ -109,8 +112,10 @@ export function UpdatePublicationDialog({ data }: DialogProps<"resume.sections.p
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
@@ -124,7 +129,7 @@ export function UpdatePublicationDialog({ data }: DialogProps<"resume.sections.p
<PublicationForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
+9 -4
View File
@@ -12,6 +12,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { referenceItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
@@ -48,8 +49,10 @@ export function CreateReferenceDialog({ data }: DialogProps<"resume.sections.ref
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
@@ -63,7 +66,7 @@ export function CreateReferenceDialog({ data }: DialogProps<"resume.sections.ref
<ReferenceForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
@@ -109,8 +112,10 @@ export function UpdateReferenceDialog({ data }: DialogProps<"resume.sections.ref
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
@@ -124,7 +129,7 @@ export function UpdateReferenceDialog({ data }: DialogProps<"resume.sections.ref
<ReferenceForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
+9 -4
View File
@@ -15,6 +15,7 @@ import { Input } from "@/components/ui/input";
import { Slider } from "@/components/ui/slider";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { skillItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
import { cn } from "@/utils/style";
@@ -52,8 +53,10 @@ export function CreateSkillDialog({ data }: DialogProps<"resume.sections.skills.
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
@@ -67,7 +70,7 @@ export function CreateSkillDialog({ data }: DialogProps<"resume.sections.skills.
<SkillForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
@@ -113,8 +116,10 @@ export function UpdateSkillDialog({ data }: DialogProps<"resume.sections.skills.
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
@@ -128,7 +133,7 @@ export function UpdateSkillDialog({ data }: DialogProps<"resume.sections.skills.
<SkillForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
+9 -4
View File
@@ -12,6 +12,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { volunteerItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
@@ -48,8 +49,10 @@ export function CreateVolunteerDialog({ data }: DialogProps<"resume.sections.vol
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
@@ -63,7 +66,7 @@ export function CreateVolunteerDialog({ data }: DialogProps<"resume.sections.vol
<VolunteerForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
@@ -109,8 +112,10 @@ export function UpdateVolunteerDialog({ data }: DialogProps<"resume.sections.vol
closeDialog();
};
const { blockEvents, requestClose } = useFormBlocker(form);
return (
<DialogContent>
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
@@ -124,7 +129,7 @@ export function UpdateVolunteerDialog({ data }: DialogProps<"resume.sections.vol
<VolunteerForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
+60
View File
@@ -0,0 +1,60 @@
import { t } from "@lingui/core/macro";
import { useCallback } from "react";
import type { FieldValues, UseFormReturn } from "react-hook-form";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
interface UseFormBlockerOptions {
shouldBlock?: () => boolean;
}
export function useFormBlocker<T extends FieldValues>(form: UseFormReturn<T>, options?: UseFormBlockerOptions) {
const confirm = useConfirm();
const closeDialog = useDialogStore((state) => state.closeDialog);
// Subscribe to formState changes by reading during render (react-hook-form requirement)
const { isDirty, isSubmitting } = form.formState;
const shouldBlock = useCallback(() => {
// Use custom shouldBlock if provided, otherwise use default form state check
if (options?.shouldBlock) return options.shouldBlock();
return isDirty && !isSubmitting;
}, [options, isDirty, isSubmitting]);
const requestClose = useCallback(async () => {
if (!shouldBlock()) {
closeDialog();
return;
}
const confirmed = await confirm(t`Are you sure you want to close this dialog?`, {
description: t`You have unsaved changes that will be lost.`,
confirmText: t`Leave`,
cancelText: t`Stay`,
});
if (confirmed) closeDialog();
}, [shouldBlock, closeDialog, confirm]);
const blockEvents = {
onEscapeKeyDown: (event: KeyboardEvent) => {
if (shouldBlock()) {
event.preventDefault();
requestClose();
}
},
onPointerDownOutside: (event: Event) => {
if (shouldBlock()) {
event.preventDefault();
requestClose();
}
},
onInteractOutside: (event: Event) => {
if (shouldBlock()) {
event.preventDefault();
}
},
};
return { requestClose, blockEvents };
}
+3 -3
View File
@@ -94,7 +94,7 @@ function BuilderLayout({ initialLayout, ...props }: BuilderLayoutProps) {
<div className="flex h-svh flex-col" {...props}>
<BuilderHeader />
<ResizableGroup orientation="horizontal" onLayoutChange={onLayoutChange} className="mt-14 flex-1">
<ResizableGroup orientation="horizontal" className="mt-14 flex-1" onLayoutChange={onLayoutChange}>
<ResizablePanel
collapsible
id="left"
@@ -107,11 +107,11 @@ function BuilderLayout({ initialLayout, ...props }: BuilderLayoutProps) {
>
<BuilderSidebarLeft />
</ResizablePanel>
<ResizableSeparator withHandle className="z-20 border-r" />
<ResizableSeparator withHandle className="z-20 border-s" />
<ResizablePanel id="artboard" defaultSize={artboardSize} className="h-[calc(100svh-3.5rem)]">
<Outlet />
</ResizablePanel>
<ResizableSeparator withHandle className="z-20 border-l" />
<ResizableSeparator withHandle className="z-20 border-e" />
<ResizablePanel
collapsible
id="right"