From 59aea24529dd650f6350de10000c5e27d021f1d6 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Mon, 12 Feb 2024 09:43:24 +0000 Subject: [PATCH] chore: generate frontend CSVs using public planx-core client (#2759) --- .../components/Confirmation/Public.test.tsx | 10 + .../@planx/components/Confirmation/Public.tsx | 49 +- .../bops/__tests__/applicationType.test.ts | 40 -- .../bops/__tests__/fileDescriptions.test.ts | 151 ----- .../Send/bops/__tests__/files.test.ts | 235 -------- .../Send/bops/__tests__/flags.test.ts | 221 -------- .../Send/bops/__tests__/makePayload.test.ts | 289 ---------- .../Send/bops/__tests__/sectionName.test.ts | 159 ------ .../Send/bops/__tests__/userRole.test.ts | 90 --- .../Send/bops/__tests__/works.test.ts | 53 -- .../src/@planx/components/Send/bops/index.ts | 536 ------------------ .../@planx/components/Send/helpers.test.ts | 91 --- .../src/@planx/components/Send/helpers.ts | 21 - .../src/@planx/components/Send/model.ts | 131 ----- .../@planx/components/Send/uniform/index.ts | 101 ---- .../src/pages/FlowEditor/lib/store/shared.ts | 12 +- editor.planx.uk/src/pages/Pay/MakePayment.tsx | 2 +- editor.planx.uk/src/pages/Preview/Node.tsx | 10 +- .../src/pages/Preview/StatusPage.tsx | 48 +- .../src/ui/public/FileDownload.tsx | 4 +- hasura.planx.uk/metadata/tables.yaml | 1 + 21 files changed, 80 insertions(+), 2174 deletions(-) delete mode 100644 editor.planx.uk/src/@planx/components/Send/bops/__tests__/applicationType.test.ts delete mode 100644 editor.planx.uk/src/@planx/components/Send/bops/__tests__/fileDescriptions.test.ts delete mode 100644 editor.planx.uk/src/@planx/components/Send/bops/__tests__/files.test.ts delete mode 100644 editor.planx.uk/src/@planx/components/Send/bops/__tests__/flags.test.ts delete mode 100644 editor.planx.uk/src/@planx/components/Send/bops/__tests__/makePayload.test.ts delete mode 100644 editor.planx.uk/src/@planx/components/Send/bops/__tests__/sectionName.test.ts delete mode 100644 editor.planx.uk/src/@planx/components/Send/bops/__tests__/userRole.test.ts delete mode 100644 editor.planx.uk/src/@planx/components/Send/bops/__tests__/works.test.ts delete mode 100644 editor.planx.uk/src/@planx/components/Send/bops/index.ts delete mode 100644 editor.planx.uk/src/@planx/components/Send/helpers.test.ts delete mode 100644 editor.planx.uk/src/@planx/components/Send/helpers.ts delete mode 100644 editor.planx.uk/src/@planx/components/Send/uniform/index.ts diff --git a/editor.planx.uk/src/@planx/components/Confirmation/Public.test.tsx b/editor.planx.uk/src/@planx/components/Confirmation/Public.test.tsx index d3670f6ca4..5c29b2f68d 100644 --- a/editor.planx.uk/src/@planx/components/Confirmation/Public.test.tsx +++ b/editor.planx.uk/src/@planx/components/Confirmation/Public.test.tsx @@ -3,6 +3,16 @@ import { axe, setup } from "testUtils"; import ConfirmationComponent from "./Public"; +jest.mock("@opensystemslab/planx-core", () => { + return { + CoreDomainClient: jest.fn().mockImplementation(() => ({ + export: { + csvData: () => jest.fn(), + }, + })), + }; +}); + it("should not have any accessibility violations", async () => { const { container } = setup( ({ @@ -31,23 +31,27 @@ const Table = styled("table")(({ theme }) => ({ export type Props = PublicProps; export default function ConfirmationComponent(props: Props) { - const [breadcrumbs, flow, passport, sessionId, flowName] = useStore( - (state) => [ - state.breadcrumbs, - state.flow, - state.computePassport(), - state.sessionId, - state.flowName, - ], - ); + const [data, setData] = useState([]); + + const [sessionId, saveToEmail, $public] = useStore((state) => [ + state.sessionId, + state.saveToEmail, + state.$public, + ]); + + useEffect(() => { + const makeCsvData = async () => { + if (sessionId && saveToEmail) { + const csvData = await $public({ + session: { sessionId: sessionId, email: saveToEmail }, + }).export.csvData(sessionId); + setData(csvData); + } + }; - // make a CSV data structure based on the payloads we Send to BOPs/Uniform - const data = makeCsvData({ - breadcrumbs, - flow, - flowName, - passport, - sessionId, + if (data?.length < 1) { + makeCsvData(); + } }); return ( @@ -80,14 +84,7 @@ export default function ConfirmationComponent(props: Props) { )} - { - - } + {} {props.nextSteps && Boolean(props.nextSteps?.length) && ( diff --git a/editor.planx.uk/src/@planx/components/Send/bops/__tests__/applicationType.test.ts b/editor.planx.uk/src/@planx/components/Send/bops/__tests__/applicationType.test.ts deleted file mode 100644 index 769bd458e5..0000000000 --- a/editor.planx.uk/src/@planx/components/Send/bops/__tests__/applicationType.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { getBOPSParams } from ".."; - -const default_application_type = "lawfulness_certificate"; - -describe("application_type is set correctly based on flowName", () => { - test("it defaults to `lawfulness_certificate` if we can't fetch current route", () => { - const result = getBOPSParams({ - breadcrumbs: {}, - flow: {}, - passport: {}, - sessionId: "session-123", - // @ts-ignore: Type 'undefined' is not assignable to type 'string' - flowName: undefined, - }); - - expect(result.application_type).toEqual(default_application_type); - }); - - test("it sets to `lawfulness_certificate` for LDC services", () => { - const result = getBOPSParams({ - breadcrumbs: {}, - flow: {}, - passport: {}, - sessionId: "session-123", - flowName: "Apply for a lawful development certificate", - }); - expect(result.application_type).toEqual(default_application_type); - }); - - test("it sets to flowName for non-LDC services", () => { - const result = getBOPSParams({ - breadcrumbs: {}, - flow: {}, - passport: {}, - sessionId: "session-123", - flowName: "Apply for prior approval", - }); - expect(result.application_type).toEqual("Apply for prior approval"); - }); -}); diff --git a/editor.planx.uk/src/@planx/components/Send/bops/__tests__/fileDescriptions.test.ts b/editor.planx.uk/src/@planx/components/Send/bops/__tests__/fileDescriptions.test.ts deleted file mode 100644 index eb2cf9eeae..0000000000 --- a/editor.planx.uk/src/@planx/components/Send/bops/__tests__/fileDescriptions.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { Store, vanillaStore } from "pages/FlowEditor/lib/store"; - -import { getBOPSParams } from ".."; - -const { getState, setState } = vanillaStore; - -// https://i.imgur.com/MsCF14s.png -const flow: Store.flow = { - _root: { - edges: ["AbV4QmqiN5", "iIb3jctBEC"], - }, - AbV4QmqiN5: { - type: 140, - data: { - color: "#EFEFEF", - fn: "project.photo", - title: "project.photo", - }, - }, - iIb3jctBEC: { - type: 100, - data: { - text: "add text to the upload?", - }, - edges: ["wPOxLLc8qI", "BHhdZowuyE", "TTOjzz2Hkr"], - }, - wPOxLLc8qI: { - type: 200, - data: { - text: "description", - }, - edges: ["fFzoLSjOrW"], - }, - BHhdZowuyE: { - type: 200, - data: { - text: "reason", - }, - edges: ["fP0Jou0Jg3"], - }, - TTOjzz2Hkr: { - type: 200, - data: { - text: "blank", - }, - }, - fFzoLSjOrW: { - type: 110, - data: { - fn: "project.photo.description", - title: "project.photo.description", - }, - }, - fP0Jou0Jg3: { - type: 110, - data: { - title: "project.photo.reason", - fn: "project.photo.reason", - }, - }, -}; - -const testScenarios = { - "included when there is a *.description textinput": { - expected: "custom description", - breadcrumbs: { - iIb3jctBEC: { - auto: false, - answers: ["wPOxLLc8qI"], - }, - fFzoLSjOrW: { - auto: false, - data: { - "project.photo.description": "custom description", - }, - }, - }, - }, - "included when there is a *.reason textinput": { - expected: "custom reason", - breadcrumbs: { - iIb3jctBEC: { - auto: false, - answers: ["BHhdZowuyE"], - }, - fP0Jou0Jg3: { - auto: false, - data: { - "project.photo.reason": "custom reason", - }, - }, - }, - }, - "empty when there is no textinput": { - expected: undefined, - breadcrumbs: { - iIb3jctBEC: { - auto: false, - answers: ["TTOjzz2Hkr"], - }, - }, - }, -}; - -describe("BOPS files[*].applicant_description", () => { - Object.entries(testScenarios).forEach( - ([name, { expected, breadcrumbs: testScenarioBreadcrumbs }]) => { - test(`${name}`, () => { - const { resetPreview, computePassport, sessionId } = getState(); - - // Set the store's state for the test scenario - resetPreview(); - const breadcrumbs: Store.breadcrumbs = { - AbV4QmqiN5: { - auto: false, - data: { - "project.photo": [ - { - url: "https://example.com/image.jpg", - filename: "image.jpg", - }, - { - url: "https://example.com/image2.jpg", - filename: "image2.jpg", - }, - ], - }, - }, - ...testScenarioBreadcrumbs, - }; - - setState({ - flow, - breadcrumbs, - }); - - // if the user has uploaded multiple files for a specific key, - // ensure that every file in the list has the same description - getBOPSParams({ - breadcrumbs, - flow, - passport: computePassport(), - sessionId, - flowName: "Apply for a lawful development certificate", - }).files?.forEach((file) => { - expect(file.applicant_description).toStrictEqual(expected); - }); - }); - }, - ); -}); diff --git a/editor.planx.uk/src/@planx/components/Send/bops/__tests__/files.test.ts b/editor.planx.uk/src/@planx/components/Send/bops/__tests__/files.test.ts deleted file mode 100644 index 8e6ce51738..0000000000 --- a/editor.planx.uk/src/@planx/components/Send/bops/__tests__/files.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { Store } from "pages/FlowEditor/lib/store"; - -import { PASSPORT_UPLOAD_KEY } from "../../../DrawBoundary/model"; -import { extractTagsFromPassportKey, getBOPSParams } from "../../bops"; -import type { FileTag } from "../../model"; - -const flow: Store.flow = { - _root: { - edges: ["FnyZh5IrV3"], - }, - FnyZh5IrV3: { - data: { - fn: "property.drawing.elevation", - color: "#EFEFEF", - }, - type: 140, - }, -}; - -test("makes file object", () => { - const breadcrumbs: Store.breadcrumbs = { - FnyZh5IrV3: { - auto: false, - data: { - "property.drawing.elevation": [ - { - url: "http://localhost:7002/file/private/y2uubi9x/placeholder.png", - filename: "placeholder.png", - cachedSlot: { - file: { - path: "placeholder.png", - type: "image/png", - size: 6146, - }, - status: "success", - progress: 1, - id: "oPd5GUV_T-bWZWJb0wGs8", - url: "http://localhost:7002/file/private/y2uubi9x/placeholder.png", - }, - }, - ], - }, - }, - }; - const passport: Store.passport = { - data: { - "property.drawing.elevation": [ - { - url: "http://localhost:7002/file/private/y2uubi9x/placeholder.png", - filename: "placeholder.png", - cachedSlot: { - file: { - path: "placeholder.png", - type: "image/png", - size: 6146, - }, - status: "success", - progress: 1, - id: "oPd5GUV_T-bWZWJb0wGs8", - url: "http://localhost:7002/file/private/y2uubi9x/placeholder.png", - }, - }, - ], - }, - }; - - const actual = getBOPSParams({ - breadcrumbs, - flow, - passport, - sessionId: "123", - flowName: "Apply for a lawful development certificate", - }).files; - - const expected = [ - expect.objectContaining({ - filename: "http://localhost:7002/file/private/y2uubi9x/placeholder.png", - }), - ]; - - expect(actual).toEqual(expected); -}); - -interface TestTag { - key: string; - tags: Array; -} - -// Google Sheet listing tags: https://bit.ly/3yIscgc -describe("It extracts tags for", () => { - const data: Record = { - "No passport key": { - key: "", - tags: [], - }, - "Unmatchable passport key": { - key: "foo", - tags: [], - }, - "Location plan": { - key: - PASSPORT_UPLOAD_KEY === "locationPlan" - ? PASSPORT_UPLOAD_KEY - : "key changed unexpectedly!", - tags: [/*"Proposed", "Drawing",*/ "Site", "Plan"], - }, - "Existing site plan": { - key: "property.drawing.sitePlan", - tags: ["Existing", /*"Drawing", "Site",*/ "Plan"], // "Site" is reserved for red-line drawings ONLY! - }, - "Proposed site plan": { - key: "proposal.drawing.sitePlan", - tags: ["Proposed", /*"Drawing", "Site",*/ "Plan"], - }, - "Existing floor plan": { - key: "property.drawing.floorPlan", - tags: ["Existing", /*"Drawing",*/ "Floor", "Plan"], - }, - "Proposed floor plan": { - key: "proposal.drawing.floorPlan", - tags: ["Proposed", /*"Drawing",*/ "Floor", "Plan"], - }, - "Existing roof plan": { - key: "property.drawing.roofPlan", - tags: ["Existing", /*"Drawing",*/ "Roof", "Plan"], - }, - "Proposed roof plan": { - key: "proposal.drawing.roofPlan", - tags: ["Proposed", /*"Drawing",*/ "Roof", "Plan"], - }, - "Existing elevations": { - key: "property.drawing.elevation", - tags: ["Existing", /*"Drawing",*/ "Elevation"], - }, - "Proposed elevations": { - key: "proposal.drawing.elevation", - tags: ["Proposed", /*"Drawing",*/ "Elevation"], - }, - "Existing sections": { - key: "property.drawing.section", - tags: ["Existing", /*"Drawing",*/ "Section"], - }, - "Proposed sections": { - key: "proposal.drawing.section", - tags: ["Proposed", /*"Drawing",*/ "Section"], - }, - "Existing Photographs": { - key: "property.photograph", - tags: ["Existing", "Photograph"], - }, - Visualisation: { - key: "proposal.visualisation", - tags: ["Proposed" /*"Visualisation"*/], - }, - "Proposed outbuilding roof plan": { - key: "proposal.drawing.roofPlan.outbuilding", - tags: ["Proposed", /*"Drawing",*/ "Roof", "Plan"], - }, - "Proposed extension roof plan": { - key: "proposal.drawing.roofPlan.extension", - tags: ["Proposed", /*"Drawing",*/ "Roof", "Plan"], - }, - "Proposed porch roof plan": { - key: "proposal.drawing.roofPlan.porch", - tags: ["Proposed", /*"Drawing",*/ "Roof", "Plan"], - }, - "Existing use plan": { - key: "property.drawing.usePlan", - tags: ["Existing", /*"Drawing", "Use",*/ "Plan"], - }, - "Proposed use plan": { - key: "proposal.drawing.usePlan", - tags: ["Proposed", /*"Drawing", "Use",*/ "Plan"], - }, - "Existing unit plans": { - key: "property.drawing.unitPlan", - tags: ["Existing", /*"Drawing", "Unit",*/ "Plan"], - }, - "Proposed unit plans": { - key: "proposal.drawing.unitPlan", - tags: ["Proposed", /*"Drawing", "Unit",*/ "Plan"], - }, - "Additional drawings": { - key: "proposal.drawing.other", - tags: ["Proposed", /*"Drawing",*/ "Other"], - }, - "Additional documents": { - key: "proposal.document.other", - tags: ["Proposed", /*"Document",*/ "Other"], - }, - // Evidence of immunity - Photographs: { - key: "proposal.photograph", - tags: ["Proposed", "Photograph"], - }, - "Utility bill": { - key: "proposal.document.utility.bill", - tags: ["Proposed" /*"Document",*/, "Utility Bill"], - }, - "Building control certificate": { - key: "proposal.document.buildingControl.certificate", - tags: ["Proposed" /*"Document",*/, "Building Control Certificate"], - }, - "Construction invoice": { - key: "proposal.document.construction.invoice", - tags: ["Proposed" /*"Document",*/, "Construction Invoice"], - }, - "Council tax documents": { - key: "proposal.document.councilTaxBill", - tags: ["Proposed" /*"Document",*/, "Council Tax Document"], - }, - "Tenancy agreements": { - key: "proposal.document.tenancyAgreement", - tags: ["Proposed" /*"Document",*/, "Tenancy Agreement"], - }, - "Tenancy invoices": { - key: "proposal.document.tenancyInvoice", - tags: ["Proposed" /*"Document"*,*/, "Tenancy Invoice"], - }, - "Bank statements": { - key: "proposal.document.bankStatement", - tags: ["Proposed" /*"Document",*/, "Bank Statement"], - }, - "Statutory declaration": { - key: "proposal.document.declaration", - tags: ["Proposed" /*"Document",*/, "Statutory Declaration"], - }, - }; - - Object.entries(data).forEach(([example, { key, tags }]) => { - test(`${example}`, () => { - expect(extractTagsFromPassportKey(key)).toStrictEqual(tags); - }); - }); -}); diff --git a/editor.planx.uk/src/@planx/components/Send/bops/__tests__/flags.test.ts b/editor.planx.uk/src/@planx/components/Send/bops/__tests__/flags.test.ts deleted file mode 100644 index fe183fc768..0000000000 --- a/editor.planx.uk/src/@planx/components/Send/bops/__tests__/flags.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { Store } from "pages/FlowEditor/lib/store"; - -import { getBOPSParams } from ".."; - -// PlanX ALWAYS sends a flag result & optional override description (test 2) -// to BOPS, even if (1) no flag result component is shown to the applicant, -// or (3) no flag is collected during the flow. - -test("sends flag result despite no result component", () => { - const breadcrumbs: Store.breadcrumbs = { - jkMtyqBwqB: { - auto: false, - answers: ["1Y1koNMXwr"], - }, - Konz0RjOmX: { - auto: false, - }, - }; - const passport: Store.passport = { - data: {}, - }; - const flowName = "Apply for a lawful development certificate"; - - const actual = getBOPSParams({ - breadcrumbs, - flow, - passport, - sessionId, - flowName, - }); - - const expected = { - application_type: "lawfulness_certificate", - proposal_details: [ - { - question: "which answer?", - responses: [ - { - metadata: { flags: ["Planning permission / Prior approval"] }, - value: "prior", - }, - ], - metadata: { portal_name: "_root" }, - }, - ], - result: { - description: - "It looks like the proposed changes do not require planning permission, however the applicant must apply for Prior Approval before proceeding.", - flag: "Planning permission / Prior approval", - heading: "Prior approval", - }, - planx_debug_data: { - breadcrumbs, - passport, - session_id: sessionId, - }, - }; - - expect(actual).toStrictEqual(expected); -}); - -test("sends override description with flag result", () => { - const breadcrumbs: Store.breadcrumbs = { - jkMtyqBwqB: { - auto: false, - answers: ["pF4ug4nuUT"], - }, - Konz0RjOmX: { - auto: false, - }, - l3JOp21fkV: { - auto: false, - data: { - "application.resultOverride.reason": "i don't agree", - }, - }, - }; - const passport: Store.passport = { - data: { - "application.resultOverride.reason": "i don't agree", - }, - }; - const flowName = "Apply for a lawful development certificate"; - - const actual = getBOPSParams({ - breadcrumbs, - flow, - passport, - sessionId, - flowName, - }); - - const expected = { - application_type: "lawfulness_certificate", - proposal_details: [ - { - question: "which answer?", - responses: [ - { - metadata: { flags: ["Planning permission / Permission needed"] }, - value: "permission", - }, - ], - metadata: { portal_name: "_root" }, - }, - { - question: "do you want to override this decision?", - responses: [{ value: "i don't agree" }], - metadata: { portal_name: "_root" }, - }, - ], - result: { - description: - "It looks like the proposed changes may require planning permission.", - flag: "Planning permission / Permission needed", - heading: "Permission needed", - override: "i don't agree", - }, - planx_debug_data: { - breadcrumbs, - passport, - session_id: sessionId, - }, - }; - - expect(actual).toStrictEqual(expected); -}); - -test("sends 'no result' to BOPS when there is no collected flag", () => { - const breadcrumbs: Store.breadcrumbs = { - jkMtyqBwqB: { - auto: false, - answers: ["ZpF48YfV5e"], - }, - }; - const passport: Store.passport = { - data: {}, - }; - const flowName = "Apply for a lawful development certificate"; - - const actual = getBOPSParams({ - breadcrumbs, - flow, - passport, - sessionId, - flowName, - }); - - const expected = { - application_type: "lawfulness_certificate", - proposal_details: [ - { - question: "which answer?", - responses: [{ value: "other" }], - metadata: { portal_name: "_root" }, - }, - ], - result: { - description: "", - flag: "Planning permission / No result", - heading: "No result", - }, - planx_debug_data: { - breadcrumbs, - passport, - session_id: sessionId, - }, - }; - - expect(actual).toStrictEqual(expected); -}); - -// https://i.imgur.com/Mx5UP6t.png -const flow: Store.flow = { - _root: { - edges: ["jkMtyqBwqB"], - }, - jkMtyqBwqB: { - type: 100, - data: { - text: "which answer?", - }, - edges: ["1Y1koNMXwr", "pF4ug4nuUT", "ZpF48YfV5e"], - }, - "1Y1koNMXwr": { - type: 200, - data: { - text: "prior", - flag: "PRIOR_APPROVAL", - }, - }, - pF4ug4nuUT: { - type: 200, - data: { - text: "permission", - flag: "PLANNING_PERMISSION_REQUIRED", - }, - edges: ["Konz0RjOmX", "l3JOp21fkV"], - }, - ZpF48YfV5e: { - type: 200, - data: { - text: "other", - }, - }, - Konz0RjOmX: { - type: 3, - data: { - flagSet: "Planning permission", - }, - }, - l3JOp21fkV: { - type: 110, - data: { - title: "do you want to override this decision?", - fn: "application.resultOverride.reason", - }, - }, -}; - -const sessionId = "123"; diff --git a/editor.planx.uk/src/@planx/components/Send/bops/__tests__/makePayload.test.ts b/editor.planx.uk/src/@planx/components/Send/bops/__tests__/makePayload.test.ts deleted file mode 100644 index d384c967ad..0000000000 --- a/editor.planx.uk/src/@planx/components/Send/bops/__tests__/makePayload.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { Store } from "pages/FlowEditor/lib/store"; - -import { makePayload } from ".."; - -const flow: Store.flow = { - _root: { - edges: [ - "zQlvAHP8lw", - "LDGBpPGxWC", - "9K5DHOJIFG", - "DzIEfGlsGa", - "JV5ochuXrU", - "BLGxRowcwn", - "aKhcyyHYAG", - "AFoFsXSPus", - "fnT4PnVhhJ", - ], - }, - zQlvAHP8lw: { - type: 9, - }, - "9K5DHOJIFG": { - data: { - text: "checklist", - allRequired: false, - }, - type: 105, - edges: ["MvzjCmtxMH", "z6NYoKldtb"], - }, - AFoFsXSPus: { - data: { - fn: "text", - title: "text question", - }, - type: 110, - }, - BLGxRowcwn: { - data: { - fn: "distance", - title: "number question", - units: "miles", - }, - type: 150, - }, - DzIEfGlsGa: { - data: { - text: "expandable checklist question", - categories: [ - { - count: 2, - title: "Section 1", - }, - { - count: 1, - title: "Section 2", - }, - ], - allRequired: false, - }, - type: 105, - edges: ["N5m527zxB9", "p57v53iXh4", "JJEclI99HP"], - }, - JJEclI99HP: { - data: { - text: "c3", - }, - type: 200, - }, - JV5ochuXrU: { - data: { - title: "date question", - }, - type: 120, - }, - LDGBpPGxWC: { - data: { - fn: "address1", - title: "address question", - }, - type: 130, - }, - MvzjCmtxMH: { - data: { - text: "1", - }, - type: 200, - }, - N5m527zxB9: { - data: { - text: "c1", - }, - type: 200, - }, - UvtXroXmvm: { - data: { - text: "a2", - }, - type: 200, - }, - aKhcyyHYAG: { - data: { - text: "regular question", - }, - type: 100, - edges: ["mlQrX0WtFc", "UvtXroXmvm"], - }, - mlQrX0WtFc: { - data: { - text: "a1", - }, - type: 200, - }, - p57v53iXh4: { - data: { - text: "c2", - }, - type: 200, - }, - z6NYoKldtb: { - data: { - text: "2", - }, - type: 200, - }, - fnT4PnVhhJ: { - data: { - title: "proposal completion date", - fn: "proposal.completion.date", - }, - type: 120, - }, -}; - -const breadcrumbs: Store.breadcrumbs = { - zQlvAHP8lw: { - auto: false, - feedback: "test", - }, - LDGBpPGxWC: { - auto: false, - data: { - address1: { - line1: "line1", - line2: "line", - town: "town", - county: "county", - postcode: "postcode", - }, - }, - }, - "9K5DHOJIFG": { - auto: false, - answers: ["MvzjCmtxMH", "z6NYoKldtb"], - }, - DzIEfGlsGa: { - auto: false, - answers: ["N5m527zxB9", "p57v53iXh4", "JJEclI99HP"], - }, - JV5ochuXrU: { - auto: false, - data: { - JV5ochuXrU: "1999-01-01", - }, - }, - BLGxRowcwn: { - auto: false, - data: { - distance: 500, - }, - }, - aKhcyyHYAG: { - auto: false, - answers: ["mlQrX0WtFc"], - }, - AFoFsXSPus: { - auto: false, - data: { - text: "testanswer", - }, - }, -}; - -test("valid node types are serialized correctly for BOPS", () => { - const expected = { - feedback: { - find_property: "test", - }, - proposal_details: [ - { - question: "address question", - responses: [{ value: "line1, line, town, county, postcode" }], - metadata: { portal_name: "_root" }, - }, - { - question: "checklist", - responses: [{ value: "1" }, { value: "2" }], - metadata: { portal_name: "_root" }, - }, - { - question: "expandable checklist question", - responses: [{ value: "c1" }, { value: "c2" }, { value: "c3" }], - metadata: { portal_name: "_root" }, - }, - { - question: "date question", - responses: [{ value: "1999-01-01" }], - metadata: { portal_name: "_root" }, - }, - { - question: "number question", - responses: [{ value: "500" }], - metadata: { portal_name: "_root" }, - }, - { - question: "regular question", - responses: [{ value: "a1" }], - metadata: { portal_name: "_root" }, - }, - { - question: "text question", - responses: [{ value: "testanswer" }], - metadata: { portal_name: "_root" }, - }, - ], - }; - - const actual = makePayload(flow, breadcrumbs); - - expect(actual).toStrictEqual(expected); -}); - -test("removed nodes are skipped", () => { - const breadcrumbsWithAdditionalNode = { - ...breadcrumbs, - AFoFsXSDog: { - auto: false, - data: { - text: "Breadcrumb which no longer exists in the flow", - }, - }, - }; - - const expected = { - feedback: { - find_property: "test", - }, - proposal_details: [ - { - question: "address question", - responses: [{ value: "line1, line, town, county, postcode" }], - metadata: { portal_name: "_root" }, - }, - { - question: "checklist", - responses: [{ value: "1" }, { value: "2" }], - metadata: { portal_name: "_root" }, - }, - { - question: "expandable checklist question", - responses: [{ value: "c1" }, { value: "c2" }, { value: "c3" }], - metadata: { portal_name: "_root" }, - }, - { - question: "date question", - responses: [{ value: "1999-01-01" }], - metadata: { portal_name: "_root" }, - }, - { - question: "number question", - responses: [{ value: "500" }], - metadata: { portal_name: "_root" }, - }, - { - question: "regular question", - responses: [{ value: "a1" }], - metadata: { portal_name: "_root" }, - }, - { - question: "text question", - responses: [{ value: "testanswer" }], - metadata: { portal_name: "_root" }, - }, - ], - }; - - const actual = makePayload(flow, breadcrumbsWithAdditionalNode); - - expect(actual).toStrictEqual(expected); -}); diff --git a/editor.planx.uk/src/@planx/components/Send/bops/__tests__/sectionName.test.ts b/editor.planx.uk/src/@planx/components/Send/bops/__tests__/sectionName.test.ts deleted file mode 100644 index 0cde4c07f1..0000000000 --- a/editor.planx.uk/src/@planx/components/Send/bops/__tests__/sectionName.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { act } from "@testing-library/react"; -import { FullStore, Store } from "pages/FlowEditor/lib/store"; -import { vanillaStore } from "pages/FlowEditor/lib/store"; - -import flowWithThreeSections from "../../../../../pages/FlowEditor/lib/__tests__/mocks/flowWithThreeSections.json"; -import { getBOPSParams } from ".."; -import { QuestionAndResponses } from "./../../model"; - -const { setState, getState } = vanillaStore; -let initialState: FullStore; - -const sectionBreadcrumbs: Store.breadcrumbs = { - firstSection: { - auto: false, - }, - firstQuestion: { - auto: false, - answers: ["firstAnswer"], - }, - secondSection: { - auto: false, - }, - secondQuestion: { - auto: false, - answers: ["secondAnswer"], - }, - thirdSection: { - auto: false, - }, - thirdQuestion: { - auto: false, - answers: ["thirdAnswer"], - }, -}; - -const simpleBreadcrumbs: Store.breadcrumbs = { - firstQuestion: { - auto: false, - answers: ["firstAnswer"], - }, - secondQuestion: { - auto: false, - answers: ["secondAnswer"], - }, - thirdQuestion: { - auto: false, - answers: ["thirdAnswer"], - }, -}; - -const simpleFlow: Store.flow = { - _root: { - edges: ["firstQuestion", "secondQuestion", "thirdQuestion"], - }, - firstAnswer: { - data: { - text: "Answer 1", - }, - type: 200, - }, - thirdAnswer: { - data: { - text: "Answer 3", - }, - type: 200, - }, - secondAnswer: { - data: { - text: "Answer 2", - }, - type: 200, - }, - firstQuestion: { - data: { - text: "First Question", - }, - type: 100, - edges: ["firstAnswer"], - }, - thirdQuestion: { - data: { - text: "Third Question", - }, - type: 100, - edges: ["thirdAnswer"], - }, - secondQuestion: { - data: { - text: "Second Question", - }, - type: 100, - edges: ["secondAnswer"], - }, -}; - -describe("Flow with sections", () => { - beforeAll(() => { - initialState = getState(); - act(() => - setState({ - flow: flowWithThreeSections, - breadcrumbs: sectionBreadcrumbs, - }), - ); - act(() => getState().initNavigationStore()); - }); - - afterAll(() => { - setState(initialState); - }); - - it("Appends a section_name to each metadata object", () => { - const result = getBOPSParams({ - breadcrumbs: sectionBreadcrumbs, - flow: flowWithThreeSections, - passport: {}, - sessionId: "session-123", - flowName: "test-flow", - }); - - result.proposal_details?.forEach((detail) => { - expect(detail.metadata).toHaveProperty("section_name"); - }); - }); - - it("Appends the correct section name to metadata objects", () => { - const result = getBOPSParams({ - breadcrumbs: sectionBreadcrumbs, - flow: flowWithThreeSections, - passport: {}, - sessionId: "session-123", - flowName: "test-flow", - }); - - console.log(result.proposal_details); - const [first, second, third] = - result.proposal_details as QuestionAndResponses[]; - - expect(first?.metadata?.section_name).toBe("First section"); - expect(second?.metadata?.section_name).toBe("Second section"); - expect(third?.metadata?.section_name).toBe("Third section"); - }); -}); - -describe("Flow without sections", () => { - it("Does not append section_name to any metadata objects", () => { - const result = getBOPSParams({ - breadcrumbs: simpleBreadcrumbs, - flow: simpleFlow, - passport: {}, - sessionId: "session-123", - flowName: "test-flow", - }); - - result.proposal_details?.forEach((detail) => { - expect(detail.metadata).not.toHaveProperty("section_name"); - }); - }); -}); diff --git a/editor.planx.uk/src/@planx/components/Send/bops/__tests__/userRole.test.ts b/editor.planx.uk/src/@planx/components/Send/bops/__tests__/userRole.test.ts deleted file mode 100644 index f13df3b583..0000000000 --- a/editor.planx.uk/src/@planx/components/Send/bops/__tests__/userRole.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Store } from "pages/FlowEditor/lib/store"; - -import { USER_ROLES } from "../../model"; -import { getBOPSParams } from ".."; - -// https://i.imgur.com/KhUnUte.png -const flow: Store.flow = { - _root: { - edges: ["user_role_question"], - }, - user_role_question: { - type: 100, - data: { - fn: "user.role", - text: "user role", - }, - edges: [ - "applicant_id", - "agent_id", - "proxy_id", - "unsupported_id", - "other_id", - ], - }, - applicant_id: { - type: 200, - data: { - text: "Applicant", - val: "applicant", - }, - }, - agent_id: { - type: 200, - data: { - text: "Agent", - val: "agent", - }, - }, - proxy_id: { - type: 200, - data: { - text: "Proxy", - val: "proxy", - }, - }, - unsupported_id: { - type: 200, - data: { - text: "Unsupported", - val: "unsupported", - }, - }, -}; - -// ensure that only supported user.role values are sent to BoPS -// https://github.com/theopensystemslab/planx-new/pull/755 - -describe("when user.role =", () => { - [ - ...USER_ROLES.map((role) => ({ passportValue: role, bopsValue: role })), - { passportValue: "unsupported", bopsValue: undefined }, - ].forEach(({ passportValue, bopsValue }) => { - const expectedStr = JSON.stringify(bopsValue); - - test(`'${passportValue}', send { user_role: ${expectedStr} } to BoPS`, () => { - const breadcrumbs = { - user_role_question: { - auto: false, - answers: [`${passportValue}_id`], - }, - }; - - const passport = { - data: { - "user.role": [passportValue], - }, - }; - - const result = getBOPSParams({ - breadcrumbs, - flow, - passport, - sessionId: "FAKE-SESSION-ID", - flowName: "Apply for a lawful development certificate", - }); - - expect(result.user_role).toEqual(bopsValue); - }); - }); -}); diff --git a/editor.planx.uk/src/@planx/components/Send/bops/__tests__/works.test.ts b/editor.planx.uk/src/@planx/components/Send/bops/__tests__/works.test.ts deleted file mode 100644 index c8bc91e833..0000000000 --- a/editor.planx.uk/src/@planx/components/Send/bops/__tests__/works.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { getBOPSParams } from ".."; - -test("Start date is set in the payload when present in passport", () => { - const result = getBOPSParams({ - breadcrumbs: {}, - flow: {}, - passport: { - data: { - "proposal.start.date": "2025-02-03", - }, - }, - sessionId: "session-123", - flowName: "Apply for a lawful development certificate", - }); - expect(result.works?.start_date).toEqual("2025-02-03"); -}); - -test("Start date is not set in the payload when not present in passport", () => { - const result = getBOPSParams({ - breadcrumbs: {}, - flow: {}, - passport: {}, - sessionId: "session-123", - flowName: "Apply for a lawful development certificate", - }); - expect(result).not.toHaveProperty("works"); -}); - -test("Completion date is set in the payload when present in passport", () => { - const result = getBOPSParams({ - breadcrumbs: {}, - flow: {}, - passport: { - data: { - "proposal.completion.date": "2025-02-03", - }, - }, - sessionId: "session-123", - flowName: "Apply for a lawful development certificate", - }); - expect(result.works?.finish_date).toEqual("2025-02-03"); -}); - -test("Completion date is not set in the payload when not present in passport", () => { - const result = getBOPSParams({ - breadcrumbs: {}, - flow: {}, - passport: {}, - sessionId: "session-123", - flowName: "Apply for a lawful development certificate", - }); - expect(result).not.toHaveProperty("works"); -}); diff --git a/editor.planx.uk/src/@planx/components/Send/bops/index.ts b/editor.planx.uk/src/@planx/components/Send/bops/index.ts deleted file mode 100644 index 2c479979f5..0000000000 --- a/editor.planx.uk/src/@planx/components/Send/bops/index.ts +++ /dev/null @@ -1,536 +0,0 @@ -// API Documentation: https://ripa.bops.services/api-docs/index.html - -// minimumPayload and fullPayload are the minimum and full expected -// POST data payloads accepted by the BOPS API, see: -// https://southwark.preview.bops.services/api-docs/index.html - -import { - flatFlags, - GOV_PAY_PASSPORT_KEY, - GovUKPayment, -} from "@opensystemslab/planx-core/types"; -import { logger } from "airbrake"; -import { isEmpty } from "lodash"; -import { useStore } from "pages/FlowEditor/lib/store"; -import { getResultData } from "pages/FlowEditor/lib/store/preview"; -import striptags from "striptags"; - -import { Store } from "../../../../pages/FlowEditor/lib/store"; -import { toPence } from "../../Pay/model"; -import { removeNilValues } from "../../shared/utils"; -import { TYPES } from "../../types"; -import { findGeoJSON } from "../helpers"; -import { - BOPSFullPayload, - FileTag, - QuestionAndResponses, - QuestionMetaData, - Response, - ResponseMetaData, - USER_ROLES, -} from "../model"; - -export const bopsDictionary = { - // applicant or agent details provided via TextInput(s) or ContactInput component - applicant_first_name: "applicant.name.first", - applicant_last_name: "applicant.name.last", - applicant_phone: "applicant.phone.primary", - applicant_email: "applicant.email", - - agent_first_name: "applicant.agent.name.first", - agent_last_name: "applicant.agent.name.last", - agent_phone: "applicant.agent.phone.primary", - agent_email: "applicant.agent.email", - - description: "proposal.description", -}; - -function exhaustiveCheck(type: never): never { - throw new Error(`Unhandled type: ${type}`); -} - -function isTypeForBopsPayload(type?: TYPES) { - if (!type) return false; - - switch (type) { - case TYPES.Calculate: - case TYPES.Confirmation: - case TYPES.Content: - case TYPES.DrawBoundary: - case TYPES.ExternalPortal: - case TYPES.FileUpload: - case TYPES.FileUploadAndLabel: - case TYPES.Filter: - case TYPES.FindProperty: - case TYPES.Flow: - case TYPES.InternalPortal: - case TYPES.NextSteps: - case TYPES.Notice: - case TYPES.Pay: - case TYPES.PlanningConstraints: - case TYPES.PropertyInformation: - case TYPES.Response: - case TYPES.Result: - case TYPES.Review: - case TYPES.Section: - case TYPES.Send: - case TYPES.SetValue: - case TYPES.TaskList: - return false; - - case TYPES.AddressInput: - case TYPES.Checklist: - case TYPES.DateInput: - case TYPES.NumberInput: - case TYPES.Statement: - case TYPES.TextInput: - case TYPES.ContactInput: - return true; - - default: - return exhaustiveCheck(type); - } -} - -// For a given node (a "Question"), recursively scan the flow schema to find which portal it belongs to -// and add the portal_name to the QuestionMetadata so BOPS can group proposal_details -const addPortalName = ( - id: string, - flow: Store.flow, - metadata: QuestionMetaData, -): QuestionMetaData => { - if (id === "_root") { - metadata.portal_name = "_root"; - } else if (flow[id]?.type === 300) { - // internal & external portals are both type 300 after flattening (ref dataMergedHotFix) - metadata.portal_name = flow[id]?.data?.text || id; - } else { - // if the current node id is not the root or a portal, then find its' next parent node and so on until we hit a portal - Object.entries(flow).forEach(([nodeId, node]) => { - if (node.edges?.includes(id)) { - addPortalName(nodeId, flow, metadata); - } - }); - } - - return metadata; -}; - -const addSectionName = ( - id: string, - metadata: QuestionMetaData, -): QuestionMetaData => { - const { hasSections, getSectionForNode } = useStore.getState(); - if (hasSections) { - const section = getSectionForNode(id); - if (section?.data?.title) metadata.section_name = section.data.title; - } - return metadata; -}; - -export const makePayload = ( - flow: Store.flow, - breadcrumbs: Store.breadcrumbs, -) => { - const feedback: BOPSFullPayload["feedback"] = {}; - - const proposal_details = Object.entries(breadcrumbs) - .map(([id, bc]) => { - // Skip nodes that may be in the breadcrumbs which are no longer in flow - if (!flow[id]) return; - const { edges: _edges = [], ...question } = flow[id]; - - try { - const trimmedFeedback = bc.feedback?.trim(); - if (trimmedFeedback) { - switch (flow[id].type) { - case TYPES.Result: - feedback["result"] = trimmedFeedback; - break; - case TYPES.FindProperty: - feedback["find_property"] = trimmedFeedback; - break; - case TYPES.PlanningConstraints: - feedback["planning_constraints"] = trimmedFeedback; - break; - default: - throw new Error(`invalid feedback type ${flow[id].type}`); - } - } - } catch (err) { - logger.notify(err); - } - - // exclude answers that have been extracted into the root object - const validKey = !Object.values(bopsDictionary).includes( - flow[id]?.data?.fn, - ); - if (!isTypeForBopsPayload(flow[id]?.type) || !validKey) return; - - const answers: Array = (() => { - switch (flow[id].type) { - case TYPES.AddressInput: - try { - const addressObject = Object.values(bc.data!).find( - (x) => x.postcode, - ); - return [Object.values(addressObject).join(", ")]; - } catch (err) { - return [JSON.stringify(bc.data)]; - } - case TYPES.ContactInput: - try { - // skip returning internal _contact data object, just return main key values - const contactObject = Object.values(bc.data!).filter( - (x) => typeof x === "string", - ); - return [Object.values(contactObject).join(" ")]; - } catch (err) { - return [JSON.stringify(bc.data)]; - } - case TYPES.DateInput: - case TYPES.NumberInput: - case TYPES.TextInput: - return Object.values(bc.data ?? {}).map((x) => String(x)); - case TYPES.Checklist: - case TYPES.Statement: - default: - return bc.answers ?? []; - } - })(); - - const responses = answers.map((id) => { - let value = id; - const metadata: ResponseMetaData = {}; - - if (flow[id]) { - // XXX: this is how we get the text representation of a node until - // we have a more standardised way of retrieving it. More info - // https://github.com/theopensystemslab/planx-new/discussions/386 - value = flow[id].data?.text ?? flow[id].data?.title ?? ""; - - if (flow[id].data?.flag) { - const flag = flatFlags.find((f) => f.value === flow[id].data?.flag); - if (flag) { - metadata.flags = [`${flag.category} / ${flag.text}`]; - } - } - } - - const ob: Response = { value }; - if (Object.keys(metadata).length > 0) ob.metadata = metadata; - return ob; - }); - - const ob: QuestionAndResponses = { - question: question?.data?.text ?? question?.data?.title ?? "", - responses, - }; - - let metadata: QuestionMetaData = {}; - if (bc.auto) metadata.auto_answered = true; - if (flow[id]?.data?.policyRef) { - metadata.policy_refs = [ - // remove html tags - { text: striptags(flow[id].data.policyRef) }, - ]; - } - metadata = addPortalName(id, flow, metadata); - metadata = addSectionName(id, metadata); - - if (Object.keys(metadata).length > 0) ob.metadata = metadata; - - return ob; - }) - .filter(Boolean) as Array; - - return { proposal_details, feedback }; -}; - -export function getBOPSParams({ - breadcrumbs, - flow, - passport, - sessionId, - flowName, -}: { - breadcrumbs: Store.breadcrumbs; - flow: Store.flow; - passport: Store.passport; - sessionId: string; - flowName: string; -}) { - const data = {} as BOPSFullPayload; - - // Default application type accepted by BOPS - data.application_type = "lawfulness_certificate"; - - // Overwrite application type if this isn't an LDC (relies on LDC flows having consistent slug) - // eg because makeCsvData which is used across services calls this method - if (flowName && flowName !== "Apply for a lawful development certificate") { - data.application_type = flowName; - } - - // 1a. address - - const address = passport.data?._address; - - if (address) { - const site = {} as BOPSFullPayload["site"]; - - site.uprn = address.uprn && String(address.uprn); - - site.address_1 = - address.single_line_address?.split(`, ${address.town}`)[0] || - address.title; - - site.town = - address.town || - passport.data?.["property.localAuthorityDistrict"]?.join(", "); - site.postcode = address.postcode; - - site.latitude = address.latitude; - site.longitude = address.longitude; - - site.x = address.x; - site.y = address.y; - - site.source = address.source; // reflects "os" or "proposed" - - data.site = site; - } - - // 1b. property boundary - const geojson = findGeoJSON(flow, breadcrumbs); - if (geojson) data.boundary_geojson = geojson; - - // 2. files - - Object.entries(passport.data || {}) - .filter(([, v]: any) => v?.[0]?.url) - .forEach(([key, arr]) => { - (arr as any[]).forEach(({ url }) => { - try { - data.files = data.files || []; - - data.files.push({ - filename: url, - tags: extractTagsFromPassportKey(key), - applicant_description: extractFileDescriptionForPassportKey( - passport.data, - key, - ), - }); - } catch (err) {} - }); - }); - - // 3. constraints - - const constraints = ( - passport.data?.["property.constraints.planning"] || [] - ).reduce((acc: Record, curr: string) => { - acc[curr] = true; - return acc; - }, {}); - if (Object.values(constraints).map(Boolean).length > 0) { - data.constraints = constraints; - } - - // 3a. constraints that we checked, but do not intersect/apply to the property - - const nots = ( - passport.data?.["_nots"]?.["property.constraints.planning"] || [] - ).reduce((acc: Record, curr: string) => { - acc[curr] = false; - return acc; - }, {}); - if (Object.keys(nots).map(Boolean).length > 0) { - data.constraints = { ...data.constraints, ...nots }; - } - - // 4. work status - const workStatus = getWorkStatus(passport); - if (workStatus) data.work_status = workStatus; - - // 5. keys - - const bopsData = removeNilValues( - Object.entries(bopsDictionary).reduce((acc, [bopsField, planxField]) => { - acc[bopsField as keyof BOPSFullPayload] = passport.data?.[planxField]; - return acc; - }, {} as Partial), - ); - - // 6a. questions+answers array - const { proposal_details, feedback } = makePayload(flow, breadcrumbs); - - data.proposal_details = proposal_details; - - // 6b. optional feedback object - // we include feedback object if it contains at least 1 key/value pair - if (Object.keys(feedback).length > 0) { - data.feedback = feedback; - } - - // 7. payment - - const payment = passport?.data?.[GOV_PAY_PASSPORT_KEY] as GovUKPayment; - if (payment) { - data.payment_amount = toPence(payment.amount); - data.payment_reference = payment.payment_id; - } - - // 8. flag data - - try { - const result = getResultData(breadcrumbs, flow); - const { flag } = Object.values(result)[0]; - data.result = removeNilValues({ - flag: [flag.category, flag.text].join(" / "), - heading: flag.text, - description: flag.description, - override: passport?.data?.["application.resultOverride.reason"], - }); - } catch (err) { - console.error("unable to get flag result"); - logger.notify(err); - } - - // 9. user role - - // XXX: toString() is a 'hack' until passport data is better structured. - // Currently values might be [string] or string, depending on Q type. - // This will extract a [string] into a string and not do anything if - // the value is already a string, for example - - // - // passport.data['user.role']= ["applicant"] - // const userRole = passport.data['user.role'].toString() - // userRole === "applicant" - - const userRole = passport?.data?.["user.role"]?.toString(); - - if (userRole && USER_ROLES.includes(userRole)) data.user_role = userRole; - - // 10. Works - const works: BOPSFullPayload["works"] = {}; - - const startedDate = parseDate(passport?.data?.["proposal.start.date"]); - if (startedDate) works.start_date = startedDate; - - const completionDate = parseDate( - passport?.data?.["proposal.completion.date"], - ); - if (completionDate) works.finish_date = completionDate; - - if (!isEmpty(works)) data.works = works; - - return { - ...data, - ...bopsData, - planx_debug_data: { - session_id: sessionId, - breadcrumbs, - passport, - }, - }; -} - -const parseDate = (dateString: string | undefined): string | undefined => { - try { - if (dateString) { - // ensure that date is valid and in yyyy-mm-dd format - return new Date(dateString).toISOString().split("T")[0]; - } - } catch (err) { - const errPayload = ["Unable to parse date", { dateString, err }]; - logger.notify(errPayload); - } -}; - -export const getWorkStatus = (passport: Store.passport) => { - // XXX: toString() is explained in XXX block above - switch (passport?.data?.["application.type"]?.toString()) { - case "ldc.existing": - return "existing"; - case "ldc.proposed": - return "proposed"; - } -}; - -const extractFileDescriptionForPassportKey = ( - passport: Store.passport["data"], - passportKey: string, -): string | undefined => { - try { - // XXX: check for .description or .reason as there might be either atm - // i.e. file = property.photograph, text = property.photograph.reason - for (const x of ["description", "reason"]) { - const key = `${passportKey}.${x}`; - const val = passport?.[key]; - if (val && typeof val === "string") { - return val; - } - } - } catch (_err) {} - return undefined; -}; - -/** - * Accepts a passport key and returns BOPS file tags associated with it - * More info: https://bit.ly/tags-spreadsheet - */ -export const extractTagsFromPassportKey = (passportKey: string) => { - const tags: FileTag[] = []; - - if (!passportKey) return tags; - - const splitKey = passportKey.split("."); - - if (splitKey[0] === "proposal") { - tags.push("Proposed"); - } else if (splitKey[0] === "property") { - tags.push("Existing"); - } - - if (splitKey.includes("locationPlan")) { - // "locationPlan" is DrawBoundary's passport key - tags.push("Site"); - tags.push("Plan"); - } else if (splitKey.includes("roofPlan")) { - tags.push("Roof"); - tags.push("Plan"); - } else if (splitKey.includes("elevation")) { - tags.push("Elevation"); - } else if (splitKey.includes("photograph")) { - tags.push("Photograph"); - } else if (splitKey.includes("section")) { - tags.push("Section"); - } else if (splitKey.includes("floorPlan")) { - tags.push("Floor"); - tags.push("Plan"); - } else if (splitKey.includes("councilTaxBill")) { - tags.push("Council Tax Document"); - } else if (splitKey.includes("tenancyAgreement")) { - tags.push("Tenancy Agreement"); - } else if (splitKey.includes("tenancyInvoice")) { - tags.push("Tenancy Invoice"); - } else if (splitKey.includes("bankStatement")) { - tags.push("Bank Statement"); - } else if (splitKey.includes("declaration")) { - tags.push("Statutory Declaration"); - } else if (passportKey.includes("utility.bill")) { - tags.push("Utility Bill"); - } else if (passportKey.includes("buildingControl.certificate")) { - tags.push("Building Control Certificate"); - } else if (passportKey.includes("construction.invoice")) { - tags.push("Construction Invoice"); - } else if (splitKey.some((x) => x.endsWith("Plan"))) { - // eg "sitePlan" - tags.push("Plan"); - } else if (splitKey.includes("other")) { - tags.push("Other"); - } - - return tags; -}; diff --git a/editor.planx.uk/src/@planx/components/Send/helpers.test.ts b/editor.planx.uk/src/@planx/components/Send/helpers.test.ts deleted file mode 100644 index e07debecf4..0000000000 --- a/editor.planx.uk/src/@planx/components/Send/helpers.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { TYPES } from "@planx/components/types"; -import { Store } from "pages/FlowEditor/lib/store"; - -import { findGeoJSON } from "./helpers"; - -describe("findGeoJSON", () => { - it("finds the first GeoJSON object when only one exists", () => { - const geojson = { - type: "Feature", - properties: {}, - geometry: { - type: "Polygon", - coordinates: [ - [ - [-0.07643975531307334, 51.485847769536015], - [-0.0764006164494183, 51.4855918619739], - [-0.07587615567891393, 51.48561867140494], - [-0.0759899845402056, 51.48584045791162], - [-0.07643975531307334, 51.485847769536015], - ], - ], - }, - }; - const breadcrumbs: Store.breadcrumbs = { - A1: { - data: { "property.boundary.site": geojson }, - }, - }; - const flow: Store.flow = { - A1: { - id: "A1", - type: TYPES.DrawBoundary, - }, - }; - const found = findGeoJSON(flow, breadcrumbs); - expect(found).toEqual(geojson); - }); - - it("finds the first GeoJSON object when multiple exist", () => { - const geojson = { - type: "Feature", - properties: {}, - geometry: { - type: "Polygon", - coordinates: [ - [ - [-0.07643975531307334, 51.485847769536015], - [-0.0764006164494183, 51.4855918619739], - [-0.07587615567891393, 51.48561867140494], - [-0.0759899845402056, 51.48584045791162], - [-0.07643975531307334, 51.485847769536015], - ], - ], - }, - }; - const breadcrumbs: Store.breadcrumbs = { - A1: { - data: { - "property.boundary.site": geojson, - "property.boundary.location": geojson, - }, - }, - }; - const flow: Store.flow = { - A1: { - id: "A1", - type: TYPES.DrawBoundary, - }, - }; - const found = findGeoJSON(flow, breadcrumbs); - expect(found).toEqual(geojson); - }); - - it("returns undefined when no GeoJSON-like objects exist", () => { - const breadcrumbs: Store.breadcrumbs = { - A1: { - data: { - "property.boundary.site": {}, - }, - }, - }; - const flow: Store.flow = { - A1: { - id: "A1", - type: TYPES.DrawBoundary, - }, - }; - const found = findGeoJSON(flow, breadcrumbs); - expect(found).toEqual(undefined); - }); -}); diff --git a/editor.planx.uk/src/@planx/components/Send/helpers.ts b/editor.planx.uk/src/@planx/components/Send/helpers.ts deleted file mode 100644 index dc180fa9f8..0000000000 --- a/editor.planx.uk/src/@planx/components/Send/helpers.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { TYPES } from "@planx/components/types"; -import { Store } from "pages/FlowEditor/lib/store"; - -export function findGeoJSON( - flow: Store.flow, - breadcrumbs: Store.breadcrumbs, -): { type: "Feature" } | undefined { - const foundNodeId = Object.keys(breadcrumbs).find( - (nodeId) => flow[nodeId]?.type === TYPES.DrawBoundary, - ); - if (!foundNodeId) return; - const { data: boundaryData } = breadcrumbs[foundNodeId]; - if (boundaryData) { - // scan the breadcrumb's data object (what got saved to passport) - // and extract the first instance of any geojson that's found - const geojson = Object.values(boundaryData).find( - (v) => v.type === "Feature", - ); - return geojson; - } -} diff --git a/editor.planx.uk/src/@planx/components/Send/model.ts b/editor.planx.uk/src/@planx/components/Send/model.ts index a296c3dee7..5c126e9021 100644 --- a/editor.planx.uk/src/@planx/components/Send/model.ts +++ b/editor.planx.uk/src/@planx/components/Send/model.ts @@ -21,8 +21,6 @@ export const parseContent = (data: Record | undefined): Send => ({ destinations: data?.destinations || [DEFAULT_DESTINATION], }); -export const USER_ROLES = ["applicant", "agent", "proxy"] as const; - export function getCombinedEventsPayload({ destinations, teamSlug, @@ -75,132 +73,3 @@ export function getCombinedEventsPayload({ return combinedEventsPayload; } - -// See minimum POST schema for /api/v1/planning_applications -// https://ripa.bops.services/api-docs/index.html -interface BOPSMinimumPayload { - application_type: "lawfulness_certificate" | string; - site: { - uprn?: string; - address_1: string; - address_2?: string; - town?: string; - postcode?: string; - latitude: string; - longitude: string; - x: string; - y: string; - source: string; - }; - applicant_email: string; -} - -export interface BOPSFullPayload extends BOPSMinimumPayload { - description?: string; - payment_reference?: string; - payment_amount?: number; - ward?: string; - work_status?: "proposed" | "existing"; - applicant_first_name?: string; - applicant_last_name?: string; - applicant_phone?: string; - agent_first_name?: string; - agent_last_name?: string; - agent_phone?: string; - agent_email?: string; - proposal_details?: Array; - feedback?: { - result?: string; - find_property?: string; - planning_constraints?: string; - }; - constraints?: Record; - files?: Array; - boundary_geojson?: Object; - result?: { - flag?: string; - heading?: string; - description?: string; - override?: string; - }; - planx_debug_data?: Record; - // typeof arr[number] > https://steveholgado.com/typescript-types-from-arrays - user_role?: (typeof USER_ROLES)[number]; - works?: { - start_date?: string; - finish_date?: string; - }; -} - -export interface QuestionMetaData { - notes?: string; - auto_answered?: boolean; - policy_refs?: Array<{ - url?: string; - text?: string; - }>; - portal_name?: string; - section_name?: string; - feedback?: string; -} - -export interface ResponseMetaData { - flags?: Array; -} - -export interface Response { - value: string; - metadata?: ResponseMetaData; -} - -export interface QuestionAndResponses { - question: string; - metadata?: QuestionMetaData; - responses: Array; -} - -// Using PLAN_TAGS & EVIDENCE_TAGS provided by BOPS, from: -// https://github.com/unboxed/bops/blob/master/app/models/document.rb -// will also be in POST Schema https://ripa.bops.services/api-docs -type PlanTag = - | "Front" - | "Rear" - | "Side" - | "Roof" - | "Floor" - | "Site" - | "Plan" - | "Elevation" - | "Section" - | "Proposed" - | "Existing"; - -type EvidenceTag = - | "Photograph" - | "Utility Bill" - | "Building Control Certificate" - | "Construction Invoice" - | "Council Tax Document" - | "Tenancy Agreement" - | "Tenancy Invoice" - | "Bank Statement" - | "Statutory Declaration" - | "Other" - | "Sitemap"; - -export type FileTag = PlanTag | EvidenceTag; - -interface File { - filename: string; - tags?: Array; - applicant_description?: string; -} - -// CSV data structure sent to Uniform & re-used for user download on Confirmation page -interface CSVRow { - question: string; - responses: any; - metadata?: any; -} - -export type CSVData = CSVRow[]; diff --git a/editor.planx.uk/src/@planx/components/Send/uniform/index.ts b/editor.planx.uk/src/@planx/components/Send/uniform/index.ts deleted file mode 100644 index 0c52e685ec..0000000000 --- a/editor.planx.uk/src/@planx/components/Send/uniform/index.ts +++ /dev/null @@ -1,101 +0,0 @@ -import omit from "lodash/omit"; - -import { Store } from "../../../../pages/FlowEditor/lib/store"; -import { getBOPSParams } from "../bops"; -import { CSVData } from "../model"; - -// create a CSV data structure based on the payload we send to BOPs -// (also used in Confirmation component for user-downloadable copy of app data) -export function makeCsvData({ - breadcrumbs, - flow, - passport, - sessionId, - flowName, -}: { - breadcrumbs: Store.breadcrumbs; - flow: Store.flow; - passport: Store.passport; - sessionId: string; - flowName: string; -}): CSVData { - // TODO: Replace with `const bopsData = $public.generateBOPSPayload(sessionId) as any;` - // once public client is updated to have optional headers to access session data - const bopsData = getBOPSParams({ - breadcrumbs, - flow, - flowName, - passport, - sessionId, - }); - - // format dedicated BOPs properties as list of questions & responses to match proposal_details - // omitting debug data and keys already in confirmation details - const summary: any = { - ...omit(bopsData, ["planx_debug_data", "files", "proposal_details"]), - }; - const formattedSummary: { question: string; responses: any }[] = []; - Object.keys(summary).forEach((key) => { - formattedSummary.push({ - question: key, - responses: summary[key], - }); - }); - - // similarly format file uploads as list of questions, responses, metadata - const formattedFiles: { - question: string; - responses: any; - metadata: string; - }[] = []; - bopsData["files"]?.forEach((file) => { - formattedFiles.push({ - question: file.tags - ? `File upload: ${file.tags.join(", ")}` - : "File upload", - responses: file.filename.split("/").pop(), - metadata: file.applicant_description || "", - }); - }); - - // gather key reference fields, these will be first rows of CSV - const references: { question: string; responses: any }[] = [ - { - question: "Planning Application Reference", // match language used on Confirmation page - responses: sessionId, - }, - { - question: "Property Address", - responses: [ - bopsData.site?.address_1, - bopsData.site?.address_2, - bopsData.site?.town, - bopsData.site?.postcode, - ] - .filter(Boolean) - .join(" ") - .replaceAll(",", ""), // omit commas for csv > pdf parsing later by Uniform - }, - ]; - - // check if the passport has payment or submission ids, add them as reference rows if exist - const conditionalKeys = [ - "application.fee.reference.govPay", - "bopsId", - "idoxSubmissionId", - ]; - conditionalKeys.forEach((key) => { - if (passport.data?.[key]) { - references.push({ - question: key, - responses: passport.data?.[key], - }); - } - }); - - // concat data sections into single list, each object will be row in CSV - return references - .concat(formattedSummary) - .concat(bopsData["proposal_details"] || []) - .concat(formattedFiles); -} diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/store/shared.ts b/editor.planx.uk/src/pages/FlowEditor/lib/store/shared.ts index e4006f0bef..d7143dfa3e 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/shared.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/shared.ts @@ -1,4 +1,5 @@ import { CoreDomainClient } from "@opensystemslab/planx-core"; +import { Auth } from "@opensystemslab/planx-core/dist/requests/graphql"; import { ROOT_NODE_KEY } from "@planx/graph"; import { capitalize } from "lodash"; import { removeSessionIdSearchParam } from "utils"; @@ -32,7 +33,7 @@ export interface SharedStore extends Store.Store { setPreviewEnvironment: (previewEnvironment: PreviewEnvironment) => void; setFlowSlug: (flowSlug: string) => void; setFlowNameFromSlug: (flowSlug: string) => void; - $public: CoreDomainClient; + $public: (auth?: Auth) => CoreDomainClient; $client: CoreDomainClient; } @@ -111,9 +112,12 @@ export const sharedStore: StateCreator< set({ flowName }); }, - $public: new CoreDomainClient({ - targetURL: process.env.REACT_APP_HASURA_URL!, - }), + $public(auth: Auth | undefined): CoreDomainClient { + return new CoreDomainClient({ + targetURL: process.env.REACT_APP_HASURA_URL!, + auth: auth, + }); + }, /** * Authenticated client is re-instantiated upon user login diff --git a/editor.planx.uk/src/pages/Pay/MakePayment.tsx b/editor.planx.uk/src/pages/Pay/MakePayment.tsx index 0587062992..39afbf201f 100644 --- a/editor.planx.uk/src/pages/Pay/MakePayment.tsx +++ b/editor.planx.uk/src/pages/Pay/MakePayment.tsx @@ -175,7 +175,7 @@ export default function MakePayment({ useEffect(() => { const fetchProjectType = async () => { - const projectType = await $public.formatRawProjectTypes( + const projectType = await $public().formatRawProjectTypes( rawProjectTypes, ); setProjectType(projectType); diff --git a/editor.planx.uk/src/pages/Preview/Node.tsx b/editor.planx.uk/src/pages/Preview/Node.tsx index 0f8a43531a..8749e5a07d 100644 --- a/editor.planx.uk/src/pages/Preview/Node.tsx +++ b/editor.planx.uk/src/pages/Preview/Node.tsx @@ -24,7 +24,6 @@ import Question from "@planx/components/Question/Public"; import Result from "@planx/components/Result/Public"; import Review from "@planx/components/Review/Public"; import Section from "@planx/components/Section/Public"; -import { getWorkStatus } from "@planx/components/Send/bops"; import Send from "@planx/components/Send/Public"; import SetValue from "@planx/components/SetValue/Public"; import TaskList from "@planx/components/TaskList/Public"; @@ -268,4 +267,13 @@ function exhaustiveCheck(type: never): never { throw new Error(`Missing type ${type}`); } +function getWorkStatus(passport: Store.passport): string | undefined { + switch (passport?.data?.["application.type"]?.toString()) { + case "ldc.existing": + return "existing"; + case "ldc.proposed": + return "proposed"; + } +} + export default Node; diff --git a/editor.planx.uk/src/pages/Preview/StatusPage.tsx b/editor.planx.uk/src/pages/Preview/StatusPage.tsx index a64c32a262..5084b6fb38 100644 --- a/editor.planx.uk/src/pages/Preview/StatusPage.tsx +++ b/editor.planx.uk/src/pages/Preview/StatusPage.tsx @@ -3,14 +3,15 @@ import Button from "@mui/material/Button"; import Link from "@mui/material/Link"; import { useTheme } from "@mui/material/styles"; import Typography from "@mui/material/Typography"; -import Card from "@planx/components/shared/Preview/Card"; -import { contentFlowSpacing } from "@planx/components/shared/Preview/Card"; +import { QuestionAndResponses } from "@opensystemslab/planx-core/types"; +import Card, { + contentFlowSpacing, +} from "@planx/components/shared/Preview/Card"; import { useStore } from "pages/FlowEditor/lib/store"; -import React from "react"; +import React, { useEffect, useState } from "react"; import Banner from "ui/public/Banner"; import { removeSessionIdSearchParam } from "utils"; -import { makeCsvData } from "../../@planx/components/Send/uniform"; import FileDownload from "../../ui/public/FileDownload"; interface Props { @@ -32,26 +33,29 @@ const StatusPage: React.FC = ({ additionalOption, children, }) => { - const [breadcrumbs, flow, passport, sessionId, flowName] = useStore( - (state) => [ - state.breadcrumbs, - state.flow, - state.computePassport(), - state.sessionId, - state.flowName, - ], - ); + const theme = useTheme(); + const [data, setData] = useState([]); - // make a CSV data structure based on the payloads we Send to BOPs/Uniform - const data = makeCsvData({ - breadcrumbs, - flow, - flowName, - passport, - sessionId, - }); + const [sessionId, saveToEmail, $public] = useStore((state) => [ + state.sessionId, + state.saveToEmail, + state.$public, + ]); - const theme = useTheme(); + useEffect(() => { + const makeCsvData = async () => { + if (sessionId && saveToEmail) { + const csvData = await $public({ + session: { sessionId: sessionId, email: saveToEmail }, + }).export.csvData(sessionId); + setData(csvData); + } + }; + + if (data?.length < 1) { + makeCsvData(); + } + }); return ( <> diff --git a/editor.planx.uk/src/ui/public/FileDownload.tsx b/editor.planx.uk/src/ui/public/FileDownload.tsx index d61d99d8ff..08fd5add42 100644 --- a/editor.planx.uk/src/ui/public/FileDownload.tsx +++ b/editor.planx.uk/src/ui/public/FileDownload.tsx @@ -2,12 +2,12 @@ import Box from "@mui/material/Box"; import Link from "@mui/material/Link"; import { styled } from "@mui/material/styles"; import Typography from "@mui/material/Typography"; -import { CSVData } from "@planx/components/Send/model"; +import { QuestionAndResponses } from "@opensystemslab/planx-core/types"; import React from "react"; export interface Props { filename: string; - data: CSVData; + data: QuestionAndResponses[]; text?: string; } diff --git a/hasura.planx.uk/metadata/tables.yaml b/hasura.planx.uk/metadata/tables.yaml index 557eefa0e7..7fdeeda356 100644 --- a/hasura.planx.uk/metadata/tables.yaml +++ b/hasura.planx.uk/metadata/tables.yaml @@ -671,6 +671,7 @@ columns: - created_at - data + - flow_id - has_user_saved - id - submitted_at