({
- 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 (
-
+
+
+ {
+ setFieldValue("allowInviteToPay", !values.allowInviteToPay);
+ // Update GovUKMetadata
+ const inviteToPayIndex = values.govPayMetadata?.findIndex(
+ ({ key }) => key === "isInviteToPay",
+ );
+ setFieldValue(
+ `govPayMetadata[${inviteToPayIndex}].value`,
+ !values.allowInviteToPay,
+ );
+ }}
+ style={{ width: "100%" }}
+ >
+ Allow applicants to invite someone else to pay
+
+ {values.allowInviteToPay ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
+ <>>
+ )}
+
+
+
+
+
+ )}
+
);
-}
+};
export default Component;
diff --git a/editor.planx.uk/src/@planx/components/Pay/model.test.ts b/editor.planx.uk/src/@planx/components/Pay/model.test.ts
new file mode 100644
index 0000000000..ecf749e49b
--- /dev/null
+++ b/editor.planx.uk/src/@planx/components/Pay/model.test.ts
@@ -0,0 +1,106 @@
+import { govPayMetadataSchema } from "./model";
+
+describe("GovPayMetadata Schema", () => {
+ const validate = async (payload: unknown) => await govPayMetadataSchema.validate(payload).catch(err => err.errors);
+
+ const defaults = [
+ { key: "flow", value: "flowName" },
+ { key: "source", value: "PlanX" },
+ { key: "isInviteToPay", value: "true" },
+ ];
+
+ test("it requires all default values", async () => {
+ const errors = await validate([]);
+ expect(errors).toHaveLength(1);
+ expect(errors[0]).toMatch(/Keys flow, source and isInviteToPay must be present/)
+ });
+
+ test("it allows valid data", async () => {
+ const input = [
+ ...defaults,
+ { key: "someKey", value: "someValue" },
+ { key: "someOtherKey", value: "someOtherValue" },
+ ];
+ const result = await validate(input);
+
+ // No errors, input returned from validation
+ expect(result).toEqual(input)
+ });
+
+ test("key is required", async () => {
+ const input = [
+ ...defaults,
+ { value: "abc123" }
+ ];
+ const errors = await validate(input);
+ expect(errors).toHaveLength(1);
+ expect(errors[0]).toMatch(/Key is a required field/)
+ });
+
+ test("key cannot be greater than 30 characters", async () => {
+ const input = [
+ ...defaults,
+ { key: "this is a very long key which exceeds the amount allowed by GovPay", value: "def456" }
+ ];
+ const errors = await validate(input);
+ expect(errors).toHaveLength(1);
+ expect(errors[0]).toMatch(/Key length cannot exceed 30 characters/)
+ });
+
+ test("value is required", async () => {
+ const input = [
+ ...defaults,
+ { key: "abc123" }
+ ];
+ const errors = await validate(input);
+ expect(errors).toHaveLength(1);
+ expect(errors[0]).toMatch(/Value is a required field/)
+ });
+
+ test("value cannot be greater than 100 characters", async () => {
+ const input = [
+ ...defaults,
+ { key: "abc123", value: "this is a very long value which exceeds the one-hundred character limit currently allowed by GovUKPay" }
+ ];
+ const errors = await validate(input);
+ expect(errors).toHaveLength(1);
+ expect(errors[0]).toMatch(/Value length cannot exceed 100 characters/)
+ });
+
+ test("keys must be unique", async () => {
+ const input = [
+ ...defaults,
+ { key: "duplicatedKey", value: "someValue" },
+ { key: "duplicatedKey", value: "someOtherValue" },
+ ];
+ const errors = await validate(input);
+ expect(errors).toHaveLength(1);
+ expect(errors[0]).toMatch(/Keys must be unique/)
+ });
+
+ test("max 10 entries can be added", async () => {
+ const input = [
+ ...defaults,
+ { key: "four", value: "someValue" },
+ { key: "five", value: "someValue" },
+ { key: "six", value: "someValue" },
+ { key: "seven", value: "someValue" },
+ { key: "eight", value: "someValue" },
+ { key: "nine", value: "someValue" },
+ { key: "ten", value: "someValue" },
+ ];
+
+ const result = await validate(input);
+
+ // No errors, input returned from validation
+ expect(result).toEqual(input);
+
+ // Try 11 total values
+ const errors = await validate([
+ ...input,
+ { key: "eleven", value: "someValue" },
+ ]);
+ expect(errors).toHaveLength(1);
+ expect(errors[0]).toMatch(/A maximum of 10 fields can be set as metadata/)
+ });
+});
\ No newline at end of file
diff --git a/editor.planx.uk/src/@planx/components/Pay/model.ts b/editor.planx.uk/src/@planx/components/Pay/model.ts
index bd99028326..3a5e33076e 100644
--- a/editor.planx.uk/src/@planx/components/Pay/model.ts
+++ b/editor.planx.uk/src/@planx/components/Pay/model.ts
@@ -1,9 +1,14 @@
import { useStore } from "pages/FlowEditor/lib/store";
import { ApplicationPath } from "types";
-import { boolean, object, string } from "yup";
+import { array, boolean, object, string } from "yup";
import type { MoreInformation } from "../shared";
+export interface GovPayMetadata {
+ key: string;
+ value: string;
+}
+
export interface Pay extends MoreInformation {
title: string;
bannerTitle?: string;
@@ -20,6 +25,7 @@ export interface Pay extends MoreInformation {
yourDetailsTitle?: string;
yourDetailsDescription?: string;
yourDetailsLabel?: string;
+ govPayMetadata?: GovPayMetadata[];
}
// https://docs.payments.service.gov.uk/making_payments/#creating-a-payment
@@ -90,6 +96,41 @@ const getReturnURL = (sessionId: string): string => {
export const GOV_UK_PAY_URL = `${process.env.REACT_APP_API_URL}/pay`;
+export const REQUIRED_GOVPAY_METADATA = ["flow", "source", "isInviteToPay"];
+
+// Validation must match requirements set out here -
+// https://docs.payments.service.gov.uk/reporting/#add-more-information-to-a-payment-39-custom-metadata-39-or-39-reporting-columns-39
+export const govPayMetadataSchema = array(object({
+ key: string()
+ .required("Key is a required field")
+ .max(30, "Key length cannot exceed 30 characters"),
+ value: string()
+ .required("Value is a required field")
+ .max(100, "Value length cannot exceed 100 characters"),
+})).max(10, "A maximum of 10 fields can be set as metadata").test({
+ name: "unique-keys",
+ message: "Keys must be unique",
+ test: (metadata) => {
+ if (!metadata) return false;
+
+ const keys = metadata.map(item => item.key)
+ const numKeys = keys?.length;
+ const numUniqueKeys = new Set(keys).size;
+
+ return numKeys === numUniqueKeys;
+ },
+}).test({
+ name: "required-keys",
+ message: `Keys ${new Intl.ListFormat("en-GB", { style: "long", type: "conjunction" }).format(REQUIRED_GOVPAY_METADATA)} must be present`,
+ test: (metadata) => {
+ if (!metadata) return false;
+
+ const keys = metadata.map(item => item.key);
+ const allRequiredKeysPresent = REQUIRED_GOVPAY_METADATA.every(requiredKey => keys.includes(requiredKey));
+ return allRequiredKeysPresent;
+ }
+});
+
export const validationSchema = object({
title: string().trim().required(),
bannerTitle: string().trim().required(),
@@ -119,4 +160,5 @@ export const validationSchema = object({
is: true,
then: string().required(),
}),
+ govPayMetadata: govPayMetadataSchema,
});
diff --git a/editor.planx.uk/src/lib/featureFlags.ts b/editor.planx.uk/src/lib/featureFlags.ts
index 1e270e08ee..52592ea1ad 100644
--- a/editor.planx.uk/src/lib/featureFlags.ts
+++ b/editor.planx.uk/src/lib/featureFlags.ts
@@ -1,5 +1,5 @@
// add/edit/remove feature flags in array below
-const AVAILABLE_FEATURE_FLAGS = ["SUBMISSION_VIEW"] as const;
+const AVAILABLE_FEATURE_FLAGS = ["SUBMISSION_VIEW", "GOVPAY_METADATA"] as const;
type FeatureFlag = (typeof AVAILABLE_FEATURE_FLAGS)[number];
diff --git a/editor.planx.uk/src/ui/editor/ListManager.tsx b/editor.planx.uk/src/ui/editor/ListManager.tsx
index 92bfb14dbc..f688b2a907 100644
--- a/editor.planx.uk/src/ui/editor/ListManager.tsx
+++ b/editor.planx.uk/src/ui/editor/ListManager.tsx
@@ -19,7 +19,7 @@ import {
import { removeAt, setAt } from "../../utils";
export interface EditorProps {
- index?: number;
+ index: number;
value: T;
onChange: (newValue: T) => void;
}
@@ -32,6 +32,8 @@ export interface Props {
Editor: React.FC & (EditorExtraProps | {})>;
editorExtraProps?: EditorExtraProps;
disableDragAndDrop?: boolean;
+ isFieldDisabled?: (item: T, index: number) => boolean;
+ maxItems?: number;
}
const Item = styled(Box)(({ theme }) => ({
@@ -42,13 +44,14 @@ const Item = styled(Box)(({ theme }) => ({
export default function ListManager(
props: Props,
) {
- const { Editor } = props;
+ const { Editor, maxItems = Infinity } = props;
// Initialize a random ID when the component mounts
const randomId = useRef(String(Math.random()));
// useStore.getState().getTeam().slug undefined here, use window instead
const teamSlug = window.location.pathname.split("/")[1];
const isViewOnly = !useStore.getState().canUserEditTeam(teamSlug);
+ const isMaxLength = props.values.length >= maxItems;
return props.disableDragAndDrop ? (
<>
@@ -74,14 +77,18 @@ export default function ListManager(
}}
{...(props.editorExtraProps || {})}
/>
-
+
{
props.onChange(removeAt(index, props.values));
}}
aria-label="Delete"
size="large"
- disabled={isViewOnly}
+ disabled={
+ isViewOnly ||
+ (props?.isFieldDisabled &&
+ props.isFieldDisabled(item, index))
+ }
>
@@ -95,7 +102,7 @@ export default function ListManager(
onClick={() => {
props.onChange([...props.values, props.newValue()]);
}}
- disabled={isViewOnly}
+ disabled={isViewOnly || isMaxLength}
>
{props.newValueLabel || "add new"}
@@ -155,7 +162,11 @@ export default function ListManager(
}}
aria-label="Delete"
size="large"
- disabled={isViewOnly}
+ disabled={
+ isViewOnly ||
+ (props?.isFieldDisabled &&
+ props.isFieldDisabled(item, index))
+ }
>
@@ -174,7 +185,7 @@ export default function ListManager(
onClick={() => {
props.onChange([...props.values, props.newValue()]);
}}
- disabled={isViewOnly}
+ disabled={isViewOnly || isMaxLength}
>
{props.newValueLabel || "add new"}
diff --git a/editor.planx.uk/src/ui/shared/Input.tsx b/editor.planx.uk/src/ui/shared/Input.tsx
index cd11330f26..49bd131f0c 100644
--- a/editor.planx.uk/src/ui/shared/Input.tsx
+++ b/editor.planx.uk/src/ui/shared/Input.tsx
@@ -101,6 +101,7 @@ export default forwardRef((props: Props, ref): FCReturn => {
errorMessage,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
+ "aria-labelledby": ariaLabelledBy,
id,
...restProps
} = props;
@@ -127,6 +128,7 @@ export default forwardRef((props: Props, ref): FCReturn => {
inputProps={{
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
+ "aria-labelledby": ariaLabelledBy,
}}
id={id}
ref={container}