diff --git a/e2e/tests/ui-driven/src/create-flow/create-flow.spec.ts b/e2e/tests/ui-driven/src/create-flow/create-flow.spec.ts index bb8b3aba97..cead9db90d 100644 --- a/e2e/tests/ui-driven/src/create-flow/create-flow.spec.ts +++ b/e2e/tests/ui-driven/src/create-flow/create-flow.spec.ts @@ -145,7 +145,45 @@ test.describe("Navigation", () => { await expect(nodes.getByText(noBranchNoticeText)).toBeVisible(); }); - test("Preview a created flow", async ({ browser }: { browser: Browser }) => { + test("Cannot preview an unpublished flow", async ({ + browser, + }: { + browser: Browser; + }) => { + const page = await createAuthenticatedSession({ + browser, + userId: context.user!.id!, + }); + + await page.goto( + `/${context.team.slug}/${serviceProps.slug}/preview?analytics=false`, + ); + + await expect(page.getByText("Not Found")).toBeVisible(); + }); + + test("Publish a flow", async ({ browser }) => { + const page = await createAuthenticatedSession({ + browser, + userId: context.user!.id!, + }); + + await page.goto(`/${context.team.slug}/${serviceProps.slug}`); + + page.getByRole("button", { name: "CHECK FOR CHANGES TO PUBLISH" }).click(); + page.getByRole("button", { name: "PUBLISH", exact: true }).click(); + + const previewLink = page.getByRole("link", { + name: "Open published service", + }); + await expect(previewLink).toBeVisible(); + }); + + test("Can preview a published flow", async ({ + browser, + }: { + browser: Browser; + }) => { const page = await createAuthenticatedSession({ browser, userId: context.user!.id!, diff --git a/editor.planx.uk/src/lib/dataMergedHotfix.ts b/editor.planx.uk/src/lib/dataMergedHotfix.ts index ad5c60e1a8..7d553138d5 100644 --- a/editor.planx.uk/src/lib/dataMergedHotfix.ts +++ b/editor.planx.uk/src/lib/dataMergedHotfix.ts @@ -1,5 +1,6 @@ import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; import gql from "graphql-tag"; +import { Store } from "pages/FlowEditor/lib/store"; import { publicClient } from "../lib/graphql"; @@ -23,10 +24,14 @@ const getFlowData = async (id: string) => { // Flatten a flow's data to include main content & portals in a single JSON representation // XXX: getFlowData & dataMerged are currently repeated in api.planx.uk/helpers.ts // in order to load frontend /preview routes for flows that are not published -export const dataMerged = async (id: string, ob: Record = {}) => { +export const dataMerged = async ( + id: string, + ob: Store.flow = {}, +): Promise => { // get the primary flow data - const { slug, data }: { slug: string; data: Record } = - await getFlowData(id); + const { slug, data }: { slug: string; data: Store.flow } = await getFlowData( + id, + ); // recursively get and flatten internal portals & external portals for (const [nodeId, node] of Object.entries(data)) { diff --git a/editor.planx.uk/src/pages/FlowEditor/components/PreviewBrowser.tsx b/editor.planx.uk/src/pages/FlowEditor/components/PreviewBrowser.tsx index 94d27731a1..2a3be0e531 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/PreviewBrowser.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/PreviewBrowser.tsx @@ -103,6 +103,7 @@ const PreviewBrowser: React.FC<{ lastPublished, lastPublisher, validateAndDiffFlow, + isFlowPublished, ] = useStore((state) => [ state.id, state.flowAnalyticsLink, @@ -111,6 +112,7 @@ const PreviewBrowser: React.FC<{ state.lastPublished, state.lastPublisher, state.validateAndDiffFlow, + state.isFlowPublished, ]); const [key, setKey] = useState(false); const [lastPublishedTitle, setLastPublishedTitle] = useState( @@ -189,17 +191,26 @@ const PreviewBrowser: React.FC<{ - - - - - - + {isFlowPublished ? ( + + + + + + ) : ( + + + + + + + + )} @@ -283,12 +294,14 @@ const PreviewBrowser: React.FC<{ try { setDialogOpen(false); setLastPublishedTitle("Publishing changes..."); - const publishedFlow = await publishFlow(flowId, summary); + const { alteredNodes, message } = await publishFlow( + flowId, + summary, + ); setLastPublishedTitle( - publishedFlow?.data.alteredNodes - ? `Successfully published changes to ${publishedFlow.data.alteredNodes.length} node(s)` - : `${publishedFlow?.data?.message}` || - "No new changes to publish", + alteredNodes + ? `Successfully published changes to ${alteredNodes.length} node(s)` + : `${message}` || "No new changes to publish", ); } catch (error) { setLastPublishedTitle("Error trying to publish"); diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts b/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts index 5e866dea52..f850c66d25 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts @@ -54,6 +54,11 @@ export const editorUIStore: StateCreator< }, }); +interface PublishFlowResponse { + alteredNodes: Store.node[]; + message: string; +} + export interface EditorStore extends Store.Store { addNode: (node: any, relationships?: any) => void; connect: (src: Store.nodeId, tgt: Store.nodeId, object?: any) => void; @@ -67,6 +72,7 @@ export interface EditorStore extends Store.Store { isClone: (id: Store.nodeId) => boolean; lastPublished: (flowId: string) => Promise; lastPublisher: (flowId: string) => Promise; + isFlowPublished: boolean; makeUnique: (id: Store.nodeId, parent?: Store.nodeId) => void; moveFlow: (flowId: string, teamSlug: string) => Promise; moveNode: ( @@ -76,7 +82,10 @@ export interface EditorStore extends Store.Store { toParent?: Store.nodeId, ) => void; pasteNode: (toParent: Store.nodeId, toBefore: Store.nodeId) => void; - publishFlow: (flowId: string, summary?: string) => Promise; + publishFlow: ( + flowId: string, + summary?: string, + ) => Promise; removeNode: (id: Store.nodeId, parent: Store.nodeId) => void; updateNode: (node: any, relationships?: any) => void; } @@ -325,6 +334,8 @@ export const editorStore: StateCreator< return first_name.concat(" ", last_name); }, + isFlowPublished: false, + makeUnique: (id, parent) => { const [, ops] = makeUnique(id, parent)(get().flow); send(ops); @@ -388,7 +399,7 @@ export const editorStore: StateCreator< } }, - publishFlow(flowId: string, summary?: string) { + async publishFlow(flowId: string, summary?: string) { const token = get().jwt; const urlWithParams = (url: string, params: any) => @@ -396,7 +407,7 @@ export const editorStore: StateCreator< .filter(Boolean) .join("?"); - return axios.post( + const { data } = await axios.post( urlWithParams( `${process.env.REACT_APP_API_URL}/flows/${flowId}/publish`, { summary }, @@ -408,6 +419,10 @@ export const editorStore: StateCreator< }, }, ); + + set({ isFlowPublished: true }); + + return data; }, removeNode: (id, parent) => { diff --git a/editor.planx.uk/src/routes/flow.tsx b/editor.planx.uk/src/routes/flow.tsx index e156e37749..3ea3d56982 100644 --- a/editor.planx.uk/src/routes/flow.tsx +++ b/editor.planx.uk/src/routes/flow.tsx @@ -9,6 +9,7 @@ import { map, Matcher, mount, + NotFoundError, redirect, route, withData, @@ -173,11 +174,31 @@ const nodeRoutes = mount({ "/:parent/nodes/:id/edit": editNode, }); +interface FlowMetadata { + flowSettings: FlowSettings; + flowAnalyticsLink: string; + isFlowPublished: boolean; +} + +interface GetFlowMetadata { + flows: { + flowSettings: FlowSettings; + flowAnalyticsLink: string; + publishedFlowsAggregate: { + aggregate: { + count: number; + }; + }; + }[]; +} + const getFlowMetadata = async ( - flow: string, + flowSlug: string, team: string, -): Promise<{ flowSettings: FlowSettings; flowAnalyticsLink: string }> => { - const { data } = await client.query({ +): Promise => { + const { + data: { flows }, + } = await client.query({ query: gql` query GetFlow($slug: String!, $team_slug: String!) { flows( @@ -187,17 +208,27 @@ const getFlowMetadata = async ( id flowSettings: settings flowAnalyticsLink: analytics_link + publishedFlowsAggregate: published_flows_aggregate { + aggregate { + count + } + } } } `, variables: { - slug: flow, + slug: flowSlug, team_slug: team, }, }); + + const flow = flows[0]; + if (!flows) throw new NotFoundError(`Flow ${flowSlug} not found for ${team}`); + const metadata = { - flowSettings: data.flows[0]?.flowSettings, - flowAnalyticsLink: data.flows[0]?.flowAnalyticsLink, + flowSettings: flow.flowSettings, + flowAnalyticsLink: flow.flowAnalyticsLink, + isFlowPublished: flow.publishedFlowsAggregate?.aggregate.count > 0, }; return metadata; }; @@ -209,11 +240,9 @@ const routes = compose( withView(async (req) => { const [flow, ...breadcrumbs] = req.params.flow.split(","); - const { flowSettings, flowAnalyticsLink } = await getFlowMetadata( - flow, - req.params.team, - ); - useStore.setState({ flowSettings, flowAnalyticsLink }); + const { flowSettings, flowAnalyticsLink, isFlowPublished } = + await getFlowMetadata(flow, req.params.team); + useStore.setState({ flowSettings, flowAnalyticsLink, isFlowPublished }); return ( <> diff --git a/editor.planx.uk/src/routes/utils.ts b/editor.planx.uk/src/routes/utils.ts index 9f8afdc0b4..934eb93e2f 100644 --- a/editor.planx.uk/src/routes/utils.ts +++ b/editor.planx.uk/src/routes/utils.ts @@ -3,6 +3,7 @@ import gql from "graphql-tag"; import { hasFeatureFlag } from "lib/featureFlags"; import { NaviRequest, NotFoundError } from "navi"; import { useStore } from "pages/FlowEditor/lib/store"; +import { Store } from "pages/FlowEditor/lib/store"; import { ApplicationPath } from "types"; import { publicClient } from "../lib/graphql"; @@ -18,14 +19,10 @@ export const rootFlowPath = (includePortals = false) => { export const rootTeamPath = () => window.location.pathname.split("/").slice(0, 2).join("/"); -export const isSaveReturnFlow = (flowData: Record): boolean => - Boolean( - Object.values(flowData).find( - (node: Record) => node.type === NodeTypes.Send, - ), - ); +export const isSaveReturnFlow = (flowData: Store.flow): boolean => + Boolean(Object.values(flowData).find((node) => node.type === NodeTypes.Send)); -export const setPath = (flowData: Record, req: NaviRequest) => { +export const setPath = (flowData: Store.flow, req: NaviRequest) => { // XXX: store.path is SingleSession by default if (!isSaveReturnFlow(flowData)) return; if (hasFeatureFlag("DISABLE_SAVE_AND_RETURN")) return; diff --git a/editor.planx.uk/src/routes/views/published.tsx b/editor.planx.uk/src/routes/views/published.tsx index 982d3ccadc..f833e5e595 100644 --- a/editor.planx.uk/src/routes/views/published.tsx +++ b/editor.planx.uk/src/routes/views/published.tsx @@ -1,9 +1,9 @@ import gql from "graphql-tag"; -import { dataMerged } from "lib/dataMergedHotfix"; import { publicClient } from "lib/graphql"; import { NaviRequest } from "navi"; import { NotFoundError } from "navi"; import { useStore } from "pages/FlowEditor/lib/store"; +import { Store } from "pages/FlowEditor/lib/store"; import PublicLayout from "pages/layout/PublicLayout"; import SaveAndReturnLayout from "pages/layout/SaveAndReturnLayout"; import React from "react"; @@ -17,7 +17,7 @@ interface PublishedViewData { } interface PreviewFlow extends Flow { - publishedFlows: Record<"data", Flow>[]; + publishedFlows: Record<"data", Store.flow>[]; } /** @@ -31,16 +31,19 @@ export const publishedView = async (req: NaviRequest) => { const data = await fetchDataForPublishedView(flowSlug, teamSlug); const flow = data.flows[0]; - if (!flow) throw new NotFoundError(req.originalUrl); + if (!flow) + throw new NotFoundError(`Flow ${flowSlug} not found for ${teamSlug}`); const publishedFlow = flow.publishedFlows[0]?.data; - const flowData = publishedFlow ? publishedFlow : await dataMerged(flow.id); - setPath(flowData, req); + if (!publishedFlow) + throw new NotFoundError(`Flow ${flowSlug} not published for ${teamSlug}`); + + setPath(publishedFlow, req); const state = useStore.getState(); // XXX: necessary as long as not every flow is published; aim to remove dataMergedHotfix.ts in future // load pre-flattened published flow if exists, else load & flatten flow - state.setFlow({ id: flow.id, flow: flowData, flowSlug }); + state.setFlow({ id: flow.id, flow: publishedFlow, flowSlug }); state.setGlobalSettings(data.globalSettings[0]); state.setFlowSettings(flow.settings); state.setTeam(flow.team); diff --git a/hasura.planx.uk/metadata/tables.yaml b/hasura.planx.uk/metadata/tables.yaml index 54dfbdf8ea..574b108f7b 100644 --- a/hasura.planx.uk/metadata/tables.yaml +++ b/hasura.planx.uk/metadata/tables.yaml @@ -425,6 +425,7 @@ computed_fields: - data_merged filter: {} + allow_aggregations: true - role: platformAdmin permission: columns: @@ -441,6 +442,7 @@ computed_fields: - data_merged filter: {} + allow_aggregations: true - role: public permission: columns: @@ -456,6 +458,7 @@ computed_fields: - data_merged filter: {} + allow_aggregations: true - role: teamEditor permission: columns: @@ -472,6 +475,7 @@ computed_fields: - data_merged filter: {} + allow_aggregations: true update_permissions: - role: api permission: @@ -1230,6 +1234,7 @@ - flow_id - data filter: {} + allow_aggregations: true - role: platformAdmin permission: columns: @@ -1240,6 +1245,7 @@ - publisher_id - summary filter: {} + allow_aggregations: true - role: public permission: columns: @@ -1250,6 +1256,7 @@ - publisher_id - summary filter: {} + allow_aggregations: true - role: teamEditor permission: columns: @@ -1260,6 +1267,7 @@ - publisher_id - summary filter: {} + allow_aggregations: true - table: name: reconciliation_requests schema: public