Skip to content

Commit

Permalink
fix(dashboard): Form blocker fix
Browse files Browse the repository at this point in the history
  • Loading branch information
desiprisg committed Feb 12, 2025
1 parent 6279344 commit f1cac5c
Show file tree
Hide file tree
Showing 11 changed files with 211 additions and 76 deletions.
22 changes: 22 additions & 0 deletions apps/dashboard/src/components/form-protection.tsx
Original file line number Diff line number Diff line change
@@ -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 <UnsavedChangesAlertDialog show={showAlert} onCancel={handleCancel} onProceed={handleProceed} />;
};
27 changes: 25 additions & 2 deletions apps/dashboard/src/components/primitives/form/form.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,6 +14,19 @@ import { FormFieldContext, FormItemContext, useFormField } from './form-context'

const Form = FormProvider;

const FormRoot = React.forwardRef<HTMLFormElement, React.ComponentPropsWithoutRef<'form'>>(
({ children, ...props }, ref) => {
const form = useFormContext();

return (
<form ref={ref} data-dirty={form.formState.isDirty || undefined} {...props}>
{children}
</form>
);
}
);
FormRoot.displayName = 'FormRoot';

const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
Expand Down Expand Up @@ -128,4 +141,14 @@ const FormTextInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWit
});
FormTextInput.displayName = 'FormTextInput';

export { Form, FormControl, FormField, FormTextInput as FormInput, FormItem, FormLabel, FormMessage, FormMessagePure };
export {
Form,
FormControl,
FormField,
FormTextInput as FormInput,
FormItem,
FormLabel,
FormMessage,
FormMessagePure,
FormRoot,
};
Original file line number Diff line number Diff line change
@@ -1,23 +1,21 @@
import { useBeforeUnload } from '@/hooks/use-before-unload';
import { useCreateSubscriber } from '@/hooks/use-create-subscriber';
import { zodResolver } from '@hookform/resolvers/zod';
import { loadLanguage } from '@uiw/codemirror-extensions-langs';
import { useForm } from 'react-hook-form';
import { RiCloseCircleLine, RiGroup2Line, RiInformationFill, RiMailLine } from 'react-icons/ri';
import { Link, useBlocker } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { ExternalToast } from 'sonner';
import { z } from 'zod';
import { Button } from '../primitives/button';
import { CompactButton } from '../primitives/button-compact';
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 { InlineToast } from '../primitives/inline-toast';
import { Input, InputRoot } from '../primitives/input';
import { PhoneInput } from '../primitives/phone-input';
import { Separator } from '../primitives/separator';
import { showErrorToast, showSuccessToast } from '../primitives/sonner-helpers';
import TruncatedText from '../truncated-text';
import { UnsavedChangesAlertDialog } from '../unsaved-changes-alert-dialog';
import { LocaleSelect } from './locale-select';
import { CreateSubscriberFormSchema } from './schema';
import { TimezoneSelect } from './timezone-select';
Expand Down Expand Up @@ -58,10 +56,6 @@ export const CreateSubscriberForm = (props: CreateSubscriberFormProps) => {
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);
Expand Down Expand Up @@ -101,7 +95,7 @@ export const CreateSubscriberForm = (props: CreateSubscriberFormProps) => {
</div>
</header>
<Form {...form}>
<form autoComplete="off" noValidate onSubmit={form.handleSubmit(onSubmit)} className="flex h-full flex-col">
<FormRoot autoComplete="off" noValidate onSubmit={form.handleSubmit(onSubmit)} className="flex h-full flex-col">
<div className="flex flex-col items-stretch gap-6 p-5">
<div className="grid grid-cols-2 gap-2.5">
<FormField
Expand Down Expand Up @@ -363,9 +357,8 @@ export const CreateSubscriberForm = (props: CreateSubscriberFormProps) => {
</Button>
</div>
</div>
</form>
</FormRoot>
</Form>
<UnsavedChangesAlertDialog blocker={blocker} />
</div>
);
};
56 changes: 39 additions & 17 deletions apps/dashboard/src/components/subscribers/subscriber-drawer.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,55 @@
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;
onOpenChange: (open: boolean) => void;
subscriberId: string;
readOnly?: boolean;
};
export const SubscriberDrawer = forwardRef<HTMLDivElement, SubscriberDrawerProps>((props, ref) => {

export const SubscriberDrawer = forwardRef<HTMLDivElement, SubscriberDrawerProps>((props, forwardedRef) => {
const { open, onOpenChange, subscriberId, readOnly = false } = props;
const localRef = useRef<HTMLDivElement>(null);
// Use the forwarded ref if it exists, otherwise use localRef
const ref = (forwardedRef || localRef) as React.RefObject<HTMLDivElement>;

const { showAlert, setShowAlert, isFormDirty } = useFormProtection(ref);

return (
<Sheet modal={false} open={open} onOpenChange={onOpenChange}>
{/* Custom overlay since SheetOverlay does not work with modal={false} */}
<div
className={cn('fade-in animate-in fixed inset-0 z-50 bg-black/20 transition-opacity duration-300', {
'pointer-events-none opacity-0': !open,
})}
/>
<SheetContent ref={ref}>
<VisuallyHidden>
<SheetTitle />
<SheetDescription />
</VisuallyHidden>
<SubscriberTabs subscriberId={subscriberId} readOnly={readOnly} />
</SheetContent>
</Sheet>
<>
<Sheet
modal={false}
open={open}
onOpenChange={(open) => {
if (isFormDirty) {
return setShowAlert(true);
}
onOpenChange(open);
}}
>
{/* Custom overlay since SheetOverlay does not work with modal={false} */}
<div
className={cn('fade-in animate-in fixed inset-0 z-50 bg-black/20 transition-opacity duration-300', {
'pointer-events-none opacity-0': !open,
})}
/>
<SheetContent ref={ref}>
<VisuallyHidden>
<SheetTitle />
<SheetDescription />
</VisuallyHidden>
<SubscriberTabs subscriberId={subscriberId} readOnly={readOnly} />
</SheetContent>
</Sheet>

<FormProtection onClose={() => onOpenChange(false)} showAlert={showAlert} setShowAlert={setShowAlert} />
</>
);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,35 +1,33 @@
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';
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';
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 };
Expand Down Expand Up @@ -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<typeof SubscriberFormSchema>) => {
const dirtyFields = form.formState.dirtyFields;

Expand All @@ -134,7 +128,7 @@ export function SubscriberOverviewForm(props: SubscriberOverviewFormProps) {
return (
<div className={cn('flex h-full flex-col')}>
<Form {...form}>
<form autoComplete="off" noValidate onSubmit={form.handleSubmit(onSubmit)} className="flex h-full flex-col">
<FormRoot autoComplete="off" noValidate onSubmit={form.handleSubmit(onSubmit)} className="flex h-full flex-col">
<div className="flex flex-col items-stretch gap-6 p-5">
<div className="flex items-center gap-3">
<Tooltip>
Expand Down Expand Up @@ -394,13 +388,13 @@ export function SubscriberOverviewForm(props: SubscriberOverviewFormProps) {
>
Delete subscriber
</Button>
<Button variant="secondary" type="submit" disabled={!isDirty}>
<Button variant="secondary" type="submit" disabled={!form.formState.isDirty}>
Save changes
</Button>
</div>
</div>
)}
</form>
</FormRoot>
</Form>
<ConfirmationModal
open={isDeleteModalOpen}
Expand All @@ -420,7 +414,6 @@ export function SubscriberOverviewForm(props: SubscriberOverviewFormProps) {
confirmButtonText="Delete subscriber"
isLoading={isDeleteSubscriberPending}
/>
<UnsavedChangesAlertDialog blocker={blocker} />
</div>
);
}
15 changes: 8 additions & 7 deletions apps/dashboard/src/components/unsaved-changes-alert-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<AlertDialog open={blocker.state === 'blocked'}>
<AlertDialog open={show}>
<AlertDialogContent>
<AlertDialogHeader className="flex flex-row items-start gap-4">
<div className="bg-warning/10 rounded-lg p-3">
Expand All @@ -31,16 +32,16 @@ export const UnsavedChangesAlertDialog = (props: UnsavedChangesAlertDialogProps)
<div className="space-y-1">
<AlertDialogTitle>You might lose your progress</AlertDialogTitle>
<AlertDialogDescription>
This form has some unsaved changes. Save progress before you leave.
{description || 'This form has some unsaved changes. Save progress before you leave.'}
</AlertDialogDescription>
</div>
</AlertDialogHeader>

<Separator />

<AlertDialogFooter>
<AlertDialogCancel onClick={() => blocker.reset?.()}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => blocker.proceed?.()} asChild>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onProceed} asChild>
<Button trailingIcon={RiArrowRightSLine} variant="error" mode="ghost" size="xs">
Proceed anyway
</Button>
Expand Down
Original file line number Diff line number Diff line change
@@ -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.';
Expand Down Expand Up @@ -113,8 +109,6 @@ export const EditStepConditionsForm = () => {
},
});
const { formState } = form;
const blocker = useBlocker(formState.isDirty);
useBeforeUnload(formState.isDirty);

const onSubmit = (values: z.infer<ReturnType<typeof getConditionsSchema>>) => {
if (!step || !workflow) return;
Expand Down Expand Up @@ -196,7 +190,6 @@ export const EditStepConditionsForm = () => {
/>
</EditStepConditionsLayout>
</Form>
<UnsavedChangesAlertDialog blocker={blocker} />
</>
);
};
Loading

0 comments on commit f1cac5c

Please sign in to comment.