diff --git a/editor.planx.uk/src/@planx/components/List/Public/Context.tsx b/editor.planx.uk/src/@planx/components/List/Public/Context.tsx index e9c22cee1a..bca7c02f9e 100644 --- a/editor.planx.uk/src/@planx/components/List/Public/Context.tsx +++ b/editor.planx.uk/src/@planx/components/List/Public/Context.tsx @@ -84,6 +84,11 @@ export const ListProvider: React.FC = (props) => { // Do not allow a new item to be added if there's still an active item if (activeIndex !== -1) return setAddItemError(true); + // Do not allow new item to be added if it will exceed max + if (schema.max && formik.values.userData.length === schema.max) { + return setMaxError(true); + } + // Add new item, and set to active setAddItemError(false); formik.values.userData.push(generateInitialValues(schema)); @@ -120,13 +125,11 @@ export const ListProvider: React.FC = (props) => { // Do not allow submissions with an unsaved item if (activeIndex !== -1) return setUnsavedItemError(true); - // Manually validate min/max + // Manually validate minimum number of items if (formik.values.userData.length < schema.min) { return setMinError(true); } - if (schema.max && formik.values.userData.length > schema.max) { - return setMaxError(true); - } + formik.handleSubmit(); }; diff --git a/editor.planx.uk/src/@planx/components/List/Public/Fields.tsx b/editor.planx.uk/src/@planx/components/List/Public/Fields.tsx index 17b3de56f3..8ccab97c07 100644 --- a/editor.planx.uk/src/@planx/components/List/Public/Fields.tsx +++ b/editor.planx.uk/src/@planx/components/List/Public/Fields.tsx @@ -4,7 +4,6 @@ import FormLabel from "@mui/material/FormLabel"; import MenuItem from "@mui/material/MenuItem"; import RadioGroup from "@mui/material/RadioGroup"; import { Option } from "@planx/components/shared"; -import { getIn } from "formik"; import React from "react"; import SelectInput from "ui/editor/SelectInput"; import InputLabel from "ui/public/InputLabel"; @@ -16,6 +15,7 @@ import { DESCRIPTION_TEXT, ERROR_MESSAGE } from "../../shared/constants"; import BasicRadio from "../../shared/Radio/BasicRadio"; import type { NumberField, QuestionField, TextField } from "../model"; import { useListContext } from "./Context"; +import { get } from "lodash"; type Props = T & { id: string }; @@ -41,9 +41,9 @@ export const TextFieldInput: React.FC> = ({ bordered value={formik.values.userData[activeIndex][data.fn]} onChange={formik.handleChange} - errorMessage={getIn( + errorMessage={get( formik.errors, - `userData[${activeIndex}][${data.fn}]`, + ["userData", activeIndex, data.fn], )} id={id} rows={ @@ -54,7 +54,7 @@ export const TextFieldInput: React.FC> = ({ inputProps={{ "aria-describedby": [ data.description ? DESCRIPTION_TEXT : "", - getIn(formik.errors, `userData[${activeIndex}][${data.fn}]`) + get(formik.errors, ["userData", activeIndex, data.fn]) ? `${ERROR_MESSAGE}-${id}` : "", ] @@ -86,14 +86,14 @@ export const NumberFieldInput: React.FC> = ({ type="number" value={formik.values.userData[activeIndex][data.fn]} onChange={formik.handleChange} - errorMessage={getIn( + errorMessage={get( formik.errors, - `userData[${activeIndex}][${data.fn}]`, + ["userData", activeIndex, data.fn], )} inputProps={{ "aria-describedby": [ data.description ? DESCRIPTION_TEXT : "", - getIn(formik.errors, `userData[${activeIndex}][${data.fn}]`) + get(formik.errors, ["userData", activeIndex, data.fn]) ? `${ERROR_MESSAGE}-${id}` : "", ] @@ -128,7 +128,7 @@ export const RadioFieldInput: React.FC> = (props) => { > = (props) => { > { }); describe("Form validation and error handling", () => { - test.todo("form validation is triggered when saving an item"); - test.todo("text fields use existing validation schemas"); - test.todo("number fields use existing validation schemas"); - test.todo("question fields use validation schema"); - test.todo("unique constraints are enforced on question where this is set"); - test.todo("optional fields can be empty when saving an item"); - test.todo("an error displays if the minimum number of items is not met"); - test.todo("an error displays if the maximum number of items is exceeded"); - test.todo( - "an error displays if you add a new item, without saving the active item", + test("form validation is triggered when saving an item", async () => { + const { user, getByRole, getAllByTestId } = setup(); + + let errorMessages = getAllByTestId(/error-message-input/); + + // Each field has an ErrorWrapper + expect(errorMessages).toHaveLength(mockZooProps.schema.fields.length) + + // All are empty initially + errorMessages.forEach(message => { + expect(message).toBeEmptyDOMElement(); + }); + + await user.click(getByRole("button", { name: /Save/ })); + + // Error wrappers persist + errorMessages = getAllByTestId(/error-message-input/); + expect(errorMessages).toHaveLength(mockZooProps.schema.fields.length) + + // Each field is in an error state + errorMessages.forEach(message => { + expect(message).not.toBeEmptyDOMElement(); + }); + }); + + /** + * These tests are not exhaustive tests of validation schemas, these can be tested in their respective model.test.ts files + * We are testing that the validation schemas are correctly "wired up" to out List component fields + */ + describe("existing validation schemas are correctly referenced", () => { + test("text fields", async () => { + const { user, getByRole, getByTestId } = setup(); + + const nameInput = screen.getByLabelText(/name/); + await user.type(nameInput, "This is a long string of text over one hundred and twenty characters, which should trigger the 'short' text validation warning"); + await user.click(getByRole("button", { name: /Save/ })); + + const nameInputErrorMessage = getByTestId(/error-message-input-text-name/); + + expect(nameInputErrorMessage).toHaveTextContent(/Your answer must be 120 characters or fewer/); + }); + + test("number fields", async () => { + const { user, getByRole, getByTestId } = setup(); + + const ageInput = screen.getByLabelText(/old/); + await user.type(ageInput, "-35"); + await user.click(getByRole("button", { name: /Save/ })); + + const ageInputErrorMessage = getByTestId(/error-message-input-number-age/); + + expect(ageInputErrorMessage).toHaveTextContent(/Enter a positive number/); + }); + + test("question fields", async () => { + const { user, getByRole, getByTestId } = setup(); + + await user.click(getByRole("button", { name: /Save/ })); + + const sizeInputErrorMessage = getByTestId(/error-message-input-question-size/); + + expect(sizeInputErrorMessage).toHaveTextContent(/Select your answer before continuing/); + }); + + test("radio fields", async () => { + const { user, getByRole, getByTestId } = setup(); + + await user.click(getByRole("button", { name: /Save/ })); + + const cuteInputErrorMessage = getByTestId(/error-message-input-question-cute/); + + expect(cuteInputErrorMessage).toHaveTextContent(/Select your answer before continuing/); + }); + + test.todo("checklist fields") + }); + + test("an error displays if the minimum number of items is not met", async () => { + const { user, getByRole, getByTestId, getByText } = setup(); + + const minNumberOfItems = mockZooProps.schema.min; + expect(minNumberOfItems).toEqual(1); + + await user.click(getByRole("button", { name: /Cancel/ })); + await user.click(getByTestId("continue-button")); + + const minItemsErrorMessage = getByText(`You must provide at least ${minNumberOfItems} response(s)`) + expect(minItemsErrorMessage).toBeVisible(); + }); + + test("an error displays if the maximum number of items is exceeded", async () => { + const { user, getAllByTestId, getByTestId, getByText } = setup(); + const addItemButton = getByTestId(/list-add-button/); + + const maxNumberOfItems = mockZooProps.schema.max; + expect(maxNumberOfItems).toEqual(3); + + // Complete three items + await fillInResponse(user); + await user.click(addItemButton); + await fillInResponse(user); + await user.click(addItemButton); + await fillInResponse(user); + + const cards = getAllByTestId(/list-card/); + expect(cards).toHaveLength(3); + + // Try to add a fourth + await user.click(getByTestId(/list-add-button/)); + + const maxItemsErrorMessage = getByText(`You can provide at most ${maxNumberOfItems} response(s)`) + expect(maxItemsErrorMessage).toBeVisible(); + }); + + test( + "an error displays if you add a new item, without saving the active item", async () => { + const { user, getByTestId, getByText, getByLabelText } = setup(); + // Start filling out item + const nameInput = getByLabelText(/name/); + await user.type(nameInput, "Richard Parker"); + + const emailInput = getByLabelText(/email/); + await user.type(emailInput, "richard.parker@pi.com"); + + // Try to add a new item + await user.click(getByTestId(/list-add-button/)); + + const activeItemErrorMessage = getByText(/Please save all responses before adding another/) + expect(activeItemErrorMessage).toBeVisible(); + } ); - test.todo( - "an error displays if you continue, without saving the active item", + + test( + "an error displays if you continue, without saving the active item", async () => { + const { user, getByTestId, getByText, getByLabelText } = setup(); + // Start filling out item + const nameInput = getByLabelText(/name/); + await user.type(nameInput, "Richard Parker"); + + const emailInput = getByLabelText(/email/); + await user.type(emailInput, "richard.parker@pi.com"); + + // Try to continue + await user.click(getByTestId(/continue-button/)); + + const unsavedItemErrorMessage = getByText(/Please save in order to continue/) + expect(unsavedItemErrorMessage).toBeVisible(); + } ); }); diff --git a/editor.planx.uk/src/@planx/components/List/schemas/mocks/Zoo.ts b/editor.planx.uk/src/@planx/components/List/schemas/mocks/Zoo.ts index dcd948d5d5..fe94d763e4 100644 --- a/editor.planx.uk/src/@planx/components/List/schemas/mocks/Zoo.ts +++ b/editor.planx.uk/src/@planx/components/List/schemas/mocks/Zoo.ts @@ -66,7 +66,7 @@ export const Zoo: Schema = { }, ], min: 1, - max: 10, + max: 3, } as const; export const mockZooProps: Props = {