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

Introduces a better Modal popup for unsaved changes in Forms. #2285

Merged
merged 11 commits into from
Aug 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/formik/BlockNavigation/Alert.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/* eslint-disable @bigbinary/neeto/file-name-and-export-name-standards */
import React, { useRef } from "react";

import { useTranslation } from "react-i18next";

import Button from "components/Button";
import Modal from "components/Modal";
import Typography from "components/Typography";
import { getLocale } from "utils";

const Alert = ({
isOpen = false,
isSubmitting = false,
onClose,
onSaveChanges,
onDiscardChanges,
}) => {
const { t, i18n } = useTranslation();

const saveChangesButtonRef = useRef(null);

const cancelButtonLabel = getLocale(
i18n,
t,
"neetoui.blockNavigation.cancelButtonLabel"
);

const submitButtonLabel = getLocale(
i18n,
t,
"neetoui.blockNavigation.submitButtonLabel"
);

return (
<Modal
{...{ isOpen, onClose }}
closeButton
closeOnEsc
closeOnOutsideClick
data-cy="alert-box"
initialFocusRef={saveChangesButtonRef}
size="medium"
>
<Modal.Header>
<Typography data-cy="alert-title" style="h2">
{getLocale(i18n, t, "neetoui.blockNavigation.alertTitle")}
</Typography>
</Modal.Header>
<Modal.Body>
<Typography data-cy="alert-message" lineHeight="normal" style="body2">
{getLocale(i18n, t, "neetoui.blockNavigation.alertMessage")}
</Typography>
</Modal.Body>
<Modal.Footer className="neeto-ui-gap-2 neeto-ui-flex neeto-ui-justify-end neeto-ui-items-center">
<Button
data-cy="alert-cancel-button"
label={cancelButtonLabel}
style="danger"
onClick={onDiscardChanges}
/>
<Button
data-cy="alert-submit-button"
disabled={!isOpen}
label={submitButtonLabel}
loading={isSubmitting}
ref={saveChangesButtonRef}
style="primary"
onClick={onSaveChanges}
/>
</Modal.Footer>
</Modal>
);
};

export default Alert;
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@ import React from "react";

import { useFormikContext } from "formik";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";

import Alert from "components/Alert";
import { useNavPrompt } from "hooks";
import { getLocale } from "utils";

import Alert from "./Alert";

const BlockNavigation = ({ isDirty = false, ...otherProps }) => {
const { t, i18n } = useTranslation();
const formikContext = useFormikContext();
const shouldBlock =
isDirty || (Boolean(formikContext) && Boolean(formikContext.dirty));
Expand All @@ -19,23 +17,27 @@ const BlockNavigation = ({ isDirty = false, ...otherProps }) => {
shouldBlock,
});

const continueAction = () => {
const handleDiscardChanges = () => {
if (formikContext) formikContext.resetForm();
hidePrompt();
continueNavigation();
};

const handleSaveChanges = () => {
if (formikContext.isValid) {
formikContext.submitForm();
continueNavigation();
} else formikContext.setTouched(formikContext.errors);

hidePrompt();
};

return (
<Alert
isOpen={isBlocked}
message={getLocale(i18n, t, "neetoui.blockNavigation.alertMessage")}
title={getLocale(i18n, t, "neetoui.blockNavigation.alertTitle")}
submitButtonLabel={getLocale(
i18n,
t,
"neetoui.blockNavigation.submitButtonLabel"
)}
onClose={hidePrompt}
onSubmit={continueAction}
onDiscardChanges={handleDiscardChanges}
onSaveChanges={handleSaveChanges}
{...otherProps}
/>
);
Expand Down
3 changes: 2 additions & 1 deletion src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"neetoui": {
"blockNavigation": {
"alertMessage": "All of your unsaved changes will be lost. This can't be undone.",
"submitButtonLabel": "Discard changes",
"submitButtonLabel": "Save and continue",
"cancelButtonLabel": "Discard changes",
"alertTitle": "You have unsaved changes"
},
"actionBlock": {
Expand Down
2 changes: 1 addition & 1 deletion stories/Formik/BlockNavigation.stories.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const FormikStory = args => (
/>
<Form
formikProps={{
initialValues: { firstName: "", lastName: "" },
initialValues: { firstName: "Oliver", lastName: "" },
validationSchema: yup.object({
firstName: yup.string().required("First name is required"),
lastName: yup.string().required("Last name is required"),
Expand Down
145 changes: 112 additions & 33 deletions tests/formik/BlockNavigation.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,44 @@ import React from "react";

import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Field } from "formik";
import { repeat } from "ramda";
import { MemoryRouter as Router, Route, Switch, Link } from "react-router-dom";
import * as yup from "yup";

import { Input } from "components";
import BlockNavigation from "formikcomponents/BlockNavigation";
import Form from "formikcomponents/Form";
import Input from "formikcomponents/Input";

const TestComponent = () => <div>test page</div>;

const firstName = "Oliver",
lastName = "Smith";
const TestComponent = () => <div>Home page</div>;
const mockSubmit = jest.fn();
const TestForm = ({ isDirty }) => (
<>
<Link to="/test">Navigate</Link>
<Form formikProps={{ initialValues: { formikInput: "test" } }}>
<Link to="/home">Home</Link>
<Form
formikProps={{
initialValues: { firstName, lastName },
validationSchema: yup.object().shape({
firstName: yup.string().required("First name is required"),
lastName: yup.string().required("Last name is required"),
}),
onSubmit: mockSubmit,
}}
>
<BlockNavigation {...{ isDirty }} />
<Field name="formikInput">
{({ field }) => (
<Input
{...field}
label="Formik Input"
placeholder="Type Something"
type="text"
/>
)}
</Field>
<Input
label="First name"
name="firstName"
placeholder="First name"
type="text"
/>
<Input
label="Last name"
name="lastName"
placeholder="Last name"
type="text"
/>
</Form>
</>
);
Expand All @@ -37,41 +51,45 @@ const TestBlockNavigation = ({ isDirty }) => (
<Route exact path="/">
<TestForm {...{ isDirty }} />
</Route>
<Route component={TestComponent} path="/test" />
<Route component={TestComponent} path="/home" />
</Switch>
</Router>
);

describe("formik/BlockNavigation", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("should render without errors", () => {
render(<TestBlockNavigation />);

expect(screen.getByRole("textbox")).toBeInTheDocument();
expect(screen.queryByText(/test page/i)).not.toBeInTheDocument();
expect(screen.getByPlaceholderText("First name")).toBeInTheDocument();
expect(screen.queryByText(/Home page/i)).not.toBeInTheDocument();
});

it("should allow navigation when form is empty", async () => {
render(<TestBlockNavigation />);

await userEvent.click(screen.getByRole("link"));
expect(screen.getByText(/test page/i)).toBeInTheDocument();
expect(screen.getByText(/Home page/i)).toBeInTheDocument();
});

it("should not allow navigation when form isn't empty", async () => {
render(<TestBlockNavigation />);

const input = screen.getByRole("textbox");
await userEvent.type(input, "test");
const firstNameInput = screen.getByPlaceholderText("First name");
await userEvent.type(firstNameInput, "Sam");
await userEvent.click(screen.getByRole("link"));

expect(screen.queryByText(/test page/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Home page/i)).not.toBeInTheDocument();
});

it("should not allow navigation if isDirty prop is true", async () => {
render(<TestBlockNavigation isDirty />);

await userEvent.click(screen.getByRole("link"));
expect(screen.queryByText(/test page/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Home page/i)).not.toBeInTheDocument();
});

it("should display an Alert modal with Continue and Cancel buttons", async () => {
Expand All @@ -82,26 +100,40 @@ describe("formik/BlockNavigation", () => {
expect(
screen.getByRole("button", { name: "Discard changes" })
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();

expect(
screen.getByRole("button", { name: "Save and continue" })
).toBeInTheDocument();
});

it("should close the modal and return to previous state on clicking the Cancel button", async () => {
it("should close the modal and return to previous state on clicking the close button", async () => {
render(<TestBlockNavigation isDirty />);

const input = screen.getByRole("textbox");
const firstNameInput = screen.getByPlaceholderText("First name");

await userEvent.click(screen.getByRole("link"));

const cancelButton = screen.getByRole("button", { name: "Cancel" });
await userEvent.click(cancelButton);
const alertCloseButton = screen.getByTestId("close-button");
await userEvent.click(alertCloseButton);

await waitFor(() => expect(cancelButton).not.toBeInTheDocument());
expect(input).toBeInTheDocument();
await waitFor(() =>
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
);
expect(firstNameInput).toBeInTheDocument();
});

it("should allow navigation if the Continue button is clicked", async () => {
it("should allow navigation and reset the form if the Discard changes button is clicked", async () => {
render(<TestBlockNavigation isDirty />);

const firstNameInput = screen.getByPlaceholderText("First name");
await userEvent.type(
firstNameInput,
repeat("{backspace}", firstName.length).join("")
);
await userEvent.type(firstNameInput, "Sam");

expect(firstNameInput.value).toBe("Sam");

await userEvent.click(screen.getByRole("link"));

const continueButton = screen.getByRole("button", {
Expand All @@ -110,6 +142,53 @@ describe("formik/BlockNavigation", () => {
await userEvent.click(continueButton);

await waitFor(() => expect(continueButton).not.toBeInTheDocument());
expect(screen.getByText(/test page/i)).toBeInTheDocument();
expect(screen.getByText(/Home page/i)).toBeInTheDocument();
expect(mockSubmit).not.toHaveBeenCalled();
});

it("should allow navigation and save the form if the Save and continue button is clicked", async () => {
render(<TestBlockNavigation isDirty />);

const firstNameInput = screen.getByPlaceholderText("First name");
await userEvent.type(
firstNameInput,
repeat("{backspace}", firstName.length).join("")
);
await userEvent.type(firstNameInput, "Sam");

expect(firstNameInput.value).toBe("Sam");

await userEvent.click(screen.getByRole("link"));

const saveButton = screen.getByRole("button", {
name: "Save and continue",
});
await userEvent.click(saveButton);

await waitFor(() => expect(saveButton).not.toBeInTheDocument());
expect(screen.getByText(/Home page/i)).toBeInTheDocument();
expect(mockSubmit).toBeCalledTimes(1);
});

it("should not allow navigation and save the form if the Save and continue button is clicked and the form has an error", async () => {
render(<TestBlockNavigation isDirty />);

const lastNameInput = screen.getByPlaceholderText("Last name");
await userEvent.type(
lastNameInput,
repeat("{backspace}", lastName.length).join("")
);
expect(lastNameInput.value).toBe("");

await userEvent.click(screen.getByRole("link"));

const saveButton = screen.getByRole("button", {
name: "Save and continue",
});
await userEvent.click(saveButton);

await waitFor(() => expect(saveButton).not.toBeInTheDocument());
expect(screen.queryByText(/Home page/i)).not.toBeInTheDocument();
expect(mockSubmit).not.toBeCalled();
});
});
Loading