Check for unsaved changes in Survey Editor before changing pages (#409)

* fix: remove environmentsNavbar on survey editor

* objects deep comparision utility function

* feat: confirm on window reload or close

* feat: confirm save on back

* feat: custom alert dialog

* remove radixui alert dialog

* replaced shadcn alert with new custom alert dialog

* fix: save button varient to darkCTA

* fix: moved beforeunload logic to surveymenubar

* fix: remove deepequal function

* installed lodash

* fix: survey not comparing on change

* fix: isqual import
This commit is contained in:
Joe
2023-06-22 12:14:04 +05:30
committed by GitHub
parent 6922b3ed3f
commit 25b84102a7
6 changed files with 95 additions and 200 deletions

View File

@@ -211,6 +211,8 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
return <ErrorComponent />;
}
if (pathname?.includes("/edit")) return null;
return (
<nav className="top-0 z-10 w-full border-b border-slate-200 bg-white">
{environment?.type === "development" && (

View File

@@ -37,6 +37,7 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
}
}, [survey]);
if (isLoadingSurvey || isLoadingProduct || !localSurvey) {
return <LoadingSpinner />;
}
@@ -50,6 +51,7 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
<SurveyMenuBar
setLocalSurvey={setLocalSurvey}
localSurvey={localSurvey}
survey={survey}
environmentId={environmentId}
activeId={activeView}
setActiveId={setActiveView}

View File

@@ -1,5 +1,6 @@
"use client";
import AlertDialog from "@/components/shared/AlertDialog";
import DeleteDialog from "@/components/shared/DeleteDialog";
import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown";
import { useProduct } from "@/lib/products/products";
@@ -11,9 +12,11 @@ import { ArrowLeftIcon, Cog8ToothIcon, ExclamationTriangleIcon } from "@heroicon
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { isEqual } from 'lodash';
interface SurveyMenuBarProps {
localSurvey: Survey;
survey: Survey;
setLocalSurvey: (survey: Survey) => void;
environmentId: string;
activeId: "questions" | "settings";
@@ -22,6 +25,7 @@ interface SurveyMenuBarProps {
export default function SurveyMenuBar({
localSurvey,
survey,
environmentId,
setLocalSurvey,
activeId,
@@ -31,6 +35,7 @@ export default function SurveyMenuBar({
const { triggerSurveyMutate, isMutatingSurvey } = useSurveyMutation(environmentId, localSurvey.id);
const [audiencePrompt, setAudiencePrompt] = useState(true);
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false);
const { product } = useProduct(environmentId);
useEffect(() => {
@@ -39,6 +44,21 @@ export default function SurveyMenuBar({
}
}, [activeId, audiencePrompt]);
useEffect(() => {
const warningText = "You have unsaved changes - are you sure you wish to leave this page?";
const handleWindowClose = (e: BeforeUnloadEvent) => {
if(!isEqual(localSurvey, survey)){
e.preventDefault();
return e.returnValue = warningText
}
};
window.addEventListener("beforeunload", handleWindowClose);
return () => {
window.removeEventListener("beforeunload", handleWindowClose);
};
}, [localSurvey, survey]);
// write a function which updates the local survey status
const updateLocalSurveyStatus = (status: Survey["status"]) => {
const updatedSurvey = { ...localSurvey, status };
@@ -58,6 +78,8 @@ export default function SurveyMenuBar({
const handleBack = () => {
if (localSurvey.createdAt === localSurvey.updatedAt && localSurvey.status === "draft") {
setDeleteDialogOpen(true);
} else if (!isEqual(localSurvey, survey)) {
setConfirmDialogOpen(true);
} else {
router.back();
}
@@ -165,6 +187,18 @@ export default function SurveyMenuBar({
useSaveInsteadOfCancel={true}
onSave={() => saveSurveyAction(true)}
/>
<AlertDialog
confirmWhat="Survey changes"
open={isConfirmDialogOpen}
setOpen={setConfirmDialogOpen}
onDiscard={() => {
setConfirmDialogOpen(false);
router.back();
}}
text="You have unsaved changes in your survey. Would you like to save them before leaving?"
useSaveInsteadOfCancel={true}
onSave={() => saveSurveyAction(true)}
/>
</div>
);
}

View File

@@ -1,150 +1,45 @@
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import Modal from "@/components/shared/Modal";
import { Button } from "@formbricks/ui";
import { cn } from "@formbricks/lib/cn";
const AlertDialog: React.ComponentType<AlertDialogPrimitive.AlertDialogContentProps> =
AlertDialogPrimitive.Root;
const AlertDialogTrigger: React.ComponentType<AlertDialogPrimitive.AlertDialogTriggerProps> =
AlertDialogPrimitive.Trigger;
const AlertDialogPortal = ({
className,
children,
...props
}: AlertDialogPrimitive.AlertDialogPortalProps) => (
<AlertDialogPrimitive.Portal className={cn(className)} {...props}>
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">{children}</div>
</AlertDialogPrimitive.Portal>
);
AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, children, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"animate-in fade-in fixed inset-0 z-50 bg-black/50 backdrop-blur-sm transition-opacity",
className
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent: React.ComponentType<AlertDialogPrimitive.AlertDialogContentProps> =
React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"animate-in fade-in-90 slide-in-from-bottom-10 sm:zoom-in-90 sm:slide-in-from-bottom-0 fixed z-50 grid w-full max-w-lg scale-100 gap-4 bg-white p-6 opacity-100 sm:rounded-lg md:w-full",
"dark:bg-slate-900",
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
interface AlertDialogHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
dangerouslySetInnerHTML?: {
__html: string;
};
interface AlertDialogProps {
open: boolean;
setOpen: (open: boolean) => void;
confirmWhat: string;
onDiscard: () => void;
text?: string;
useSaveInsteadOfCancel?: boolean;
onSave?: () => void;
}
const AlertDialogHeader = ({ className, ...props }: AlertDialogHeaderProps) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
AlertDialogHeader.displayName = "AlertDialogHeader";
interface AlertDialogFooterProps extends React.HTMLAttributes<HTMLDivElement> {
dangerouslySetInnerHTML?: {
__html: string;
};
export default function AlertDialog({
open,
setOpen,
confirmWhat,
onDiscard,
text,
useSaveInsteadOfCancel = false,
onSave,
}: AlertDialogProps) {
return (
<Modal open={open} setOpen={setOpen} title={`Confirm ${confirmWhat}`}>
<p>{text || "Are you sure? This action cannot be undone."}</p>
<div className="my-4 space-x-2 text-right">
<Button variant="warn" onClick={onDiscard}>
Discard
</Button>
<Button
variant="darkCTA"
onClick={() => {
if (useSaveInsteadOfCancel && onSave) {
onSave();
}
setOpen(false);
}}>
{useSaveInsteadOfCancel ? "Save" : "Cancel"}
</Button>
</div>
</Modal>
);
}
const AlertDialogFooter = ({ className, ...props }: AlertDialogFooterProps) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle: React.ComponentType<AlertDialogPrimitive.AlertDialogTitleProps> = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-slate-900", "dark:text-slate-50", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription: React.ComponentType<AlertDialogPrimitive.AlertDialogDescriptionProps> =
React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-500", "dark:text-slate-400", className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction: React.ComponentType<AlertDialogPrimitive.AlertDialogActionProps> = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900",
className
)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel: React.ComponentType<AlertDialogPrimitive.AlertDialogCancelProps> = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
"mt-2 inline-flex h-10 items-center justify-center rounded-md border border-slate-200 bg-transparent px-4 py-2 text-sm font-semibold text-slate-900 transition-colors hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-100 dark:hover:bg-slate-700 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900 sm:mt-0",
className
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -18,7 +18,6 @@
"@heroicons/react": "^2.0.18",
"@json2csv/node": "^7.0.1",
"@paralleldrive/cuid2": "^2.2.0",
"@radix-ui/react-alert-dialog": "^1.0.3",
"@radix-ui/react-collapsible": "^1.0.2",
"@radix-ui/react-dropdown-menu": "^2.0.4",
"@types/node": "20.2.3",
@@ -31,6 +30,7 @@
"eslint": "8.41.0",
"eslint-config-next": "^13.4.3",
"jsonwebtoken": "^9.0.0",
"lodash": "^4.17.21",
"lucide-react": "^0.221.0",
"markdown-it": "^13.0.1",
"next": "13.4.3",
@@ -60,6 +60,7 @@
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
"@types/bcryptjs": "^2.4.2",
"@types/lodash": "^4.14.195",
"@types/markdown-it": "^12.2.3",
"autoprefixer": "^10.4.14",
"eslint-config-formbricks": "workspace:*",

71
pnpm-lock.yaml generated
View File

@@ -220,9 +220,6 @@ importers:
'@paralleldrive/cuid2':
specifier: ^2.2.0
version: 2.2.0
'@radix-ui/react-alert-dialog':
specifier: ^1.0.3
version: 1.0.3(@types/react@18.2.7)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-collapsible':
specifier: ^1.0.2
version: 1.0.2(react-dom@18.2.0)(react@18.2.0)
@@ -259,6 +256,9 @@ importers:
jsonwebtoken:
specifier: ^9.0.0
version: 9.0.0
lodash:
specifier: ^4.17.21
version: 4.17.21
lucide-react:
specifier: ^0.221.0
version: 0.221.0(react@18.2.0)
@@ -341,6 +341,9 @@ importers:
'@types/bcryptjs':
specifier: ^2.4.2
version: 2.4.2
'@types/lodash':
specifier: ^4.14.195
version: 4.14.195
'@types/markdown-it':
specifier: ^12.2.3
version: 12.2.3
@@ -496,7 +499,7 @@ importers:
version: 8.8.0(eslint@8.41.0)
eslint-config-turbo:
specifier: latest
version: 1.8.8(eslint@8.41.0)
version: 1.10.3(eslint@8.41.0)
eslint-plugin-react:
specifier: 7.32.2
version: 7.32.2(eslint@8.41.0)
@@ -4438,25 +4441,6 @@ packages:
'@babel/runtime': 7.21.0
dev: false
/@radix-ui/react-alert-dialog@1.0.3(@types/react@18.2.7)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-QXFy7+bhGi0u+paF2QbJeSCHZs4gLMJIPm6sajUamyW0fro6g1CaSGc5zmc4QmK2NlSGUrq8m+UsUqJYtzvXow==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.21.0
'@radix-ui/primitive': 1.0.0
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
'@radix-ui/react-context': 1.0.0(react@18.2.0)
'@radix-ui/react-dialog': 1.0.3(@types/react@18.2.7)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot': 1.0.1(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
dev: false
/@radix-ui/react-arrow@1.0.2(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-fqYwhhI9IarZ0ll2cUSfKuXHlJK0qE4AfnRrPBbRwEH/4mGQn04/QFGomLi8TXWIdv9WJk//KgGm+aDxVIr1wA==}
peerDependencies:
@@ -4622,33 +4606,6 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-dialog@1.0.3(@types/react@18.2.7)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-owNhq36kNPqC2/a+zJRioPg6HHnTn5B/sh/NjTY8r4W9g1L5VJlrzZIVcBr7R9Mg8iLjVmh6MGgMlfoVf/WO/A==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.21.0
'@radix-ui/primitive': 1.0.0
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
'@radix-ui/react-context': 1.0.0(react@18.2.0)
'@radix-ui/react-dismissable-layer': 1.0.3(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-focus-guards': 1.0.0(react@18.2.0)
'@radix-ui/react-focus-scope': 1.0.2(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-id': 1.0.0(react@18.2.0)
'@radix-ui/react-portal': 1.0.2(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-presence': 1.0.0(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot': 1.0.1(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.0(react@18.2.0)
aria-hidden: 1.2.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-remove-scroll: 2.5.5(@types/react@18.2.7)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
dev: false
/@radix-ui/react-direction@1.0.0(react@18.2.0):
resolution: {integrity: sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==}
peerDependencies:
@@ -5950,6 +5907,10 @@ packages:
resolution: {integrity: sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==}
dev: true
/@types/lodash@4.14.195:
resolution: {integrity: sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg==}
dev: true
/@types/markdown-it@12.2.3:
resolution: {integrity: sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==}
dependencies:
@@ -10350,13 +10311,13 @@ packages:
eslint: 8.41.0
dev: false
/eslint-config-turbo@1.8.8(eslint@8.41.0):
resolution: {integrity: sha512-+yT22sHOT5iC1sbBXfLIdXfbZuiv9bAyOXsxTxFCWelTeFFnANqmuKB3x274CFvf7WRuZ/vYP/VMjzU9xnFnxA==}
/eslint-config-turbo@1.10.3(eslint@8.41.0):
resolution: {integrity: sha512-ggzPfTJfMsMS383oZ4zfTP1zQvyMyiigOQJRUnLt1nqII6SKkTzdKZdwmXRDHU24KFwUfEFtT6c8vnm2VhL0uQ==}
peerDependencies:
eslint: '>6.6.0'
dependencies:
eslint: 8.41.0
eslint-plugin-turbo: 1.8.8(eslint@8.41.0)
eslint-plugin-turbo: 1.10.3(eslint@8.41.0)
dev: false
/eslint-import-resolver-node@0.3.6:
@@ -10575,8 +10536,8 @@ packages:
string.prototype.matchall: 4.0.8
dev: true
/eslint-plugin-turbo@1.8.8(eslint@8.41.0):
resolution: {integrity: sha512-zqyTIvveOY4YU5jviDWw9GXHd4RiKmfEgwsjBrV/a965w0PpDwJgEUoSMB/C/dU310Sv9mF3DSdEjxjJLaw6rA==}
/eslint-plugin-turbo@1.10.3(eslint@8.41.0):
resolution: {integrity: sha512-g3Mnnk7el1FqxHfqbE/MayLvCsYjA/vKmAnUj66kV4AlM7p/EZqdt42NMcMSKtDVEm0w+utQkkzWG2Xsa0Pd/g==}
peerDependencies:
eslint: '>6.6.0'
dependencies: