diff --git a/.env.example b/.env.example index 7ee1b254c6..1b6765c61b 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,7 @@ AWS_SECRET_KEY=👻 FILE_API_KEY=👻 FILE_API_KEY_NEXUS=👻 -# Editor/Preview +# Editor EDITOR_URL_EXT=http://localhost:3000 # Hasura @@ -87,28 +87,15 @@ SUPPRESS_LOGS=true # Local authority specific integrations ## Lambeth -GOV_UK_PAY_TOKEN_LAMBETH=👻 UNIFORM_CLIENT_LAMBETH=👻 ## Southwark -GOV_UK_PAY_TOKEN_SOUTHWARK=👻 UNIFORM_CLIENT_SOUTHWARK=👻 ## Buckinghamshire -GOV_UK_PAY_TOKEN_BUCKINGHAMSHIRE=👻 UNIFORM_CLIENT_AYLESBURY_VALE=👻 UNIFORM_CLIENT_CHILTERN=👻 UNIFORM_CLIENT_WYCOMBE=👻 -## Camden -GOV_UK_PAY_TOKEN_CAMDEN=👻 - -## Gloucester -GOV_UK_PAY_TOKEN_GLOUCESTER=👻 - -## Medway -GOV_UK_PAY_TOKEN_MEDWAY=👻 - ## End-to-end test team (borrows Lambeth's details) GOV_UK_PAY_SECRET_E2E=👻 -GOV_UK_PAY_TOKEN_E2E=👻 diff --git a/api.planx.uk/.env.test.example b/api.planx.uk/.env.test.example index d422761044..2970a6f0c4 100644 --- a/api.planx.uk/.env.test.example +++ b/api.planx.uk/.env.test.example @@ -35,23 +35,4 @@ UNIFORM_SUBMISSION_URL=👻 SLACK_WEBHOOK_URL=👻 -ORDNANCE_SURVEY_API_KEY=👻 - -# Local authority specific integrations -## Lambeth -GOV_UK_PAY_TOKEN_LAMBETH=👻 - -## Southwark -GOV_UK_PAY_TOKEN_SOUTHWARK=👻 - -## Buckinghamshire -GOV_UK_PAY_TOKEN_BUCKINGHAMSHIRE=👻 - -## Camden -GOV_UK_PAY_TOKEN_CAMDEN=👻 - -## Gloucester -GOV_UK_PAY_TOKEN_GLOUCESTER=👻 - -## Medway -GOV_UK_PAY_TOKEN_MEDWAY=👻 +ORDNANCE_SURVEY_API_KEY=👻 \ No newline at end of file diff --git a/api.planx.uk/helpers.test.ts b/api.planx.uk/helpers.test.ts index 5c275e1437..460201e362 100644 --- a/api.planx.uk/helpers.test.ts +++ b/api.planx.uk/helpers.test.ts @@ -6,10 +6,13 @@ import { isLiveEnv, } from "./helpers"; import { queryMock } from "./tests/graphqlQueryMock"; -import { userContext } from "./modules/auth/middleware"; -import { getJWT } from "./tests/mockJWT"; +import { + childFlow, + draftParentFlow, + flattenedParentFlow, +} from "./tests/mocks/validateAndPublishMocks"; -describe("getEnvironment function", () => { +describe("getFormattedEnvironment() function", () => { const OLD_ENV = process.env; beforeEach(() => { @@ -69,121 +72,107 @@ describe("isLiveEnv() function", () => { }); describe("dataMerged() function", () => { - const getStoreMock = jest.spyOn(userContext, "getStore"); - getStoreMock.mockReturnValue({ - user: { - sub: "123", - jwt: getJWT({ role: "teamEditor" }), - }, - }); - beforeEach(() => { - const unflattenedParent = { - _root: { - edges: ["Zj0ZKa0PwT", "Rur8iS88x3"], - }, - "5yElH96W7I": { - data: { - text: "Option 2", - }, - type: 200, - edges: ["aMlxwR7ONH"], - }, - Rur8iS88x3: { - data: { - color: "#EFEFEF", - title: "End of the line", - resetButton: false, - }, - type: 8, - }, - SShTHaRo2k: { - data: { - flowId: "child-id", - }, - type: 310, - }, - Zj0ZKa0PwT: { - data: { - text: "This is a question with many options", - }, - type: 100, - edges: ["c8hZwm0a9c", "5yElH96W7I", "UMsI68BuAy"], - }, - c8hZwm0a9c: { - data: { - text: "Option 1", - }, - type: 200, - edges: ["SShTHaRo2k"], - }, - aMlxwR7ONH: { - type: 310, - data: { - flowId: "child-id", - }, + queryMock.mockQuery({ + name: "GetFlowData", + matchOnVariables: true, + variables: { + id: "parent-flow-with-external-portal", }, - UMsI68BuAy: { - type: 200, - data: { - text: "Option 3", + data: { + flow: { + data: draftParentFlow, + slug: "parent-flow", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [{ data: flattenedParentFlow }], }, }, - }; + }); + }); - const unflattenedChild = { - _root: { - edges: ["sbDyJVsyXg"], + it("flattens published external portal nodes by overwriting their type", async () => { + queryMock.mockQuery({ + name: "GetFlowData", + matchOnVariables: true, + variables: { + id: "child-flow-id", }, - sbDyJVsyXg: { - type: 100, - data: { - description: "

Hello there 👋

", - text: "This is within the portal", + data: { + flow: { + data: childFlow, + slug: "child-flow", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [{ data: childFlow }], }, }, - }; + }); + + const result = await dataMerged("parent-flow-with-external-portal"); + const nodeTypes = Object.values(result).map((node) => + "type" in node ? node.type : undefined, + ); + expect(nodeTypes.includes(ComponentType.ExternalPortal)).toBe(false); + }); + + it("throws an error when an external portal is not published", async () => { queryMock.mockQuery({ name: "GetFlowData", + matchOnVariables: true, variables: { - id: "child-id", + id: "child-flow-id", }, data: { flow: { + data: childFlow, slug: "child-flow", - data: unflattenedChild, - team_id: 123, + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [], }, }, }); + await expect( + dataMerged("parent-flow-with-external-portal"), + ).rejects.toThrow(); + }); + + it("flattens any published or draft external portal nodes when isDraftData only is set to true", async () => { queryMock.mockQuery({ name: "GetFlowData", + matchOnVariables: true, variables: { - id: "parent-id", + id: "child-flow-id", }, data: { flow: { - slug: "parent-flow", - data: unflattenedParent, - team_id: 123, + data: childFlow, + slug: "child-flow", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [], }, }, }); - }); - it("handles multiple external portal nodes", async () => { - const result = await dataMerged("parent-id"); - const nodeTypes = Object.values(result).map((node) => - "type" in node ? node.type : undefined, + const result = await dataMerged( + "parent-flow-with-external-portal", + {}, + false, + true, ); - const areAllPortalsFlattened = !nodeTypes.includes( - ComponentType.ExternalPortal, - ); - - // All external portals have been flattened / replaced - expect(areAllPortalsFlattened).toBe(true); + expect(result).toEqual(flattenedParentFlow); }); }); diff --git a/api.planx.uk/helpers.ts b/api.planx.uk/helpers.ts index 0813bcbbb5..6bbcf5b71e 100644 --- a/api.planx.uk/helpers.ts +++ b/api.planx.uk/helpers.ts @@ -4,15 +4,44 @@ import { Flow, Node } from "./types"; import { ComponentType, FlowGraph } from "@opensystemslab/planx-core/types"; import { $public, getClient } from "./client"; +export interface FlowData { + slug: string; + data: Flow["data"]; + team_id: number; + team: { slug: string }; + publishedFlows: + | { + data: Flow["data"]; + id: number; + created_at: string; + summary: string; + publisher_id: number; + }[] + | []; +} + // Get a flow's data (unflattened, without external portal nodes) -const getFlowData = async (id: string): Promise => { - const { flow } = await $public.client.request<{ flow: Flow | null }>( +const getFlowData = async (id: string): Promise => { + const { flow } = await $public.client.request<{ flow: FlowData | null }>( gql` query GetFlowData($id: uuid!) { flow: flows_by_pk(id: $id) { slug data team_id + team { + slug + } + publishedFlows: published_flows( + limit: 1 + order_by: { created_at: desc } + ) { + data + id + created_at + summary + publisher_id + } } } `, @@ -108,6 +137,7 @@ interface PublishedFlows { publishedFlows: { // TODO: use FlowGraph from planx-core here data: Flow["data"]; + id: number; }[]; } | null; } @@ -136,50 +166,112 @@ const getMostRecentPublishedFlow = async ( return mostRecent; }; -// Flatten a flow's data to include main content & portals in a single JSON representation -// XXX: getFlowData & dataMerged are currently repeated in ../editor.planx.uk/src/lib/dataMergedHotfix.ts -// in order to load frontend /preview routes for flows that are not published +const getMostRecentPublishedFlowVersion = async ( + id: string, +): Promise => { + const { flow } = await $public.client.request( + gql` + query GetMostRecentPublishedFlowVersion($id: uuid!) { + flow: flows_by_pk(id: $id) { + publishedFlows: published_flows( + limit: 1 + order_by: { created_at: desc } + ) { + id + } + } + } + `, + { id }, + ); + + const mostRecent = flow?.publishedFlows?.[0]?.id; + return mostRecent; +}; + +/** + * Flatten a flow to create a single JSON representation of the main flow data and any external portals + * By default, requires that any external portals are published and flattens their latest published version + * When draftDataOnly = true, flattens the draft data of the main flow and the draft data of any external portals published or otherwise + */ const dataMerged = async ( id: string, ob: { [key: string]: Node } = {}, + isPortal = false, + draftDataOnly = false, ): Promise => { - // get the primary flow data - const { slug, data } = await getFlowData(id); + // get the primary draft flow data, including its' latest published version + const response = await getFlowData(id); + const { slug, team, publishedFlows } = response; + let { data } = response; + + // only flatten external portals that are published, unless we're loading draftDataOnly + if (isPortal && !draftDataOnly) { + if (publishedFlows?.[0]?.data) { + data = publishedFlows[0].data; + } else { + throw new Error( + `Publish flow ${team.slug}/${slug} before proceeding. All flows used as external portals must be published.`, + ); + } + } - // recursively get and flatten internal portals & external portals + // recursively get and flatten external portals for (const [nodeId, node] of Object.entries(data)) { const isExternalPortalRoot = nodeId === "_root" && Object.keys(ob).length > 0; const isExternalPortal = node.type === ComponentType.ExternalPortal; const isMerged = ob[node.data?.flowId]; - // Merge portal root as a new node in the graph + // merge external portal _root node as a new node in the graph using its' flowId as nodeId if (isExternalPortalRoot) { ob[id] = { - ...node, + ...node, // includes _root edges for navigation to all child nodes in this portal type: ComponentType.InternalPortal, - data: { text: slug }, + data: { + text: `${team.slug}/${slug}`, + // add extra metadata about latest published version when applicable + ...(!draftDataOnly && { + publishedFlowId: publishedFlows?.[0]?.id, + publishedAt: publishedFlows?.[0]?.created_at, + publishedBy: publishedFlows?.[0]?.publisher_id, + summary: publishedFlows?.[0]?.summary, + }), + }, }; } - // Merge as internal portal, with reference to flowId + // merge external portal type node as an internal portal type node, with an edge pointing to flowId (to navigate to the externalPortalRoot set above) else if (isExternalPortal) { ob[nodeId] = { type: ComponentType.InternalPortal, edges: [node.data?.flowId], }; - // Recursively merge flow + // recursively merge flow if (!isMerged) { - await dataMerged(node.data?.flowId, ob); + await dataMerged(node.data?.flowId, ob, true, draftDataOnly); } } - // Merge all other nodes + // merge all other nodes else ob[nodeId] = node; } - // TODO: Don't cast here once types updated across API + // for every external portal that has been merged, confirm its' latest version was merged. If not, overwrite older snapshot with newest version + // ** this is a final/separate step because older snapshots can be nested in _already_ flattened data (eg not picked up as ComponentType.ExternalPortal above) + if (!draftDataOnly) { + for (const [nodeId, node] of Object.entries(ob).filter( + ([_nodeId, node]) => node.data?.publishedFlowId, + )) { + const mostRecentPublishedFlowId = + await getMostRecentPublishedFlowVersion(nodeId); + if (mostRecentPublishedFlowId !== node.data?.publishedFlowId) { + await dataMerged(nodeId, ob, true, draftDataOnly); + } + } + } + return ob as FlowGraph; }; @@ -255,6 +347,7 @@ const getFormattedEnvironment = (): string => { export { getFlowData, getMostRecentPublishedFlow, + getMostRecentPublishedFlowVersion, dataMerged, getChildren, makeUniqueFlow, diff --git a/api.planx.uk/modules/flows/copyFlow/service.ts b/api.planx.uk/modules/flows/copyFlow/service.ts index d79707388a..6cf7ef8fbf 100644 --- a/api.planx.uk/modules/flows/copyFlow/service.ts +++ b/api.planx.uk/modules/flows/copyFlow/service.ts @@ -1,5 +1,4 @@ import { makeUniqueFlow, getFlowData, insertFlow } from "../../../helpers"; -import { Flow } from "../../../types"; import { userContext } from "../../auth/middleware"; const copyFlow = async ( @@ -8,7 +7,7 @@ const copyFlow = async ( insert: boolean, ) => { // Fetch the original flow - const flow: Flow = await getFlowData(flowId); + const flow = await getFlowData(flowId); // Generate new flow data which is an exact "content" copy of the original but with unique nodeIds const uniqueFlowData = makeUniqueFlow(flow.data, replaceValue); diff --git a/api.planx.uk/modules/flows/docs.yaml b/api.planx.uk/modules/flows/docs.yaml index 76d25589ce..6498bae81f 100644 --- a/api.planx.uk/modules/flows/docs.yaml +++ b/api.planx.uk/modules/flows/docs.yaml @@ -137,6 +137,13 @@ components: updatedFlow: $ref: "#/components/schemas/FlowData" required: false + FlattenFlow: + content: + application/json: + schema: + type: object + properties: + $ref: "#/components/schemas/FlowData" paths: /flows/{flowId}/copy: post: @@ -242,12 +249,10 @@ paths: "500": $ref: "#/components/responses/ErrorMessage" /flows/{flowId}/download-schema: - post: + get: summary: Download flow schema description: Download a CSV file representing the flow's schema tags: ["flows"] - security: - - bearerAuth: [] parameters: - $ref: "#/components/parameters/flowId" responses: @@ -258,3 +263,20 @@ paths: type: string "500": $ref: "#/components/responses/ErrorMessage" + /flows/{flowId}/flatten-flow: + get: + summary: Flatten a flow and its' external portals into a single graph + description: Flatten a flow and its' external portals into a single graph. The external portals must be published unless ?draft=true is set + tags: ["flows"] + parameters: + - $ref: "#/components/parameters/flowId" + - in: query + name: draft + type: boolean + required: false + description: Optional param to flatten a flow and its' external portals in their draft state. + responses: + "200": + $ref: "#/components/responses/FlattenFlow" + "500": + $ref: "#/components/responses/ErrorMessage" diff --git a/api.planx.uk/modules/flows/downloadSchema/controller.ts b/api.planx.uk/modules/flows/downloadSchema/controller.ts index f93551817b..4fb32b3f83 100644 --- a/api.planx.uk/modules/flows/downloadSchema/controller.ts +++ b/api.planx.uk/modules/flows/downloadSchema/controller.ts @@ -20,7 +20,7 @@ export type DownloadFlowSchemaController = ValidatedRequestHandler< >; export const downloadFlowSchemaController: DownloadFlowSchemaController = - async (_res, res, next) => { + async (_req, res, next) => { try { const { flowId } = res.locals.parsedReq.params; const flowSchema = await getFlowSchema(flowId); diff --git a/api.planx.uk/modules/flows/flattenFlow/controller.ts b/api.planx.uk/modules/flows/flattenFlow/controller.ts new file mode 100644 index 0000000000..ebe3d8498f --- /dev/null +++ b/api.planx.uk/modules/flows/flattenFlow/controller.ts @@ -0,0 +1,48 @@ +import { FlowGraph } from "@opensystemslab/planx-core/types"; +import { z } from "zod"; +import { ServerError } from "../../../errors"; +import { ValidatedRequestHandler } from "../../../shared/middleware/validate"; +import { dataMerged } from "../../../helpers"; + +type FlattenFlowDataResponse = FlowGraph; + +export const flattenFlowData = z.object({ + params: z.object({ + flowId: z.string(), + }), + query: z.object({ + draft: z + .string() + .optional() + .transform((val) => val?.toLowerCase() === "true"), // proxy for z.boolean() + }), +}); + +export type FlattenFlowDataController = ValidatedRequestHandler< + typeof flattenFlowData, + FlattenFlowDataResponse +>; + +export const flattenFlowDataController: FlattenFlowDataController = async ( + req, + res, + next, +) => { + try { + const { flowId } = res.locals.parsedReq.params; + + if (req.query?.draft?.toString().toLowerCase() === "true") { + const draftFlattenedFlowData = await dataMerged(flowId, {}, false, true); + res.status(200).send(draftFlattenedFlowData); + } else { + const flattenedFlowData = await dataMerged(flowId); + res.status(200).send(flattenedFlowData); + } + } catch (error) { + return next( + new ServerError({ + message: `Failed to flatten flow ${res.locals.parsedReq.params?.flowId}: ${error}`, + }), + ); + } +}; diff --git a/api.planx.uk/modules/flows/flattenFlow/flattenFlow.test.ts b/api.planx.uk/modules/flows/flattenFlow/flattenFlow.test.ts new file mode 100644 index 0000000000..16fad1c204 --- /dev/null +++ b/api.planx.uk/modules/flows/flattenFlow/flattenFlow.test.ts @@ -0,0 +1,155 @@ +import supertest from "supertest"; + +import app from "../../../server"; +import { queryMock } from "../../../tests/graphqlQueryMock"; +import { + childFlow, + draftParentFlow, + flattenedParentFlow, + mockFlowData, +} from "../../../tests/mocks/validateAndPublishMocks"; + +beforeEach(() => { + queryMock.mockQuery({ + name: "GetFlowData", + matchOnVariables: true, + variables: { + id: "basic-flow-no-external-portals", + }, + data: { + flow: { + data: mockFlowData, + slug: "mock-flow", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [{ data: mockFlowData }], + }, + }, + }); + + queryMock.mockQuery({ + name: "GetFlowData", + matchOnVariables: true, + variables: { + id: "parent-flow-with-external-portal", + }, + data: { + flow: { + data: draftParentFlow, + slug: "parent-flow", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [{ data: flattenedParentFlow }], + }, + }, + }); +}); + +it("is publicly accessible and does not require a user to be signed in", async () => { + await supertest(app) + .get("/flows/basic-flow-no-external-portals/flatten-data") + .expect(200); +}); + +it("returns the expected result for a simple flow without external portals", async () => { + await supertest(app) + .get("/flows/basic-flow-no-external-portals/flatten-data") + .expect(200) + .then((res) => { + expect(res.body).toEqual(mockFlowData); + }); +}); + +it("returns the expected result for a simple flow without external portals when the ?draft=true query param is set", async () => { + await supertest(app) + .get("/flows/basic-flow-no-external-portals/flatten-data?draft=true") + .expect(200) + .then((res) => { + expect(res.body).toEqual(mockFlowData); + }); +}); + +it("returns the expected result for a flow with published external portals", async () => { + queryMock.mockQuery({ + name: "GetFlowData", + matchOnVariables: true, + variables: { + id: "child-flow-id", + }, + data: { + flow: { + data: childFlow, + slug: "child-flow", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [{ data: childFlow }], + }, + }, + }); + + await supertest(app) + .get("/flows/parent-flow-with-external-portal/flatten-data") + .expect(200) + .then((res) => expect(res.body).toEqual(flattenedParentFlow)); +}); + +it("throws an error for a flow with unpublished external portals", async () => { + queryMock.mockQuery({ + name: "GetFlowData", + matchOnVariables: true, + variables: { + id: "child-flow-id", + }, + data: { + flow: { + data: childFlow, + slug: "child-flow", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [], + }, + }, + }); + + await supertest(app) + .get("/flows/parent-flow-with-external-portal/flatten-data") + .expect(500); +}); + +it("returns the expected draft result for a flow with unpublished external portals when ?draft=true query param is set", async () => { + queryMock.mockQuery({ + name: "GetFlowData", + matchOnVariables: true, + variables: { + id: "child-flow-id", + }, + data: { + flow: { + data: childFlow, + slug: "child-flow", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [], + }, + }, + }); + + await supertest(app) + .get("/flows/parent-flow-with-external-portal/flatten-data?draft=true") + .expect(200) + .then((res) => expect(res.body).toEqual(flattenedParentFlow)); +}); + +it("throws an error if no flow is found", async () => { + await supertest(app).get("/flows/invalid-id/flatten-data").expect(500); +}); diff --git a/api.planx.uk/modules/flows/publish/publish.test.ts b/api.planx.uk/modules/flows/publish/publish.test.ts index 691b4991ec..2262e367a0 100644 --- a/api.planx.uk/modules/flows/publish/publish.test.ts +++ b/api.planx.uk/modules/flows/publish/publish.test.ts @@ -23,6 +23,12 @@ beforeEach(() => { data: { flow: { data: mockFlowData, + slug: "mock-flow-name", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [{ data: mockFlowData }], }, }, }); @@ -115,6 +121,12 @@ describe("publish", () => { data: { flow: { data: alteredFlow, + slug: "altered-flow-name", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [{ data: alteredFlow }], }, }, }); diff --git a/api.planx.uk/modules/flows/routes.ts b/api.planx.uk/modules/flows/routes.ts index ca5e0bb23a..157bf6f679 100644 --- a/api.planx.uk/modules/flows/routes.ts +++ b/api.planx.uk/modules/flows/routes.ts @@ -1,26 +1,29 @@ import { Router } from "express"; +import { validate } from "../../shared/middleware/validate"; import { usePlatformAdminAuth, useTeamEditorAuth } from "../auth/middleware"; -import { publishFlowController } from "./publish/controller"; import { copyFlowController, copyFlowSchema } from "./copyFlow/controller"; -import { validate } from "../../shared/middleware/validate"; import { copyFlowAsPortalSchema, copyPortalAsFlowController, } from "./copyFlowAsPortal/controller"; +import { + downloadFlowSchema, + downloadFlowSchemaController, +} from "./downloadSchema/controller"; import { findAndReplaceController, findAndReplaceSchema, } from "./findReplace/controller"; +import { + flattenFlowData, + flattenFlowDataController, +} from "./flattenFlow/controller"; import { moveFlowController, moveFlowSchema } from "./moveFlow/controller"; +import { publishFlowController, publishFlowSchema } from "./publish/controller"; import { validateAndDiffFlowController, validateAndDiffSchema, } from "./validate/controller"; -import { publishFlowSchema } from "./publish/controller"; -import { - downloadFlowSchema, - downloadFlowSchemaController, -} from "./downloadSchema/controller"; const router = Router(); router.post( @@ -71,4 +74,10 @@ router.get( downloadFlowSchemaController, ); +router.get( + "/flows/:flowId/flatten-data", + validate(flattenFlowData), + flattenFlowDataController, +); + export default router; diff --git a/api.planx.uk/modules/flows/validate/controller.ts b/api.planx.uk/modules/flows/validate/controller.ts index 5020a798a5..3e32ca98b8 100644 --- a/api.planx.uk/modules/flows/validate/controller.ts +++ b/api.planx.uk/modules/flows/validate/controller.ts @@ -30,7 +30,7 @@ export const validateAndDiffFlowController: ValidateAndDiffFlowController = } catch (error) { return next( new ServerError({ - message: `Failed to validate and diff flow: ${error}`, + message: `Failed to validate and diff flow for publishing. \n${error}`, }), ); } diff --git a/api.planx.uk/modules/flows/validate/validate.test.ts b/api.planx.uk/modules/flows/validate/validate.test.ts index 41519f2925..e3be178f10 100644 --- a/api.planx.uk/modules/flows/validate/validate.test.ts +++ b/api.planx.uk/modules/flows/validate/validate.test.ts @@ -25,6 +25,12 @@ beforeEach(() => { data: { flow: { data: mockFlowData, + slug: "flow-name", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [{ data: mockFlowData }], }, }, }); @@ -86,6 +92,12 @@ describe("sections validation on diff", () => { data: { flow: { data: alteredFlow, + slug: "altered-flow-name", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [{ data: alteredFlow }], }, }, }); @@ -121,6 +133,12 @@ describe("sections validation on diff", () => { data: { flow: { data: flowWithSections, + slug: "sections-flow-name", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [{ data: flowWithSections }], }, }, }); @@ -186,6 +204,12 @@ describe("invite to pay validation on diff", () => { data: { flow: { data: alteredFlow, + slug: "altered-flow-name", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [{ data: alteredFlow }], }, }, }); @@ -215,6 +239,12 @@ describe("invite to pay validation on diff", () => { data: { flow: { data: invalidatedFlow, + slug: "invalidated-flow-name", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [{ data: invalidatedFlow }], }, }, }); @@ -246,6 +276,12 @@ describe("invite to pay validation on diff", () => { data: { flow: { data: invalidFlow, + slug: "invalid-flow-name", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [{ data: invalidFlow }], }, }, }); @@ -279,6 +315,12 @@ describe("invite to pay validation on diff", () => { data: { flow: { data: invalidatedFlow, + slug: "invalidated-flow-name", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [{ data: invalidatedFlow }], }, }, }); diff --git a/api.planx.uk/modules/gis/service/local_authorities/metadata/base.ts b/api.planx.uk/modules/gis/service/local_authorities/metadata/base.ts index 2196f2d0b3..298d40bf1b 100644 --- a/api.planx.uk/modules/gis/service/local_authorities/metadata/base.ts +++ b/api.planx.uk/modules/gis/service/local_authorities/metadata/base.ts @@ -95,7 +95,7 @@ const baseSchema: PlanningConstraintsBaseSchema = { category: "Heritage and conservation", }, locallyListed: { - active: true, + active: false, neg: "is not, or is not within, a Locally Listed Building", pos: "is, or is within, a Locally Listed Building", "digital-land-datasets": ["locally-listed-building"], diff --git a/api.planx.uk/modules/pay/service/utils.test.ts b/api.planx.uk/modules/pay/service/utils.test.ts new file mode 100644 index 0000000000..7ca52c07f4 --- /dev/null +++ b/api.planx.uk/modules/pay/service/utils.test.ts @@ -0,0 +1,42 @@ +import { GovUKPayment } from "@opensystemslab/planx-core/types"; +import { isTestPayment } from "./utils"; + +describe("isTestPayment() helper function", () => { + const OLD_ENV = process.env; + + beforeEach(() => { + process.env = { ...OLD_ENV }; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + test("sandbox payments", () => { + const result = isTestPayment({ + payment_provider: "sandbox", + } as GovUKPayment); + expect(result).toBe(true); + }); + + test("other payment providers", () => { + const result = isTestPayment({ payment_provider: "Visa" } as GovUKPayment); + expect(result).toBe(false); + }); + + test("stripe payments (staging)", () => { + process.env.APP_ENVIRONMENT = "staging"; + const result = isTestPayment({ + payment_provider: "stripe", + } as GovUKPayment); + expect(result).toBe(true); + }); + + test("stripe payments (production)", () => { + process.env.APP_ENVIRONMENT = "production"; + const result = isTestPayment({ + payment_provider: "stripe", + } as GovUKPayment); + expect(result).toBe(false); + }); +}); diff --git a/api.planx.uk/modules/pay/service/utils.ts b/api.planx.uk/modules/pay/service/utils.ts index d5057fd48c..84dfd71a80 100644 --- a/api.planx.uk/modules/pay/service/utils.ts +++ b/api.planx.uk/modules/pay/service/utils.ts @@ -32,22 +32,42 @@ export const addGovPayPaymentIdToPaymentRequest = async ( } }; +/** + * Identify if a payment is using dummy card details for testing + * Docs: https://docs.payments.service.gov.uk/testing_govuk_pay/#mock-card-numbers-and-email-addresses + */ +export const isTestPayment = ({ + payment_provider: paymentProvider, +}: GovUKPayment) => { + // Payment using "sandbox" account + const isSandbox = paymentProvider === "sandbox"; + const isProduction = process.env.APP_ENVIRONMENT === "production"; + + // Payment using Stripe in non-production environment + // Stripe test accounts do not have a specific test code + const isStripeTest = paymentProvider === "stripe" && !isProduction; + + return isSandbox || isStripeTest; +}; + +/** + * Notify #planx-notifications so we can monitor for subsequent submissions + */ export async function postPaymentNotificationToSlack( req: Request, govUkResponse: GovUKPayment, label = "", ) { - // if it's a prod payment, notify #planx-notifications so we can monitor for subsequent submissions - if (govUkResponse?.payment_provider !== "sandbox") { - const slack = SlackNotify(process.env.SLACK_WEBHOOK_URL!); - const getStatus = (state: GovUKPayment["state"]) => - state.status + (state.message ? ` (${state.message})` : ""); - const payMessage = `:coin: New GOV Pay payment ${label} *${ - govUkResponse.payment_id - }* with status *${getStatus(govUkResponse.state)}* [${ - req.params.localAuthority - }]`; - await slack.send(payMessage); - console.log("Payment notification posted to Slack"); - } + if (isTestPayment(govUkResponse)) return; + + const slack = SlackNotify(process.env.SLACK_WEBHOOK_URL!); + const getStatus = (state: GovUKPayment["state"]) => + state.status + (state.message ? ` (${state.message})` : ""); + const payMessage = `:coin: New GOV Pay payment ${label} *${ + govUkResponse.payment_id + }* with status *${getStatus(govUkResponse.state)}* [${ + req.params.localAuthority + }]`; + await slack.send(payMessage); + console.log("Payment notification posted to Slack"); } diff --git a/api.planx.uk/modules/saveAndReturn/service/resumeApplication.test.ts b/api.planx.uk/modules/saveAndReturn/service/resumeApplication.test.ts index 08b94a6ab7..4adf570b9e 100644 --- a/api.planx.uk/modules/saveAndReturn/service/resumeApplication.test.ts +++ b/api.planx.uk/modules/saveAndReturn/service/resumeApplication.test.ts @@ -57,7 +57,7 @@ describe("buildContentFromSessions function", () => { Address: 1 High Street Project type: New office premises Expiry Date: 29 May 2026 - Link: example.com/team/apply-for-a-lawful-development-certificate/preview?sessionId=123`; + Link: example.com/team/apply-for-a-lawful-development-certificate/published?sessionId=123`; expect( await buildContentFromSessions( sessions as LowCalSession[], @@ -124,15 +124,15 @@ describe("buildContentFromSessions function", () => { Address: 1 High Street Project type: New office premises Expiry Date: 29 May 2026 - Link: example.com/team/apply-for-a-lawful-development-certificate/preview?sessionId=123\n\nService: Apply for a lawful development certificate + Link: example.com/team/apply-for-a-lawful-development-certificate/published?sessionId=123\n\nService: Apply for a lawful development certificate Address: 2 High Street Project type: New office premises Expiry Date: 29 May 2026 - Link: example.com/team/apply-for-a-lawful-development-certificate/preview?sessionId=456\n\nService: Apply for a lawful development certificate + Link: example.com/team/apply-for-a-lawful-development-certificate/published?sessionId=456\n\nService: Apply for a lawful development certificate Address: 3 High Street Project type: New office premises Expiry Date: 29 May 2026 - Link: example.com/team/apply-for-a-lawful-development-certificate/preview?sessionId=789`; + Link: example.com/team/apply-for-a-lawful-development-certificate/published?sessionId=789`; expect( await buildContentFromSessions( sessions as LowCalSession[], @@ -182,7 +182,7 @@ describe("buildContentFromSessions function", () => { Address: 1 High Street Project type: New office premises Expiry Date: 29 May 2026 - Link: example.com/team/apply-for-a-lawful-development-certificate/preview?sessionId=123`; + Link: example.com/team/apply-for-a-lawful-development-certificate/published?sessionId=123`; expect( await buildContentFromSessions( sessions as LowCalSession[], @@ -214,7 +214,7 @@ describe("buildContentFromSessions function", () => { Address: Address not submitted Project type: New office premises Expiry Date: 29 May 2026 - Link: example.com/team/apply-for-a-lawful-development-certificate/preview?sessionId=123`; + Link: example.com/team/apply-for-a-lawful-development-certificate/published?sessionId=123`; expect( await buildContentFromSessions( sessions as LowCalSession[], @@ -248,7 +248,7 @@ describe("buildContentFromSessions function", () => { Address: 1 High Street Project type: Project type not submitted Expiry Date: 29 May 2026 - Link: example.com/team/apply-for-a-lawful-development-certificate/preview?sessionId=123`; + Link: example.com/team/apply-for-a-lawful-development-certificate/published?sessionId=123`; expect( await buildContentFromSessions( sessions as LowCalSession[], diff --git a/api.planx.uk/modules/saveAndReturn/service/utils.test.ts b/api.planx.uk/modules/saveAndReturn/service/utils.test.ts index d272205f34..4e8b7b21a4 100644 --- a/api.planx.uk/modules/saveAndReturn/service/utils.test.ts +++ b/api.planx.uk/modules/saveAndReturn/service/utils.test.ts @@ -43,7 +43,7 @@ describe("getResumeLink util function", () => { propertyType: "house", }; const testCase = getResumeLink(session, { slug: "team" } as Team, "flow"); - const expectedResult = "example.com/team/flow/preview?sessionId=123"; + const expectedResult = "example.com/team/flow/published?sessionId=123"; expect(testCase).toEqual(expectedResult); }); }); diff --git a/api.planx.uk/modules/saveAndReturn/service/utils.ts b/api.planx.uk/modules/saveAndReturn/service/utils.ts index b1b26b5f38..17da363f69 100644 --- a/api.planx.uk/modules/saveAndReturn/service/utils.ts +++ b/api.planx.uk/modules/saveAndReturn/service/utils.ts @@ -36,7 +36,7 @@ export const getServiceLink = (team: Team, flowSlug: string): string => { // Link to custom domain if (team.domain) return `https://${team.domain}/${flowSlug}`; // Fallback to PlanX domain - return `${process.env.EDITOR_URL_EXT}/${team.slug}/${flowSlug}/preview`; + return `${process.env.EDITOR_URL_EXT}/${team.slug}/${flowSlug}/published`; }; /** diff --git a/api.planx.uk/package.json b/api.planx.uk/package.json index d2964bdec1..3ab0f288e7 100644 --- a/api.planx.uk/package.json +++ b/api.planx.uk/package.json @@ -4,11 +4,11 @@ "private": true, "dependencies": { "@airbrake/node": "^2.1.8", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#3cc46b7", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#eda1e67", "@types/isomorphic-fetch": "^0.0.36", "adm-zip": "^0.5.10", "aws-sdk": "^2.1467.0", - "axios": "^1.6.5", + "axios": "^1.6.8", "body-parser": "^1.20.2", "cookie-parser": "^1.4.6", "cookie-session": "^2.1.0", @@ -125,7 +125,7 @@ ] }, "overrides": { - "follow-redirects@<1.15.4": ">=1.15.4" + "follow-redirects@<=1.15.5": ">=1.15.6" } } } diff --git a/api.planx.uk/pnpm-lock.yaml b/api.planx.uk/pnpm-lock.yaml index 18c1aa5fb2..fc487f83d0 100644 --- a/api.planx.uk/pnpm-lock.yaml +++ b/api.planx.uk/pnpm-lock.yaml @@ -5,15 +5,15 @@ settings: excludeLinksFromLockfile: false overrides: - follow-redirects@<1.15.4: '>=1.15.4' + follow-redirects@<=1.15.5: '>=1.15.6' dependencies: '@airbrake/node': specifier: ^2.1.8 version: 2.1.8 '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#3cc46b7 - version: github.com/theopensystemslab/planx-core/3cc46b7 + specifier: git+https://github.com/theopensystemslab/planx-core#eda1e67 + version: github.com/theopensystemslab/planx-core/eda1e67 '@types/isomorphic-fetch': specifier: ^0.0.36 version: 0.0.36 @@ -24,8 +24,8 @@ dependencies: specifier: ^2.1467.0 version: 2.1467.0 axios: - specifier: ^1.6.5 - version: 1.6.5 + specifier: ^1.6.8 + version: 1.6.8 body-parser: specifier: ^1.20.2 version: 1.20.2 @@ -2555,10 +2555,10 @@ packages: xml2js: 0.5.0 dev: false - /axios@1.6.5: - resolution: {integrity: sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==} + /axios@1.6.8: + resolution: {integrity: sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==} dependencies: - follow-redirects: 1.15.5 + follow-redirects: 1.15.6 form-data: 4.0.0 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -4314,6 +4314,16 @@ packages: optional: true dev: false + /follow-redirects@1.15.6: + resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dev: false + /for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} dependencies: @@ -6342,7 +6352,7 @@ packages: resolution: {integrity: sha512-65BxorFYVFOpJ9c2lud/4Ju+Bfn3L/vkih+FuzMhBw1wcOPjwgu4doVH6xO91fHYiAi/0uIx0Mc+NorXeANMHw==} engines: {node: '>=14.17.3', npm: '>=6.14.13'} dependencies: - axios: 1.6.5 + axios: 1.6.8 jsonwebtoken: 9.0.2 transitivePeerDependencies: - debug @@ -8409,8 +8419,8 @@ packages: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} dev: false - github.com/theopensystemslab/planx-core/3cc46b7: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/3cc46b7} + github.com/theopensystemslab/planx-core/eda1e67: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/eda1e67} name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true diff --git a/api.planx.uk/server.ts b/api.planx.uk/server.ts index d66dc68814..c9f99f93c0 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -107,11 +107,6 @@ assert(process.env.BOPS_API_TOKEN); assert(process.env.UNIFORM_TOKEN_URL); assert(process.env.UNIFORM_SUBMISSION_URL); -// Camden, Medway & Gloucester have sandbox pay only, so skip assertion as this will fail in production -["BUCKINGHAMSHIRE", "LAMBETH", "SOUTHWARK"].forEach((authority) => { - assert(process.env[`GOV_UK_PAY_TOKEN_${authority}`]); -}); - // needed for storing original URL to redirect to in login flow app.use( cookieSession({ diff --git a/api.planx.uk/tests/mocks/digitalPlanningDataMocks.ts b/api.planx.uk/tests/mocks/digitalPlanningDataMocks.ts index 6265025838..15624fe01a 100644 --- a/api.planx.uk/tests/mocks/digitalPlanningDataMocks.ts +++ b/api.planx.uk/tests/mocks/digitalPlanningDataMocks.ts @@ -1698,7 +1698,7 @@ export const expectedPlanningPermissionPayload = { flowId: "01e38c5d-e701-4e44-acdc-4d6b5cc3b854", name: "Apply for planning permission", owner: "Lambeth", - url: "https://www.editor.planx.dev/lambeth/apply-for-planning-permission/preview", + url: "https://www.editor.planx.dev/lambeth/apply-for-planning-permission/published", }, session: { source: "PlanX", diff --git a/api.planx.uk/tests/mocks/flattenPortals.ts b/api.planx.uk/tests/mocks/flattenPortals.ts new file mode 100644 index 0000000000..990575dc4e --- /dev/null +++ b/api.planx.uk/tests/mocks/flattenPortals.ts @@ -0,0 +1,224 @@ +import { FlowGraph } from "@opensystemslab/planx-core/types"; + +export const mockDraftParent: FlowGraph = { + _root: { + edges: ["4rrycjaUGg", "IgXmnffRPH", "N2efCk7D3G"], + }, + "4rrycjaUGg": { + data: { + text: "Are you doing works to a house in London?", + }, + type: 100, + edges: ["H5l1YcuVP4", "KJtvK7K2wn"], + }, + H5l1YcuVP4: { + data: { + text: "Yes", + }, + type: 200, + edges: ["Z4QpXm6CyR"], + }, + IgXmnffRPH: { + data: { + title: "Check your answers before sending your application", + }, + type: 600, + }, + KJtvK7K2wn: { + data: { + text: "No", + }, + type: 200, + edges: ["T0iJIxbOIn"], + }, + N2efCk7D3G: { + data: { + color: "#EFEFEF", + title: "End of test", + resetButton: true, + }, + type: 8, + }, + T0iJIxbOIn: { + data: { + flowId: "63f80a88-b7b3-4554-b719-2293dd4de0d5", + }, + type: 310, + }, + Z4QpXm6CyR: { + data: { + flowId: "cb0ce521-2f8c-48e3-b61f-19e5f9e00a44", + }, + type: 310, + }, +}; + +export const mockPublishedParent: FlowGraph = { + _root: { + edges: ["4rrycjaUGg", "IgXmnffRPH", "N2efCk7D3G"], + }, + "4rrycjaUGg": { + data: { + text: "Are you doing works to a house in London?", + }, + type: 100, + edges: ["H5l1YcuVP4", "KJtvK7K2wn"], + }, + H5l1YcuVP4: { + data: { + text: "Yes", + }, + type: 200, + edges: ["Z4QpXm6CyR"], + }, + IgXmnffRPH: { + data: { + title: "Check your answers before sending your application", + }, + type: 600, + }, + KJtvK7K2wn: { + data: { + text: "No", + }, + type: 200, + edges: ["T0iJIxbOIn"], + }, + N2efCk7D3G: { + data: { + color: "#EFEFEF", + title: "End of test", + resetButton: true, + }, + type: 8, + }, + T0iJIxbOIn: { + type: 300, + edges: ["63f80a88-b7b3-4554-b719-2293dd4de0d5"], + }, + "63f80a88-b7b3-4554-b719-2293dd4de0d5": { + data: { + text: "testing/mock-units", + }, + type: 300, + edges: ["pAiZxKp2pZ"], + }, + pAiZxKp2pZ: { + data: { + title: "How many bedrooms are you adding?", + description: "

(This is v1)

", + }, + type: 150, + }, + Z4QpXm6CyR: { + type: 300, + edges: ["cb0ce521-2f8c-48e3-b61f-19e5f9e00a44"], + }, + "cb0ce521-2f8c-48e3-b61f-19e5f9e00a44": { + edges: ["8OfL0QE39s", "95iLrV22Dn"], + type: 300, + data: { + text: "testing/mock-london-data-hub", + }, + }, + "8OfL0QE39s": { + data: { + type: "short", + title: "Which borough of London?", + }, + type: 110, + }, + "95iLrV22Dn": { + type: 300, + edges: ["63f80a88-b7b3-4554-b719-2293dd4de0d5"], + }, +}; + +export const mockLondonDataHub: FlowGraph = { + _root: { + edges: ["8OfL0QE39s", "95iLrV22Dn"], + }, + "8OfL0QE39s": { + data: { + type: "short", + title: "Which borough of London?", + }, + type: 110, + }, + "95iLrV22Dn": { + data: { + flowId: "63f80a88-b7b3-4554-b719-2293dd4de0d5", + }, + type: 310, + }, +}; + +export const mockLondonDataHubPublishedUnitsV1: FlowGraph = { + _root: { + edges: ["8OfL0QE39s", "95iLrV22Dn"], + }, + "8OfL0QE39s": { + data: { + type: "short", + title: "Which borough of London?", + }, + type: 110, + }, + "95iLrV22Dn": { + type: 300, + edges: ["63f80a88-b7b3-4554-b719-2293dd4de0d5"], + }, + pAiZxKp2pZ: { + data: { + title: "How many bedrooms are you adding?", + description: "

(This is v1)

", + }, + type: 150, + }, + "63f80a88-b7b3-4554-b719-2293dd4de0d5": { + data: { + text: "testing/mock-units", + }, + type: 300, + edges: ["pAiZxKp2pZ"], + }, +}; + +export const mockUnits: FlowGraph = { + _root: { + edges: ["pAiZxKp2pZ"], + }, + pAiZxKp2pZ: { + data: { + title: "How many bedrooms are you adding?", + description: "

(This is v2)

", + }, + type: 150, + }, +}; + +export const mockUnitsPublishedV1: FlowGraph = { + _root: { + edges: ["pAiZxKp2pZ"], + }, + pAiZxKp2pZ: { + data: { + title: "How many bedrooms are you adding?", + description: "

(This is v1)

", + }, + type: 150, + }, +}; + +export const mockUnitsPublishedV2: FlowGraph = { + _root: { + edges: ["pAiZxKp2pZ"], + }, + pAiZxKp2pZ: { + data: { + title: "How many bedrooms are you adding?", + description: "

(This is v2)

", + }, + type: 150, + }, +}; diff --git a/api.planx.uk/tests/mocks/validateAndPublishMocks.ts b/api.planx.uk/tests/mocks/validateAndPublishMocks.ts index c137f6095d..d3db8718cf 100644 --- a/api.planx.uk/tests/mocks/validateAndPublishMocks.ts +++ b/api.planx.uk/tests/mocks/validateAndPublishMocks.ts @@ -109,3 +109,116 @@ export const mockFlowData: FlowGraph = { type: 650, }, }; + +export const draftParentFlow: FlowGraph = { + _root: { + edges: ["ContentNode", "ExternalPortalNode", "NoticeNode"], + }, + ContentNode: { + type: 250, + data: { + content: "

This is a test flow with external portal content

", + }, + }, + NoticeNode: { + type: 8, + data: { + title: "End of test", + color: "#EFEFEF", + resetButton: true, + }, + }, + ExternalPortalNode: { + type: 310, + data: { + flowId: "child-flow-id", + }, + }, +}; + +export const flattenedParentFlow: FlowGraph = { + _root: { + edges: ["ContentNode", "ExternalPortalNode", "NoticeNode"], + }, + ContentNode: { + data: { + content: "

This is a test flow with external portal content

", + }, + type: 250, + }, + NoticeNode: { + data: { + color: "#EFEFEF", + title: "End of test", + resetButton: true, + }, + type: 8, + }, + ExternalPortalNode: { + type: 300, + edges: ["child-flow-id"], + }, + "child-flow-id": { + edges: ["QuestionInsidePortal"], + type: 300, + data: { + text: "testing/child-flow", + }, + }, + OptionB: { + data: { + text: "B", + }, + type: 200, + }, + OptionC: { + data: { + text: "C", + }, + type: 200, + }, + QuestionInsidePortal: { + data: { + text: "This is a question in a flow referenced as an external portal", + }, + type: 100, + edges: ["OptionA", "OptionB", "OptionC"], + }, + OptionA: { + data: { + text: "A", + }, + type: 200, + }, +}; + +export const childFlow: FlowGraph = { + _root: { + edges: ["QuestionInsidePortal"], + }, + OptionB: { + data: { + text: "B", + }, + type: 200, + }, + OptionC: { + data: { + text: "C", + }, + type: 200, + }, + QuestionInsidePortal: { + data: { + text: "This is a question in a flow referenced as an external portal", + }, + type: 100, + edges: ["OptionA", "OptionB", "OptionC"], + }, + OptionA: { + data: { + text: "A", + }, + type: 200, + }, +}; diff --git a/api.planx.uk/types.ts b/api.planx.uk/types.ts index f1be34cf00..496d195e1e 100644 --- a/api.planx.uk/types.ts +++ b/api.planx.uk/types.ts @@ -21,6 +21,11 @@ export interface Flow { [key: string]: Node; }; team_id: number; + publishedFlows?: + | { + data: { [key: string]: Node }; + }[] + | []; } export interface PublishedFlow { diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index b3ce738817..6e17dfebd2 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -28,5 +28,4 @@ services: environment: UNIFORM_SUBMISSION_URL: http://mock-server:8080 UNIFORM_TOKEN_URL: http://mock-server:8080 - UNIFORM_CLIENT_E2E: e2e:123 - GOV_UK_PAY_TOKEN_E2E: ${GOV_UK_PAY_TOKEN_E2E} \ No newline at end of file + UNIFORM_CLIENT_E2E: e2e:123 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 6041dbbe42..b2631928d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -145,22 +145,13 @@ services: ENCRYPTION_KEY: ${ENCRYPTION_KEY} # Local authority config # Lambeth - GOV_UK_PAY_TOKEN_LAMBETH: ${GOV_UK_PAY_TOKEN_LAMBETH} UNIFORM_CLIENT_LAMBETH: ${UNIFORM_CLIENT_LAMBETH} # Southwark - GOV_UK_PAY_TOKEN_SOUTHWARK: ${GOV_UK_PAY_TOKEN_SOUTHWARK} UNIFORM_CLIENT_SOUTHWARK: ${UNIFORM_CLIENT_SOUTHWARK} # Buckinghamshire - GOV_UK_PAY_TOKEN_BUCKINGHAMSHIRE: ${GOV_UK_PAY_TOKEN_BUCKINGHAMSHIRE} UNIFORM_CLIENT_AYLESBURY_VALE: ${UNIFORM_CLIENT_AYLESBURY_VALE} UNIFORM_CLIENT_CHILTERN: ${UNIFORM_CLIENT_CHILTERN} UNIFORM_CLIENT_WYCOMBE: ${UNIFORM_CLIENT_WYCOMBE} - #Camden - GOV_UK_PAY_TOKEN_CAMDEN: ${GOV_UK_PAY_TOKEN_CAMDEN} - # Medway - GOV_UK_PAY_TOKEN_MEDWAY: ${GOV_UK_PAY_TOKEN_MEDWAY} - # Gloucester - GOV_UK_PAY_TOKEN_GLOUCESTER: ${GOV_UK_PAY_TOKEN_GLOUCESTER} sharedb: restart: unless-stopped diff --git a/e2e/tests/api-driven/package.json b/e2e/tests/api-driven/package.json index 79c8cab010..db22e08b4c 100644 --- a/e2e/tests/api-driven/package.json +++ b/e2e/tests/api-driven/package.json @@ -6,8 +6,8 @@ }, "dependencies": { "@cucumber/cucumber": "^9.3.0", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#f187295", - "axios": "^1.6.0", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#eda1e67", + "axios": "^1.6.8", "dotenv": "^16.3.1", "dotenv-expand": "^10.0.0", "graphql": "^16.8.1", diff --git a/e2e/tests/api-driven/pnpm-lock.yaml b/e2e/tests/api-driven/pnpm-lock.yaml index 627e12bce9..5405a8121b 100644 --- a/e2e/tests/api-driven/pnpm-lock.yaml +++ b/e2e/tests/api-driven/pnpm-lock.yaml @@ -9,11 +9,11 @@ dependencies: specifier: ^9.3.0 version: 9.3.0 '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#f187295 - version: github.com/theopensystemslab/planx-core/f187295 + specifier: git+https://github.com/theopensystemslab/planx-core#eda1e67 + version: github.com/theopensystemslab/planx-core/eda1e67 axios: - specifier: ^1.6.0 - version: 1.6.0 + specifier: ^1.6.8 + version: 1.6.8 dotenv: specifier: ^16.3.1 version: 16.3.1 @@ -896,10 +896,10 @@ packages: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} dev: false - /axios@1.6.0: - resolution: {integrity: sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==} + /axios@1.6.8: + resolution: {integrity: sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==} dependencies: - follow-redirects: 1.15.5 + follow-redirects: 1.15.6 form-data: 4.0.0 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -1527,8 +1527,8 @@ packages: resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} dev: false - /follow-redirects@1.15.5: - resolution: {integrity: sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==} + /follow-redirects@1.15.6: + resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -2943,8 +2943,8 @@ packages: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} dev: false - github.com/theopensystemslab/planx-core/f187295: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/f187295} + github.com/theopensystemslab/planx-core/eda1e67: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/eda1e67} name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true diff --git a/e2e/tests/ui-driven/package.json b/e2e/tests/ui-driven/package.json index 930dbbe471..8372e306d7 100644 --- a/e2e/tests/ui-driven/package.json +++ b/e2e/tests/ui-driven/package.json @@ -8,8 +8,8 @@ "postinstall": "./install-dependencies.sh" }, "dependencies": { - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#f187295", - "axios": "^1.6.2", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#eda1e67", + "axios": "^1.6.8", "dotenv": "^16.3.1", "eslint": "^8.56.0", "graphql": "^16.8.1", diff --git a/e2e/tests/ui-driven/pnpm-lock.yaml b/e2e/tests/ui-driven/pnpm-lock.yaml index f6dba2d80d..1a703c4ed7 100644 --- a/e2e/tests/ui-driven/pnpm-lock.yaml +++ b/e2e/tests/ui-driven/pnpm-lock.yaml @@ -6,11 +6,11 @@ settings: dependencies: '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#f187295 - version: github.com/theopensystemslab/planx-core/f187295 + specifier: git+https://github.com/theopensystemslab/planx-core#eda1e67 + version: github.com/theopensystemslab/planx-core/eda1e67 axios: - specifier: ^1.6.2 - version: 1.6.2 + specifier: ^1.6.8 + version: 1.6.8 dotenv: specifier: ^16.3.1 version: 16.3.1 @@ -694,10 +694,10 @@ packages: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} dev: false - /axios@1.6.2: - resolution: {integrity: sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==} + /axios@1.6.8: + resolution: {integrity: sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==} dependencies: - follow-redirects: 1.15.5 + follow-redirects: 1.15.6 form-data: 4.0.0 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -1389,8 +1389,8 @@ packages: /flatted@3.3.1: resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} - /follow-redirects@1.15.5: - resolution: {integrity: sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==} + /follow-redirects@1.15.6: + resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -2609,8 +2609,8 @@ packages: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} dev: false - github.com/theopensystemslab/planx-core/f187295: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/f187295} + github.com/theopensystemslab/planx-core/eda1e67: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/eda1e67} name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true diff --git a/e2e/tests/ui-driven/src/create-flow/create-flow.spec.ts b/e2e/tests/ui-driven/src/create-flow/create-flow.spec.ts index cead9db90d..6d7229a71c 100644 --- a/e2e/tests/ui-driven/src/create-flow/create-flow.spec.ts +++ b/e2e/tests/ui-driven/src/create-flow/create-flow.spec.ts @@ -156,7 +156,7 @@ test.describe("Navigation", () => { }); await page.goto( - `/${context.team.slug}/${serviceProps.slug}/preview?analytics=false`, + `/${context.team.slug}/${serviceProps.slug}/published?analytics=false`, ); await expect(page.getByText("Not Found")).toBeVisible(); @@ -190,7 +190,7 @@ test.describe("Navigation", () => { }); await page.goto( - `/${context.team.slug}/${serviceProps.slug}/preview?analytics=false`, + `/${context.team.slug}/${serviceProps.slug}/published?analytics=false`, ); await answerQuestion({ page, title: "Is this a test?", answer: "Yes" }); diff --git a/e2e/tests/ui-driven/src/globalHelpers.ts b/e2e/tests/ui-driven/src/globalHelpers.ts index 52be7509c0..45b2f43b6c 100644 --- a/e2e/tests/ui-driven/src/globalHelpers.ts +++ b/e2e/tests/ui-driven/src/globalHelpers.ts @@ -102,7 +102,7 @@ export async function returnToSession({ sessionId: string; shouldContinue?: boolean; }) { - const returnURL = `/${context.team?.slug}/${context.flow?.slug}/preview?analytics=false&sessionId=${sessionId}`; + const returnURL = `/${context.team?.slug}/${context.flow?.slug}/published?analytics=false&sessionId=${sessionId}`; log(`returning to http://localhost:3000/${returnURL}`); await page.goto(returnURL, { waitUntil: "load" }); await page.locator("#email").fill(context.user?.email); diff --git a/e2e/tests/ui-driven/src/invite-to-pay/agent.spec.ts b/e2e/tests/ui-driven/src/invite-to-pay/agent.spec.ts index 2d78f35348..71710647e3 100644 --- a/e2e/tests/ui-driven/src/invite-to-pay/agent.spec.ts +++ b/e2e/tests/ui-driven/src/invite-to-pay/agent.spec.ts @@ -102,7 +102,7 @@ test.describe("Agent journey @regression", async () => { // Resume session const resumeLink = `/${context.team!.slug!}/${context.flow! - .slug!}/preview?analytics=false&sessionId=${sessionId}`; + .slug!}/published?analytics=false&sessionId=${sessionId}`; const secondPage = await browserContext.newPage(); await secondPage.goto(resumeLink); await expect( @@ -132,7 +132,7 @@ test.describe("Agent journey @regression", async () => { // Navigate to resume session link const resumeLink = `/${context.team!.slug!}/${context.flow! - .slug!}/preview?analytics=false&sessionId=${sessionId}`; + .slug!}/published?analytics=false&sessionId=${sessionId}`; const secondPage = await browserContext.newPage(); await secondPage.goto(resumeLink); await expect( diff --git a/e2e/tests/ui-driven/src/invite-to-pay/helpers.ts b/e2e/tests/ui-driven/src/invite-to-pay/helpers.ts index 7b84115020..36e60c26ec 100644 --- a/e2e/tests/ui-driven/src/invite-to-pay/helpers.ts +++ b/e2e/tests/ui-driven/src/invite-to-pay/helpers.ts @@ -17,7 +17,7 @@ import { Context } from "../context"; */ export async function navigateToPayComponent(page: Page, context: Context) { const previewURL = `/${context.team!.slug!}/${context.flow! - .slug!}/preview?analytics=false`; + .slug!}/published?analytics=false`; await page.goto(previewURL); await fillInEmail({ page, context }); diff --git a/e2e/tests/ui-driven/src/pay.spec.ts b/e2e/tests/ui-driven/src/pay.spec.ts index dde9ea19df..406b60310e 100644 --- a/e2e/tests/ui-driven/src/pay.spec.ts +++ b/e2e/tests/ui-driven/src/pay.spec.ts @@ -27,7 +27,7 @@ let context: Context = { sessionIds: [], // used to collect and clean up sessions }; const previewURL = `/${context.team!.slug!}/${context.flow! - .slug!}/preview?analytics=false`; + .slug!}/published?analytics=false`; const payButtonText = "Pay now using GOV.UK Pay"; diff --git a/e2e/tests/ui-driven/src/save-and-return.spec.ts b/e2e/tests/ui-driven/src/save-and-return.spec.ts index ea957adb77..0188ef8624 100644 --- a/e2e/tests/ui-driven/src/save-and-return.spec.ts +++ b/e2e/tests/ui-driven/src/save-and-return.spec.ts @@ -27,7 +27,7 @@ test.describe("Save and return", () => { data: simpleSendFlow, }, }; - const previewURL = `/${context.team?.slug}/${context.flow?.slug}/preview?analytics=false`; + const previewURL = `/${context.team?.slug}/${context.flow?.slug}/published?analytics=false`; test.beforeAll(async () => { try { diff --git a/e2e/tests/ui-driven/src/sections.spec.ts b/e2e/tests/ui-driven/src/sections.spec.ts index fa0999cd5c..c9189eb6eb 100644 --- a/e2e/tests/ui-driven/src/sections.spec.ts +++ b/e2e/tests/ui-driven/src/sections.spec.ts @@ -52,7 +52,7 @@ test.describe("Section statuses", () => { }); test.beforeEach(async ({ page }) => { - const previewURL = `/${context.team?.slug}/${context.flow?.slug}/preview?analytics=false`; + const previewURL = `/${context.team?.slug}/${context.flow?.slug}/published?analytics=false`; await page.goto(previewURL); }); diff --git a/editor.planx.uk/package.json b/editor.planx.uk/package.json index 80ac0d525f..93a2676960 100644 --- a/editor.planx.uk/package.json +++ b/editor.planx.uk/package.json @@ -13,7 +13,7 @@ "@mui/styles": "^5.15.2", "@mui/utils": "^5.15.2", "@opensystemslab/map": "^0.8.0", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#f187295", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#eda1e67", "@tiptap/core": "^2.0.3", "@tiptap/extension-bold": "^2.0.3", "@tiptap/extension-bubble-menu": "^2.1.13", @@ -24,7 +24,7 @@ "@tiptap/extension-history": "^2.0.3", "@tiptap/extension-image": "^2.0.3", "@tiptap/extension-italic": "^2.0.3", - "@tiptap/extension-link": "^2.1.13", + "@tiptap/extension-link": "2.0.0-beta.36", "@tiptap/extension-list-item": "^2.0.3", "@tiptap/extension-mention": "^2.1.13", "@tiptap/extension-ordered-list": "^2.1.13", @@ -112,7 +112,6 @@ "@storybook/react-webpack5": "^7.6.7", "@storybook/testing-library": "^0.2.2", "@storybook/theming": "^7.6.7", - "@testing-library/dom": "^8.20.1", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.2.1", "@testing-library/user-event": "^14.4.3", @@ -166,6 +165,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", @@ -222,9 +222,7 @@ "overrides": { "nth-check@<2.0.1": ">=2.0.1", "postcss@<8.4.31": ">=8.4.31", - "semver@>=7.0.0 <7.5.2": ">=7.5.2", - "@adobe/css-tools@<4.3.2": ">=4.3.2", - "follow-redirects@<1.15.4": ">=1.15.4" + "follow-redirects@<=1.15.5": ">=1.15.6" } } } diff --git a/editor.planx.uk/pnpm-lock.yaml b/editor.planx.uk/pnpm-lock.yaml index df41a454ec..ec1de02653 100644 --- a/editor.planx.uk/pnpm-lock.yaml +++ b/editor.planx.uk/pnpm-lock.yaml @@ -7,9 +7,7 @@ settings: overrides: nth-check@<2.0.1: '>=2.0.1' postcss@<8.4.31: '>=8.4.31' - semver@>=7.0.0 <7.5.2: '>=7.5.2' - '@adobe/css-tools@<4.3.2': '>=4.3.2' - follow-redirects@<1.15.4: '>=1.15.4' + follow-redirects@<=1.15.5: '>=1.15.6' dependencies: '@airbrake/browser': @@ -43,8 +41,8 @@ dependencies: specifier: ^0.8.0 version: 0.8.0 '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#f187295 - version: github.com/theopensystemslab/planx-core/f187295(@types/react@18.2.45) + specifier: git+https://github.com/theopensystemslab/planx-core#eda1e67 + version: github.com/theopensystemslab/planx-core/eda1e67(@types/react@18.2.45) '@tiptap/core': specifier: ^2.0.3 version: 2.0.3(@tiptap/pm@2.0.3) @@ -76,8 +74,8 @@ dependencies: specifier: ^2.0.3 version: 2.0.3(@tiptap/core@2.0.3) '@tiptap/extension-link': - specifier: ^2.1.13 - version: 2.1.13(@tiptap/core@2.0.3)(@tiptap/pm@2.0.3) + specifier: 2.0.0-beta.36 + version: 2.0.0-beta.36(@tiptap/core@2.0.3) '@tiptap/extension-list-item': specifier: ^2.0.3 version: 2.0.3(@tiptap/core@2.0.3) @@ -335,9 +333,6 @@ devDependencies: '@storybook/theming': specifier: ^7.6.7 version: 7.6.7(react-dom@18.2.0)(react@18.2.0) - '@testing-library/dom': - specifier: ^8.20.1 - version: 8.20.1 '@testing-library/jest-dom': specifier: ^5.16.5 version: 5.16.5 @@ -7273,15 +7268,15 @@ packages: '@tiptap/core': 2.0.3(@tiptap/pm@2.0.3) dev: false - /@tiptap/extension-link@2.1.13(@tiptap/core@2.0.3)(@tiptap/pm@2.0.3): - resolution: {integrity: sha512-wuGMf3zRtMHhMrKm9l6Tft5M2N21Z0UP1dZ5t1IlOAvOeYV2QZ5UynwFryxGKLO0NslCBLF/4b/HAdNXbfXWUA==} + /@tiptap/extension-link@2.0.0-beta.36(@tiptap/core@2.0.3): + resolution: {integrity: sha512-jV0EBM/QPfR4e5FG5OPHZARnYS+CL8yhCzHO4J1Nb1i/+vRY9QpPVBruZABBwt+J+PMdq6t/6vvIXejCR3wyAg==} peerDependencies: - '@tiptap/core': ^2.0.0 - '@tiptap/pm': ^2.0.0 + '@tiptap/core': ^2.0.0-beta.1 dependencies: '@tiptap/core': 2.0.3(@tiptap/pm@2.0.3) - '@tiptap/pm': 2.0.3(@tiptap/core@2.0.3) - linkifyjs: 4.1.3 + linkifyjs: 3.0.5 + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 dev: false /@tiptap/extension-list-item@2.0.3(@tiptap/core@2.0.3): @@ -14695,8 +14690,8 @@ packages: uc.micro: 2.0.0 dev: false - /linkifyjs@4.1.3: - resolution: {integrity: sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg==} + /linkifyjs@3.0.5: + resolution: {integrity: sha512-1Y9XQH65eQKA9p2xtk+zxvnTeQBG7rdAXSkUG97DmuI/Xhji9uaUzaWxRj6rf9YC0v8KKHkxav7tnLX82Sz5Fg==} dev: false /lint-staged@13.2.3: @@ -21120,9 +21115,9 @@ packages: use-sync-external-store: 1.2.0(react@18.2.0) dev: false - github.com/theopensystemslab/planx-core/f187295(@types/react@18.2.45): - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/f187295} - id: github.com/theopensystemslab/planx-core/f187295 + github.com/theopensystemslab/planx-core/eda1e67(@types/react@18.2.45): + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/eda1e67} + id: github.com/theopensystemslab/planx-core/eda1e67 name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true 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 ( -