diff --git a/editor.planx.uk/package.json b/editor.planx.uk/package.json index 357c99a587..e0957cf21a 100644 --- a/editor.planx.uk/package.json +++ b/editor.planx.uk/package.json @@ -166,6 +166,7 @@ "start": "craco start", "build": "CI=false && craco build", "test": "react-scripts test", + "test:silent": "react-scripts test --silent", "eject": "react-scripts eject", "classic:start": "react-app-rewired start", "classic:build": "react-scripts build", diff --git a/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx b/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx index 9daec45f92..894bcc9505 100644 --- a/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx @@ -48,7 +48,7 @@ interface ChecklistProps extends Checklist { } const OptionEditor: React.FC<{ - index?: number; + index: number; value: Option; onChange: (newVal: Option) => void; groupIndex?: number; diff --git a/editor.planx.uk/src/@planx/components/Pay/Editor.test.tsx b/editor.planx.uk/src/@planx/components/Pay/Editor.test.tsx new file mode 100644 index 0000000000..7824c32e27 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Pay/Editor.test.tsx @@ -0,0 +1,324 @@ +import { User } from "@opensystemslab/planx-core/types"; +import { fireEvent, waitFor } from "@testing-library/react"; +import { toggleFeatureFlag } from "lib/featureFlags"; +import { FullStore, vanillaStore } from "pages/FlowEditor/lib/store"; +import React from "react"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; +import { act } from "react-dom/test-utils"; +import { axe, setup } from "testUtils"; + +import PayComponent from "./Editor"; + +describe("Pay component - Editor Modal", () => { + it("renders", () => { + const { getByText } = setup( + + + , + ); + expect(getByText("Payment")).toBeInTheDocument(); + }); + + // Currently failing, Editor not a11y compliant + it.skip("should not have any accessibility violations upon initial load", async () => { + const { container } = setup( + + + , + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + describe("GOV.UK Pay Metadata section", () => { + jest.setTimeout(20000); + + beforeAll(() => toggleFeatureFlag("GOVPAY_METADATA")); + afterAll(() => toggleFeatureFlag("GOVPAY_METADATA")); + + // Set up mock state with platformAdmin user so all Editor features are enabled + const { getState, setState } = vanillaStore; + const mockUser: User = { + id: 123, + email: "b.baggins@shire.com", + isPlatformAdmin: true, + firstName: "Bilbo", + lastName: "Baggins", + teams: [], + }; + + let initialState: FullStore; + + beforeAll(() => (initialState = getState())); + afterEach(() => act(() => setState(initialState))); + + it("renders the section", () => { + const { getByText } = setup( + + + , + ); + expect(getByText("GOV.UK Pay Metadata")).toBeInTheDocument(); + }); + + it("lists the default values", () => { + const { getByDisplayValue } = setup( + + + , + ); + expect(getByDisplayValue("flow")).toBeInTheDocument(); + expect(getByDisplayValue("source")).toBeInTheDocument(); + expect(getByDisplayValue("isInviteToPay")).toBeInTheDocument(); + }); + + it("does not allow default sections to be deleted", () => { + const { getAllByLabelText } = setup( + + + , + ); + + const deleteIcons = getAllByLabelText("Delete"); + + expect(deleteIcons).toHaveLength(3); + expect(deleteIcons[0]).toBeDisabled(); + expect(deleteIcons[1]).toBeDisabled(); + expect(deleteIcons[2]).toBeDisabled(); + }); + + it("updates the 'isInviteToPay' metadata value inline with the 'isInviteToPay' form value", async () => { + const node = { + data: { + allowInviteToPay: false, + }, + }; + + const { user, getAllByLabelText, getByText } = setup( + + + , + ); + + const keyInputs = getAllByLabelText("Key"); + const valueInputs = getAllByLabelText("Value"); + + expect(keyInputs[2]).toHaveDisplayValue("isInviteToPay"); + expect(valueInputs[2]).toHaveDisplayValue("false"); + + await user.click( + getByText("Allow applicants to invite someone else to pay"), + ); + + expect(valueInputs[2]).toHaveValue("true"); + }); + + it("pre-populates existing values", () => { + const node = { + data: { + govPayMetadata: [ + { + key: "myKey", + value: "myValue", + }, + ], + }, + }; + + const { getByDisplayValue } = setup( + + + , + ); + + expect(getByDisplayValue("myKey")).toBeInTheDocument(); + expect(getByDisplayValue("myValue")).toBeInTheDocument(); + }); + + it("allows new values to be added", async () => { + act(() => setState({ user: mockUser, flowName: "test flow" })); + + const handleSubmit = jest.fn(); + + const { getAllByPlaceholderText, getAllByLabelText, user, getByRole } = + setup( + + + , + ); + + // Three default rows displayed + expect(getAllByLabelText("Delete")).toHaveLength(3); + + await user.click(getByRole("button", { name: "add new" })); + + // New row added + expect(getAllByLabelText("Delete")).toHaveLength(4); + + const keyInput = getAllByPlaceholderText("key")[3]; + const valueInput = getAllByPlaceholderText("value")[3]; + + await user.type(keyInput, "myNewKey"); + await user.type(valueInput, "myNewValue"); + + // Required to trigger submission outside the context of FormModal component + fireEvent.submit(getByRole("form")); + + await waitFor(() => expect(handleSubmit).toHaveBeenCalled()); + }); + + it("allows new values to be deleted", async () => { + act(() => setState({ user: mockUser, flowName: "test flow" })); + const mockNode = { + data: { + fn: "fee", + govPayMetadata: [ + { key: "flow", value: "flowName" }, + { key: "source", value: "PlanX" }, + { key: "isInviteToPay", value: "true" }, + { key: "deleteMe", value: "abc123" }, + ], + }, + }; + + const handleSubmit = jest.fn(); + + const { getAllByLabelText, user, getByRole } = setup( + + + , + ); + + // Use delete buttons as proxy for rows + const deleteButtons = getAllByLabelText("Delete"); + expect(deleteButtons).toHaveLength(4); + const finalDeleteButton = deleteButtons[3]; + expect(finalDeleteButton).toBeDefined(); + + await user.click(finalDeleteButton); + expect(getAllByLabelText("Delete")).toHaveLength(3); + + // Required to trigger submission outside the context of FormModal component + fireEvent.submit(getByRole("form")); + + await waitFor(() => expect(handleSubmit).toHaveBeenCalled()); + + expect(handleSubmit.mock.lastCall[0].data.govPayMetadata).toEqual([ + { key: "flow", value: "flowName" }, + { key: "source", value: "PlanX" }, + { key: "isInviteToPay", value: "true" }, + ]); + }); + + it("displays field-level errors", async () => { + act(() => setState({ user: mockUser, flowName: "test flow" })); + + const handleSubmit = jest.fn(); + + const { getByText, user, getByRole } = setup( + + + , + ); + + await user.click(getByRole("button", { name: "add new" })); + fireEvent.submit(getByRole("form")); + + expect(handleSubmit).not.toHaveBeenCalled(); + + // Test that validation schema is wired up to UI + // model.test.ts tests validation schema behaviour in-depth + await waitFor(() => + expect(getByText("Key is a required field")).toBeVisible(), + ); + }); + + it("displays array-level errors", async () => { + act(() => setState({ user: mockUser, flowName: "test flow" })); + + const handleSubmit = jest.fn(); + + const { getByText, user, getByRole, getAllByPlaceholderText } = setup( + + + , + ); + + // Add first duplicate key + await user.click(getByRole("button", { name: "add new" })); + const keyInput4 = getAllByPlaceholderText("key")[3]; + const valueInput4 = getAllByPlaceholderText("value")[3]; + await user.type(keyInput4, "duplicatedKey"); + await user.type(valueInput4, "myNewValue"); + + // Add second duplicate key + await user.click(getByRole("button", { name: "add new" })); + const keyInput5 = getAllByPlaceholderText("key")[4]; + const valueInput5 = getAllByPlaceholderText("value")[4]; + await user.type(keyInput5, "duplicatedKey"); + await user.type(valueInput5, "myNewValue"); + + fireEvent.submit(getByRole("form")); + + expect(handleSubmit).not.toHaveBeenCalled(); + + // Test that validation schema is wired up to UI + // model.test.ts tests validation schema behaviour in-depth + await waitFor(() => + expect(getByText("Keys must be unique")).toBeVisible(), + ); + }); + + it("only disables the first instance of a required filed", async () => { + act(() => setState({ user: mockUser, flowName: "test flow" })); + + const handleSubmit = jest.fn(); + + const { + getAllByPlaceholderText, + getAllByLabelText, + user, + getByRole, + getByText, + } = setup( + + + , + ); + + await user.click(getByRole("button", { name: "add new" })); + + const keyInput = getAllByPlaceholderText("key")[3]; + const valueInput = getAllByPlaceholderText("value")[3]; + + await user.type(keyInput, "flow"); + + expect(valueInput).not.toBeDisabled(); + await user.type(valueInput, "myNewValue"); + + // Required to trigger submission outside the context of FormModal component + fireEvent.submit(getByRole("form")); + + expect(handleSubmit).not.toHaveBeenCalled(); + + // Test that validation schema is wired up to UI + await waitFor(() => + expect(getByText("Keys must be unique")).toBeVisible(), + ); + + const duplicateKeyDeleteIcon = getAllByLabelText("Delete")[3]; + + // This tests that the user is able to fix their mistake + expect(duplicateKeyDeleteIcon).not.toBeDisabled(); + }); + }); +}); diff --git a/editor.planx.uk/src/@planx/components/Pay/Editor.tsx b/editor.planx.uk/src/@planx/components/Pay/Editor.tsx index ce1ab1c820..b5f832b661 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Editor.tsx @@ -1,225 +1,449 @@ +import DataObjectIcon from "@mui/icons-material/DataObject"; import Box from "@mui/material/Box"; +import Link from "@mui/material/Link"; +import Typography from "@mui/material/Typography"; import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; -import { Pay, validationSchema } from "@planx/components/Pay/model"; +import { + GovPayMetadata, + Pay, + REQUIRED_GOVPAY_METADATA, + validationSchema, +} from "@planx/components/Pay/model"; import { parseMoreInformation } from "@planx/components/shared"; -import { ICONS, InternalNotes, MoreInformation } from "@planx/components/ui"; -import { useFormik } from "formik"; +import { + EditorProps, + ICONS, + InternalNotes, + MoreInformation, +} from "@planx/components/ui"; +import { Form, Formik, useFormikContext } from "formik"; +import { hasFeatureFlag } from "lib/featureFlags"; +import { useStore } from "pages/FlowEditor/lib/store"; import React from "react"; +import ListManager, { + EditorProps as ListManagerEditorProps, +} from "ui/editor/ListManager"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; import OptionButton from "ui/editor/OptionButton"; import RichTextInput from "ui/editor/RichTextInput"; +import ErrorWrapper from "ui/shared/ErrorWrapper"; import Input from "ui/shared/Input"; import InputRow from "ui/shared/InputRow"; -function Component(props: any) { - const formik = useFormik({ - initialValues: { - title: props.node?.data?.title || "Pay for your application", - bannerTitle: - props.node?.data?.bannerTitle || - "The planning fee for this application is", - description: - props.node?.data?.description || - `

The planning fee covers the cost of processing your application.\ +const GOVPAY_DOCS_URL = + "https://docs.payments.service.gov.uk/reporting/#add-more-information-to-a-payment-39-custom-metadata-39-or-39-reporting-columns-39"; + +/** + * Helper method to handle Formik errors in arrays + * Required as errors can be at array-level or field-level and the useFormikContext hook cannot correctly type infer this from the validation schema + * Docs: https://formik.org/docs/api/fieldarray#fieldarray-validation-gotchas + */ +const parseError = ( + errors: string | undefined | GovPayMetadata[], + index: number, +): string | undefined => { + // No errors + if (!errors) return; + + // Array-level error - handled at a higher level + if (typeof errors === "string") return; + + // No error for this field + if (!errors[index]) return; + + // Specific field-level error + return errors[index].key || errors[index].value; +}; + +/** + * Helper method to handle Formik "touched" in arrays + * Please see parseError() for additional context + */ +const parseTouched = ( + touched: string | undefined | GovPayMetadata[], + index: number, +): string | undefined => { + // No errors + if (!touched) return; + + // Array-level error - handled at a higher level + if (typeof touched === "string") return; + + // No error for this field + if (!touched[index]) return; + + // Specific field-level error + return touched[index].key && touched[index].value; +}; + +/** + * Disable required fields so they cannot be edited + * Only disable first instance, otherwise any field beginning with a required field will be disabled, and user will not be able to fix their mistake as the delete icon is also disabled + */ +const isFieldDisabled = (key: string, index: number) => + REQUIRED_GOVPAY_METADATA.includes(key) && + index === REQUIRED_GOVPAY_METADATA.indexOf(key); + +function GovPayMetadataEditor(props: ListManagerEditorProps) { + const { key: currKey, value: currVal } = props.value; + const isDisabled = isFieldDisabled(currKey, props.index); + const { errors, touched } = useFormikContext(); + const error = parseError( + errors.govPayMetadata as string | undefined | GovPayMetadata[], + props.index, + ); + const isTouched = parseTouched( + touched.govPayMetadata as string | undefined | GovPayMetadata[], + props.index, + ); + + return ( + + + + + props.onChange({ key: newKey, value: currVal }) + } + placeholder="key" + /> + + props.onChange({ key: currKey, value: newVal }) + } + placeholder="value" + /> + + + + ); +} + +export type Props = EditorProps; + +const Component: React.FC = (props: Props) => { + const [flowName] = useStore((store) => [store.flowName]); + const displayGovPayMetadataSection = hasFeatureFlag("GOVPAY_METADATA"); + + const initialValues: Pay = { + title: props.node?.data?.title || "Pay for your application", + bannerTitle: + props.node?.data?.bannerTitle || + "The planning fee for this application is", + description: + props.node?.data?.description || + `

The planning fee covers the cost of processing your application.\ Find out more about how planning fees are calculated (opens in new tab).

`, - fn: props.node?.data?.fn, - instructionsTitle: props.node?.data?.instructionsTitle || "How to pay", - instructionsDescription: - props.node?.data?.instructionsDescription || - `

You can pay for your application by using GOV.UK Pay.

\ + fn: props.node?.data?.fn, + instructionsTitle: props.node?.data?.instructionsTitle || "How to pay", + instructionsDescription: + props.node?.data?.instructionsDescription || + `

You can pay for your application by using GOV.UK Pay.

\

Your application will be sent after you have paid the fee. \ Wait until you see an application sent message before closing your browser.

`, - hidePay: props.node?.data?.hidePay || false, - allowInviteToPay: props.node?.data?.allowInviteToPay ?? true, - secondaryPageTitle: - props.node?.data?.secondaryPageTitle || - "Invite someone else to pay for this application", - nomineeTitle: - props.node?.data?.nomineeTitle || "Details of the person paying", - nomineeDescription: props.node?.data?.nomineeDescription, - yourDetailsTitle: props.node?.data?.yourDetailsTitle || "Your details", - yourDetailsDescription: props.node?.data?.yourDetailsDescription, - yourDetailsLabel: - props.node?.data?.yourDetailsLabel || "Your name or organisation name", - ...parseMoreInformation(props.node?.data), - }, - onSubmit: (newValues) => { - if (props.handleSubmit) { - props.handleSubmit({ type: TYPES.Pay, data: newValues }); - } - }, - validationSchema, - validateOnChange: false, - validateOnBlur: false, - }); + hidePay: props.node?.data?.hidePay || false, + allowInviteToPay: props.node?.data?.allowInviteToPay ?? true, + secondaryPageTitle: + props.node?.data?.secondaryPageTitle || + "Invite someone else to pay for this application", + nomineeTitle: + props.node?.data?.nomineeTitle || "Details of the person paying", + nomineeDescription: props.node?.data?.nomineeDescription, + yourDetailsTitle: props.node?.data?.yourDetailsTitle || "Your details", + yourDetailsDescription: props.node?.data?.yourDetailsDescription, + yourDetailsLabel: + props.node?.data?.yourDetailsLabel || "Your name or organisation name", + govPayMetadata: props.node?.data?.govPayMetadata || [ + { + key: "flow", + value: flowName, + }, + { + key: "source", + value: "PlanX", + }, + { + key: "isInviteToPay", + value: props.node?.data?.allowInviteToPay ?? true, + }, + ], + ...parseMoreInformation(props.node?.data), + }; + + const onSubmit = (newValues: Pay) => { + if (props.handleSubmit) { + props.handleSubmit({ type: TYPES.Pay, data: newValues }); + } + }; return ( -