Skip to content

Commit

Permalink
Introduces a better Modal popup for unsaved changes in Forms. (#2285)
Browse files Browse the repository at this point in the history
* moved block navigation component to a folder and added handlers to discard changes, save changes and close the alert.

* Created a custom alert component to cater the requirements of BlockNavigation.

* Refactored the test to verify the buttons in the alert to check for the new set of buttons.

* Refactored the test to verify the close modal behaviour to use the changed UI

* Refactored the test to verify all the use cases of discard chagnes.

* Added test to verify the save and continue feature in the BlockNavigation.

* Refactored the test UI with real world values for easier readability and expansion.

* Removed the unused part of the test.

* Replaced the Field component and normal input with FormikInput, added validations and refactored the queries and added one more extra field in the form.

* Added test to verify that the navigation is prevented even if save and continue is cled when there's an error in the form.

* Removed the unused props from the Alert component.
  • Loading branch information
deepakjosp authored Aug 14, 2024
1 parent 2258beb commit 0a1ea42
Show file tree
Hide file tree
Showing 5 changed files with 205 additions and 48 deletions.
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();
});
});

0 comments on commit 0a1ea42

Please sign in to comment.