diff --git a/api.planx.uk/gis/helpers.js b/api.planx.uk/gis/helpers.js index 189576d848..cd1bbfeea0 100644 --- a/api.planx.uk/gis/helpers.js +++ b/api.planx.uk/gis/helpers.js @@ -31,7 +31,6 @@ const makeEsriUrl = (domain, id, serverIndex = 0, overrideParams = {}) => { .map((key) => key + "=" + escape(params[key])) .join("&"), ].join("?"); - console.log({ url }); return url; }; diff --git a/api.planx.uk/gis/local_authorities/southwark.js b/api.planx.uk/gis/local_authorities/southwark.js index f83c13c7af..5698c99974 100644 --- a/api.planx.uk/gis/local_authorities/southwark.js +++ b/api.planx.uk/gis/local_authorities/southwark.js @@ -127,8 +127,6 @@ async function locationSearch(x, y, extras) { : false, }; - console.log(responses); - responses .filter(([_key, result]) => result instanceof Error) .forEach(([key, _result]) => { diff --git a/api.planx.uk/jest.setup.js b/api.planx.uk/jest.setup.js index 6053c1b845..fa1ca699f4 100644 --- a/api.planx.uk/jest.setup.js +++ b/api.planx.uk/jest.setup.js @@ -1,28 +1,7 @@ -const { QueryMock } = require("graphql-query-test-mock"); - require("dotenv").config({ path: "./.env.test" }); -const queryMock = new QueryMock(); +const { queryMock } = require("./tests/graphqlQueryMock"); beforeEach(() => { queryMock.setup(process.env.HASURA_GRAPHQL_URL); - - queryMock.mockQuery({ - name: "GetTeams", - data: { - teams: [{ id: 1 }], - }, - }); - - queryMock.mockQuery({ - name: "CreateApplication", - data: { - insert_bops_applications_one: { id: 22 }, - }, - matchOnVariables: false, - variables: { - destination_url: - "https://southwark.bops.services/api/v1/planning_applications", - }, - }); }); diff --git a/api.planx.uk/package.json b/api.planx.uk/package.json index ca5d6dc451..16aa89a5e9 100644 --- a/api.planx.uk/package.json +++ b/api.planx.uk/package.json @@ -21,6 +21,7 @@ "http-proxy-middleware": "^1.3.1", "https": "^1.0.0", "isomorphic-fetch": "^2.2.1", + "jsondiffpatch": "^0.4.1", "jsonwebtoken": "^8.5.1", "mime": "^2.4.6", "nanoid": "^3.1.12", diff --git a/api.planx.uk/publish.js b/api.planx.uk/publish.js index e1f9d1b5d6..3929aeed48 100644 --- a/api.planx.uk/publish.js +++ b/api.planx.uk/publish.js @@ -1,4 +1,5 @@ const { GraphQLClient } = require("graphql-request"); +const jsondiffpatch = require("jsondiffpatch"); const client = new GraphQLClient(process.env.HASURA_GRAPHQL_URL, { headers: { @@ -21,6 +22,26 @@ const getFlowData = async (id) => { return data.flows_by_pk.data; }; +const getMostRecentPublishedFlow = async (id) => { + const data = await client.request( + ` + query GetMostRecentPublishedFlow($id: uuid!) { + flows_by_pk(id: $id) { + published_flows(limit: 1, order_by: { id: desc }) { + data + } + } + } + `, + { id } + ); + + return ( + data.flows_by_pk.published_flows[0] && + data.flows_by_pk.published_flows[0].data + ); +}; + // XXX: getFlowData & dataMerged are currently repeated in ../editor.planx.uk/src/lib/dataMergedHotfix.ts // in order to load previews for flows that have not been published yet const dataMerged = async (id, ob = {}) => { @@ -50,38 +71,58 @@ const dataMerged = async (id, ob = {}) => { const publishFlow = async (req, res) => { if (!req.user?.sub) return res.status(401).json({ error: "User ID missing from JWT" }); - - const flattenedFlow = await dataMerged(req.params.flowId); - const publishedFlow = await client.request( - ` - mutation PublishFlow( - $data: jsonb = {}, - $flow_id: uuid, - $publisher_id: Int, - ) { - insert_published_flows_one(object: { - data: $data, - flow_id: $flow_id, - publisher_id: $publisher_id, - }) { - id - flow_id - publisher_id - created_at - data + try { + const flattenedFlow = await dataMerged(req.params.flowId); + const mostRecent = await getMostRecentPublishedFlow(req.params.flowId); + + const delta = jsondiffpatch.diff(mostRecent, flattenedFlow); + + if (delta) { + const response = await client.request( + ` + mutation PublishFlow( + $data: jsonb = {}, + $flow_id: uuid, + $publisher_id: Int, + ) { + insert_published_flows_one(object: { + data: $data, + flow_id: $flow_id, + publisher_id: $publisher_id, + }) { + id + flow_id + publisher_id + created_at + data + } + } + `, + { + data: flattenedFlow, + flow_id: req.params.flowId, + publisher_id: parseInt(req.user.sub, 10), } - }`, - { - data: flattenedFlow, - flow_id: req.params.flowId, - publisher_id: parseInt(req.user.sub, 10), - } - ); + ); + const publishedFlow = + response.insert_published_flows_one && + response.insert_published_flows_one.data; - try { - // return published flow record - res.json(publishedFlow.insert_published_flows_one); + const alteredNodes = Object.keys(delta).map((key) => ({ + id: key, + ...publishedFlow[key], + })); + + res.json({ + alteredNodes, + }); + } else { + res.json({ + alteredNodes: null, + message: "No new changes", + }); + } } catch (error) { console.error(error); res.status(500).json({ error }); diff --git a/api.planx.uk/publish.test.js b/api.planx.uk/publish.test.js new file mode 100644 index 0000000000..1760ea5e4d --- /dev/null +++ b/api.planx.uk/publish.test.js @@ -0,0 +1,217 @@ +const supertest = require("supertest"); + +const { queryMock } = require("./tests/graphqlQueryMock"); +const { authHeader } = require("./tests/mockJWT"); +const app = require("./server"); + +beforeEach(() => { + queryMock.mockQuery({ + name: "GetFlowData", + matchOnVariables: false, + data: { + flows_by_pk: { + data: mockFlowData, + }, + }, + }); + + queryMock.mockQuery({ + name: "GetMostRecentPublishedFlow", + matchOnVariables: false, + data: { + flows_by_pk: { + published_flows: [ + { + data: mockFlowData, + }, + ], + }, + }, + }); + + queryMock.mockQuery({ + name: "PublishFlow", + matchOnVariables: false, + data: { + insert_published_flows_one: { + data: mockFlowData, + }, + }, + }); +}); + +it("does not update if there are no new changes", async () => { + await supertest(app) + .post("/flows/1/publish") + .set(authHeader()) + .expect(200) + .then((res) => { + expect(res.body).toEqual({ + alteredNodes: null, + message: "No new changes", + }); + }); +}); + +it("updates published flow and returns altered nodes if there have been changes", async () => { + const alteredFlow = { + ...mockFlowData, + "4CJgXe8Ttl": { + data: { + flagSet: "Planning permission", + overrides: { + NO_APP_REQUIRED: { + heading: "Some Other Heading", + }, + }, + }, + type: 3, + }, + }; + + queryMock.mockQuery({ + name: "GetFlowData", + matchOnVariables: false, + data: { + flows_by_pk: { + data: alteredFlow, + }, + }, + }); + + queryMock.mockQuery({ + name: "PublishFlow", + matchOnVariables: false, + data: { + insert_published_flows_one: { + data: alteredFlow, + }, + }, + }); + + await supertest(app) + .post("/flows/1/publish") + .set(authHeader()) + .expect(200) + .then((res) => { + expect(res.body).toEqual({ + alteredNodes: [ + { + id: "4CJgXe8Ttl", + type: 3, + data: { + flagSet: "Planning permission", + overrides: { + NO_APP_REQUIRED: { + heading: "Some Other Heading", + }, + }, + }, + }, + ], + }); + }); +}); + +const mockFlowData = { + _root: { + edges: [ + "RYYckLE2cH", + "R99ncwKifm", + "3qssvGXmMO", + "SEp0QeNsTS", + "q8Foul9hRN", + "4CJgXe8Ttl", + "dnVqd6zt4N", + ], + }, + "3qssvGXmMO": { + type: 9, + }, + "4CJgXe8Ttl": { + data: { + flagSet: "Planning permission", + overrides: { + NO_APP_REQUIRED: { + heading: "Congratulations!", + }, + }, + }, + type: 3, + }, + "5sWfsvXphd": { + data: { + text: "?", + }, + type: 200, + }, + BV2VJhOC0I: { + data: { + text: "internal question", + }, + type: 100, + edges: ["ScjaYmpbVK", "b7j9tq22dj"], + }, + OL9JENldcI: { + data: { + text: "!!", + }, + type: 200, + }, + R99ncwKifm: { + data: { + text: "portal", + }, + type: 300, + edges: ["BV2VJhOC0I"], + }, + RYYckLE2cH: { + data: { + text: "Question", + }, + type: 100, + edges: ["5sWfsvXphd", "OL9JENldcI"], + }, + SEp0QeNsTS: { + data: { + fn: "application.fee.payable", + url: "http://localhost:7002/pay", + color: "#EFEFEF", + title: "Pay for your application", + description: + '

The planning fee covers the cost of processing your application. Find out more about how planning fees are calculated here.

', + }, + type: 400, + }, + ScjaYmpbVK: { + data: { + text: "?", + }, + type: 200, + }, + b7j9tq22dj: { + data: { + text: "*", + }, + type: 200, + }, + dnVqd6zt4N: { + data: { + heading: "Application sent", + moreInfo: + "

You will be contacted

\n\n", + contactInfo: + '

You can contact us at planning@lambeth.gov.uk

\n', + description: + "A payment receipt has been emailed to you. You will also receive an email to confirm when your application has been received.", + feedbackCTA: "What did you think of this service? (takes 30 seconds)", + }, + type: 725, + }, + q8Foul9hRN: { + data: { + url: "http://localhost:7002/bops/southwark", + }, + type: 650, + }, +}; diff --git a/api.planx.uk/server.test.js b/api.planx.uk/server.test.js index 05730c677e..0875d91db9 100644 --- a/api.planx.uk/server.test.js +++ b/api.planx.uk/server.test.js @@ -2,6 +2,7 @@ const nock = require("nock"); const supertest = require("supertest"); const loadOrRecordNockRequests = require("./tests/loadOrRecordNockRequests"); +const { queryMock } = require("./tests/graphqlQueryMock"); const app = require("./server"); it("works", async () => { @@ -14,6 +15,13 @@ it("works", async () => { }); it("mocks hasura", async () => { + queryMock.mockQuery({ + name: "GetTeams", + data: { + teams: [{ id: 1 }], + }, + }); + await supertest(app) .get("/hasura") .expect(200) @@ -35,6 +43,20 @@ it("mocks hasura", async () => { describe(`sending an application to BOPS ${env}`, () => { const ORIGINAL_BOPS_API_ROOT_DOMAIN = process.env.BOPS_API_ROOT_DOMAIN; + beforeEach(() => { + queryMock.mockQuery({ + name: "CreateApplication", + matchOnVariables: false, + data: { + insert_bops_applications_one: { id: 22 }, + }, + variables: { + destination_url: + "https://southwark.bops.services/api/v1/planning_applications", + }, + }); + }); + beforeAll(() => { process.env.BOPS_API_ROOT_DOMAIN = bopsApiRootDomain; }); diff --git a/api.planx.uk/tests/graphqlQueryMock.js b/api.planx.uk/tests/graphqlQueryMock.js new file mode 100644 index 0000000000..5bd4a947b2 --- /dev/null +++ b/api.planx.uk/tests/graphqlQueryMock.js @@ -0,0 +1,7 @@ +const { QueryMock } = require("graphql-query-test-mock"); + +const queryMock = new QueryMock(); + +module.exports = { + queryMock, +}; diff --git a/api.planx.uk/tests/mockJWT.js b/api.planx.uk/tests/mockJWT.js new file mode 100644 index 0000000000..65f55fcf8e --- /dev/null +++ b/api.planx.uk/tests/mockJWT.js @@ -0,0 +1,23 @@ +const { sign } = require("jsonwebtoken"); + +function getJWT(userId) { + const data = { + sub: String(userId), + "https://hasura.io/jwt/claims": { + "x-hasura-allowed-roles": ["admin"], + "x-hasura-default-role": "admin", + "x-hasura-user-id": String(userId), + }, + }; + + return sign(data, process.env.JWT_SECRET); +} + +function authHeader(userId) { + return { Authorization: `Bearer ${getJWT(userId || 0)}` }; +} + +module.exports = { + authHeader, + getJWT, +}; diff --git a/api.planx.uk/yarn.lock b/api.planx.uk/yarn.lock index 3947fd32de..2b90767563 100644 --- a/api.planx.uk/yarn.lock +++ b/api.planx.uk/yarn.lock @@ -1026,7 +1026,7 @@ caseless@~0.12.0: resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= -chalk@^2.0.0, chalk@^2.0.1: +chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -1391,6 +1391,11 @@ detect-newline@^3.0.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== +diff-match-patch@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37" + integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== + diff-sequences@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" @@ -2916,6 +2921,14 @@ json5@^2.1.2: dependencies: minimist "^1.2.5" +jsondiffpatch@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/jsondiffpatch/-/jsondiffpatch-0.4.1.tgz#9fb085036767f03534ebd46dcd841df6070c5773" + integrity sha512-t0etAxTUk1w5MYdNOkZBZ8rvYYN5iL+2dHCCx/DpkFm/bW28M6y5nUS83D4XdZiHy35Fpaw6LBb+F88fHZnVCw== + dependencies: + chalk "^2.3.0" + diff-match-patch "^1.0.0" + jsonwebtoken@^8.1.0, jsonwebtoken@^8.5.1: version "8.5.1" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" diff --git a/editor.planx.uk/src/pages/FlowEditor/components/PreviewBrowser.tsx b/editor.planx.uk/src/pages/FlowEditor/components/PreviewBrowser.tsx index c048e89a50..df4210d9af 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/PreviewBrowser.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/PreviewBrowser.tsx @@ -144,8 +144,13 @@ const PreviewBrowser: React.FC<{ url: string }> = React.memo((props) => { variant="contained" color="primary" onClick={async () => { + setLastPublishedTitle("Sending changes..."); const publishedFlow = await publishFlow(flowId); - setLastPublishedTitle("Successfully published"); + setLastPublishedTitle( + publishedFlow?.data.alteredNodes + ? `Successfully published changes to ${publishedFlow.data.alteredNodes.length} node(s)` + : "No new changes to publish" + ); }} disabled={window.location.hostname.endsWith("planx.uk")} >