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

test: Outstanding List component validation tests #3323

Merged
merged 1 commit into from
Jun 28, 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
11 changes: 7 additions & 4 deletions editor.planx.uk/src/@planx/components/List/Public/Context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ export const ListProvider: React.FC<ListProviderProps> = (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);
}
Comment on lines +87 to +90
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving this here feels like a better user experience than just checking this when a user hits "Continue" (and may have added too many items).


// Add new item, and set to active
setAddItemError(false);
formik.values.userData.push(generateInitialValues(schema));
Expand Down Expand Up @@ -120,13 +125,11 @@ export const ListProvider: React.FC<ListProviderProps> = (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();
};

Expand Down
18 changes: 9 additions & 9 deletions editor.planx.uk/src/@planx/components/List/Public/Fields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change was required as the formik's getIn() doesn't correctly handle fn fields using dot notation (e.g. proposal.propertyType).

Docs: https://formik.org/docs/api/fieldarray#fieldarray-validation-gotchas

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡


type Props<T> = T & { id: string };

Expand All @@ -40,9 +40,9 @@ export const TextFieldInput: React.FC<Props<TextField>> = ({
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={
Expand All @@ -53,7 +53,7 @@ export const TextFieldInput: React.FC<Props<TextField>> = ({
inputProps={{
"aria-describedby": [
data.description ? DESCRIPTION_TEXT : "",
getIn(formik.errors, `userData[${activeIndex}][${data.fn}]`)
get(formik.errors, ["userData", activeIndex, data.fn])
? `${ERROR_MESSAGE}-${id}`
: "",
]
Expand Down Expand Up @@ -84,14 +84,14 @@ export const NumberFieldInput: React.FC<Props<NumberField>> = ({
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}`
: "",
]
Expand Down Expand Up @@ -126,7 +126,7 @@ export const RadioFieldInput: React.FC<Props<QuestionField>> = (props) => {
</FormLabel>
<ErrorWrapper
id={`${id}-error`}
error={getIn(formik.errors, `userData[${activeIndex}][${data.fn}]`)}
error={get(formik.errors, ["userData", activeIndex, data.fn])}
>
<RadioGroup
aria-labelledby={`radio-buttons-group-label-${id}`}
Expand Down Expand Up @@ -159,7 +159,7 @@ export const SelectFieldInput: React.FC<Props<QuestionField>> = (props) => {
>
<ErrorWrapper
id={`${id}-error`}
error={getIn(formik.errors, `userData[${activeIndex}][${data.fn}]`)}
error={get(formik.errors, ["userData", activeIndex, data.fn])}
>
<SelectInput
bordered
Expand Down
157 changes: 147 additions & 10 deletions editor.planx.uk/src/@planx/components/List/Public/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,17 +379,154 @@ describe("Building a list", () => {
});

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("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(<ListComponent {...mockZooProps} />);

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(<ListComponent {...mockZooProps} />);

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(<ListComponent {...mockZooProps} />);

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(<ListComponent {...mockZooProps} />);

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(<ListComponent {...mockZooProps} />);

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(<ListComponent {...mockZooProps} />);

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(<ListComponent {...mockZooProps} />);
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(<ListComponent {...mockZooProps} />);
// Start filling out item
const nameInput = getByLabelText(/name/);
await user.type(nameInput, "Richard Parker");

const emailInput = getByLabelText(/email/);
await user.type(emailInput, "[email protected]");

// 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(<ListComponent {...mockZooProps} />);
// Start filling out item
const nameInput = getByLabelText(/name/);
await user.type(nameInput, "Richard Parker");

const emailInput = getByLabelText(/email/);
await user.type(emailInput, "[email protected]");

// Try to continue
await user.click(getByTestId(/continue-button/));

const unsavedItemErrorMessage = getByText(/Please save in order to continue/)
expect(unsavedItemErrorMessage).toBeVisible();
}
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export const Zoo: Schema = {
},
],
min: 1,
max: 10,
max: 3,
} as const;

export const mockZooProps: Props = {
Expand Down
Loading