From ba611e49d8c542d16475464ed370568d83d13455 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Thu, 5 Oct 2023 17:01:59 +0200 Subject: [PATCH 1/5] add info-only toggle to editor, update relevant pay states --- .../src/@planx/components/Pay/Editor.tsx | 39 +++--- .../@planx/components/Pay/Public/Confirm.tsx | 115 ++++++++++-------- .../components/Pay/Public/Pay.stories.tsx | 14 +++ .../@planx/components/Pay/Public/Pay.test.tsx | 44 ++++++- .../src/@planx/components/Pay/Public/Pay.tsx | 41 +++++-- .../src/@planx/components/Pay/model.ts | 2 + 6 files changed, 179 insertions(+), 76 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/Pay/Editor.tsx b/editor.planx.uk/src/@planx/components/Pay/Editor.tsx index e37945e05b..b6fac2b43e 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Editor.tsx @@ -31,6 +31,7 @@ function Component(props: any) { `

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 || @@ -113,20 +114,30 @@ function Component(props: any) { /> - - - { - formik.setFieldValue( - "allowInviteToPay", - !formik.values.allowInviteToPay, - ); - }} - > - Allow applicants to invite someone else to pay - - + { + formik.setFieldValue("hidePay", !formik.values.hidePay); + }} + style={{ width: "100%" }} + > + Hide the pay buttons and show fee for information only + + + + + { + formik.setFieldValue( + "allowInviteToPay", + !formik.values.allowInviteToPay, + ); + }} + style={{ width: "100%" }} + > + Allow applicants to invite someone else to pay + {formik.values.allowInviteToPay ? ( <> diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx index a482eaa863..465e81d73b 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx @@ -35,6 +35,7 @@ export interface Props { onConfirm: () => void; error?: string; hideFeeBanner?: boolean; + hidePay?: boolean; } interface PayBodyProps extends Props { @@ -60,53 +61,9 @@ const PayBody: React.FC = (props) => { const path = useStore((state) => state.path); const isSaveReturn = path === ApplicationPath.SaveAndReturn; - return ( - <> - {!props.error ? ( - - - - {props.instructionsTitle || "How to pay"} - - 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.

` - } - openLinksOnNewTab - /> - - {props.showInviteToPay && ( - <> - - - )} - {isSaveReturn && } -
-
- ) : ( + if (props.error) { + if (props.error.endsWith("local authority")) { + return ( @@ -118,8 +75,68 @@ const PayBody: React.FC = (props) => { - )} - + ); + } else { + return ( + + + + {props.error} + + + This error has been logged and our team will see it soon. You can + safely close this tab and try resuming again soon by returning to + this URL. + + + + ); + } + } + + return ( + + + + {props.instructionsTitle || "How to pay"} + + 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.

` + } + openLinksOnNewTab + /> + + {!props.hidePay && props.showInviteToPay && ( + <> + + + )} + {isSaveReturn && } +
+
); }; diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/Pay.stories.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/Pay.stories.tsx index 8fe3f80878..e4b8df5b53 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/Pay.stories.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/Pay.stories.tsx @@ -56,3 +56,17 @@ export const WithInviteToPay = { yourDetailsLabel: "Your name or organisation name", }, } satisfies Story; + +export const ForInformationOnly = { + args: { + title: "What you can expect to pay for your application", + bannerTitle: "The calculated fee is", + description: + "Based on your answers so far, this is the fee that we've calculated. The fee will cover the cost of processing your application.", + instructionsTitle: "How to pay", + instructionsDescription: + "Payments will be accepted for your future application via GOV.UK Pay. You will have the option to pay yourself or invite someone else to pay.", + fee: 103, + hidePay: true, + }, +} satisfies Story; diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx index b75dfabbee..149a4b0033 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx @@ -89,7 +89,7 @@ describe("Confirm component without inviteToPay", () => { it("displays an error and continue-with-testing button if Pay is not enabled for this team", async () => { const handleSubmit = jest.fn(); - const errorMessage = "No pay token found for this team!"; + const errorMessage = "No pay token found for this local authority"; const { user } = setup( { expect(results).toHaveNoViolations(); }); }); + +describe("Confirm component in information-only mode", () => { + beforeAll(() => (initialState = getState())); + afterEach(() => act(() => setState(initialState))); + + it("renders correctly", () => { + setup(); + + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "Pay for your application", + ); + expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent( + "The fee is", + ); + expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent( + "How to pay", + ); + + expect(screen.getByRole("button")).toHaveTextContent("Continue"); + expect(screen.getByRole("button")).not.toHaveTextContent("Pay"); + }); + + it("renders correctly when inviteToPay is also toggled on by an editor", () => { + setup(); + + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "Pay for your application", + ); + expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent( + "The fee is", + ); + expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent( + "How to pay", + ); + + expect(screen.getByRole("button")).toHaveTextContent("Continue"); + expect(screen.getByRole("button")).not.toHaveTextContent("Pay"); + expect(screen.getByRole("button")).not.toHaveTextContent( + "Invite someone else to pay for this application", + ); + }); +}); diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/Pay.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/Pay.tsx index c843b3d5dc..b807a0a957 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/Pay.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/Pay.tsx @@ -30,7 +30,8 @@ type ComponentState = | { status: "fetching_payment"; displayText?: string } | { status: "retry" } | { status: "success"; displayText?: string } - | { status: "unsupported_team" }; + | { status: "unsupported_team" } + | { status: "undefined_fee" }; enum Action { NoPaymentFound, @@ -68,7 +69,11 @@ function Component(props: Props) { const reducer = (_state: ComponentState, action: Action): ComponentState => { switch (action) { case Action.NoPaymentFound: - return { status: "init" }; + if (isNaN(fee)) { + return { status: "undefined_fee" }; + } else { + return { status: "init" }; + } case Action.IncompletePaymentFound: return { status: "fetching_payment", @@ -101,11 +106,17 @@ function Component(props: Props) { const handleError = useErrorHandler(); useEffect(() => { - if (isNaN(fee) || fee <= 0) { - // skip the pay component because there's no fee to charge + // Auto-skip component when fee=0 + if (fee <= 0) { return props.handleSubmit({ auto: true }); } + // If props.fn is undefined, display & log an error + if (isNaN(fee)) { + dispatch(Action.NoPaymentFound); + logger.notify(`Unable to calculate fee for session ${sessionId}`); + } + if (!govUkPayment) { dispatch(Action.NoPaymentFound); return; @@ -247,7 +258,8 @@ function Component(props: Props) { <> {state.status === "init" || state.status === "retry" || - state.status === "unsupported_team" ? ( + state.status === "unsupported_team" || + state.status === "undefined_fee" ? ( ) : ( diff --git a/editor.planx.uk/src/@planx/components/Pay/model.ts b/editor.planx.uk/src/@planx/components/Pay/model.ts index 7ff2c6d0e7..9d0fe202d3 100644 --- a/editor.planx.uk/src/@planx/components/Pay/model.ts +++ b/editor.planx.uk/src/@planx/components/Pay/model.ts @@ -12,6 +12,7 @@ export interface Pay extends MoreInformation { fn?: string; instructionsTitle?: string; instructionsDescription?: string; + hidePay?: boolean; allowInviteToPay?: boolean; secondaryPageTitle?: string; nomineeTitle?: string; @@ -87,6 +88,7 @@ export const validationSchema = object({ fn: string().trim().required("Data field is required"), instructionsTitle: string().trim().required(), instructionsDescription: string().trim().required(), + hidePay: boolean(), allowInviteToPay: boolean(), nomineeTitle: string().trim().when("allowInviteToPay", { is: true, From b2c23e8498c06c5881be47a8ee5b473ab732a3ea Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Thu, 5 Oct 2023 19:53:18 +0200 Subject: [PATCH 2/5] re-order onConfirm conditions --- .../@planx/components/Pay/Public/Pay.test.tsx | 25 ++++++++++++++++--- .../src/@planx/components/Pay/Public/Pay.tsx | 8 +++--- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx index 149a4b0033..6f58f5fae0 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx @@ -298,8 +298,11 @@ describe("Confirm component in information-only mode", () => { beforeAll(() => (initialState = getState())); afterEach(() => act(() => setState(initialState))); - it("renders correctly", () => { - setup(); + it("renders correctly", async () => { + const handleSubmit = jest.fn(); + const { user } = setup( + , + ); expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( "Pay for your application", @@ -313,10 +316,21 @@ describe("Confirm component in information-only mode", () => { expect(screen.getByRole("button")).toHaveTextContent("Continue"); expect(screen.getByRole("button")).not.toHaveTextContent("Pay"); + + await user.click(screen.getByText("Continue")); + expect(handleSubmit).toHaveBeenCalled(); }); - it("renders correctly when inviteToPay is also toggled on by an editor", () => { - setup(); + it("renders correctly when inviteToPay is also toggled on by an editor", async () => { + const handleSubmit = jest.fn(); + const { user } = setup( + , + ); expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( "Pay for your application", @@ -333,5 +347,8 @@ describe("Confirm component in information-only mode", () => { expect(screen.getByRole("button")).not.toHaveTextContent( "Invite someone else to pay for this application", ); + + await user.click(screen.getByText("Continue")); + expect(handleSubmit).toHaveBeenCalled(); }); }); diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/Pay.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/Pay.tsx index b807a0a957..5b4a1985e0 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/Pay.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/Pay.tsx @@ -264,13 +264,13 @@ function Component(props: Props) { {...props} fee={fee} onConfirm={() => { - if (state.status === "init") { + if (props.hidePay || state.status === "unsupported_team") { + // Show "Continue" button to proceed + props.handleSubmit({ auto: false }); + } else if (state.status === "init") { startNewPayment(); } else if (state.status === "retry") { resumeExistingPayment(); - } else if (state.status === "unsupported_team" || props.hidePay) { - // Show "Continue" button to proceed - props.handleSubmit({ auto: false }); } }} buttonTitle={ From 7f6e2abf211a3dc66ba02da8f1ff642ef7f1674c Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Fri, 6 Oct 2023 09:45:26 +0200 Subject: [PATCH 3/5] more robust tests for undefined and 0 fee cases --- .../@planx/components/Pay/Public/Pay.test.tsx | 121 +++++++++++++++--- 1 file changed, 101 insertions(+), 20 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx index 6f58f5fae0..3cf568e81d 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx @@ -1,11 +1,12 @@ import { PaymentStatus } from "@opensystemslab/planx-core/types"; +import { TYPES } from "@planx/components/types"; import { screen } from "@testing-library/react"; -import { FullStore, vanillaStore } from "pages/FlowEditor/lib/store"; +import { FullStore, Store, vanillaStore } from "pages/FlowEditor/lib/store"; import React from "react"; import { act } from "react-dom/test-utils"; import * as ReactNavi from "react-navi"; import { axe, setup } from "testUtils"; -import { ApplicationPath } from "types"; +import { ApplicationPath, Breadcrumbs } from "types"; import Confirm, { Props } from "./Confirm"; import Pay from "./Pay"; @@ -21,21 +22,92 @@ jest const resumeButtonText = "Resume an application you have already started"; const saveButtonText = "Save and return to this application later"; -it("renders correctly (is hidden) with <= £0 fee", () => { - const handleSubmit = jest.fn(); +const flowWithUndefinedFee: Store.flow = { + _root: { + edges: ["setValue", "pay"], + }, + setValue: { + type: TYPES.SetValue, + edges: ["pay"], + data: { + fn: "application.fee.payable", + val: "0", + }, + }, + pay: { + type: TYPES.Pay, + data: { + fn: "application.fee.typo", + }, + }, +}; - // if no props.fn, then fee defaults to 0 - setup(); +const flowWithZeroFee: Store.flow = { + _root: { + edges: ["setValue", "pay"], + }, + setValue: { + type: TYPES.SetValue, + edges: ["pay"], + data: { + fn: "application.fee.payable", + val: "0", + }, + }, + pay: { + type: TYPES.Pay, + data: { + fn: "application.fee.payable", + }, + }, +}; - // handleSubmit is still called to set auto = true so Pay isn't seen in card sequence - expect(handleSubmit).toHaveBeenCalled(); -}); +// Mimic having passed setValue to reach Pay +const breadcrumbs: Breadcrumbs = { + setValue: { + auto: true, + data: { + "application.fee.payable": ["0"], + }, + }, +}; + +describe("Pay component when fee is undefined or £0", () => { + beforeEach(() => { + getState().resetPreview(); + }); + + it("Shows an error if fee is undefined", () => { + const handleSubmit = jest.fn(); + + setState({ flow: flowWithUndefinedFee, breadcrumbs: breadcrumbs }); + expect(getState().computePassport()).toEqual({ + data: { "application.fee.payable": ["0"] }, + }); + + setup(); -it("should not have any accessibility violations", async () => { - const handleSubmit = jest.fn(); - const { container } = setup(); - const results = await axe(container); - expect(results).toHaveNoViolations(); + // handleSubmit has NOT been called (not skipped), Pay shows error instead + expect(handleSubmit).not.toHaveBeenCalled(); + expect( + screen.getByText("We are unable to calculate your fee right now"), + ).toBeInTheDocument(); + expect(screen.queryByText("Continue")).not.toBeInTheDocument(); + }); + + it("Skips pay if fee = 0", () => { + const handleSubmit = jest.fn(); + + setState({ flow: flowWithZeroFee, breadcrumbs: breadcrumbs }); + expect(getState().computePassport()).toEqual({ + data: { "application.fee.payable": ["0"] }, + }); + + setup(); + + // handleSubmit is called to auto-answer Pay (aka "skip" in card sequence) + expect(handleSubmit).toHaveBeenCalled(); + }); }); const defaultProps = { @@ -116,12 +188,6 @@ describe("Confirm component without inviteToPay", () => { expect(handleSubmit).toHaveBeenCalled(); }); - it("should not have any accessibility violations", async () => { - const { container } = setup(); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); - it("displays the Save/Resume option if the application path requires it", () => { act(() => setState({ @@ -141,6 +207,12 @@ describe("Confirm component without inviteToPay", () => { expect(screen.queryByText(saveButtonText)).not.toBeInTheDocument(); expect(screen.queryByText(resumeButtonText)).not.toBeInTheDocument(); }); + + it("should not have any accessibility violations", async () => { + const { container } = setup(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); }); describe("Confirm component with inviteToPay", () => { @@ -351,4 +423,13 @@ describe("Confirm component in information-only mode", () => { await user.click(screen.getByText("Continue")); expect(handleSubmit).toHaveBeenCalled(); }); + + it("should not have any accessibility violations", async () => { + const handleSubmit = jest.fn(); + const { container } = setup( + , + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); }); From dab569233dfcf20656c06ac103d185f8e78469df Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Fri, 6 Oct 2023 09:52:26 +0200 Subject: [PATCH 4/5] unsupported_team state needs to account for recently changed API messages --- .../src/@planx/components/Pay/Public/Confirm.tsx | 3 ++- .../src/@planx/components/Pay/Public/Pay.test.tsx | 3 ++- editor.planx.uk/src/@planx/components/Pay/Public/Pay.tsx | 9 ++++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx index 465e81d73b..f8980f6ac5 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx @@ -15,6 +15,7 @@ import ReactMarkdownOrHtml from "ui/ReactMarkdownOrHtml"; import { formattedPriceWithCurrencySymbol } from "../model"; import InviteToPayForm, { InviteToPayFormProps } from "./InviteToPayForm"; +import { PAY_API_ERROR_UNSUPPORTED_TEAM } from "./Pay"; export interface Props { title?: string; @@ -62,7 +63,7 @@ const PayBody: React.FC = (props) => { const isSaveReturn = path === ApplicationPath.SaveAndReturn; if (props.error) { - if (props.error.endsWith("local authority")) { + if (props.error.startsWith(PAY_API_ERROR_UNSUPPORTED_TEAM)) { return ( diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx index 3cf568e81d..df133bf213 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx @@ -161,7 +161,8 @@ describe("Confirm component without inviteToPay", () => { it("displays an error and continue-with-testing button if Pay is not enabled for this team", async () => { const handleSubmit = jest.fn(); - const errorMessage = "No pay token found for this local authority"; + const errorMessage = + "GOV.UK Pay is not enabled for this local authority (testing)"; const { user } = setup( { - if (error.response?.data?.error?.endsWith("local authority")) { + if ( + error.response?.data?.error?.startsWith( + PAY_API_ERROR_UNSUPPORTED_TEAM, + ) + ) { // Show a custom message if this team isn't set up to use Pay yet dispatch(Action.StartNewPaymentError); } else { From 66578bf432b8d4aa7860db76453d877cbc59c38f Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Fri, 6 Oct 2023 11:15:02 +0200 Subject: [PATCH 5/5] dispatch action on undefined fee --- .../src/@planx/components/Pay/Public/Confirm.tsx | 4 +++- .../src/@planx/components/Pay/Public/Pay.tsx | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx index f8980f6ac5..2b5c6efa93 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx @@ -194,7 +194,9 @@ export default function Confirm(props: Props) { className="marginBottom" component="span" > - {formattedPriceWithCurrencySymbol(props.fee)} + {isNaN(props.fee) + ? "Unknown" + : formattedPriceWithCurrencySymbol(props.fee)} { switch (action) { + case Action.NoFeeFound: + return { status: "undefined_fee" }; case Action.NoPaymentFound: - if (isNaN(fee)) { - return { status: "undefined_fee" }; - } else { - return { status: "init" }; - } + return { status: "init" }; case Action.IncompletePaymentFound: return { status: "fetching_payment", @@ -116,8 +115,9 @@ function Component(props: Props) { // If props.fn is undefined, display & log an error if (isNaN(fee)) { - dispatch(Action.NoPaymentFound); + dispatch(Action.NoFeeFound); logger.notify(`Unable to calculate fee for session ${sessionId}`); + return; } if (!govUkPayment) {