Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prevent forms from being closed if there are unsaved changes #4419

Merged
merged 14 commits into from
Sep 24, 2024
1 change: 1 addition & 0 deletions changelog/4419.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Prevent the form from being closed if there are unsaved changes.
3 changes: 2 additions & 1 deletion frontend/app/.eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"tsconfig.json"
],
"rules": {
"react/prop-types": "off",
"quotes": [
"error",
"double",
Expand Down Expand Up @@ -74,4 +75,4 @@
"semi": "error",
"no-trailing-spaces": "error"
}
}
}
111 changes: 68 additions & 43 deletions frontend/app/src/components/display/slide-over.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Dialog, Transition } from "@headlessui/react";
import React, { Fragment, useRef } from "react";
import React, { Fragment, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Icon } from "@iconify-icon/react";
import { ObjectHelpButton } from "@/components/menu/object-help-button";
import { useAtomValue } from "jotai/index";
import { currentBranchAtom } from "@/state/atoms/branches.atom";
import { IModelSchema } from "@/state/atoms/schema.atom";
import ModalDelete from "../modals/modal-delete";

interface Props {
open: boolean;
Expand All @@ -15,9 +16,16 @@ interface Props {
offset?: number;
}

interface SlideOverContextProps {
setPreventClose?: (value: boolean) => void;
}

export const SlideOverContext = React.createContext<SlideOverContextProps>({});

export default function SlideOver(props: Props) {
const { open, setOpen, title, offset = 0 } = props;
const initialFocusRef = useRef(null);
const [preventClose, setPreventClose] = useState(false);

// Need to define full classes so tailwind can compile the css
const panelWidth = "w-[400px]";
Expand All @@ -27,52 +35,69 @@ export default function SlideOver(props: Props) {
1: "-translate-x-[400px]",
};

const context = {
isOpen: open || (!open && preventClose),
setPreventClose: (value: boolean) => setPreventClose(value),
};

return (
<Transition.Root show={open} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={setOpen} initialFocus={initialFocusRef}>
<Transition.Child
as={Fragment}
enter="ease-in-out duration-500"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in-out duration-500"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div
className="fixed inset-0 bg-black bg-opacity-40 transition-opacity"
data-cy="side-panel-background"
data-testid="side-panel-background"
/>
</Transition.Child>

<div className="before:fixed inset-0 overflow-hidden">
<div className="absolute inset-0 overflow-hidden">
<div className="pointer-events-none fixed inset-y-0 right-0 flex">
<button type="button" tabIndex={-1} ref={initialFocusRef} />
<Transition.Child
as={Fragment}
enter="transform transition ease-in-out duration-500"
enterFrom="translate-x-full"
enterTo={`${offestWidth[offset]}`}
leave="transform transition ease-in-out duration-500"
leaveFrom={`${offestWidth[offset]}`}
leaveTo="translate-x-full">
<Dialog.Panel
className={`bg-custom-white pointer-events-auto shadow-xl flex flex-col ${panelWidth} ${offestWidth[offset]}`}
data-testid="side-panel-container">
<div className="px-4 py-4 sm:px-4 bg-gray-50 border-b">
<div className="w-full">
<Dialog.Title className="text-base leading-6">{title}</Dialog.Title>
<SlideOverContext.Provider value={context}>
<Transition.Root show={open || (!open && preventClose)} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={setOpen} initialFocus={initialFocusRef}>
<Transition.Child
as={Fragment}
enter="ease-in-out duration-500"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in-out duration-500"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div
className="fixed inset-0 bg-black bg-opacity-40 transition-opacity"
data-cy="side-panel-background"
data-testid="side-panel-background"
/>
</Transition.Child>

<div className="before:fixed inset-0 overflow-hidden">
<div className="absolute inset-0 overflow-hidden">
<div className="pointer-events-none fixed inset-y-0 right-0 flex">
<button type="button" tabIndex={-1} ref={initialFocusRef} />
<Transition.Child
as={Fragment}
enter="transform transition ease-in-out duration-500"
enterFrom="translate-x-full"
enterTo={`${offestWidth[offset]}`}
leave="transform transition ease-in-out duration-500"
leaveFrom={`${offestWidth[offset]}`}
leaveTo="translate-x-full">
<Dialog.Panel
className={`bg-custom-white pointer-events-auto shadow-xl flex flex-col ${panelWidth} ${offestWidth[offset]}`}
data-testid="side-panel-container">
<div className="px-4 py-4 sm:px-4 bg-gray-50 border-b">
<div className="w-full">
<Dialog.Title className="text-base leading-6">{title}</Dialog.Title>
</div>
</div>
</div>
{props.children}
</Dialog.Panel>
</Transition.Child>
{props.children}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</div>
</div>
</Dialog>
</Transition.Root>
</Dialog>
</Transition.Root>

<ModalDelete
title="Closing form"
description={"Are you sure you want to close this form? All unsaved changes will be lost."}
onCancel={() => setOpen(true)}
onDelete={() => setPreventClose(false)}
open={!open && preventClose}
setOpen={() => setPreventClose(false)}
confirmLabel="Close"
/>
</SlideOverContext.Provider>
);
}

Expand Down
4 changes: 2 additions & 2 deletions frontend/app/src/components/modals/modal-confirm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ interface iProps {
isLoading?: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
title: string;
description: string | React.ReactNode;
description?: string | React.ReactNode;
onConfirm: Function;
onCancel: Function;
children: ReactNode;
children?: ReactNode;
icon?: string;
}

Expand Down
18 changes: 16 additions & 2 deletions frontend/app/src/components/ui/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "react-hook-form";
import { Spinner } from "@/components/ui/spinner";
import Label, { LabelProps } from "@/components/ui/label";
import { SlideOverContext } from "../display/slide-over";

export type FormRef = ReturnType<typeof useForm>;

Expand All @@ -33,12 +34,23 @@ export const Form = React.forwardRef<FormRef, FormProps>(
({ form, defaultValues, className, children, onSubmit, ...props }: FormProps, ref) => {
const currentForm = form ?? useForm({ defaultValues });

const slideOverContext = useContext(SlideOverContext);

useImperativeHandle(ref, () => currentForm);

useEffect(() => {
currentForm.reset(defaultValues);
}, [JSON.stringify(defaultValues)]);

useEffect(() => {
// Stop logic if there is no context to prevent the slide over close
if (!slideOverContext?.setPreventClose) return;

if (!currentForm.formState.isDirty) return;

slideOverContext?.setPreventClose(true);
}, [currentForm.formState.isDirty]);

return (
<FormProvider {...currentForm}>
<form
Expand All @@ -47,9 +59,11 @@ export const Form = React.forwardRef<FormRef, FormProps>(
event.stopPropagation();
}

if (!onSubmit) return;
if (onSubmit) currentForm.handleSubmit(onSubmit)(event);

currentForm.handleSubmit(onSubmit)(event);
if (slideOverContext?.setPreventClose) {
slideOverContext?.setPreventClose(false);
}
}}
className={classNames("space-y-4", className)}
{...props}>
Expand Down
7 changes: 5 additions & 2 deletions frontend/app/src/hooks/usePagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,12 @@ const usePagination = (): [tPagination, Function] => {

// Set the pagination in the QSP
const setPagination = (newPagination: tPagination) => {
const newLimit = getVerifiedLimit(newPagination?.limit, config);
const newOffset = getVerifiedOffset(newPagination?.offset, config);

const newValidatedPagination = {
limit: getVerifiedLimit(newPagination?.limit, config),
offset: getVerifiedOffset(newPagination?.offset, config),
limit: newLimit,
offset: newOffset,
};

setPaginationInQueryString(JSON.stringify(newValidatedPagination));
Expand Down
Loading