Skip to content

Commit

Permalink
feat(dashboard): Easy form protection for tabs
Browse files Browse the repository at this point in the history
  • Loading branch information
desiprisg committed Feb 13, 2025
1 parent 952063f commit 42886cc
Show file tree
Hide file tree
Showing 7 changed files with 119 additions and 84 deletions.
12 changes: 9 additions & 3 deletions apps/dashboard/src/components/subscribers/subscriber-drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/
import { VisuallyHidden } from '@/components/primitives/visually-hidden';
import { SubscriberTabs } from '@/components/subscribers/subscriber-tabs';
import { useCombinedRefs } from '@/hooks/use-combined-refs';
import { useFormDialogProtection } from '@/hooks/use-form-dialog-protection';
import { useFormProtection } from '@/hooks/use-form-protection';
import { cn } from '@/utils/ui';
import { forwardRef, useState } from 'react';

Expand All @@ -16,13 +16,19 @@ type SubscriberDrawerProps = {
export const SubscriberDrawer = forwardRef<HTMLDivElement, SubscriberDrawerProps>((props, forwardedRef) => {
const { open, onOpenChange, subscriberId, readOnly = false } = props;

const { protectedOnOpenChange, ProtectionAlert, ref: protectionRef } = useFormDialogProtection({ onOpenChange });
const {
protectedOnValueChange,
ProtectionAlert,
ref: protectionRef,
} = useFormProtection({
onValueChange: onOpenChange,
});

const combinedRef = useCombinedRefs(forwardedRef, protectionRef);

return (
<>
<Sheet modal={false} open={open} onOpenChange={protectedOnOpenChange}>
<Sheet modal={false} open={open} onOpenChange={protectedOnValueChange}>
{/* 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', {
Expand Down
18 changes: 17 additions & 1 deletion apps/dashboard/src/components/subscribers/subscriber-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { SubscriberOverviewSkeleton } from '@/components/subscribers/subscriber-
import TruncatedText from '@/components/truncated-text';
import { useFetchSubscriber } from '@/hooks/use-fetch-subscriber';
import useFetchSubscriberPreferences from '@/hooks/use-fetch-subscriber-preferences';
import { useFormProtection } from '@/hooks/use-form-protection';
import { useState } from 'react';
import { RiGroup2Line } from 'react-icons/ri';
import { Separator } from '../primitives/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../primitives/tabs';
Expand Down Expand Up @@ -54,9 +56,22 @@ type SubscriberTabsProps = {
};
export function SubscriberTabs(props: SubscriberTabsProps) {
const { subscriberId, readOnly = false } = props;
const [tab, setTab] = useState('overview');
const {
protectedOnValueChange,
ProtectionAlert,
ref: protectionRef,
} = useFormProtection({
onValueChange: setTab,
});

return (
<Tabs defaultValue="overview" className="flex h-full w-full flex-col">
<Tabs
ref={protectionRef}
className="flex h-full w-full flex-col"
value={tab}
onValueChange={protectedOnValueChange}
>
<header className="border-bg-soft flex h-12 w-full flex-row items-center gap-3 border-b px-3 py-4">
<div className="flex flex-1 items-center gap-1 overflow-hidden text-sm font-medium">
<RiGroup2Line className="size-5 p-0.5" />
Expand Down Expand Up @@ -85,6 +100,7 @@ export function SubscriberTabs(props: SubscriberTabsProps) {
<SubscriberActivity subscriberId={subscriberId} />
</TabsContent>
<Separator />
<ProtectionAlert />
</Tabs>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/
import { VisuallyHidden } from '@/components/primitives/visually-hidden';
import { useWorkflow } from '@/components/workflow-editor/workflow-provider';
import { useCombinedRefs } from '@/hooks/use-combined-refs';
import { useFormDialogProtection } from '@/hooks/use-form-dialog-protection';
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';
Expand All @@ -28,10 +28,12 @@ export const StepDrawer = ({ children, title }: { children: React.ReactNode; tit
});

const {
protectedOnOpenChange,
protectedOnValueChange,
ProtectionAlert,
ref: protectionRef,
} = useFormDialogProtection({ onOpenChange: setIsOpen });
} = useFormProtection({
onValueChange: setIsOpen,
});

const combinedRef = useCombinedRefs(unmountRef, protectionRef);

Expand All @@ -42,7 +44,7 @@ export const StepDrawer = ({ children, title }: { children: React.ReactNode; tit
return (
<>
<PageMeta title={title} />
<Sheet modal={false} open={isOpen} onOpenChange={protectedOnOpenChange}>
<Sheet modal={false} open={isOpen} onOpenChange={protectedOnValueChange}>
<div
className={cn('animate-in fade-in fixed inset-0 z-50 bg-black/20 transition-opacity duration-300', {
'pointer-events-none opacity-0': !isOpen,
Expand Down
34 changes: 34 additions & 0 deletions apps/dashboard/src/hooks/use-find-dirty-form.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useCallback, useState } from 'react';

export function useFindDirtyForm() {
const [isDirty, setIsDirty] = useState(false);

const setRef = useCallback((element: HTMLElement | null) => {
if (!element) return;

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 { isDirty, ref: setRef };
}
71 changes: 0 additions & 71 deletions apps/dashboard/src/hooks/use-form-dialog-protection.tsx

This file was deleted.

48 changes: 48 additions & 0 deletions apps/dashboard/src/hooks/use-form-protection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { UnsavedChangesAlertDialog } from '@/components/unsaved-changes-alert-dialog';
import { useBeforeUnload } from '@/hooks/use-before-unload';
import { useCallback, useState } from 'react';
import { useFindDirtyForm } from './use-find-dirty-form';

type UseFormProtectionProps<T> = {
onValueChange: (value: T) => void;
};

export function useFormProtection<T>(props: UseFormProtectionProps<T>) {
const { onValueChange } = props;
const [showAlert, setShowAlert] = useState(false);
const [pendingChange, setPendingChange] = useState<{ value: T } | null>(null);
const { isDirty, ref } = useFindDirtyForm();

useBeforeUnload(isDirty);

const protectedOnValueChange = useCallback(
(value: T) => {
if (isDirty) {
setShowAlert(true);
setPendingChange({ value });
} else {
onValueChange(value);
}
},
[isDirty, onValueChange]
);

const ProtectionAlert = () => (
<UnsavedChangesAlertDialog
show={showAlert}
onCancel={() => {
setShowAlert(false);
setPendingChange(null);
}}
onProceed={() => {
if (pendingChange) {
onValueChange(pendingChange.value);
}
setShowAlert(false);
setPendingChange(null);
}}
/>
);

return { isDirty, protectedOnValueChange, ProtectionAlert, ref };
}
10 changes: 5 additions & 5 deletions apps/dashboard/src/pages/create-subscriber.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Sheet, SheetContent } from '@/components/primitives/sheet';
import { CreateSubscriberForm } from '@/components/subscribers/create-subscriber-form';
import { useCombinedRefs } from '@/hooks/use-combined-refs';
import { useFormDialogProtection } from '@/hooks/use-form-dialog-protection';
import { useFormProtection } from '@/hooks/use-form-protection';
import { useOnElementUnmount } from '@/hooks/use-on-element-unmount';
import { cn } from '@/utils/ui';
import { useState } from 'react';
Expand All @@ -12,11 +12,11 @@ export function CreateSubscriberPage() {
const [open, setOpen] = useState(true);

const {
protectedOnOpenChange,
protectedOnValueChange,
ProtectionAlert,
ref: protectionRef,
} = useFormDialogProtection({
onOpenChange: setOpen,
} = useFormProtection({
onValueChange: setOpen,
});

const { ref: unmountRef } = useOnElementUnmount({
Expand All @@ -29,7 +29,7 @@ export function CreateSubscriberPage() {

return (
<>
<Sheet open={open} onOpenChange={protectedOnOpenChange}>
<Sheet open={open} onOpenChange={protectedOnValueChange}>
{/* 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', {
Expand Down

0 comments on commit 42886cc

Please sign in to comment.