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) {