From f1cac5c9d64c41029c04518cfbb8873ee5e4249e Mon Sep 17 00:00:00 2001 From: desiprisg Date: Wed, 12 Feb 2025 16:09:48 +0200 Subject: [PATCH] fix(dashboard): Form blocker fix --- .../src/components/form-protection.tsx | 22 +++++++ .../src/components/primitives/form/form.tsx | 27 ++++++++- .../subscribers/create-subscriber-form.tsx | 15 ++--- .../subscribers/subscriber-drawer.tsx | 56 ++++++++++++------ .../subscribers/subscriber-overview-form.tsx | 21 +++---- .../unsaved-changes-alert-dialog.tsx | 15 ++--- .../conditions/edit-step-conditions-form.tsx | 21 +++---- .../edit-step-conditions-layout.tsx | 8 +-- .../workflow-editor/steps/step-drawer.tsx | 19 +++++- .../src/hooks/use-form-protection.ts | 58 +++++++++++++++++++ .../dashboard/src/pages/create-subscriber.tsx | 25 ++++++-- 11 files changed, 211 insertions(+), 76 deletions(-) create mode 100644 apps/dashboard/src/components/form-protection.tsx create mode 100644 apps/dashboard/src/hooks/use-form-protection.ts diff --git a/apps/dashboard/src/components/form-protection.tsx b/apps/dashboard/src/components/form-protection.tsx new file mode 100644 index 00000000000..5c5da97dc7a --- /dev/null +++ b/apps/dashboard/src/components/form-protection.tsx @@ -0,0 +1,22 @@ +import { UnsavedChangesAlertDialog } from '@/components/unsaved-changes-alert-dialog'; + +export interface FormProtectionProps { + onClose: () => void; + showAlert: boolean; + setShowAlert: (show: boolean) => void; +} + +export const FormProtection = ({ onClose, showAlert, setShowAlert }: FormProtectionProps) => { + // User confirms that unsaved changes should be discarded, so close. + const handleProceed = () => { + setShowAlert(false); + onClose(); + }; + + // User cancels the closure. + const handleCancel = () => { + setShowAlert(false); + }; + + return ; +}; diff --git a/apps/dashboard/src/components/primitives/form/form.tsx b/apps/dashboard/src/components/primitives/form/form.tsx index 8723f642ca7..ba1e73ec62e 100644 --- a/apps/dashboard/src/components/primitives/form/form.tsx +++ b/apps/dashboard/src/components/primitives/form/form.tsx @@ -1,7 +1,7 @@ import * as LabelPrimitive from '@radix-ui/react-label'; import { Slot } from '@radix-ui/react-slot'; import * as React from 'react'; -import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider } from 'react-hook-form'; +import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from 'react-hook-form'; import { Input } from '@/components/primitives/input'; import { Label, LabelAsterisk, LabelSub } from '@/components/primitives/label'; @@ -14,6 +14,19 @@ import { FormFieldContext, FormItemContext, useFormField } from './form-context' const Form = FormProvider; +const FormRoot = React.forwardRef>( + ({ children, ...props }, ref) => { + const form = useFormContext(); + + return ( +
+ {children} +
+ ); + } +); +FormRoot.displayName = 'FormRoot'; + const FormField = < TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, @@ -128,4 +141,14 @@ const FormTextInput = React.forwardRef { mode: 'onBlur', }); - const isDirty = Object.keys(form.formState.dirtyFields).length > 0; - const blocker = useBlocker(isDirty); - useBeforeUnload(isDirty); - const { createSubscriber } = useCreateSubscriber({ onSuccess: () => { showSuccessToast('Created subscriber successfully', undefined, toastOptions); @@ -101,7 +95,7 @@ export const CreateSubscriberForm = (props: CreateSubscriberFormProps) => {
- +
{
- +
- ); }; diff --git a/apps/dashboard/src/components/subscribers/subscriber-drawer.tsx b/apps/dashboard/src/components/subscribers/subscriber-drawer.tsx index 3bdbfd1b13c..3eae9fdff60 100644 --- a/apps/dashboard/src/components/subscribers/subscriber-drawer.tsx +++ b/apps/dashboard/src/components/subscribers/subscriber-drawer.tsx @@ -1,8 +1,10 @@ +import { FormProtection } from '@/components/form-protection'; import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/primitives/sheet'; import { VisuallyHidden } from '@/components/primitives/visually-hidden'; import { SubscriberTabs } from '@/components/subscribers/subscriber-tabs'; +import { useFormProtection } from '@/hooks/use-form-protection'; import { cn } from '@/utils/ui'; -import { forwardRef, useState } from 'react'; +import { forwardRef, useRef, useState } from 'react'; type SubscriberDrawerProps = { open: boolean; @@ -10,24 +12,44 @@ type SubscriberDrawerProps = { subscriberId: string; readOnly?: boolean; }; -export const SubscriberDrawer = forwardRef((props, ref) => { + +export const SubscriberDrawer = forwardRef((props, forwardedRef) => { const { open, onOpenChange, subscriberId, readOnly = false } = props; + const localRef = useRef(null); + // Use the forwarded ref if it exists, otherwise use localRef + const ref = (forwardedRef || localRef) as React.RefObject; + + const { showAlert, setShowAlert, isFormDirty } = useFormProtection(ref); + return ( - - {/* Custom overlay since SheetOverlay does not work with modal={false} */} -
- - - - - - - - + <> + { + if (isFormDirty) { + return setShowAlert(true); + } + onOpenChange(open); + }} + > + {/* Custom overlay since SheetOverlay does not work with modal={false} */} +
+ + + + + + + + + + onOpenChange(false)} showAlert={showAlert} setShowAlert={setShowAlert} /> + ); }); diff --git a/apps/dashboard/src/components/subscribers/subscriber-overview-form.tsx b/apps/dashboard/src/components/subscribers/subscriber-overview-form.tsx index 18542572538..f639066be8b 100644 --- a/apps/dashboard/src/components/subscribers/subscriber-overview-form.tsx +++ b/apps/dashboard/src/components/subscribers/subscriber-overview-form.tsx @@ -1,9 +1,10 @@ import { PhoneInput } from '@/components/primitives/phone-input'; import { LocaleSelect } from '@/components/subscribers/locale-select'; -import { useBeforeUnload } from '@/hooks/use-before-unload'; import { useDeleteSubscriber } from '@/hooks/use-delete-subscriber'; import { usePatchSubscriber } from '@/hooks/use-patch-subscriber'; +import { useTelemetry } from '@/hooks/use-telemetry'; import { formatDateSimple } from '@/utils/format-date'; +import { TelemetryEvent } from '@/utils/telemetry'; import { cn } from '@/utils/ui'; import { zodResolver } from '@hookform/resolvers/zod'; import { SubscriberResponseDto } from '@novu/api/models/components'; @@ -11,7 +12,7 @@ import { loadLanguage } from '@uiw/codemirror-extensions-langs'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { RiDeleteBin2Line, RiMailLine } from 'react-icons/ri'; -import { Link, useBlocker, useNavigate } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { ExternalToast } from 'sonner'; import { z } from 'zod'; import { ConfirmationModal } from '../confirmation-modal'; @@ -19,17 +20,14 @@ import { Avatar, AvatarFallback, AvatarImage } from '../primitives/avatar'; import { Button } from '../primitives/button'; import { CopyButton } from '../primitives/copy-button'; import { Editor } from '../primitives/editor'; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../primitives/form/form'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormRoot } from '../primitives/form/form'; import { Input, InputRoot } from '../primitives/input'; import { Separator } from '../primitives/separator'; import { showErrorToast, showSuccessToast } from '../primitives/sonner-helpers'; import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'; -import { UnsavedChangesAlertDialog } from '../unsaved-changes-alert-dialog'; import { SubscriberFormSchema } from './schema'; import { TimezoneSelect } from './timezone-select'; import { getSubscriberTitle } from './utils'; -import { useTelemetry } from '@/hooks/use-telemetry'; -import { TelemetryEvent } from '@/utils/telemetry'; const extensions = [loadLanguage('json')?.extension ?? []]; const basicSetup = { lineNumbers: true, defaultKeymap: true }; @@ -108,10 +106,6 @@ export function SubscriberOverviewForm(props: SubscriberOverviewFormProps) { } }, [subscriber, form]); - const isDirty = Object.keys(form.formState.dirtyFields).length > 0; - const blocker = useBlocker(isDirty); - useBeforeUnload(isDirty); - const onSubmit = async (formData: z.infer) => { const dirtyFields = form.formState.dirtyFields; @@ -134,7 +128,7 @@ export function SubscriberOverviewForm(props: SubscriberOverviewFormProps) { return (
- +
@@ -394,13 +388,13 @@ export function SubscriberOverviewForm(props: SubscriberOverviewFormProps) { > Delete subscriber -
)} - +
-
); } diff --git a/apps/dashboard/src/components/unsaved-changes-alert-dialog.tsx b/apps/dashboard/src/components/unsaved-changes-alert-dialog.tsx index 57eb30c5aa6..74eb9f3e67a 100644 --- a/apps/dashboard/src/components/unsaved-changes-alert-dialog.tsx +++ b/apps/dashboard/src/components/unsaved-changes-alert-dialog.tsx @@ -10,19 +10,20 @@ import { } from '@/components/primitives/alert-dialog'; import { Separator } from '@/components/primitives/separator'; import { RiAlertFill, RiArrowRightSLine } from 'react-icons/ri'; -import { Blocker } from 'react-router-dom'; import { Button } from './primitives/button'; type UnsavedChangesAlertDialogProps = { - blocker: Blocker; + show?: boolean; description?: string; + onCancel?: () => void; + onProceed?: () => void; }; export const UnsavedChangesAlertDialog = (props: UnsavedChangesAlertDialogProps) => { - const { blocker } = props; + const { show, description, onCancel, onProceed } = props; return ( - +
@@ -31,7 +32,7 @@ export const UnsavedChangesAlertDialog = (props: UnsavedChangesAlertDialogProps)
You might lose your progress - This form has some unsaved changes. Save progress before you leave. + {description || 'This form has some unsaved changes. Save progress before you leave.'}
@@ -39,8 +40,8 @@ export const UnsavedChangesAlertDialog = (props: UnsavedChangesAlertDialogProps) - blocker.reset?.()}>Cancel - blocker.proceed?.()} asChild> + Cancel + diff --git a/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-form.tsx index 72e5b27d5b0..7d678842376 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-form.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-form.tsx @@ -1,24 +1,20 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { StepContentIssueEnum, type StepUpdateDto } from '@novu/shared'; import { useEffect, useMemo } from 'react'; -import { useBlocker } from 'react-router-dom'; -import { formatQuery, RQBJsonLogic, RuleGroupType, RuleType } from 'react-querybuilder'; import { useForm } from 'react-hook-form'; -import { z } from 'zod'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { prepareRuleGroup } from 'react-querybuilder'; +import { formatQuery, prepareRuleGroup, RQBJsonLogic, RuleGroupType, RuleType } from 'react-querybuilder'; import { parseJsonLogic } from 'react-querybuilder/parseJsonLogic'; -import { StepContentIssueEnum, type StepUpdateDto } from '@novu/shared'; +import { z } from 'zod'; import { ConditionsEditor } from '@/components/conditions-editor/conditions-editor'; import { Form, FormField } from '@/components/primitives/form/form'; -import { parseStepVariables } from '@/utils/parseStepVariablesToLiquidVariables'; import { updateStepInWorkflow } from '@/components/workflow-editor/step-utils'; import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; -import { UnsavedChangesAlertDialog } from '@/components/unsaved-changes-alert-dialog'; -import { useBeforeUnload } from '@/hooks/use-before-unload'; -import { EditStepConditionsLayout } from './edit-step-conditions-layout'; import { useTelemetry } from '@/hooks/use-telemetry'; +import { countConditions, getUniqueFieldNamespaces, getUniqueOperators } from '@/utils/conditions'; +import { parseStepVariables } from '@/utils/parseStepVariablesToLiquidVariables'; import { TelemetryEvent } from '@/utils/telemetry'; -import { countConditions, getUniqueOperators, getUniqueFieldNamespaces } from '@/utils/conditions'; +import { EditStepConditionsLayout } from './edit-step-conditions-layout'; const PAYLOAD_FIELD_PREFIX = 'payload.'; const SUBSCRIBER_DATA_FIELD_PREFIX = 'subscriber.data.'; @@ -113,8 +109,6 @@ export const EditStepConditionsForm = () => { }, }); const { formState } = form; - const blocker = useBlocker(formState.isDirty); - useBeforeUnload(formState.isDirty); const onSubmit = (values: z.infer>) => { if (!step || !workflow) return; @@ -196,7 +190,6 @@ export const EditStepConditionsForm = () => { /> - ); }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-layout.tsx b/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-layout.tsx index 4ca271d0ac2..3ab5c920b5c 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-layout.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-layout.tsx @@ -1,8 +1,8 @@ -import { RiQuestionLine } from 'react-icons/ri'; +import { RiInputField, RiQuestionLine } from 'react-icons/ri'; import { Link } from 'react-router-dom'; -import { RiInputField } from 'react-icons/ri'; import { Button } from '@/components/primitives/button'; +import { FormRoot } from '@/components/primitives/form/form'; import { Panel, PanelContent, PanelHeader } from '@/components/primitives/panel'; export const EditStepConditionsLayout = ({ @@ -17,7 +17,7 @@ export const EditStepConditionsLayout = ({ children: React.ReactNode; }) => { return ( -
+
@@ -39,6 +39,6 @@ export const EditStepConditionsLayout = ({ Save Conditions
- +
); }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/step-drawer.tsx b/apps/dashboard/src/components/workflow-editor/steps/step-drawer.tsx index d1ac6c010b4..4c8ad0706c4 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/step-drawer.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/step-drawer.tsx @@ -1,9 +1,11 @@ import { useNavigate } from 'react-router-dom'; +import { FormProtection } from '@/components/form-protection'; import { PageMeta } from '@/components/page-meta'; import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/primitives/sheet'; import { VisuallyHidden } from '@/components/primitives/visually-hidden'; import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; +import { useFormProtection } from '@/hooks/use-form-protection'; import { useOnElementUnmount } from '@/hooks/use-on-element-unmount'; import { cn } from '@/utils/ui'; import { StepTypeEnum } from '@novu/shared'; @@ -26,6 +28,9 @@ export const StepDrawer = ({ children, title }: { children: React.ReactNode; tit navigate(-1); }, }); + + const { isFormDirty, setShowAlert, showAlert } = useFormProtection(sheetRef); + if (!workflow || !step) { return null; } @@ -33,8 +38,16 @@ export const StepDrawer = ({ children, title }: { children: React.ReactNode; tit return ( <> - - {/* Custom overlay since SheetOverlay does not work with modal={false} */} + { + if (isFormDirty) { + return setShowAlert(true); + } + setIsOpen(open); + }} + >
+ + setIsOpen(false)} showAlert={showAlert} setShowAlert={setShowAlert} /> ); }; diff --git a/apps/dashboard/src/hooks/use-form-protection.ts b/apps/dashboard/src/hooks/use-form-protection.ts new file mode 100644 index 00000000000..b44d965c690 --- /dev/null +++ b/apps/dashboard/src/hooks/use-form-protection.ts @@ -0,0 +1,58 @@ +import { useBeforeUnload } from '@/hooks/use-before-unload'; +import { useEffect, useState } from 'react'; + +export function useFormProtection(ref: React.RefObject) { + const [isDirty, setIsDirty] = useState(false); + const [showAlert, setShowAlert] = useState(false); + + useBeforeUnload(isDirty); + + useEffect(() => { + if (!ref.current) { + const documentObserver = new MutationObserver(() => { + if (ref.current) { + documentObserver.disconnect(); + setupElementObserver(ref.current); + } + }); + + documentObserver.observe(document.body, { + childList: true, + subtree: true, + }); + + return () => { + documentObserver.disconnect(); + }; + } + + return setupElementObserver(ref.current); + }, [ref]); + + function setupElementObserver(element: HTMLElement) { + const checkDirty = () => { + const dirtyFound = element.querySelector('[data-dirty="true"]') !== null; + setIsDirty(dirtyFound); + }; + + checkDirty(); + + const observer = new MutationObserver((mutations) => { + const shouldCheck = mutations.some((mutation) => mutation.type === 'attributes' || mutation.type === 'childList'); + + if (shouldCheck) { + checkDirty(); + } + }); + + observer.observe(element, { + attributes: true, + childList: true, + subtree: true, + }); + + return () => observer.disconnect(); + } + + return { isFormDirty: isDirty, showAlert, setShowAlert }; +} diff --git a/apps/dashboard/src/pages/create-subscriber.tsx b/apps/dashboard/src/pages/create-subscriber.tsx index cb310f1d60e..edaec50d804 100644 --- a/apps/dashboard/src/pages/create-subscriber.tsx +++ b/apps/dashboard/src/pages/create-subscriber.tsx @@ -1,5 +1,7 @@ +import { FormProtection } from '@/components/form-protection'; import { Sheet, SheetContent } from '@/components/primitives/sheet'; import { CreateSubscriberForm } from '@/components/subscribers/create-subscriber-form'; +import { useFormProtection } from '@/hooks/use-form-protection'; import { useOnElementUnmount } from '@/hooks/use-on-element-unmount'; import { buildRoute, ROUTES } from '@/utils/routes'; import { useRef, useState } from 'react'; @@ -19,6 +21,8 @@ export function CreateSubscriberPage() { ); }; + const { isFormDirty, setShowAlert, showAlert } = useFormProtection(sheetRef); + useOnElementUnmount({ element: sheetRef.current, callback: () => { @@ -27,10 +31,21 @@ export function CreateSubscriberPage() { }); return ( - - - - - + <> + { + if (isFormDirty) { + return setShowAlert(true); + } + setOpen(open); + }} + > + + navigate(-1)} /> + + + setOpen(false)} showAlert={showAlert} setShowAlert={setShowAlert} /> + ); }