diff --git a/api.planx.uk/modules/flows/validate/helpers.ts b/api.planx.uk/modules/flows/validate/helpers.ts new file mode 100644 index 0000000000..1f50262e81 --- /dev/null +++ b/api.planx.uk/modules/flows/validate/helpers.ts @@ -0,0 +1,51 @@ +import { + ComponentType, + FlowGraph, + Node, +} from "@opensystemslab/planx-core/types"; +import { Entry } from "type-fest"; + +export const isComponentType = ( + entry: Entry, + type: ComponentType, +): entry is [string, Node] => { + const [nodeId, node] = entry; + if (nodeId === "_root") return false; + return Boolean(node?.type === type); +}; + +export const hasComponentType = ( + flowGraph: FlowGraph, + type: ComponentType, + fn?: string, +): boolean => { + const nodeIds = Object.entries(flowGraph).filter( + (entry): entry is [string, Node] => isComponentType(entry, type), + ); + if (fn) { + nodeIds + ?.filter(([_nodeId, nodeData]) => nodeData?.data?.fn === fn) + ?.map(([nodeId, _nodeData]) => nodeId); + } else { + nodeIds?.map(([nodeId, _nodeData]) => nodeId); + } + return Boolean(nodeIds?.length); +}; + +export const numberOfComponentType = ( + flowGraph: FlowGraph, + type: ComponentType, + fn?: string, +): number => { + const nodeIds = Object.entries(flowGraph).filter( + (entry): entry is [string, Node] => isComponentType(entry, type), + ); + if (fn) { + nodeIds + ?.filter(([_nodeId, nodeData]) => nodeData?.data?.fn === fn) + ?.map(([nodeId, _nodeData]) => nodeId); + } else { + nodeIds?.map(([nodeId, _nodeData]) => nodeId); + } + return nodeIds?.length; +}; diff --git a/api.planx.uk/modules/flows/validate/service.ts b/api.planx.uk/modules/flows/validate/service.ts index 132725e59e..6b4dc388c4 100644 --- a/api.planx.uk/modules/flows/validate/service.ts +++ b/api.planx.uk/modules/flows/validate/service.ts @@ -1,91 +1,108 @@ -import * as jsondiffpatch from "jsondiffpatch"; -import { dataMerged, getMostRecentPublishedFlow } from "../../../helpers"; -import intersection from "lodash/intersection"; import { ComponentType, + Edges, FlowGraph, Node, } from "@opensystemslab/planx-core/types"; -import type { Entry } from "type-fest"; +import * as jsondiffpatch from "jsondiffpatch"; +import intersection from "lodash/intersection"; -const validateAndDiffFlow = async (flowId: string) => { - const flattenedFlow = await dataMerged(flowId); +import { dataMerged, getMostRecentPublishedFlow } from "../../../helpers"; +import { + hasComponentType, + isComponentType, + numberOfComponentType, +} from "./helpers"; + +type AlteredNode = { + id: string; + type?: ComponentType; + edges?: Edges; + data?: Node["data"]; +}; - const { - isValid: sectionsAreValid, - message: sectionsValidationMessage, - description: sectionsValidationDescription, - } = validateSections(flattenedFlow); - if (!sectionsAreValid) { - return { - alteredNodes: null, - message: sectionsValidationMessage, - description: sectionsValidationDescription, - }; - } +type ValidationResponse = { + title: string; + status: "Pass" | "Fail" | "Not applicable"; + message: string; +}; - const { - isValid: payIsValid, - message: payValidationMessage, - description: payValidationDescription, - } = validateInviteToPay(flattenedFlow); - if (!payIsValid) { - return { - alteredNodes: null, - message: payValidationMessage, - description: payValidationDescription, - }; - } +interface ValidateAndDiffResponse { + alteredNodes: AlteredNode[] | null; + message: string; + validationChecks?: ValidationResponse[]; +} +const validateAndDiffFlow = async ( + flowId: string, +): Promise => { + const flattenedFlow = await dataMerged(flowId); const mostRecent = await getMostRecentPublishedFlow(flowId); - const delta = jsondiffpatch.diff(mostRecent, flattenedFlow); + const delta = jsondiffpatch.diff(mostRecent, flattenedFlow); if (!delta) return { alteredNodes: null, message: "No new changes to publish", }; + // Only get alteredNodes and do validationChecks if there have been changes const alteredNodes = Object.keys(delta).map((key) => ({ id: key, ...flattenedFlow[key], })); + const validationChecks = []; + const sections = validateSections(flattenedFlow); + const inviteToPay = validateInviteToPay(flattenedFlow); + validationChecks.push(sections, inviteToPay); + + // 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)); + const notApplicableChecks = validationChecks.filter( + (v) => v.status === "Not applicable", + ); + const sortedValidationChecks = applicableChecks.concat(notApplicableChecks); + return { alteredNodes, - message: "Changes valid", + message: "Changes queued to publish", + validationChecks: sortedValidationChecks, }; }; -type ValidationResponse = { - isValid: boolean; - message: string; - description?: string; -}; - const validateSections = (flowGraph: FlowGraph): ValidationResponse => { if (getSectionNodeIds(flowGraph)?.length > 0) { if (!sectionIsInFirstPosition(flowGraph)) { return { - isValid: false, - message: "Cannot publish an invalid flow", - description: "When using Sections, your flow must start with a Section", + title: "Sections", + status: "Fail", + message: "When using Sections, your flow must start with a Section", }; } if (!allSectionsOnRoot(flowGraph)) { return { - isValid: false, - message: "Cannot publish an invalid flow", - description: + title: "Sections", + status: "Fail", + message: "Found Sections in one or more External Portals, but Sections are only allowed in main flow", }; } + + return { + title: "Sections", + status: "Pass", + message: "Your flow has valid Sections", + }; } return { - isValid: true, - message: "This flow has valid Sections or is not using Sections", + title: "Sections", + status: "Not applicable", + message: "Your flow is not using Sections", }; }; @@ -111,40 +128,38 @@ const allSectionsOnRoot = (flowData: FlowGraph): boolean => { }; const validateInviteToPay = (flowGraph: FlowGraph): ValidationResponse => { - const invalidResponseTemplate = { - isValid: false, - message: "Cannot publish an invalid flow", - }; - if (inviteToPayEnabled(flowGraph)) { if (numberOfComponentType(flowGraph, ComponentType.Pay) > 1) { return { - ...invalidResponseTemplate, - description: + title: "Invite to Pay", + status: "Fail", + message: "When using Invite to Pay, your flow must have exactly ONE Pay", }; } if (!hasComponentType(flowGraph, ComponentType.Send)) { return { - ...invalidResponseTemplate, - description: "When using Invite to Pay, your flow must have a Send", + title: "Invite to Pay", + status: "Fail", + message: "When using Invite to Pay, your flow must have a Send", }; } if (numberOfComponentType(flowGraph, ComponentType.Send) > 1) { return { - ...invalidResponseTemplate, - description: + title: "Invite to Pay", + status: "Fail", + message: "When using Invite to Pay, your flow must have exactly ONE Send. It can select many destinations", }; } if (!hasComponentType(flowGraph, ComponentType.FindProperty)) { return { - ...invalidResponseTemplate, - description: - "When using Invite to Pay, your flow must have a FindProperty", + title: "Invite to Pay", + status: "Fail", + message: "When using Invite to Pay, your flow must have a FindProperty", }; } @@ -156,17 +171,24 @@ const validateInviteToPay = (flowGraph: FlowGraph): ValidationResponse => { ) ) { return { - ...invalidResponseTemplate, - description: - "When using Invite to Pay, your flow must have a Checklist that sets the passport variable `proposal.projectType`", + title: "Invite to Pay", + status: "Fail", + message: + "When using Invite to Pay, your flow must have a Checklist that sets `proposal.projectType`", }; } + + return { + title: "Invite to Pay", + status: "Pass", + message: "Your flow has valid Invite to Pay", + }; } return { - isValid: true, - message: - "This flow is valid for Invite to Pay or is not using Invite to Pay", + title: "Invite to Pay", + status: "Not applicable", + message: "Your flow is not using Invite to Pay", }; }; @@ -184,49 +206,4 @@ const inviteToPayEnabled = (flowGraph: FlowGraph): boolean => { ); }; -const isComponentType = ( - entry: Entry, - type: ComponentType, -): entry is [string, Node] => { - const [nodeId, node] = entry; - if (nodeId === "_root") return false; - return Boolean(node?.type === type); -}; - -const hasComponentType = ( - flowGraph: FlowGraph, - type: ComponentType, - fn?: string, -): boolean => { - const nodeIds = Object.entries(flowGraph).filter( - (entry): entry is [string, Node] => isComponentType(entry, type), - ); - if (fn) { - nodeIds - ?.filter(([_nodeId, nodeData]) => nodeData?.data?.fn === fn) - ?.map(([nodeId, _nodeData]) => nodeId); - } else { - nodeIds?.map(([nodeId, _nodeData]) => nodeId); - } - return Boolean(nodeIds?.length); -}; - -const numberOfComponentType = ( - flowGraph: FlowGraph, - type: ComponentType, - fn?: string, -): number => { - const nodeIds = Object.entries(flowGraph).filter( - (entry): entry is [string, Node] => isComponentType(entry, type), - ); - if (fn) { - nodeIds - ?.filter(([_nodeId, nodeData]) => nodeData?.data?.fn === fn) - ?.map(([nodeId, _nodeData]) => nodeId); - } else { - nodeIds?.map(([nodeId, _nodeData]) => nodeId); - } - return nodeIds?.length; -}; - 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 e3be178f10..f17a73052d 100644 --- a/api.planx.uk/modules/flows/validate/validate.test.ts +++ b/api.planx.uk/modules/flows/validate/validate.test.ts @@ -107,12 +107,20 @@ describe("sections validation on diff", () => { .set(auth) .expect(200) .then((res) => { - expect(res.body).toEqual({ - alteredNodes: null, - message: "Cannot publish an invalid flow", - description: - "Found Sections in one or more External Portals, but Sections are only allowed in main flow", - }); + expect(res.body.message).toEqual("Changes queued to publish"); + expect(res.body.validationChecks).toEqual([ + { + title: "Sections", + status: "Fail", + message: + "Found Sections in one or more External Portals, but Sections are only allowed in main flow", + }, + { + title: "Invite to Pay", + status: "Not applicable", + message: "Your flow is not using Invite to Pay", + }, + ]); }); }); @@ -148,12 +156,19 @@ describe("sections validation on diff", () => { .set(auth) .expect(200) .then((res) => { - expect(res.body).toEqual({ - alteredNodes: null, - message: "Cannot publish an invalid flow", - description: - "When using Sections, your flow must start with a Section", - }); + expect(res.body.message).toEqual("Changes queued to publish"); + expect(res.body.validationChecks).toEqual([ + { + title: "Sections", + status: "Fail", + message: "When using Sections, your flow must start with a Section", + }, + { + title: "Invite to Pay", + status: "Not applicable", + message: "Your flow is not using Invite to Pay", + }, + ]); }); }); }); @@ -180,10 +195,19 @@ describe("invite to pay validation on diff", () => { .set(auth) .expect(200) .then((res) => { - expect(res.body.message).toEqual("Cannot publish an invalid flow"); - expect(res.body.description).toEqual( - "When using Invite to Pay, your flow must have a Send", - ); + expect(res.body.message).toEqual("Changes queued to publish"); + expect(res.body.validationChecks).toEqual([ + { + title: "Invite to Pay", + status: "Fail", + message: "When using Invite to Pay, your flow must have a Send", + }, + { + title: "Sections", + status: "Not applicable", + message: "Your flow is not using Sections", + }, + ]); }); }); @@ -219,10 +243,20 @@ describe("invite to pay validation on diff", () => { .set(auth) .expect(200) .then((res) => { - expect(res.body.message).toEqual("Cannot publish an invalid flow"); - expect(res.body.description).toEqual( - "When using Invite to Pay, your flow must have exactly ONE Send. It can select many destinations", - ); + expect(res.body.message).toEqual("Changes queued to publish"); + expect(res.body.validationChecks).toEqual([ + { + title: "Invite to Pay", + status: "Fail", + message: + "When using Invite to Pay, your flow must have exactly ONE Send. It can select many destinations", + }, + { + title: "Sections", + status: "Not applicable", + message: "Your flow is not using Sections", + }, + ]); }); }); @@ -254,10 +288,20 @@ describe("invite to pay validation on diff", () => { .set(auth) .expect(200) .then((res) => { - expect(res.body.message).toEqual("Cannot publish an invalid flow"); - expect(res.body.description).toEqual( - "When using Invite to Pay, your flow must have a FindProperty", - ); + expect(res.body.message).toEqual("Changes queued to publish"); + expect(res.body.validationChecks).toEqual([ + { + title: "Invite to Pay", + status: "Fail", + message: + "When using Invite to Pay, your flow must have a FindProperty", + }, + { + title: "Sections", + status: "Not applicable", + message: "Your flow is not using Sections", + }, + ]); }); }); @@ -291,10 +335,20 @@ describe("invite to pay validation on diff", () => { .set(auth) .expect(200) .then((res) => { - expect(res.body.message).toEqual("Cannot publish an invalid flow"); - expect(res.body.description).toEqual( - "When using Invite to Pay, your flow must have exactly ONE Pay", - ); + expect(res.body.message).toEqual("Changes queued to publish"); + expect(res.body.validationChecks).toEqual([ + { + title: "Invite to Pay", + status: "Fail", + message: + "When using Invite to Pay, your flow must have exactly ONE Pay", + }, + { + title: "Sections", + status: "Not applicable", + message: "Your flow is not using Sections", + }, + ]); }); }); @@ -330,10 +384,20 @@ describe("invite to pay validation on diff", () => { .set(auth) .expect(200) .then((res) => { - expect(res.body.message).toEqual("Cannot publish an invalid flow"); - expect(res.body.description).toEqual( - "When using Invite to Pay, your flow must have a Checklist that sets the passport variable `proposal.projectType`", - ); + expect(res.body.message).toEqual("Changes queued to publish"); + expect(res.body.validationChecks).toEqual([ + { + title: "Invite to Pay", + status: "Fail", + message: + "When using Invite to Pay, your flow must have a Checklist that sets `proposal.projectType`", + }, + { + title: "Sections", + status: "Not applicable", + message: "Your flow is not using Sections", + }, + ]); }); }); }); diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/PublishDialog.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/PublishDialog.tsx new file mode 100644 index 0000000000..da3cd924cb --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/PublishDialog.tsx @@ -0,0 +1,262 @@ +import Close from "@mui/icons-material/Close"; +import Done from "@mui/icons-material/Done"; +import NotInterested from "@mui/icons-material/NotInterested"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Collapse from "@mui/material/Collapse"; +import Divider from "@mui/material/Divider"; +import Link from "@mui/material/Link"; +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import Typography from "@mui/material/Typography"; +import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; +import React, { useState } from "react"; +import { useAsync } from "react-use"; +import Caret from "ui/icons/Caret"; + +import { formatLastPublishMessage } from "pages/FlowEditor/utils"; +import { useStore } from "../../lib/store"; + +export interface AlteredNode { + id: string; + type: TYPES; + data?: any; +} + +const AlteredNodeListItem = (props: { node: AlteredNode }) => { + const { node } = props; + let text, data; + + if (node.id === "_root") { + text = "Changed _root service by adding, deleting or re-ordering nodes"; + } else if (node.id === "0") { + text = `The entire _root service will be published for the first time`; + } else if (node.id && Object.keys(node).length === 1) { + text = `Deleted node ${node.id}`; + } else if (node.type && node.data) { + text = `Added/edited ${TYPES[node.type]}`; + data = JSON.stringify(node.data, null, 2); + } else { + text = `Added/edited ${TYPES[node.type]}`; + } + + return ( +
  • + {text} + {data &&
    {data}
    } +
  • + ); +}; + +interface Portal { + text: string; + flowId: string; + publishedFlowId: number; + summary: string; + publishedBy: number; + publishedAt: string; +} + +const AlteredNestedFlowListItem = (props: Portal) => { + const { text, flowId, publishedFlowId, summary, publishedAt } = props; + + const [nestedFlowLastPublishedTitle, setNestedFlowLastPublishedTitle] = + useState(); + const lastPublisher = useStore((state) => state.lastPublisher); + + const _nestedFlowLastPublishedRequest = useAsync(async () => { + const user = await lastPublisher(flowId); + setNestedFlowLastPublishedTitle(formatLastPublishMessage(publishedAt, user)); + }); + + return ( + + + {text} + + ) : ( + {text} + ) + } + secondary={ + <> + + {nestedFlowLastPublishedTitle} + + {summary && ( + + {summary} + + )} + + } + /> + + ); +}; + +interface AlteredNodesSummary { + title: string; + portals: Portal[]; + updated: number; + deleted: number; +} + +export const AlteredNodesSummaryContent = (props: { + alteredNodes: AlteredNode[]; + lastPublishedTitle: string; +}) => { + const { alteredNodes, lastPublishedTitle } = props; + const [expandNodes, setExpandNodes] = useState(false); + + const changeSummary: AlteredNodesSummary = { + title: lastPublishedTitle, + portals: [], + updated: 0, + deleted: 0, + }; + + alteredNodes.map((node) => { + if (node.id === "0") { + changeSummary["title"] = + "You are publishing the main service for the first time."; + } else if (node.id && Object.keys(node).length === 1) { + changeSummary["deleted"] += 1; + } else if (node.type === TYPES.InternalPortal) { + if (node.data?.text?.includes("/")) { + changeSummary["portals"].push({ ...node.data, flowId: node.id }); + } + } else if (node.type) { + changeSummary["updated"] += 1; + } + + return changeSummary; + }); + + return ( + + + {`Changes`} + + {changeSummary["title"] && ( + + {changeSummary["title"]} + + )} + {(changeSummary["updated"] > 0 || changeSummary["deleted"] > 0) && ( + + + + {`${changeSummary["updated"]} nodes have been updated or added`} + + + {`${changeSummary["deleted"]} nodes have been deleted`} + + + + + {`See detailed changelog `} + + + + +
      + {alteredNodes.map((node) => ( + + ))} +
    +
    +
    +
    +
    + )} + + {changeSummary["portals"].length > 0 && ( + <> + + {`This includes recently published changes in the following nested services:`} + + {changeSummary["portals"].map((portal) => ( + + ))} + + + + + )} +
    + ); +}; + +export interface ValidationCheck { + title: string; + status: "Pass" | "Fail" | "Not applicable"; + message: string; +} + +export const ValidationChecks = (props: { + validationChecks: ValidationCheck[] +}) => { + const { validationChecks } = props; + + const Icon: Record = { + "Pass": , + "Fail": , + "Not applicable": + }; + + return ( + + + Validation checks + + + {validationChecks.map((check, i) => ( + + theme.spacing(3) }}> + {Icon[check.status]} + + {check.title}} + secondary={{check.message}} + /> + + ))} + + + + ); +} diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/index.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/index.tsx index 978dbed22d..346ce7146e 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/index.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/index.tsx @@ -7,33 +7,27 @@ import SignalCellularAltIcon from "@mui/icons-material/SignalCellularAlt"; import Badge from "@mui/material/Badge"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; -import Collapse from "@mui/material/Collapse"; import Container from "@mui/material/Container"; import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; -import Divider from "@mui/material/Divider"; import Link from "@mui/material/Link"; -import List from "@mui/material/List"; -import ListItem from "@mui/material/ListItem"; -import ListItemText from "@mui/material/ListItemText"; -import { styled } from "@mui/material/styles"; import Tab, { tabClasses } from "@mui/material/Tab"; import Tabs from "@mui/material/Tabs"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; -import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; +import { styled } from "@mui/material/styles"; import { AxiosError } from "axios"; import React, { useState } from "react"; import { useAsync } from "react-use"; -import Caret from "ui/icons/Caret"; import Input from "ui/shared/Input"; import { formatLastPublishMessage } from "pages/FlowEditor/utils"; import Questions from "../../../Preview/Questions"; import { useStore } from "../../lib/store"; import EditHistory from "./EditHistory"; +import { AlteredNode, AlteredNodesSummaryContent, ValidationCheck, ValidationChecks } from "./PublishDialog"; type SidebarTabs = "PreviewBrowser" | "History"; @@ -151,211 +145,6 @@ const DebugConsole = () => { ); }; -interface AlteredNode { - id: string; - type: TYPES; - data?: any; -} - -const AlteredNodeListItem = (props: { node: AlteredNode }) => { - const { node } = props; - let text, data; - - if (node.id === "_root") { - text = "Changed _root service by adding, deleting or re-ordering nodes"; - } else if (node.id === "0") { - text = `The entire _root service will be published for the first time`; - } else if (node.id && Object.keys(node).length === 1) { - text = `Deleted node ${node.id}`; - } else if (node.type && node.data) { - text = `Added/edited ${TYPES[node.type]}`; - data = JSON.stringify(node.data, null, 2); - } else { - text = `Added/edited ${TYPES[node.type]}`; - } - - return ( -
  • - {text} - {data &&
    {data}
    } -
  • - ); -}; - -interface Portal { - text: string; - flowId: string; - publishedFlowId: number; - summary: string; - publishedBy: number; - publishedAt: string; -} - -const AlteredNestedFlowListItem = (props: Portal) => { - const { text, flowId, publishedFlowId, summary, publishedAt } = props; - - const [nestedFlowLastPublishedTitle, setNestedFlowLastPublishedTitle] = - useState(); - const lastPublisher = useStore((state) => state.lastPublisher); - - const _nestedFlowLastPublishedRequest = useAsync(async () => { - const user = await lastPublisher(flowId); - setNestedFlowLastPublishedTitle(formatLastPublishMessage(publishedAt, user)); - }); - - return ( - - - {text} - - ) : ( - {text} - ) - } - secondary={ - <> - - {nestedFlowLastPublishedTitle} - - {summary && ( - - {summary} - - )} - - } - /> - - ); -}; - -interface AlteredNodesSummary { - title: string; - portals: Portal[]; - updated: number; - deleted: number; -} - -const AlteredNodesSummaryContent = (props: { - alteredNodes: AlteredNode[]; - url: string; -}) => { - const { alteredNodes, url } = props; - const [expandNodes, setExpandNodes] = useState(false); - - const changeSummary: AlteredNodesSummary = { - title: "", - portals: [], - updated: 0, - deleted: 0, - }; - - alteredNodes.map((node) => { - if (node.id === "0") { - changeSummary["title"] = - "You are publishing the main service for the first time."; - } else if (node.id && Object.keys(node).length === 1) { - changeSummary["deleted"] += 1; - } else if (node.type === TYPES.InternalPortal) { - if (node.data?.text?.includes("/")) { - changeSummary["portals"].push({ ...node.data, flowId: node.id }); - } - } else if (node.type) { - changeSummary["updated"] += 1; - } - - return changeSummary; - }); - - return ( - - {changeSummary["title"] && ( - - {changeSummary["title"]} - - )} - {(changeSummary["updated"] > 0 || changeSummary["deleted"] > 0) && ( - -
      -
    • - {`${changeSummary["updated"]} nodes have been updated or added`} -
    • -
    • - {`${changeSummary["deleted"]} nodes have been deleted`} -
    • -
    - - - {`See detailed changelog `} - - - - -
      - {alteredNodes.map((node) => ( - - ))} -
    -
    -
    -
    -
    - )} - - {changeSummary["portals"].length > 0 && ( - - {`This includes recently published changes in the following nested services:`} - - {changeSummary["portals"].map((portal) => ( - - ))} - - - )} - - - - {`Preview these content changes in-service before publishing `} - - {`here (opens in a new tab).`} - - - -
    - ); -}; - const Sidebar: React.FC<{ url: string; }> = React.memo((props) => { @@ -385,7 +174,7 @@ const Sidebar: React.FC<{ const [lastPublishedTitle, setLastPublishedTitle] = useState( "This flow is not published yet", ); - const [validationMessage, setValidationMessage] = useState(); + const [validationChecks, setValidationChecks] = useState([]); const [alteredNodes, setAlteredNodes] = useState(); const [dialogOpen, setDialogOpen] = useState(false); const [summary, setSummary] = useState(); @@ -395,6 +184,56 @@ const Sidebar: React.FC<{ setActiveTab(newValue); }; + const handleCheckForChangesToPublish = async () => { + try { + setLastPublishedTitle("Checking for changes..."); + const alteredFlow = await validateAndDiffFlow(flowId); + setAlteredNodes( + alteredFlow?.data.alteredNodes + ? alteredFlow.data.alteredNodes + : [], + ); + setLastPublishedTitle( + alteredFlow?.data.alteredNodes + ? `Found changes to ${alteredFlow.data.alteredNodes.length} nodes` + : alteredFlow?.data.message, + ); + setValidationChecks(alteredFlow?.data?.validationChecks); + setDialogOpen(true); + } catch (error) { + setLastPublishedTitle( + "Error checking for changes to publish", + ); + + if (error instanceof AxiosError) { + alert(error.response?.data?.error); + } else { + alert( + `Error checking for changes to publish. Confirm that your graph does not have any corrupted nodes and that all external portals are valid. \n${error}`, + ); + } + } + }; + + const handlePublish = async () => { + try { + setDialogOpen(false); + setLastPublishedTitle("Publishing changes..."); + const { alteredNodes, message } = await publishFlow( + flowId, + summary, + ); + setLastPublishedTitle( + alteredNodes + ? `Successfully published changes to ${alteredNodes.length} nodes` + : `${message}` || "No new changes to publish", + ); + } catch (error) { + setLastPublishedTitle("Error trying to publish"); + alert(error); + } + }; + const _lastPublishedRequest = useAsync(async () => { const date = await lastPublished(flowId); const user = await lastPublisher(flowId); @@ -521,36 +360,7 @@ const Sidebar: React.FC<{ variant="contained" color="primary" disabled={!useStore.getState().canUserEditTeam(teamSlug)} - onClick={async () => { - try { - setLastPublishedTitle("Checking for changes..."); - const alteredFlow = await validateAndDiffFlow(flowId); - setAlteredNodes( - alteredFlow?.data.alteredNodes - ? alteredFlow.data.alteredNodes - : [], - ); - setLastPublishedTitle( - alteredFlow?.data.alteredNodes - ? `Found changes to ${alteredFlow.data.alteredNodes.length} node(s)` - : alteredFlow?.data.message, - ); - setValidationMessage(alteredFlow?.data.description); - setDialogOpen(true); - } catch (error) { - setLastPublishedTitle( - "Error checking for changes to publish", - ); - - if (error instanceof AxiosError) { - alert(error.response?.data?.error); - } else { - alert( - `Error checking for changes to publish. Confirm that your graph does not have any corrupted nodes and that all external portals are valid. \n${error}`, - ); - } - } - }} + onClick={handleCheckForChangesToPublish} > CHECK FOR CHANGES TO PUBLISH @@ -560,17 +370,26 @@ const Sidebar: React.FC<{ onClose={() => setDialogOpen(false)} aria-labelledby="alert-dialog-title" aria-describedby="alert-dialog-description" + maxWidth="md" > - - {lastPublishedTitle} + + {`Check for changes to publish`} {alteredNodes?.length ? ( <> + + + + {`Preview these content changes in-service before publishing `} + + {`here (opens in a new tab).`} + + + setSummary(e.target.value)} /> - ) : validationMessage ? ( - validationMessage ) : ( - lastPublishedTitle + + {`No new changes to publish`} + )} - + diff --git a/editor.planx.uk/src/pages/FlowEditor/utils.ts b/editor.planx.uk/src/pages/FlowEditor/utils.ts index 19873545a2..ed3c5d0688 100644 --- a/editor.planx.uk/src/pages/FlowEditor/utils.ts +++ b/editor.planx.uk/src/pages/FlowEditor/utils.ts @@ -20,4 +20,4 @@ export const formatLastEditMessage = ( }; export const formatLastPublishMessage = (date: string, user: string): string => - `Last published ${formatLastEditDate(date)} ago by ${user}`; + `Last published ${formatLastEditDate(date)} by ${user}`;