diff --git a/api.planx.uk/modules/flows/docs.yaml b/api.planx.uk/modules/flows/docs.yaml index 389ebf4075..d02b1290db 100644 --- a/api.planx.uk/modules/flows/docs.yaml +++ b/api.planx.uk/modules/flows/docs.yaml @@ -56,6 +56,25 @@ components: type: array items: type: string + ValidationCheck: + type: object + properties: + title: + type: string + example: File types + required: true + status: + type: string + enum: + - Pass + - Fail + - Warn + - Not applicable + example: Pass + message: + type: string + example: Your flow has valid file types + required: true responses: CopyFlow: content: @@ -124,19 +143,20 @@ components: properties: message: type: string - required: false - description: - type: string - required: false + required: true alteredNodes: oneOf: - type: array items: $ref: "#/components/schemas/Node" - type: "null" - updatedFlow: - $ref: "#/components/schemas/FlowData" + validationChecks: required: false + oneOf: + - type: array + items: + $ref: "#/components/schemas/ValidationCheck" + - type: "null" FlattenData: content: application/json: diff --git a/api.planx.uk/modules/flows/validate/service.ts b/api.planx.uk/modules/flows/validate/service.ts index 6b4dc388c4..802f8f5c9e 100644 --- a/api.planx.uk/modules/flows/validate/service.ts +++ b/api.planx.uk/modules/flows/validate/service.ts @@ -1,3 +1,4 @@ +import { getValidSchemaValues } from "@opensystemslab/planx-core"; import { ComponentType, Edges, @@ -6,6 +7,7 @@ import { } from "@opensystemslab/planx-core/types"; import * as jsondiffpatch from "jsondiffpatch"; import intersection from "lodash/intersection"; +import countBy from "lodash/countBy"; import { dataMerged, getMostRecentPublishedFlow } from "../../../helpers"; import { @@ -23,7 +25,7 @@ type AlteredNode = { type ValidationResponse = { title: string; - status: "Pass" | "Fail" | "Not applicable"; + status: "Pass" | "Fail" | "Warn" | "Not applicable"; message: string; }; @@ -55,16 +57,20 @@ const validateAndDiffFlow = async ( const validationChecks = []; const sections = validateSections(flattenedFlow); const inviteToPay = validateInviteToPay(flattenedFlow); - validationChecks.push(sections, inviteToPay); + const fileTypes = validateFileTypes(flattenedFlow); + validationChecks.push(sections, inviteToPay, fileTypes); - // Sort validation checks by status: Fail, Pass, Not applicable - const applicableChecks = validationChecks - .filter((v) => v.status !== "Not applicable") - .sort((a, b) => a.status.localeCompare(b.status)); + // Arrange list of validation checks in order of status: Fail, Warn, Pass, Not applicable + const failingChecks = validationChecks.filter((v) => v.status == "Fail"); + const warningChecks = validationChecks.filter((v) => v.status === "Warn"); + const passingChecks = validationChecks.filter((v) => v.status === "Pass"); const notApplicableChecks = validationChecks.filter( (v) => v.status === "Not applicable", ); - const sortedValidationChecks = applicableChecks.concat(notApplicableChecks); + const sortedValidationChecks = failingChecks + .concat(warningChecks) + .concat(passingChecks) + .concat(notApplicableChecks); return { alteredNodes, @@ -193,9 +199,8 @@ const validateInviteToPay = (flowGraph: FlowGraph): ValidationResponse => { }; const inviteToPayEnabled = (flowGraph: FlowGraph): boolean => { - const payNodes = Object.entries(flowGraph).filter( - (entry): entry is [string, Node] => - isComponentType(entry, ComponentType.Pay), + const payNodes = Object.entries(flowGraph).filter((entry) => + isComponentType(entry, ComponentType.Pay), ); const payNodeStatuses = payNodes.map( ([_nodeId, node]) => node?.data?.allowInviteToPay, @@ -206,4 +211,68 @@ const inviteToPayEnabled = (flowGraph: FlowGraph): boolean => { ); }; +const validateFileTypes = (flowGraph: FlowGraph): ValidationResponse => { + // Get all passport variables set by FileUpload and/or FileUploadAndLabel + const allFileFns = [ + ...getFileUploadNodeFns(flowGraph), + ...getFileUploadAndLabelNodeFns(flowGraph), + ]; + if (allFileFns.length < 1) { + return { + title: "File types", + status: "Not applicable", + message: "Your flow is not using FileUpload or UploadAndLabel", + }; + } + + // Get all file types supported by current release of ODP Schema & compare + const validFileTypes = getValidSchemaValues("FileType"); + const invalidFileFns: string[] = []; + allFileFns.forEach((fn) => { + if (!validFileTypes?.includes(fn)) { + invalidFileFns.push(fn); + } + }); + if (invalidFileFns.length > 0) { + // Get unique fns with count of occurances + const countInvalidFileFns = countBy(invalidFileFns); + const summarisedInvalidFileFns: string[] = []; + Object.entries(countInvalidFileFns).map(([k, v]: [string, number]) => { + summarisedInvalidFileFns.push(`${k} (${v})`); + }); + return { + title: "File types", + status: "Warn", + message: `Your FileUpload or UploadAndLabel are setting data fields that are not supported by the current release of the ODP Schema: ${summarisedInvalidFileFns.join(", ")}`, + }; + } + + return { + title: "File types", + status: "Pass", + message: + "Files collected via FileUpload or UploadAndLabel are all supported by the ODP Schema", + }; +}; + +const getFileUploadNodeFns = (flowGraph: FlowGraph): string[] => { + const fileUploadNodes = Object.entries(flowGraph).filter((entry) => + isComponentType(entry, ComponentType.FileUpload), + ); + return fileUploadNodes.map(([_nodeId, node]) => node.data?.fn as string); +}; + +const getFileUploadAndLabelNodeFns = (flowGraph: FlowGraph): string[] => { + // Exclude Upload & Label nodes used in "info-only" mode with a hidden dropzone + const uploadAndLabelNodes = Object.entries(flowGraph).filter( + (entry) => + isComponentType(entry, ComponentType.FileUploadAndLabel) && + entry[1].data?.hideDropZone !== true, + ); + const uploadAndLabelFileTypes = uploadAndLabelNodes + .map(([_nodeId, node]: [string, Node]) => node.data?.fileTypes) + .flat(); + return uploadAndLabelFileTypes?.map((file: any) => file?.fn as string); +}; + export { validateAndDiffFlow }; diff --git a/api.planx.uk/modules/flows/validate/validate.test.ts b/api.planx.uk/modules/flows/validate/validate.test.ts index f17a73052d..dce4af082e 100644 --- a/api.planx.uk/modules/flows/validate/validate.test.ts +++ b/api.planx.uk/modules/flows/validate/validate.test.ts @@ -120,6 +120,11 @@ describe("sections validation on diff", () => { status: "Not applicable", message: "Your flow is not using Invite to Pay", }, + { + title: "File types", + status: "Not applicable", + message: "Your flow is not using FileUpload or UploadAndLabel", + }, ]); }); }); @@ -168,6 +173,11 @@ describe("sections validation on diff", () => { status: "Not applicable", message: "Your flow is not using Invite to Pay", }, + { + title: "File types", + status: "Not applicable", + message: "Your flow is not using FileUpload or UploadAndLabel", + }, ]); }); }); @@ -207,6 +217,11 @@ describe("invite to pay validation on diff", () => { status: "Not applicable", message: "Your flow is not using Sections", }, + { + title: "File types", + status: "Not applicable", + message: "Your flow is not using FileUpload or UploadAndLabel", + }, ]); }); }); @@ -256,6 +271,11 @@ describe("invite to pay validation on diff", () => { status: "Not applicable", message: "Your flow is not using Sections", }, + { + title: "File types", + status: "Not applicable", + message: "Your flow is not using FileUpload or UploadAndLabel", + }, ]); }); }); @@ -301,6 +321,11 @@ describe("invite to pay validation on diff", () => { status: "Not applicable", message: "Your flow is not using Sections", }, + { + title: "File types", + status: "Not applicable", + message: "Your flow is not using FileUpload or UploadAndLabel", + }, ]); }); }); @@ -348,6 +373,11 @@ describe("invite to pay validation on diff", () => { status: "Not applicable", message: "Your flow is not using Sections", }, + { + title: "File types", + status: "Not applicable", + message: "Your flow is not using FileUpload or UploadAndLabel", + }, ]); }); }); @@ -397,6 +427,164 @@ describe("invite to pay validation on diff", () => { status: "Not applicable", message: "Your flow is not using Sections", }, + { + title: "File types", + status: "Not applicable", + message: "Your flow is not using FileUpload or UploadAndLabel", + }, + ]); + }); + }); +}); + +describe("ODP Schema file type validation on diff", () => { + it("warns if any file data fields aren't supported by the ODP Schema", async () => { + const alteredFlow = { + ...mockFlowData, + fileUpload: { + type: 140, + data: { + color: "#EFEFEF", + fn: "roofPlan.existing", + title: "Roof plans", + }, + }, + fileUploadAndLabel: { + type: 145, + data: { + title: "Upload and label", + fileTypes: [ + { + name: "Site plans", + fn: "sitePlanTypo", + rule: { + condition: "AlwaysRequired", + }, + }, + { + name: "Heritage statement", + fn: "heritageStatement", + rule: { + condition: "AlwaysRequired", + }, + }, + ], + hideDropZone: false, + }, + }, + }; + + queryMock.mockQuery({ + name: "GetFlowData", + matchOnVariables: false, + data: { + flow: { + data: alteredFlow, + slug: "altered-flow-name", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [{ data: alteredFlow }], + }, + }, + }); + + await supertest(app) + .post("/flows/1/diff") + .set(auth) + .expect(200) + .then((res) => { + expect(res.body.message).toEqual("Changes queued to publish"); + expect(res.body.validationChecks).toEqual([ + { + title: "File types", + status: "Warn", + message: + "Your FileUpload or UploadAndLabel are setting data fields that are not supported by the current release of the ODP Schema: sitePlanTypo (1)", + }, + { + title: "Sections", + status: "Pass", + message: "Your flow has valid Sections", + }, + { + title: "Invite to Pay", + status: "Not applicable", + message: "Your flow is not using Invite to Pay", + }, + ]); + }); + }); + + it("skips validation checks for UploadAndLabel components used in info-only mode with hidden dropzone", async () => { + const alteredFlow = { + ...mockFlowData, + fileUpload: { + type: 140, + data: { + color: "#EFEFEF", + fn: "roofPlan.existing", + title: "Roof plans", + }, + }, + fileUploadAndLabelInfoOnly: { + type: 145, + data: { + title: "Prepare these documents", + fileTypes: [ + { + name: "Design and access statement", + fn: "designAndAccessTypo", + rule: { + condition: "AlwaysRequired", + }, + }, + ], + hideDropZone: true, + }, + }, + }; + + queryMock.mockQuery({ + name: "GetFlowData", + matchOnVariables: false, + data: { + flow: { + data: alteredFlow, + slug: "altered-flow-name", + team_id: 1, + team: { + slug: "testing", + }, + publishedFlows: [{ data: alteredFlow }], + }, + }, + }); + + await supertest(app) + .post("/flows/1/diff") + .set(auth) + .expect(200) + .then((res) => { + expect(res.body.message).toEqual("Changes queued to publish"); + expect(res.body.validationChecks).toEqual([ + { + title: "Sections", + status: "Pass", + message: "Your flow has valid Sections", + }, + { + title: "File types", + status: "Pass", + message: + "Files collected via FileUpload or UploadAndLabel are all supported by the ODP Schema", + }, + { + title: "Invite to Pay", + status: "Not applicable", + message: "Your flow is not using Invite to Pay", + }, ]); }); }); diff --git a/api.planx.uk/package.json b/api.planx.uk/package.json index 09c750cf4c..3ae697df3f 100644 --- a/api.planx.uk/package.json +++ b/api.planx.uk/package.json @@ -5,7 +5,7 @@ "packageManager": "pnpm@8.6.6", "dependencies": { "@airbrake/node": "^2.1.8", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#60158b2", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#ed7c187", "@types/isomorphic-fetch": "^0.0.36", "adm-zip": "^0.5.10", "aws-sdk": "^2.1467.0", diff --git a/api.planx.uk/pnpm-lock.yaml b/api.planx.uk/pnpm-lock.yaml index a4251b7ad7..c28fa8d50e 100644 --- a/api.planx.uk/pnpm-lock.yaml +++ b/api.planx.uk/pnpm-lock.yaml @@ -14,8 +14,8 @@ dependencies: specifier: ^2.1.8 version: 2.1.8 '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#60158b2 - version: github.com/theopensystemslab/planx-core/60158b2 + specifier: git+https://github.com/theopensystemslab/planx-core#ed7c187 + version: github.com/theopensystemslab/planx-core/ed7c187 '@types/isomorphic-fetch': specifier: ^0.0.36 version: 0.0.36 @@ -5528,6 +5528,7 @@ packages: /js-yaml@3.14.1: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true dependencies: argparse: 1.0.10 esprima: 4.0.1 @@ -8202,8 +8203,8 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: false - github.com/theopensystemslab/planx-core/60158b2: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/60158b2} + github.com/theopensystemslab/planx-core/ed7c187: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/ed7c187} name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true diff --git a/e2e/tests/api-driven/package.json b/e2e/tests/api-driven/package.json index 3d7bb5de3d..f0542e3572 100644 --- a/e2e/tests/api-driven/package.json +++ b/e2e/tests/api-driven/package.json @@ -7,7 +7,7 @@ "packageManager": "pnpm@8.6.6", "dependencies": { "@cucumber/cucumber": "^9.3.0", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#60158b2", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#ed7c187", "axios": "^1.6.8", "dotenv": "^16.3.1", "dotenv-expand": "^10.0.0", diff --git a/e2e/tests/api-driven/pnpm-lock.yaml b/e2e/tests/api-driven/pnpm-lock.yaml index 5852716546..d193c924e6 100644 --- a/e2e/tests/api-driven/pnpm-lock.yaml +++ b/e2e/tests/api-driven/pnpm-lock.yaml @@ -9,8 +9,8 @@ dependencies: specifier: ^9.3.0 version: 9.3.0 '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#60158b2 - version: github.com/theopensystemslab/planx-core/60158b2 + specifier: git+https://github.com/theopensystemslab/planx-core#ed7c187 + version: github.com/theopensystemslab/planx-core/ed7c187 axios: specifier: ^1.6.8 version: 1.6.8 @@ -3053,8 +3053,8 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: false - github.com/theopensystemslab/planx-core/60158b2: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/60158b2} + github.com/theopensystemslab/planx-core/ed7c187: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/ed7c187} 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 920b91f9c2..8838e01c2e 100644 --- a/e2e/tests/ui-driven/package.json +++ b/e2e/tests/ui-driven/package.json @@ -8,7 +8,7 @@ "postinstall": "./install-dependencies.sh" }, "dependencies": { - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#60158b2", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#ed7c187", "axios": "^1.6.8", "dotenv": "^16.3.1", "eslint": "^8.56.0", diff --git a/e2e/tests/ui-driven/pnpm-lock.yaml b/e2e/tests/ui-driven/pnpm-lock.yaml index 6a74c3ec0b..f055a392b8 100644 --- a/e2e/tests/ui-driven/pnpm-lock.yaml +++ b/e2e/tests/ui-driven/pnpm-lock.yaml @@ -6,8 +6,8 @@ settings: dependencies: '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#60158b2 - version: github.com/theopensystemslab/planx-core/60158b2 + specifier: git+https://github.com/theopensystemslab/planx-core#ed7c187 + version: github.com/theopensystemslab/planx-core/ed7c187 axios: specifier: ^1.6.8 version: 1.6.8 @@ -2780,8 +2780,8 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: false - github.com/theopensystemslab/planx-core/60158b2: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/60158b2} + github.com/theopensystemslab/planx-core/ed7c187: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/ed7c187} name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true diff --git a/editor.planx.uk/package.json b/editor.planx.uk/package.json index bc7dc36125..3fe13db76c 100644 --- a/editor.planx.uk/package.json +++ b/editor.planx.uk/package.json @@ -13,7 +13,7 @@ "@mui/material": "^5.15.2", "@mui/utils": "^5.15.2", "@opensystemslab/map": "^0.8.3", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#60158b2", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#ed7c187", "@tiptap/core": "^2.4.0", "@tiptap/extension-bold": "^2.0.3", "@tiptap/extension-bubble-menu": "^2.1.13", diff --git a/editor.planx.uk/pnpm-lock.yaml b/editor.planx.uk/pnpm-lock.yaml index dcd836ee57..a9111c6554 100644 --- a/editor.planx.uk/pnpm-lock.yaml +++ b/editor.planx.uk/pnpm-lock.yaml @@ -42,8 +42,8 @@ dependencies: specifier: ^0.8.3 version: 0.8.3 '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#60158b2 - version: github.com/theopensystemslab/planx-core/60158b2(@types/react@18.2.45) + specifier: git+https://github.com/theopensystemslab/planx-core#ed7c187 + version: github.com/theopensystemslab/planx-core/ed7c187(@types/react@18.2.45) '@tiptap/core': specifier: ^2.4.0 version: 2.4.0(@tiptap/pm@2.0.3) @@ -13022,6 +13022,7 @@ packages: /glob@10.4.2: resolution: {integrity: sha512-GwMlUF6PkPo3Gk21UxkCohOv0PLcIXVtKyLlpEI28R/cO/4eNOdmLk3CMW1wROV/WR/EsZOWAfBbBOqYvs88/w==} engines: {node: '>=16 || 14 >=14.18'} + hasBin: true dependencies: foreground-child: 3.2.1 jackspeak: 3.4.0 @@ -14758,6 +14759,7 @@ packages: /js-yaml@3.14.1: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true dependencies: argparse: 1.0.10 esprima: 4.0.1 @@ -21521,9 +21523,9 @@ packages: use-sync-external-store: 1.2.0(react@18.2.0) dev: false - github.com/theopensystemslab/planx-core/60158b2(@types/react@18.2.45): - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/60158b2} - id: github.com/theopensystemslab/planx-core/60158b2 + github.com/theopensystemslab/planx-core/ed7c187(@types/react@18.2.45): + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/ed7c187} + id: github.com/theopensystemslab/planx-core/ed7c187 name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/PublishDialog.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/PublishDialog.tsx index cb33d3ba3c..446cfafedd 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/PublishDialog.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/PublishDialog.tsx @@ -18,6 +18,7 @@ import { useAsync } from "react-use"; import Caret from "ui/icons/Caret"; import { useStore } from "../../lib/store"; +import Warning from "@mui/icons-material/Warning"; export interface AlteredNode { id: string; @@ -231,7 +232,7 @@ export const AlteredNodesSummaryContent = (props: { export interface ValidationCheck { title: string; - status: "Pass" | "Fail" | "Not applicable"; + status: "Pass" | "Fail" | "Warn" | "Not applicable"; message: string; } @@ -243,6 +244,7 @@ export const ValidationChecks = (props: { const Icon: Record<ValidationCheck["status"], React.ReactElement> = { Pass: <Done color="success" />, Fail: <Close color="error" />, + Warn: <Warning color="warning" />, "Not applicable": <NotInterested color="disabled" />, };