From 2a0918891d10d1d54406e43ef9cf5185904bd931 Mon Sep 17 00:00:00 2001 From: Dan G Date: Fri, 1 Nov 2024 17:10:56 +0000 Subject: [PATCH 01/12] [api] security hardening for microsoft auth (#3895) --- api.planx.uk/modules/auth/controller.ts | 1 + api.planx.uk/modules/auth/middleware.ts | 22 ++++++++++++++---- .../modules/auth/strategy/microsoft-oidc.ts | 23 +++++++++++++++---- api.planx.uk/server.ts | 9 ++++---- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/api.planx.uk/modules/auth/controller.ts b/api.planx.uk/modules/auth/controller.ts index a03fdc4b17..694ccb34ec 100644 --- a/api.planx.uk/modules/auth/controller.ts +++ b/api.planx.uk/modules/auth/controller.ts @@ -39,6 +39,7 @@ function setJWTCookie(returnTo: string, res: Response, req: Request) { maxAge: new Date( new Date().setFullYear(new Date().getFullYear() + 1), ).getTime(), + // pizzas rely on staging API for auth (due to static redirect URIs), so we have to allow cross-site sameSite: "none", secure: true, }; diff --git a/api.planx.uk/modules/auth/middleware.ts b/api.planx.uk/modules/auth/middleware.ts index 2443f0dace..b280b2b135 100644 --- a/api.planx.uk/modules/auth/middleware.ts +++ b/api.planx.uk/modules/auth/middleware.ts @@ -8,7 +8,7 @@ import type { Authenticator } from "passport"; import type { RequestHandler } from "http-proxy-middleware"; import type { Role } from "@opensystemslab/planx-core/types"; import { AsyncLocalStorage } from "async_hooks"; -import type { Request } from "express"; +import type { CookieOptions, Request } from "express"; export const userContext = new AsyncLocalStorage<{ user: Express.User }>(); @@ -136,15 +136,27 @@ export const getMicrosoftAuthHandler = ( return (req, res, next) => { req.session!.returnTo = req.get("Referrer"); - // generate a nonce to enable us to validate the response from OP + // generate a nonce to enable us to validate the response from OP (mitigates against CSRF attacks) const nonce = generators.nonce(); console.debug(`Generated a nonce: %s`, nonce); - req.session!.nonce = nonce; - // @ts-expect-error (method not typed to accept nonce, but it does pass it to the strategy) + // we hash the nonce to avoid sending it plaintext over the wire in our auth request + const hash = crypto.createHash("sha256").update(nonce).digest("hex"); + console.debug(`Hashed nonce: %s`, hash); + + // we store the original nonce in a short-lived, httpOnly but cross-site cookie + const httpOnlyCookieOptions: CookieOptions = { + maxAge: 15 * 60 * 1000, // 15 mins + sameSite: "none", + httpOnly: true, + secure: true, + }; + res.cookie("ms-oidc-nonce", nonce, httpOnlyCookieOptions); + + // @ts-expect-error (method not typed to accept nonce, but it does include it in the request) return passport.authenticate("microsoft-oidc", { prompt: "select_account", - nonce, + nonce: hash, })(req, res, next); }; }; diff --git a/api.planx.uk/modules/auth/strategy/microsoft-oidc.ts b/api.planx.uk/modules/auth/strategy/microsoft-oidc.ts index cc9c1dc015..82fcc437b1 100644 --- a/api.planx.uk/modules/auth/strategy/microsoft-oidc.ts +++ b/api.planx.uk/modules/auth/strategy/microsoft-oidc.ts @@ -1,3 +1,4 @@ +import crypto from "crypto"; import type { Client, ClientMetadata, @@ -41,18 +42,30 @@ export const getMicrosoftOidcStrategy = (client: Client): Strategy => { }; const verifyCallback: StrategyVerifyCallbackReq = async ( - req: Http.IncomingMessageWithSession, + req: Http.IncomingMessageWithCookies, tokenSet, done, ): Promise => { // TODO: use tokenSet.state to pass the redirectTo query param through the auth flow + // TODO: validate id_token sig with the public key from the jwks_uri (...v2.0/keys) const claims: IdTokenClaims = tokenSet.claims(); - const email = claims.email; - const returned_nonce = claims.nonce; - if (returned_nonce != req.session?.nonce) { - return done(new Error("Returned nonce does not match session nonce")); + // ensure the response is authentic by comparing nonce + const returned_nonce = claims.nonce; + if (!req.cookies || !req.cookies["ms-oidc-nonce"]) { + return done(new Error("No nonce found in appropriate cookie")); } + const original_nonce = req.cookies["ms-oidc-nonce"]; + const hash = crypto.createHash("sha256").update(original_nonce).digest("hex"); + if (returned_nonce != hash) { + return done( + new Error( + "Returned nonce does not match nonce sent with original request", + ), + ); + } + + const email = claims.email; if (!email) { return done(new Error("Unable to authenticate without email")); } diff --git a/api.planx.uk/server.ts b/api.planx.uk/server.ts index 04730a5be0..39062dcafc 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -112,7 +112,8 @@ assert(process.env.UNIFORM_SUBMISSION_URL); // needed for storing original URL to redirect to in login flow app.use( cookieSession({ - maxAge: 24 * 60 * 60 * 100, + // we don't need session to persist for long - it's only required for auth flow + maxAge: 2 * 60 * 60 * 1000, // 2hrs name: "session", secret: process.env.SESSION_SECRET, }), @@ -198,9 +199,9 @@ declare global { } namespace Http { - interface IncomingMessageWithSession extends IncomingMessage { - session?: { - nonce: string; + interface IncomingMessageWithCookies extends IncomingMessage { + cookies?: { + "ms-oidc-nonce": string; }; } } From 8002201079b0adeaffe5682a54ed93a6f3d7ea97 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Nov 2024 08:47:15 +0100 Subject: [PATCH 02/12] [skip pizza] Bump uuid from 10.0.0 to 11.0.2 in /hasura.planx.uk/tests (#3897) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- hasura.planx.uk/tests/package.json | 2 +- hasura.planx.uk/tests/pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/hasura.planx.uk/tests/package.json b/hasura.planx.uk/tests/package.json index b5f2938381..e7bb967963 100644 --- a/hasura.planx.uk/tests/package.json +++ b/hasura.planx.uk/tests/package.json @@ -9,7 +9,7 @@ "isomorphic-fetch": "^3.0.0", "jest": "^29.7.0", "jsonwebtoken": "^9.0.1", - "uuid": "^10.0.0" + "uuid": "^11.0.2" }, "jest": { "setupFilesAfterEnv": [ diff --git a/hasura.planx.uk/tests/pnpm-lock.yaml b/hasura.planx.uk/tests/pnpm-lock.yaml index 781745a677..37bf0acb91 100644 --- a/hasura.planx.uk/tests/pnpm-lock.yaml +++ b/hasura.planx.uk/tests/pnpm-lock.yaml @@ -21,8 +21,8 @@ dependencies: specifier: ^9.0.1 version: 9.0.1 uuid: - specifier: ^10.0.0 - version: 10.0.0 + specifier: ^11.0.2 + version: 11.0.2 packages: @@ -2242,8 +2242,8 @@ packages: picocolors: 1.0.1 dev: false - /uuid@10.0.0: - resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + /uuid@11.0.2: + resolution: {integrity: sha512-14FfcOJmqdjbBPdDjFQyk/SdT4NySW4eM0zcG+HqbHP5jzuH56xO3J1DGhgs/cEMCfwYi3HQI1gnTO62iaG+tQ==} hasBin: true dev: false From 21755b044fa6d94c68933dc790ef3044b9ebe4f4 Mon Sep 17 00:00:00 2001 From: Dan G Date: Mon, 4 Nov 2024 10:03:11 +0000 Subject: [PATCH 03/12] docs: add ADR#0008 re Microsoft auth and related HOWTO re Azure application (#3902) --- .../decisions/0003-testing-approach.md | 2 +- .../0008-authentication-with-microsoft.md | 48 +++++++++++++++++++ doc/how-to/how-to-setup-azure-application.md | 19 ++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 doc/architecture/decisions/0008-authentication-with-microsoft.md create mode 100644 doc/how-to/how-to-setup-azure-application.md diff --git a/doc/architecture/decisions/0003-testing-approach.md b/doc/architecture/decisions/0003-testing-approach.md index f669425858..03865b7071 100644 --- a/doc/architecture/decisions/0003-testing-approach.md +++ b/doc/architecture/decisions/0003-testing-approach.md @@ -1,4 +1,4 @@ -# 1. Testing Principles +# 3. Testing Principles ## Status diff --git a/doc/architecture/decisions/0008-authentication-with-microsoft.md b/doc/architecture/decisions/0008-authentication-with-microsoft.md new file mode 100644 index 0000000000..1034fe8af0 --- /dev/null +++ b/doc/architecture/decisions/0008-authentication-with-microsoft.md @@ -0,0 +1,48 @@ +# 8. User authentication via Microsoft SSO + +Date: 2024-10-31 + +## Status + +Accepted + +## Context + +Previously, PlanX users could only log in to the editor using Google single sign on (SSO). This was true for OSL devs and end-users alike. Most councils run Microsoft, so we needed to add an additional option to authenticate via Microsoft instead. + +## Decisions + +We considered replacing the auth business logic wholesale with AWS Cognito, but this seemed like it would be a much more significant overhaul as compared with simply adding a new `passportjs` '[strategy](https://www.passportjs.org/concepts/authentication/strategies/)'. + +Since we already had a model strategy for Google, a principle decision which guided this work was to build a strategy for Microsoft which had parity with that existing solution. This also meant leveraging the existing method for a user to identify themselves to the client/editor, which is for the API to return a persistent `jwt` cookie after auth. + +### `passportjs` + +We considered a few strategies: [`passport-microsoft`](https://www.passportjs.org/packages/passport-microsoft/) didn't pass muster and [`passport-azure-ad`](https://www.passportjs.org/packages/passport-azure-ad/) was [deprecated](https://github.com/AzureAD/microsoft-identity-web/discussions/2405) (without replacement) by Microsoft, so we opted for building a custom solution using [OpenID Connect](https://openid.net/) (aka. OIDC, a layer on top of OAuth2 for which Microsoft has [good documentation](https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc)) with the [certified](https://openid.net/developers/certified-openid-connect-implementations/) [`openid-client`](https://www.passportjs.org/packages/openid-client/) strategy. + +### Auth flow + +OIDC provides for multiple '[authorisation flows](https://auth0.com/docs/get-started/authentication-and-authorization-flow)'. In our case the frontend, which is a public client, directs users to the API for auth purposes, which can store secrets. This enables us to use Hybrid Flow, rather than the more cumbersome Authorization Code Flow. + +### Logging out + +We also considered what happens when users who have authenticated via Microsoft SSO log out, either from PlanX, or from their Microsoft account more widely. + +In the former case, we simply delete the `jwt` as usual. For the latter case, there was the option of implementing a [front channel logout URL](https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc#what-is-a-front-channel-logout-url). Given the current setup, integrating this feature would have required a significant amount of work, and would also move us beyond parity with the Google solution, so we didn't deem it necessary at this time. + +## Consequences + +The upshot is that both devs and end-users can log into PlanX with any Microsoft email address (whether personal or belonging to/managed by an organisation). + +### ESM adoption + +With the implementationd described above, the API server has to fetch the [Microsoft OIDC config](https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration) on boot, which it does asynchronously. Observing this functionality would have required us to either refactor the codebase extensively, or write a [top-level](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await#top_level_await) `await`. + +The latter is much simpler and more elegant, but in turn requires that the API codebase is entirely ESM-compliant. This turned out to be a significant spike. Without going into detail, it prompted us to do the following (inter alia): + +- replace `ts-node-dev` with `tsx` +- enforce the `verbatimModuleSyntax` [tsconfig option](https://www.typescriptlang.org/tsconfig/#verbatimModuleSyntax) +- migrate our testing framework from Jest to Vitest +- bump `passportjs` to latest version (a longstanding ticket) + +The happy byproduct of all this is a much more modern configuration of the API TypeScript setup, testing suite and dev framework. \ No newline at end of file diff --git a/doc/how-to/how-to-setup-azure-application.md b/doc/how-to/how-to-setup-azure-application.md new file mode 100644 index 0000000000..4806fd2e83 --- /dev/null +++ b/doc/how-to/how-to-setup-azure-application.md @@ -0,0 +1,19 @@ +# How to setup Azure application for Microsoft OIDC + +## Context + +One of the more complicated aspects of implementing the Microsoft single sign on via OpenID Connect (see ADR#0008) is setting up an appropriate application on Azure (Microsoft's cloud platform). + +We document the process here for posterity/future reproducibility. + +## Process + +1. Start a new registration in the [App registrations](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) panel (this will automatically create a corresponding [Enterprise application](https://portal.azure.com/#view/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/~/AppAppsPreview)) +2. We will support account types ‘in any organizational directory’ (i.e. multitenant), *and* personal accounts +3. Register the application as an SPA, with the following redirect URIs: + - `https://api.editor.planx.uk/auth/microsoft/callback` (production) + - `https://api.editor.planx.dev/auth/microsoft/callback` (staging) + - `http://localhost/auth/microsoft/callback` (local development) +3. Navigate to *Manage/Authentication*, and allow both access and ID tokens +4. Go to *Manage/Certificates and secrets*, add a new client secret, and record the value - this is your `MICROSOFT_CLIENT_SECRET` environment variable +5. Go to *Overview*, and copy the `Application (client) ID` - this is your `MICROSOFT_CLIENT_ID` \ No newline at end of file From cc6fa05c230fbc660eadef3d968be2ba7de68ccb Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Mon, 4 Nov 2024 15:50:38 +0100 Subject: [PATCH 04/12] fix: bump timeout on `/diff` endpoint (#3905) --- api.planx.uk/modules/flows/validate/controller.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api.planx.uk/modules/flows/validate/controller.ts b/api.planx.uk/modules/flows/validate/controller.ts index 57c90d3849..92272eb053 100644 --- a/api.planx.uk/modules/flows/validate/controller.ts +++ b/api.planx.uk/modules/flows/validate/controller.ts @@ -22,7 +22,9 @@ export type ValidateAndDiffFlowController = ValidatedRequestHandler< >; export const validateAndDiffFlowController: ValidateAndDiffFlowController = - async (_req, res, next) => { + async (req, res, next) => { + req.setTimeout(120 * 1000); // Temporary bump to address large diff timeouts + try { const { flowId } = res.locals.parsedReq.params; const result = await validateAndDiffFlow(flowId); From bdf40be31b432822b7fc0779659a07046402ab32 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Mon, 4 Nov 2024 16:21:55 +0100 Subject: [PATCH 05/12] fix: ensure questions that set a data field with no option data values are not automated (#3903) --- .../@planx/components/Checklist/Editor.tsx | 13 ++++++++++-- .../src/@planx/components/Question/Editor.tsx | 20 +++++++++++++++---- .../src/pages/FlowEditor/lib/store/preview.ts | 7 ++++--- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx b/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx index 28bbda8ce5..8c52fd02fd 100644 --- a/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx @@ -5,7 +5,7 @@ import FormControlLabel from "@mui/material/FormControlLabel"; import IconButton from "@mui/material/IconButton"; import Switch from "@mui/material/Switch"; import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; -import { useFormik } from "formik"; +import { FormikErrors, FormikValues, useFormik } from "formik"; import adjust from "ramda/src/adjust"; import compose from "ramda/src/compose"; import remove from "ramda/src/remove"; @@ -312,7 +312,14 @@ export const ChecklistComponent: React.FC = (props) => { alert(JSON.stringify({ type, ...values, options }, null, 2)); } }, - validate: () => {}, + validate: ({ options, ...values }) => { + const errors: FormikErrors = {}; + if (values.fn && !options?.some((option) => option.data.val)) { + errors.fn = + "At least one option must set a data value when the checklist has a data field"; + } + return errors; + }, }); const focusRef = useRef(null); @@ -365,6 +372,8 @@ export const ChecklistComponent: React.FC = (props) => { value={formik.values.fn} placeholder="Data Field" onChange={formik.handleChange} + error={Boolean(formik.errors?.fn)} + errorMessage={formik.errors?.fn} /> diff --git a/editor.planx.uk/src/@planx/components/Question/Editor.tsx b/editor.planx.uk/src/@planx/components/Question/Editor.tsx index d9a33237e0..c3160c7238 100644 --- a/editor.planx.uk/src/@planx/components/Question/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Question/Editor.tsx @@ -1,7 +1,7 @@ import FormControlLabel from "@mui/material/FormControlLabel"; import Switch from "@mui/material/Switch"; import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; -import { useFormik } from "formik"; +import { FormikErrors, FormikValues, useFormik } from "formik"; import React, { useEffect, useRef } from "react"; import { ComponentTagSelect } from "ui/editor/ComponentTagSelect"; import ImgInput from "ui/editor/ImgInput/ImgInput"; @@ -109,7 +109,11 @@ const OptionEditor: React.FC<{ )} { props.onChange({ ...props.value, @@ -151,7 +155,14 @@ export const Question: React.FC = (props) => { alert(JSON.stringify({ type, ...values, children }, null, 2)); } }, - validate: () => { }, + validate: ({ options, ...values }) => { + const errors: FormikErrors = {}; + if (values.fn && !options.some((option) => option.data.val)) { + errors.fn = + "At least one option must set a data value when the question has a data field"; + } + return errors; + }, }); const focusRef = useRef(null); @@ -201,6 +212,8 @@ export const Question: React.FC = (props) => { value={formik.values.fn} placeholder="Data Field" onChange={formik.handleChange} + error={Boolean(formik.errors?.fn)} + errorMessage={formik.errors?.fn} /> @@ -221,7 +234,6 @@ export const Question: React.FC = (props) => { - flow[nodeId].data?.fn === data.fn, + ([nodeId, _breadcrumb]) => + [flow[nodeId].data?.fn, flow[nodeId].data?.output].includes(data.fn), ); - if (!visitedFns) return; + if (!visitedFns.length) return; // Get all options (aka edges or Answer nodes) for this node const options: Array = edges.map((edgeId) => ({ From a126195506e719d7705a127f1632d4f6bdd2f12e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Mon, 4 Nov 2024 16:08:58 +0000 Subject: [PATCH 06/12] fix: Add guard around add member button (#3907) --- .../Team/components/MembersTable.tsx | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/components/MembersTable.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/components/MembersTable.tsx index 59785ae653..ef3aea6b00 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Team/components/MembersTable.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/components/MembersTable.tsx @@ -94,19 +94,21 @@ export const MembersTable = ({ {showAddMemberButton && ( - - - - { - addUser(); - }} - > - Add a new member - - - - + + + + + { + addUser(); + }} + > + Add a new member + + + + + )} {showModal && ( From aa80b328f2de34a37d19725d2f43e6282398ec8f Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Tue, 5 Nov 2024 09:30:39 +0100 Subject: [PATCH 07/12] fix: add missing postgres indexes (#3910) --- hasura.planx.uk/metadata/tables.yaml | 76 +++++++++---------- .../down.sql | 7 ++ .../up.sql | 14 ++++ 3 files changed, 59 insertions(+), 38 deletions(-) create mode 100644 hasura.planx.uk/migrations/1730742914548_create_index_bops_applications_session_id/down.sql create mode 100644 hasura.planx.uk/migrations/1730742914548_create_index_bops_applications_session_id/up.sql diff --git a/hasura.planx.uk/metadata/tables.yaml b/hasura.planx.uk/metadata/tables.yaml index f1b5797f3f..25dafe08a7 100644 --- a/hasura.planx.uk/metadata/tables.yaml +++ b/hasura.planx.uk/metadata/tables.yaml @@ -157,7 +157,7 @@ definition: enable_manual: false insert: - columns: "*" + columns: '*' retry_conf: interval_sec: 30 num_retries: 1 @@ -171,7 +171,7 @@ query_params: type: bops-submission template_engine: Kriti - url: "{{$base_url}}/webhooks/hasura/send-slack-notification" + url: '{{$base_url}}/webhooks/hasura/send-slack-notification' version: 2 - table: name: document_template @@ -225,7 +225,7 @@ definition: enable_manual: false insert: - columns: "*" + columns: '*' retry_conf: interval_sec: 30 num_retries: 1 @@ -239,7 +239,7 @@ query_params: type: email-submission template_engine: Kriti - url: "{{$base_url}}/webhooks/hasura/send-slack-notification" + url: '{{$base_url}}/webhooks/hasura/send-slack-notification' version: 2 - table: name: feedback @@ -483,9 +483,9 @@ forward_client_headers: false headers: - name: authorization - value: "{{HASURA_PLANX_API_KEY}}" + value: '{{HASURA_PLANX_API_KEY}}' timeout: 10 - url: "{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html" + url: '{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html' type: http - role: demoUser permission: @@ -529,9 +529,9 @@ forward_client_headers: false headers: - name: authorization - value: "{{HASURA_PLANX_API_KEY}}" + value: '{{HASURA_PLANX_API_KEY}}' timeout: 10 - url: "{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html" + url: '{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html' type: http - role: teamEditor permission: @@ -562,9 +562,9 @@ forward_client_headers: false headers: - name: authorization - value: "{{HASURA_PLANX_API_KEY}}" + value: '{{HASURA_PLANX_API_KEY}}' timeout: 10 - url: "{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html" + url: '{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html' type: http select_permissions: - role: api @@ -617,7 +617,7 @@ - 1 - 29 - 30 - comment: "For the demo user, we want to ensure they can only see their own flows, and flows from the Open Digital Planning [id = 30], Open Systems Lab [id = 1], and Templates [id = 29] team " + comment: 'For the demo user, we want to ensure they can only see their own flows, and flows from the Open Digital Planning [id = 30], Open Systems Lab [id = 1], and Templates [id = 29] team ' - role: platformAdmin permission: columns: @@ -698,9 +698,9 @@ forward_client_headers: false headers: - name: authorization - value: "{{HASURA_PLANX_API_KEY}}" + value: '{{HASURA_PLANX_API_KEY}}' timeout: 10 - url: "{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html" + url: '{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html' type: http - role: demoUser permission: @@ -740,9 +740,9 @@ forward_client_headers: false headers: - name: authorization - value: "{{HASURA_PLANX_API_KEY}}" + value: '{{HASURA_PLANX_API_KEY}}' timeout: 10 - url: "{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html" + url: '{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html' type: http - role: teamEditor permission: @@ -767,9 +767,9 @@ forward_client_headers: false headers: - name: authorization - value: "{{HASURA_PLANX_API_KEY}}" + value: '{{HASURA_PLANX_API_KEY}}' timeout: 10 - url: "{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html" + url: '{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html' type: http delete_permissions: - role: demoUser @@ -809,9 +809,9 @@ forward_client_headers: false headers: - name: authorization - value: "{{HASURA_PLANX_API_KEY}}" + value: '{{HASURA_PLANX_API_KEY}}' timeout: 10 - url: "{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html" + url: '{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html' type: http select_permissions: - role: demoUser @@ -849,9 +849,9 @@ forward_client_headers: false headers: - name: authorization - value: "{{HASURA_PLANX_API_KEY}}" + value: '{{HASURA_PLANX_API_KEY}}' timeout: 10 - url: "{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html" + url: '{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html' type: http - table: name: lowcal_sessions @@ -998,7 +998,7 @@ method: POST query_params: {} template_engine: Kriti - url: "{{$base_url}}/send-email/confirmation" + url: '{{$base_url}}/send-email/confirmation' version: 2 - name: setup_lowcal_expiry_events definition: @@ -1028,7 +1028,7 @@ method: POST query_params: {} template_engine: Kriti - url: "{{$base_url}}/webhooks/hasura/create-expiry-event" + url: '{{$base_url}}/webhooks/hasura/create-expiry-event' version: 2 - name: setup_lowcal_reminder_events definition: @@ -1058,7 +1058,7 @@ method: POST query_params: {} template_engine: Kriti - url: "{{$base_url}}/webhooks/hasura/create-reminder-event" + url: '{{$base_url}}/webhooks/hasura/create-reminder-event' version: 2 - table: name: operations @@ -1278,7 +1278,7 @@ definition: enable_manual: false insert: - columns: "*" + columns: '*' retry_conf: interval_sec: 10 num_retries: 3 @@ -1300,13 +1300,13 @@ method: POST query_params: {} template_engine: Kriti - url: "{{$base_url}}/webhooks/hasura/create-payment-expiry-events" + url: '{{$base_url}}/webhooks/hasura/create-payment-expiry-events' version: 2 - name: setup_payment_invitation_events definition: enable_manual: false insert: - columns: "*" + columns: '*' retry_conf: interval_sec: 10 num_retries: 3 @@ -1328,13 +1328,13 @@ method: POST query_params: {} template_engine: Kriti - url: "{{$base_url}}/webhooks/hasura/create-payment-invitation-events" + url: '{{$base_url}}/webhooks/hasura/create-payment-invitation-events' version: 2 - name: setup_payment_reminder_events definition: enable_manual: false insert: - columns: "*" + columns: '*' retry_conf: interval_sec: 10 num_retries: 3 @@ -1356,7 +1356,7 @@ method: POST query_params: {} template_engine: Kriti - url: "{{$base_url}}/webhooks/hasura/create-payment-reminder-events" + url: '{{$base_url}}/webhooks/hasura/create-payment-reminder-events' version: 2 - name: setup_payment_send_events definition: @@ -1385,7 +1385,7 @@ method: POST query_params: {} template_engine: Kriti - url: "{{$base_url}}/webhooks/hasura/create-payment-send-events" + url: '{{$base_url}}/webhooks/hasura/create-payment-send-events' version: 2 - table: name: payment_status @@ -1647,12 +1647,12 @@ definition: enable_manual: false insert: - columns: "*" + columns: '*' retry_conf: interval_sec: 30 num_retries: 1 timeout_sec: 60 - webhook: "{{HASURA_PLANX_API_URL}}" + webhook: '{{HASURA_PLANX_API_URL}}' headers: - name: authorization value_from_env: HASURA_PLANX_API_KEY @@ -1661,7 +1661,7 @@ query_params: type: s3-submission template_engine: Kriti - url: "{{$base_url}}/webhooks/hasura/send-slack-notification" + url: '{{$base_url}}/webhooks/hasura/send-slack-notification' version: 2 - table: name: sessions @@ -1804,7 +1804,7 @@ - created_at - submitted_at filter: {} - comment: "For future, if this moves outside of the Flow to somewhere like Team, we should update 'demoUser' to only see submission data related to only their flows. " + comment: 'For future, if this moves outside of the Flow to somewhere like Team, we should update ''demoUser'' to only see submission data related to only their flows. ' - role: platformAdmin permission: columns: @@ -2365,7 +2365,7 @@ - 29 - 30 - 32 - comment: "For the demo user, we want to ensure they can only see their own team [id = 32], and the teams: Open Digital Planning [id = 30], Open Systems Lab [id = 1], and Templates [id = 29] team " + comment: 'For the demo user, we want to ensure they can only see their own team [id = 32], and the teams: Open Digital Planning [id = 30], Open Systems Lab [id = 1], and Templates [id = 29] team ' - role: platformAdmin permission: columns: @@ -2482,7 +2482,7 @@ definition: enable_manual: false insert: - columns: "*" + columns: '*' retry_conf: interval_sec: 30 num_retries: 1 @@ -2496,7 +2496,7 @@ query_params: type: uniform-submission template_engine: Kriti - url: "{{$base_url}}/webhooks/hasura/send-slack-notification" + url: '{{$base_url}}/webhooks/hasura/send-slack-notification' version: 2 - table: name: user_roles diff --git a/hasura.planx.uk/migrations/1730742914548_create_index_bops_applications_session_id/down.sql b/hasura.planx.uk/migrations/1730742914548_create_index_bops_applications_session_id/down.sql new file mode 100644 index 0000000000..f41ebac702 --- /dev/null +++ b/hasura.planx.uk/migrations/1730742914548_create_index_bops_applications_session_id/down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS "public"."bops_applications_session_id"; +DROP INDEX IF EXISTS "public"."uniform_applications_session_id_idx"; +DROP INDEX IF EXISTS "public"."email_applications_session_id_idx"; +DROP INDEX IF EXISTS "public"."s3_applications_session_id_idx"; +DROP INDEX IF EXISTS "public"."feedback_flow_id_team_id_idx"; +DROP INDEX IF EXISTS "public"."reconciliation_requests_session_id_idx"; +DROP INDEX IF EXISTS "public"."published_flows_created_at_idx"; diff --git a/hasura.planx.uk/migrations/1730742914548_create_index_bops_applications_session_id/up.sql b/hasura.planx.uk/migrations/1730742914548_create_index_bops_applications_session_id/up.sql new file mode 100644 index 0000000000..61b2af6eca --- /dev/null +++ b/hasura.planx.uk/migrations/1730742914548_create_index_bops_applications_session_id/up.sql @@ -0,0 +1,14 @@ +CREATE INDEX "bops_applications_session_id_idx" on + "public"."bops_applications" using hash ("session_id"); +CREATE INDEX "uniform_applications_session_id_idx" on + "public"."uniform_applications" using hash ("submission_reference"); +CREATE INDEX "email_applications_session_id_idx" on + "public"."email_applications" using hash ("session_id"); +CREATE INDEX "s3_applications_session_id_idx" on + "public"."s3_applications" using hash ("session_id"); +CREATE INDEX "feedback_flow_id_team_id_idx" on + "public"."feedback" using btree ("team_id", "flow_id"); +CREATE INDEX "reconciliation_requests_session_id_idx" on + "public"."reconciliation_requests" using hash ("session_id"); +CREATE INDEX "published_flows_created_at_idx" on + "public"."published_flows" using btree ("created_at" DESC NULLS LAST); From 5bb064a41ac19b93364ddb52d701b3ef6257efb1 Mon Sep 17 00:00:00 2001 From: Rory Doak <138574807+RODO94@users.noreply.github.com> Date: Tue, 5 Nov 2024 09:15:14 +0000 Subject: [PATCH 08/12] feat: add `team_settings` introspection tests (#3909) --- hasura.planx.uk/tests/team_settings.test.js | 114 ++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 hasura.planx.uk/tests/team_settings.test.js diff --git a/hasura.planx.uk/tests/team_settings.test.js b/hasura.planx.uk/tests/team_settings.test.js new file mode 100644 index 0000000000..4a97c6636e --- /dev/null +++ b/hasura.planx.uk/tests/team_settings.test.js @@ -0,0 +1,114 @@ +const { introspectAs } = require("./utils"); + +describe("team_settings", () => { + describe("public", () => { + let i; + beforeAll(async () => { + i = await introspectAs("public"); + }); + + test("can query team_settings", () => { + expect(i.queries).toContain("team_settings"); + }); + + test("cannot create, update, or delete team_settings", () => { + expect(i).toHaveNoMutationsFor("team_settings"); + }); + }); + + describe("admin", () => { + let i; + beforeAll(async () => { + i = await introspectAs("admin"); + }); + + test("can query team_settings and team members", () => { + expect(i.queries).toContain("team_settings"); + }); + }); + + describe("platformAdmin", () => { + let i; + beforeAll(async () => { + i = await introspectAs("platformAdmin"); + }); + + test("can query team_settings", () => { + expect(i.queries).toContain("team_settings"); + }); + + test("cannot query insert team_settings", () => { + expect(i.queries).not.toContain("insert_team_settings"); + }); + + test("can query team_settings", async () => { + expect(i.queries).toContain("team_settings"); + }); + + test("can insert team_settings", () => { + expect(i.mutations).toContain("insert_team_settings"); + }); + + test("can mutate team_settings", async () => { + expect(i.mutations).toContain("update_team_settings"); + expect(i.mutations).toContain("update_team_settings_by_pk"); + }); + + test("cannot delete team_settings", async () => { + expect(i.mutations).not.toContain("delete_team_settings"); + }); + }); + + describe("teamEditor", () => { + beforeAll(async () => { + i = await introspectAs("teamEditor"); + }); + + test("can query team_settings", () => { + expect(i.queries).toContain("team_settings"); + }); + + test("can update team_settings", () => { + expect(i.mutations).toContain("update_team_settings"); + expect(i.mutations).toContain("update_team_settings_by_pk"); + }); + + test("cannot delete team_settings", async () => { + expect(i.mutations).not.toContain("delete_team_settings"); + }); + + test("cannot insert team_settings", async () => { + expect(i.mutations).not.toContain("insert_team_settings"); + }); + }); + + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("can query team_settings", () => { + expect(i.queries).toContain("team_settings"); + }); + + test("cannot create, update, or delete team_settings", () => { + expect(i).toHaveNoMutationsFor("team_settings"); + }); + }); + + describe("api", () => { + let i; + beforeAll(async () => { + i = await introspectAs("api"); + }); + + test("can query team_settings", () => { + expect(i.queries).toContain("team_settings"); + }); + + test("cannot create, update, or delete team_settings", () => { + expect(i).toHaveNoMutationsFor("team_settings"); + }); + }); +}); From 042df8dda72bd59dbe7e1ba941af3f3219af3e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Tue, 5 Nov 2024 14:39:12 +0000 Subject: [PATCH 09/12] fix: Hide `/:teams` `EditorNavMenu` from `demoUser` role (#3911) --- .../src/components/EditorNavMenu.tsx | 29 ++++++++----------- .../src/pages/FlowEditor/lib/store/user.ts | 18 ++++++++++-- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/editor.planx.uk/src/components/EditorNavMenu.tsx b/editor.planx.uk/src/components/EditorNavMenu.tsx index 1675a4a0c0..05ad74e1a5 100644 --- a/editor.planx.uk/src/components/EditorNavMenu.tsx +++ b/editor.planx.uk/src/components/EditorNavMenu.tsx @@ -9,7 +9,7 @@ import TuneIcon from "@mui/icons-material/Tune"; import Box from "@mui/material/Box"; import IconButton from "@mui/material/IconButton"; import { styled } from "@mui/material/styles"; -import Tooltip, { tooltipClasses, TooltipProps } from "@mui/material/Tooltip"; +import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import { Role } from "@opensystemslab/planx-core/types"; import { useStore } from "pages/FlowEditor/lib/store"; @@ -95,13 +95,12 @@ function EditorNavMenu() { const { navigate } = useNavigation(); const { url } = useCurrentRoute(); const isRouteLoading = useLoadingRoute(); - const [teamSlug, flowSlug, user, canUserEditTeam, flowAnalyticsLink] = + const [teamSlug, flowSlug, flowAnalyticsLink, role] = useStore((state) => [ state.teamSlug, state.flowSlug, - state.user, - state.canUserEditTeam, state.flowAnalyticsLink, + state.getUserRoleForCurrentTeam() ]); const isActive = (route: string) => url.href.endsWith(route); @@ -122,7 +121,7 @@ function EditorNavMenu() { title: "Select a team", Icon: FormatListBulletedIcon, route: "/", - accessibleBy: ["platformAdmin", "teamEditor", "teamViewer"], + accessibleBy: ["platformAdmin", "teamEditor", "demoUser", "teamViewer"], }, { title: "Global settings", @@ -143,7 +142,7 @@ function EditorNavMenu() { title: "Services", Icon: FormatListBulletedIcon, route: `/${teamSlug}`, - accessibleBy: ["platformAdmin", "teamEditor", "teamViewer"], + accessibleBy: ["platformAdmin", "teamEditor", "demoUser", "teamViewer"], }, { title: "Settings", @@ -170,25 +169,25 @@ function EditorNavMenu() { title: "Editor", Icon: EditorIcon, route: `/${teamSlug}/${flowSlug}`, - accessibleBy: ["platformAdmin", "teamEditor", "teamViewer"], + accessibleBy: ["platformAdmin", "teamEditor", "demoUser", "teamViewer"], }, { title: "Service settings", Icon: TuneIcon, route: `/${teamSlug}/${flowSlug}/service`, - accessibleBy: ["platformAdmin", "teamEditor"], + accessibleBy: ["platformAdmin", "teamEditor", "demoUser"], }, { title: "Submissions log", Icon: FactCheckIcon, route: `/${teamSlug}/${flowSlug}/submissions-log`, - accessibleBy: ["platformAdmin", "teamEditor"], + accessibleBy: ["platformAdmin", "teamEditor", "demoUser"], }, { title: "Feedback", Icon: RateReviewIcon, route: `/${teamSlug}/${flowSlug}/feedback`, - accessibleBy: ["platformAdmin", "teamEditor"], + accessibleBy: ["platformAdmin", "teamEditor", "demoUser"], }, ]; @@ -198,7 +197,7 @@ function EditorNavMenu() { title: "Analytics (external link)", Icon: LeaderboardIcon, route: flowAnalyticsLink, - accessibleBy: ["platformAdmin", "teamEditor"], + accessibleBy: ["platformAdmin", "teamEditor", "demoUser"], }, ] : [ @@ -206,7 +205,7 @@ function EditorNavMenu() { title: "Analytics page unavailable", Icon: LeaderboardIcon, route: "#", - accessibleBy: ["platformAdmin", "teamEditor"], + accessibleBy: ["platformAdmin", "teamEditor", "demoUser"], disabled: true, }, ]; @@ -243,11 +242,7 @@ function EditorNavMenu() { const { routes, compact } = getRoutesForUrl(url.href); - const visibleRoutes = routes.filter(({ accessibleBy }) => { - if (user?.isPlatformAdmin) return accessibleBy.includes("platformAdmin"); - if (canUserEditTeam(teamSlug)) return accessibleBy.includes("teamEditor"); - return accessibleBy.includes("teamViewer"); - }); + const visibleRoutes = routes.filter(({ accessibleBy }) => role && accessibleBy.includes(role)); // Hide menu if the user does not have a selection of items if (visibleRoutes.length < 2) return null; diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/store/user.ts b/editor.planx.uk/src/pages/FlowEditor/lib/store/user.ts index eabf4710cd..a22123a265 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/user.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/user.ts @@ -1,9 +1,10 @@ import { CoreDomainClient } from "@opensystemslab/planx-core"; -import { Team, User, UserTeams } from "@opensystemslab/planx-core/types"; +import { Role, Team, User, UserTeams } from "@opensystemslab/planx-core/types"; import axios from "axios"; import type { StateCreator } from "zustand"; import { EditorStore } from "./editor"; +import { TeamStore } from "./team"; export interface UserStore { user?: User; @@ -13,10 +14,11 @@ export interface UserStore { getUser: () => User | undefined; canUserEditTeam: (teamSlug: Team["slug"]) => boolean; initUserStore: () => Promise; + getUserRoleForCurrentTeam: () => Role | undefined; } export const userStore: StateCreator< - UserStore & EditorStore, + UserStore & EditorStore & TeamStore, [], [], UserStore @@ -50,6 +52,18 @@ export const userStore: StateCreator< const user = await getLoggedInUser(); setUser(user); }, + + getUserRoleForCurrentTeam: () => { + const { user, teamSlug } = get(); + if (!user || !teamSlug) return; + + if (user.isPlatformAdmin) return "platformAdmin"; + + const currentUserTeam = user.teams.find(({ team: { slug } }) => slug === teamSlug ); + if (!currentUserTeam) return; + + return currentUserTeam.role; + } }); const getLoggedInUser = async () => { From 9008373a25aeee74e84a34ae56b25509d445d222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Tue, 5 Nov 2024 14:55:55 +0000 Subject: [PATCH 10/12] feat: "No services found" card (#3891) --- .../Team/components/MembersTable.tsx | 2 +- .../src/pages/FlowEditor/lib/store/editor.ts | 30 +++- editor.planx.uk/src/pages/Team.tsx | 149 +++++++++--------- editor.planx.uk/src/pages/Teams.tsx | 3 +- editor.planx.uk/src/ui/editor/AddButton.tsx | 28 ++++ 5 files changed, 131 insertions(+), 81 deletions(-) create mode 100644 editor.planx.uk/src/ui/editor/AddButton.tsx diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/components/MembersTable.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/components/MembersTable.tsx index ef3aea6b00..d07d63184e 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Team/components/MembersTable.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/components/MembersTable.tsx @@ -9,8 +9,8 @@ import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import { Role } from "@opensystemslab/planx-core/types"; -import { AddButton } from "pages/Team"; import React, { useState } from "react"; +import { AddButton } from "ui/editor/AddButton"; import Permission from "ui/editor/Permission"; import { StyledAvatar, StyledTableRow } from "./../styles"; 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 b12d7f920e..64271e0ae7 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts @@ -104,16 +104,34 @@ interface PublishFlowResponse { message: string; } +export interface FlowSummary { + id: string; + name: string; + slug: string; + updatedAt: string; + operations: { + createdAt: string; + actor: { + firstName: string; + lastName: string; + } + }[] +} + export interface EditorStore extends Store.Store { addNode: (node: any, relationships?: any) => void; connect: (src: NodeId, tgt: NodeId, object?: any) => void; connectTo: (id: NodeId) => void; copyFlow: (flowId: string) => Promise; copyNode: (id: NodeId) => void; - createFlow: (teamId: any, newSlug: any, newName: string) => Promise; + createFlow: ( + teamId: number, + newSlug: string, + newName: string + ) => Promise; deleteFlow: (teamId: number, flowSlug: string) => Promise; validateAndDiffFlow: (flowId: string) => Promise; - getFlows: (teamId: number) => Promise; + getFlows: (teamId: number) => Promise; isClone: (id: NodeId) => boolean; lastPublished: (flowId: string) => Promise; lastPublisher: (flowId: string) => Promise; @@ -124,12 +142,12 @@ export interface EditorStore extends Store.Store { id: NodeId, parent?: NodeId, toBefore?: NodeId, - toParent?: NodeId, + toParent?: NodeId ) => void; pasteNode: (toParent: NodeId, toBefore: NodeId) => void; publishFlow: ( flowId: string, - summary?: string, + summary?: string ) => Promise; removeNode: (id: NodeId, parent: NodeId) => void; updateNode: (node: any, relationships?: any) => void; @@ -326,7 +344,7 @@ export const editorStore: StateCreator< getFlows: async (teamId) => { client.cache.reset(); - const { data } = await client.query({ + const { data: { flows } } = await client.query<{ flows: FlowSummary[] }>({ query: gql` query GetFlows($teamId: Int!) { flows(where: { team: { id: { _eq: $teamId } } }) { @@ -349,7 +367,7 @@ export const editorStore: StateCreator< }, }); - return data; + return flows; }, isClone: (id) => { diff --git a/editor.planx.uk/src/pages/Team.tsx b/editor.planx.uk/src/pages/Team.tsx index 7b9b2964cb..ad0c8b9d0b 100644 --- a/editor.planx.uk/src/pages/Team.tsx +++ b/editor.planx.uk/src/pages/Team.tsx @@ -1,10 +1,8 @@ import { gql } from "@apollo/client"; -import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline"; import Edit from "@mui/icons-material/Edit"; import Visibility from "@mui/icons-material/Visibility"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; -import ButtonBase from "@mui/material/ButtonBase"; import Container from "@mui/material/Container"; import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; @@ -13,15 +11,18 @@ import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; import { styled } from "@mui/material/styles"; import Typography from "@mui/material/Typography"; +import { flow } from "lodash"; import React, { useCallback, useEffect, useState } from "react"; import { Link, useNavigation } from "react-navi"; import { FONT_WEIGHT_SEMI_BOLD } from "theme"; import { borderedFocusStyle } from "theme"; +import { AddButton } from "ui/editor/AddButton"; import { slugify } from "utils"; import { client } from "../lib/graphql"; import SimpleMenu from "../ui/editor/SimpleMenu"; import { useStore } from "./FlowEditor/lib/store"; +import { FlowSummary } from "./FlowEditor/lib/store/editor"; import { formatLastEditMessage } from "./FlowEditor/utils"; const DashboardList = styled("ul")(({ theme }) => ({ @@ -103,32 +104,9 @@ const Confirm = ({ ); -const AddButtonRoot = styled(ButtonBase)(({ theme }) => ({ - fontSize: 20, - display: "flex", - alignItems: "center", - textAlign: "left", - color: theme.palette.primary.main, - fontWeight: FONT_WEIGHT_SEMI_BOLD, -})); - -export function AddButton({ - children, - onClick, -}: { - children: string; - onClick: () => void; -}): FCReturn { - return ( - - {children} - - ); -} - interface FlowItemProps { - flow: any; - flows: any; + flow: FlowSummary; + flows: FlowSummary[]; teamId: number; teamSlug: string; refreshFlows: () => void; @@ -279,30 +257,77 @@ const FlowItem: React.FC = ({ ); }; +const GetStarted: React.FC<{ flows: FlowSummary[] }> = ({ flows }) => ( + ({ + mt: 4, + backgroundColor: theme.palette.background.paper, + borderRadius: "8px", + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 2, + padding: 2 + })}> + No services found + Get started by creating your first service + + +) + +const AddFlowButton: React.FC<{ flows: FlowSummary[] }> = ({ flows }) => { + const { navigate } = useNavigation(); + const { teamId, createFlow, teamSlug } = useStore() + + const addFlow = async () => { + const newFlowName = prompt("Service name"); + if (!newFlowName) return; + + const newFlowSlug = slugify(newFlowName); + const duplicateFlowName = flows?.find( + (flow) => flow.slug === newFlowSlug, + ); + + if (duplicateFlowName) { + alert( + `The flow "${newFlowName}" already exists. Enter a unique flow name to continue`, + ); + } + + const newId = await createFlow(teamId, newFlowSlug, newFlowName); + navigate(`/${teamSlug}/${newId}`); + } + + return( + + Add a new service + + ) +} + const Team: React.FC = () => { - const { id: teamId, slug } = useStore((state) => state.getTeam()); - const [flows, setFlows] = useState(null); - const navigation = useNavigation(); + const [{ id: teamId, slug }, canUserEditTeam, getFlows] = useStore((state) => [state.getTeam(), state.canUserEditTeam, state.getFlows ]); + const [flows, setFlows] = useState(null); const fetchFlows = useCallback(() => { - useStore - .getState() - .getFlows(teamId) - .then((res: { flows: any[] }) => { - // Copy the array and sort by most recently edited desc using last associated operation.createdAt, not flow.updatedAt - const sortedFlows = res.flows.toSorted((a, b) => - b.operations[0]["createdAt"].localeCompare( - a.operations[0]["createdAt"], - ), - ); - setFlows(sortedFlows); - }); - }, [teamId, setFlows]); + getFlows(teamId) + .then((flows) => { + // Copy the array and sort by most recently edited desc using last associated operation.createdAt, not flow.updatedAt + const sortedFlows = flows.toSorted((a, b) => + b.operations[0]["createdAt"].localeCompare( + a.operations[0]["createdAt"], + ), + ); + setFlows(sortedFlows); + }); + }, [teamId, setFlows, getFlows]); useEffect(() => { fetchFlows(); }, [fetchFlows]); + const teamHasFlows = flows && Boolean(flows.length) + const showAddFlowButton = teamHasFlows && canUserEditTeam(slug); + return ( { Services - {useStore.getState().canUserEditTeam(slug) ? ( + {canUserEditTeam(slug) ? ( ) : ( )} - {useStore.getState().canUserEditTeam(slug) && ( - { - const newFlowName = prompt("Service name"); - if (newFlowName) { - const newFlowSlug = slugify(newFlowName); - const duplicateFlowName = flows?.find( - (flow) => flow.slug === newFlowSlug, - ); - - !duplicateFlowName - ? useStore - .getState() - .createFlow(teamId, newFlowSlug, newFlowName) - .then((newId: string) => { - navigation.navigate(`/${slug}/${newId}`); - }) - : alert( - `The flow "${newFlowName}" already exists. Enter a unique flow name to continue`, - ); - } - }} - > - Add a new service - + {showAddFlowButton && ( + )} - {flows && ( + {teamHasFlows && ( - {flows.map((flow: any) => ( + {flows.map((flow) => ( { }} /> ))} - - )} + ) + } + { flows && !flows.length && } ); }; diff --git a/editor.planx.uk/src/pages/Teams.tsx b/editor.planx.uk/src/pages/Teams.tsx index 58c78f88b6..2c42c259b0 100644 --- a/editor.planx.uk/src/pages/Teams.tsx +++ b/editor.planx.uk/src/pages/Teams.tsx @@ -8,11 +8,12 @@ import navigation from "lib/navigation"; import React from "react"; import { Link } from "react-navi"; import { borderedFocusStyle } from "theme"; +import { AddButton } from "ui/editor/AddButton"; import Permission from "ui/editor/Permission"; import { slugify } from "utils"; import { useStore } from "./FlowEditor/lib/store"; -import { AddButton } from "./Team"; + interface TeamTheme { slug: string; diff --git a/editor.planx.uk/src/ui/editor/AddButton.tsx b/editor.planx.uk/src/ui/editor/AddButton.tsx new file mode 100644 index 0000000000..a0b1b446fa --- /dev/null +++ b/editor.planx.uk/src/ui/editor/AddButton.tsx @@ -0,0 +1,28 @@ +import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline"; +import ButtonBase from "@mui/material/ButtonBase"; +import { styled } from "@mui/material/styles"; +import React, { } from "react"; +import { FONT_WEIGHT_SEMI_BOLD } from "theme"; + +const AddButtonRoot = styled(ButtonBase)(({ theme }) => ({ + fontSize: 20, + display: "flex", + alignItems: "center", + textAlign: "left", + color: theme.palette.primary.main, + fontWeight: FONT_WEIGHT_SEMI_BOLD, +})); + +export function AddButton({ + children, + onClick, +}: { + children: string; + onClick: () => void; +}): FCReturn { + return ( + + {children} + + ); +} \ No newline at end of file From 422e9fe1c6df62a38b73cbc4c7bd4394da0577ea Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Tue, 5 Nov 2024 16:16:24 +0100 Subject: [PATCH 11/12] chore: bump planx-core (#3913) --- api.planx.uk/package.json | 2 +- api.planx.uk/pnpm-lock.yaml | 91 ++++++++++++++++++++-------- e2e/tests/api-driven/package.json | 2 +- e2e/tests/api-driven/pnpm-lock.yaml | 93 ++++++++++++++++++++--------- e2e/tests/ui-driven/package.json | 2 +- e2e/tests/ui-driven/pnpm-lock.yaml | 93 ++++++++++++++++++++--------- editor.planx.uk/package.json | 2 +- editor.planx.uk/pnpm-lock.yaml | 36 +++++------ 8 files changed, 221 insertions(+), 100 deletions(-) diff --git a/api.planx.uk/package.json b/api.planx.uk/package.json index 8014d80aca..122d3c1ca2 100644 --- a/api.planx.uk/package.json +++ b/api.planx.uk/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@airbrake/node": "^2.1.8", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#54be9e0", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#d3d933c", "@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 00a44cedfa..27ffb05977 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#54be9e0 - version: github.com/theopensystemslab/planx-core/54be9e0 + specifier: git+https://github.com/theopensystemslab/planx-core#d3d933c + version: github.com/theopensystemslab/planx-core/d3d933c '@types/isomorphic-fetch': specifier: ^0.0.36 version: 0.0.36 @@ -617,6 +617,13 @@ packages: regenerator-runtime: 0.14.1 dev: false + /@babel/runtime@7.26.0: + resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.1 + dev: false + /@babel/template@7.25.7: resolution: {integrity: sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==} engines: {node: '>=6.9.0'} @@ -1064,30 +1071,30 @@ packages: resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} dev: false - /@formatjs/ecma402-abstract@2.2.0: - resolution: {integrity: sha512-IpM+ev1E4QLtstniOE29W1rqH9eTdx5hQdNL8pzrflMj/gogfaoONZqL83LUeQScHAvyMbpqP5C9MzNf+fFwhQ==} + /@formatjs/ecma402-abstract@2.2.3: + resolution: {integrity: sha512-aElGmleuReGnk2wtYOzYFmNWYoiWWmf1pPPCYg0oiIQSJj0mjc4eUfzUXaSOJ4S8WzI/cLqnCTWjqz904FT2OQ==} dependencies: - '@formatjs/fast-memoize': 2.2.1 - '@formatjs/intl-localematcher': 0.5.5 + '@formatjs/fast-memoize': 2.2.3 + '@formatjs/intl-localematcher': 0.5.7 tslib: 2.7.0 dev: false - /@formatjs/fast-memoize@2.2.1: - resolution: {integrity: sha512-XS2RcOSyWxmUB7BUjj3mlPH0exsUzlf6QfhhijgI941WaJhVxXQ6mEWkdUFIdnKi3TuTYxRdelsgv3mjieIGIA==} + /@formatjs/fast-memoize@2.2.3: + resolution: {integrity: sha512-3jeJ+HyOfu8osl3GNSL4vVHUuWFXR03Iz9jjgI7RwjG6ysu/Ymdr0JRCPHfF5yGbTE6JCrd63EpvX1/WybYRbA==} dependencies: tslib: 2.7.0 dev: false - /@formatjs/intl-listformat@7.5.9: - resolution: {integrity: sha512-HqtGkxUh2Uz0oGVTxHAvPZ3EGxc8+ol5+Bx7S9xB97d4PEJJd9oOgHrerIghHA0gtIjsNKBFUae3P0My+F6YUA==} + /@formatjs/intl-listformat@7.7.4: + resolution: {integrity: sha512-lipFspH2MZcoeXxR6WSR/Jy9unzJ/iT0w+gbL8vgv25Ap0S9cUtcDVAce4ECEKI1bDtAvEU3b6+9Dha27gAikA==} dependencies: - '@formatjs/ecma402-abstract': 2.2.0 - '@formatjs/intl-localematcher': 0.5.5 + '@formatjs/ecma402-abstract': 2.2.3 + '@formatjs/intl-localematcher': 0.5.7 tslib: 2.7.0 dev: false - /@formatjs/intl-localematcher@0.5.5: - resolution: {integrity: sha512-t5tOGMgZ/i5+ALl2/offNqAQq/lfUnKLEw0mXQI4N4bqpedhrSE+fyKLpwnd22sK0dif6AV+ufQcTsKShB9J1g==} + /@formatjs/intl-localematcher@0.5.7: + resolution: {integrity: sha512-GGFtfHGQVFe/niOZp24Kal5b2i36eE2bNL0xi9Sg/yd0TR8aLjcteApZdHmismP5QQax1cMnZM9yWySUUjJteA==} dependencies: tslib: 2.7.0 dev: false @@ -1177,9 +1184,9 @@ packages: resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} dev: false - /@mui/base@5.0.0-beta.40(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==} - engines: {node: '>=12.0.0'} + /@mui/base@5.0.0-beta.60(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-w8twR3qCUI+uJHO5xDOuc1yB5l46KFbvNsTwIvEW9tQkKxVaiEFf2GAXHuvFJiHfZLqjzett6drZjghy8D1Z1A==} + engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0 @@ -1194,8 +1201,8 @@ packages: dependencies: '@babel/runtime': 7.25.7 '@floating-ui/react-dom': 2.1.2(react-dom@18.3.1)(react@18.3.1) - '@mui/types': 7.2.17 - '@mui/utils': 5.16.6(react@18.3.1) + '@mui/types': 7.2.19 + '@mui/utils': 6.1.6(react@18.3.1) '@popperjs/core': 2.11.8 clsx: 2.1.1 prop-types: 15.8.1 @@ -1328,6 +1335,15 @@ packages: optional: true dev: false + /@mui/types@7.2.19: + resolution: {integrity: sha512-6XpZEM/Q3epK9RN8ENoXuygnqUQxE+siN/6rGRi2iwJPgBUR25mphYQ9ZI87plGh58YoZ5pp40bFvKYOCDJ3tA==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dev: false + /@mui/utils@5.16.6(react@18.3.1): resolution: {integrity: sha512-tWiQqlhxAt3KENNiSRL+DIn9H5xNVK6Jjf70x3PnfQPz1MPBdh7yyIcAyVBT9xiw7hP3SomRhPR7hzBMBCjqEA==} engines: {node: '>=12.0.0'} @@ -1349,6 +1365,27 @@ packages: react-is: 18.3.1 dev: false + /@mui/utils@6.1.6(react@18.3.1): + resolution: {integrity: sha512-sBS6D9mJECtELASLM+18WUcXF6RH3zNxBRFeyCRg8wad6NbyNrdxLuwK+Ikvc38sTZwBzAz691HmSofLqHd9sQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + dependencies: + '@babel/runtime': 7.26.0 + '@mui/types': 7.2.19 + '@types/prop-types': 15.7.13 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.3.1 + react-is: 18.3.1 + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -5920,6 +5957,12 @@ packages: /uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true + dev: true + + /uuid@11.0.2: + resolution: {integrity: sha512-14FfcOJmqdjbBPdDjFQyk/SdT4NySW4eM0zcG+HqbHP5jzuH56xO3J1DGhgs/cEMCfwYi3HQI1gnTO62iaG+tQ==} + hasBin: true + dev: false /uuid@8.0.0: resolution: {integrity: sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==} @@ -6265,8 +6308,8 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: false - github.com/theopensystemslab/planx-core/54be9e0: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/54be9e0} + github.com/theopensystemslab/planx-core/d3d933c: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/d3d933c} name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true @@ -6274,8 +6317,8 @@ packages: dependencies: '@emotion/react': 11.13.3(react@18.3.1) '@emotion/styled': 11.13.0(@emotion/react@11.13.3)(react@18.3.1) - '@formatjs/intl-listformat': 7.5.9 - '@mui/base': 5.0.0-beta.40(react-dom@18.3.1)(react@18.3.1) + '@formatjs/intl-listformat': 7.7.4 + '@mui/base': 5.0.0-beta.60(react-dom@18.3.1)(react@18.3.1) '@mui/material': 5.16.7(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(react-dom@18.3.1)(react@18.3.1) ajv: 8.17.1 ajv-formats: 2.1.1(ajv@8.17.1) @@ -6293,7 +6336,7 @@ packages: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) type-fest: 4.26.1 - uuid: 10.0.0 + uuid: 11.0.2 zod: 3.23.8 transitivePeerDependencies: - '@types/react' diff --git a/e2e/tests/api-driven/package.json b/e2e/tests/api-driven/package.json index 44c794c009..89f282b275 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#54be9e0", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#d3d933c", "axios": "^1.7.4", "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 d31ecc7a51..0746661a2e 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#54be9e0 - version: github.com/theopensystemslab/planx-core/54be9e0 + specifier: git+https://github.com/theopensystemslab/planx-core#d3d933c + version: github.com/theopensystemslab/planx-core/d3d933c axios: specifier: ^1.7.4 version: 1.7.4 @@ -121,6 +121,13 @@ packages: regenerator-runtime: 0.14.1 dev: false + /@babel/runtime@7.26.0: + resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.1 + dev: false + /@babel/template@7.25.0: resolution: {integrity: sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==} engines: {node: '>=6.9.0'} @@ -486,30 +493,30 @@ packages: resolution: {integrity: sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==} dev: false - /@formatjs/ecma402-abstract@2.2.0: - resolution: {integrity: sha512-IpM+ev1E4QLtstniOE29W1rqH9eTdx5hQdNL8pzrflMj/gogfaoONZqL83LUeQScHAvyMbpqP5C9MzNf+fFwhQ==} + /@formatjs/ecma402-abstract@2.2.3: + resolution: {integrity: sha512-aElGmleuReGnk2wtYOzYFmNWYoiWWmf1pPPCYg0oiIQSJj0mjc4eUfzUXaSOJ4S8WzI/cLqnCTWjqz904FT2OQ==} dependencies: - '@formatjs/fast-memoize': 2.2.1 - '@formatjs/intl-localematcher': 0.5.5 + '@formatjs/fast-memoize': 2.2.3 + '@formatjs/intl-localematcher': 0.5.7 tslib: 2.7.0 dev: false - /@formatjs/fast-memoize@2.2.1: - resolution: {integrity: sha512-XS2RcOSyWxmUB7BUjj3mlPH0exsUzlf6QfhhijgI941WaJhVxXQ6mEWkdUFIdnKi3TuTYxRdelsgv3mjieIGIA==} + /@formatjs/fast-memoize@2.2.3: + resolution: {integrity: sha512-3jeJ+HyOfu8osl3GNSL4vVHUuWFXR03Iz9jjgI7RwjG6ysu/Ymdr0JRCPHfF5yGbTE6JCrd63EpvX1/WybYRbA==} dependencies: tslib: 2.7.0 dev: false - /@formatjs/intl-listformat@7.5.9: - resolution: {integrity: sha512-HqtGkxUh2Uz0oGVTxHAvPZ3EGxc8+ol5+Bx7S9xB97d4PEJJd9oOgHrerIghHA0gtIjsNKBFUae3P0My+F6YUA==} + /@formatjs/intl-listformat@7.7.4: + resolution: {integrity: sha512-lipFspH2MZcoeXxR6WSR/Jy9unzJ/iT0w+gbL8vgv25Ap0S9cUtcDVAce4ECEKI1bDtAvEU3b6+9Dha27gAikA==} dependencies: - '@formatjs/ecma402-abstract': 2.2.0 - '@formatjs/intl-localematcher': 0.5.5 + '@formatjs/ecma402-abstract': 2.2.3 + '@formatjs/intl-localematcher': 0.5.7 tslib: 2.7.0 dev: false - /@formatjs/intl-localematcher@0.5.5: - resolution: {integrity: sha512-t5tOGMgZ/i5+ALl2/offNqAQq/lfUnKLEw0mXQI4N4bqpedhrSE+fyKLpwnd22sK0dif6AV+ufQcTsKShB9J1g==} + /@formatjs/intl-localematcher@0.5.7: + resolution: {integrity: sha512-GGFtfHGQVFe/niOZp24Kal5b2i36eE2bNL0xi9Sg/yd0TR8aLjcteApZdHmismP5QQax1cMnZM9yWySUUjJteA==} dependencies: tslib: 2.7.0 dev: false @@ -595,9 +602,9 @@ packages: resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} dev: false - /@mui/base@5.0.0-beta.40(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==} - engines: {node: '>=12.0.0'} + /@mui/base@5.0.0-beta.60(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-w8twR3qCUI+uJHO5xDOuc1yB5l46KFbvNsTwIvEW9tQkKxVaiEFf2GAXHuvFJiHfZLqjzett6drZjghy8D1Z1A==} + engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0 @@ -606,10 +613,10 @@ packages: '@types/react': optional: true dependencies: - '@babel/runtime': 7.25.0 + '@babel/runtime': 7.26.0 '@floating-ui/react-dom': 2.1.1(react-dom@18.3.1)(react@18.3.1) - '@mui/types': 7.2.15 - '@mui/utils': 5.16.6(react@18.3.1) + '@mui/types': 7.2.19 + '@mui/utils': 6.1.6(react@18.3.1) '@popperjs/core': 2.11.8 clsx: 2.1.1 prop-types: 15.8.1 @@ -732,6 +739,15 @@ packages: optional: true dev: false + /@mui/types@7.2.19: + resolution: {integrity: sha512-6XpZEM/Q3epK9RN8ENoXuygnqUQxE+siN/6rGRi2iwJPgBUR25mphYQ9ZI87plGh58YoZ5pp40bFvKYOCDJ3tA==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dev: false + /@mui/utils@5.16.6(react@18.3.1): resolution: {integrity: sha512-tWiQqlhxAt3KENNiSRL+DIn9H5xNVK6Jjf70x3PnfQPz1MPBdh7yyIcAyVBT9xiw7hP3SomRhPR7hzBMBCjqEA==} engines: {node: '>=12.0.0'} @@ -751,6 +767,25 @@ packages: react-is: 18.3.1 dev: false + /@mui/utils@6.1.6(react@18.3.1): + resolution: {integrity: sha512-sBS6D9mJECtELASLM+18WUcXF6RH3zNxBRFeyCRg8wad6NbyNrdxLuwK+Ikvc38sTZwBzAz691HmSofLqHd9sQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.26.0 + '@mui/types': 7.2.19 + '@types/prop-types': 15.7.13 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.3.1 + react-is: 18.3.1 + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -835,6 +870,10 @@ packages: resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} dev: false + /@types/prop-types@15.7.13: + resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} + dev: false + /@types/react-transition-group@4.4.11: resolution: {integrity: sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==} dependencies: @@ -2789,8 +2828,8 @@ packages: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: false - /uuid@10.0.0: - resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + /uuid@11.0.2: + resolution: {integrity: sha512-14FfcOJmqdjbBPdDjFQyk/SdT4NySW4eM0zcG+HqbHP5jzuH56xO3J1DGhgs/cEMCfwYi3HQI1gnTO62iaG+tQ==} hasBin: true dev: false @@ -2956,8 +2995,8 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: false - github.com/theopensystemslab/planx-core/54be9e0: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/54be9e0} + github.com/theopensystemslab/planx-core/d3d933c: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/d3d933c} name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true @@ -2965,8 +3004,8 @@ packages: dependencies: '@emotion/react': 11.13.3(react@18.3.1) '@emotion/styled': 11.13.0(@emotion/react@11.13.3)(react@18.3.1) - '@formatjs/intl-listformat': 7.5.9 - '@mui/base': 5.0.0-beta.40(react-dom@18.3.1)(react@18.3.1) + '@formatjs/intl-listformat': 7.7.4 + '@mui/base': 5.0.0-beta.60(react-dom@18.3.1)(react@18.3.1) '@mui/material': 5.16.7(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(react-dom@18.3.1)(react@18.3.1) ajv: 8.17.1 ajv-formats: 2.1.1(ajv@8.17.1) @@ -2984,7 +3023,7 @@ packages: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) type-fest: 4.26.1 - uuid: 10.0.0 + uuid: 11.0.2 zod: 3.23.8 transitivePeerDependencies: - '@types/react' diff --git a/e2e/tests/ui-driven/package.json b/e2e/tests/ui-driven/package.json index a4e2baf367..09e840c168 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#54be9e0", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#d3d933c", "axios": "^1.7.4", "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 52cf5a39e4..491073da90 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#54be9e0 - version: github.com/theopensystemslab/planx-core/54be9e0 + specifier: git+https://github.com/theopensystemslab/planx-core#d3d933c + version: github.com/theopensystemslab/planx-core/d3d933c axios: specifier: ^1.7.4 version: 1.7.4 @@ -121,6 +121,13 @@ packages: regenerator-runtime: 0.14.1 dev: false + /@babel/runtime@7.26.0: + resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.1 + dev: false + /@babel/template@7.25.0: resolution: {integrity: sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==} engines: {node: '>=6.9.0'} @@ -350,30 +357,30 @@ packages: resolution: {integrity: sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==} dev: false - /@formatjs/ecma402-abstract@2.2.0: - resolution: {integrity: sha512-IpM+ev1E4QLtstniOE29W1rqH9eTdx5hQdNL8pzrflMj/gogfaoONZqL83LUeQScHAvyMbpqP5C9MzNf+fFwhQ==} + /@formatjs/ecma402-abstract@2.2.3: + resolution: {integrity: sha512-aElGmleuReGnk2wtYOzYFmNWYoiWWmf1pPPCYg0oiIQSJj0mjc4eUfzUXaSOJ4S8WzI/cLqnCTWjqz904FT2OQ==} dependencies: - '@formatjs/fast-memoize': 2.2.1 - '@formatjs/intl-localematcher': 0.5.5 + '@formatjs/fast-memoize': 2.2.3 + '@formatjs/intl-localematcher': 0.5.7 tslib: 2.7.0 dev: false - /@formatjs/fast-memoize@2.2.1: - resolution: {integrity: sha512-XS2RcOSyWxmUB7BUjj3mlPH0exsUzlf6QfhhijgI941WaJhVxXQ6mEWkdUFIdnKi3TuTYxRdelsgv3mjieIGIA==} + /@formatjs/fast-memoize@2.2.3: + resolution: {integrity: sha512-3jeJ+HyOfu8osl3GNSL4vVHUuWFXR03Iz9jjgI7RwjG6ysu/Ymdr0JRCPHfF5yGbTE6JCrd63EpvX1/WybYRbA==} dependencies: tslib: 2.7.0 dev: false - /@formatjs/intl-listformat@7.5.9: - resolution: {integrity: sha512-HqtGkxUh2Uz0oGVTxHAvPZ3EGxc8+ol5+Bx7S9xB97d4PEJJd9oOgHrerIghHA0gtIjsNKBFUae3P0My+F6YUA==} + /@formatjs/intl-listformat@7.7.4: + resolution: {integrity: sha512-lipFspH2MZcoeXxR6WSR/Jy9unzJ/iT0w+gbL8vgv25Ap0S9cUtcDVAce4ECEKI1bDtAvEU3b6+9Dha27gAikA==} dependencies: - '@formatjs/ecma402-abstract': 2.2.0 - '@formatjs/intl-localematcher': 0.5.5 + '@formatjs/ecma402-abstract': 2.2.3 + '@formatjs/intl-localematcher': 0.5.7 tslib: 2.7.0 dev: false - /@formatjs/intl-localematcher@0.5.5: - resolution: {integrity: sha512-t5tOGMgZ/i5+ALl2/offNqAQq/lfUnKLEw0mXQI4N4bqpedhrSE+fyKLpwnd22sK0dif6AV+ufQcTsKShB9J1g==} + /@formatjs/intl-localematcher@0.5.7: + resolution: {integrity: sha512-GGFtfHGQVFe/niOZp24Kal5b2i36eE2bNL0xi9Sg/yd0TR8aLjcteApZdHmismP5QQax1cMnZM9yWySUUjJteA==} dependencies: tslib: 2.7.0 dev: false @@ -463,9 +470,9 @@ packages: resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} dev: false - /@mui/base@5.0.0-beta.40(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==} - engines: {node: '>=12.0.0'} + /@mui/base@5.0.0-beta.60(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-w8twR3qCUI+uJHO5xDOuc1yB5l46KFbvNsTwIvEW9tQkKxVaiEFf2GAXHuvFJiHfZLqjzett6drZjghy8D1Z1A==} + engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0 @@ -474,10 +481,10 @@ packages: '@types/react': optional: true dependencies: - '@babel/runtime': 7.25.0 + '@babel/runtime': 7.26.0 '@floating-ui/react-dom': 2.1.1(react-dom@18.3.1)(react@18.3.1) - '@mui/types': 7.2.15 - '@mui/utils': 5.16.6(react@18.3.1) + '@mui/types': 7.2.19 + '@mui/utils': 6.1.6(react@18.3.1) '@popperjs/core': 2.11.8 clsx: 2.1.1 prop-types: 15.8.1 @@ -600,6 +607,15 @@ packages: optional: true dev: false + /@mui/types@7.2.19: + resolution: {integrity: sha512-6XpZEM/Q3epK9RN8ENoXuygnqUQxE+siN/6rGRi2iwJPgBUR25mphYQ9ZI87plGh58YoZ5pp40bFvKYOCDJ3tA==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dev: false + /@mui/utils@5.16.6(react@18.3.1): resolution: {integrity: sha512-tWiQqlhxAt3KENNiSRL+DIn9H5xNVK6Jjf70x3PnfQPz1MPBdh7yyIcAyVBT9xiw7hP3SomRhPR7hzBMBCjqEA==} engines: {node: '>=12.0.0'} @@ -619,6 +635,25 @@ packages: react-is: 18.3.1 dev: false + /@mui/utils@6.1.6(react@18.3.1): + resolution: {integrity: sha512-sBS6D9mJECtELASLM+18WUcXF6RH3zNxBRFeyCRg8wad6NbyNrdxLuwK+Ikvc38sTZwBzAz691HmSofLqHd9sQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.26.0 + '@mui/types': 7.2.19 + '@types/prop-types': 15.7.13 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.3.1 + react-is: 18.3.1 + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -682,6 +717,10 @@ packages: resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} dev: false + /@types/prop-types@15.7.13: + resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} + dev: false + /@types/react-transition-group@4.4.11: resolution: {integrity: sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==} dependencies: @@ -2562,8 +2601,8 @@ packages: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: false - /uuid@10.0.0: - resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + /uuid@11.0.2: + resolution: {integrity: sha512-14FfcOJmqdjbBPdDjFQyk/SdT4NySW4eM0zcG+HqbHP5jzuH56xO3J1DGhgs/cEMCfwYi3HQI1gnTO62iaG+tQ==} hasBin: true dev: false @@ -2695,8 +2734,8 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: false - github.com/theopensystemslab/planx-core/54be9e0: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/54be9e0} + github.com/theopensystemslab/planx-core/d3d933c: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/d3d933c} name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true @@ -2704,8 +2743,8 @@ packages: dependencies: '@emotion/react': 11.13.3(react@18.3.1) '@emotion/styled': 11.13.0(@emotion/react@11.13.3)(react@18.3.1) - '@formatjs/intl-listformat': 7.5.9 - '@mui/base': 5.0.0-beta.40(react-dom@18.3.1)(react@18.3.1) + '@formatjs/intl-listformat': 7.7.4 + '@mui/base': 5.0.0-beta.60(react-dom@18.3.1)(react@18.3.1) '@mui/material': 5.16.7(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(react-dom@18.3.1)(react@18.3.1) ajv: 8.17.1 ajv-formats: 2.1.1(ajv@8.17.1) @@ -2723,7 +2762,7 @@ packages: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) type-fest: 4.26.1 - uuid: 10.0.0 + uuid: 11.0.2 zod: 3.23.8 transitivePeerDependencies: - '@types/react' diff --git a/editor.planx.uk/package.json b/editor.planx.uk/package.json index 4ec4c918a9..a6fc3e8153 100644 --- a/editor.planx.uk/package.json +++ b/editor.planx.uk/package.json @@ -15,7 +15,7 @@ "@mui/material": "^5.15.10", "@mui/utils": "^5.15.11", "@opensystemslab/map": "1.0.0-alpha.4", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#b4ca5a9", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#d3d933c", "@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 603a2812c4..affc30cf9d 100644 --- a/editor.planx.uk/pnpm-lock.yaml +++ b/editor.planx.uk/pnpm-lock.yaml @@ -47,8 +47,8 @@ dependencies: specifier: 1.0.0-alpha.4 version: 1.0.0-alpha.4 '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#b4ca5a9 - version: github.com/theopensystemslab/planx-core/b4ca5a9(@types/react@18.2.45) + specifier: git+https://github.com/theopensystemslab/planx-core#d3d933c + version: github.com/theopensystemslab/planx-core/d3d933c(@types/react@18.2.45) '@tiptap/core': specifier: ^2.4.0 version: 2.4.0(@tiptap/pm@2.0.3) @@ -2789,30 +2789,30 @@ packages: react: 18.2.0 dev: true - /@formatjs/ecma402-abstract@2.2.1: - resolution: {integrity: sha512-O4ywpkdJybrjFc9zyL8qK5aklleIAi5O4nYhBVJaOFtCkNrnU+lKFeJOFC48zpsZQmR8Aok2V79hGpHnzbmFpg==} + /@formatjs/ecma402-abstract@2.2.3: + resolution: {integrity: sha512-aElGmleuReGnk2wtYOzYFmNWYoiWWmf1pPPCYg0oiIQSJj0mjc4eUfzUXaSOJ4S8WzI/cLqnCTWjqz904FT2OQ==} dependencies: - '@formatjs/fast-memoize': 2.2.2 - '@formatjs/intl-localematcher': 0.5.6 + '@formatjs/fast-memoize': 2.2.3 + '@formatjs/intl-localematcher': 0.5.7 tslib: 2.7.0 dev: false - /@formatjs/fast-memoize@2.2.2: - resolution: {integrity: sha512-mzxZcS0g1pOzwZTslJOBTmLzDXseMLLvnh25ymRilCm8QLMObsQ7x/rj9GNrH0iUhZMlFisVOD6J1n6WQqpKPQ==} + /@formatjs/fast-memoize@2.2.3: + resolution: {integrity: sha512-3jeJ+HyOfu8osl3GNSL4vVHUuWFXR03Iz9jjgI7RwjG6ysu/Ymdr0JRCPHfF5yGbTE6JCrd63EpvX1/WybYRbA==} dependencies: tslib: 2.7.0 dev: false - /@formatjs/intl-listformat@7.7.1: - resolution: {integrity: sha512-bjBxWaUhYAbJFUlFSMWZGn3r2mglXwk+BLyGRu8dY8Q83ZPsqmmVQzjQKENHE3lV6eoQGHT2oZHxUaVndJlk6Q==} + /@formatjs/intl-listformat@7.7.4: + resolution: {integrity: sha512-lipFspH2MZcoeXxR6WSR/Jy9unzJ/iT0w+gbL8vgv25Ap0S9cUtcDVAce4ECEKI1bDtAvEU3b6+9Dha27gAikA==} dependencies: - '@formatjs/ecma402-abstract': 2.2.1 - '@formatjs/intl-localematcher': 0.5.6 + '@formatjs/ecma402-abstract': 2.2.3 + '@formatjs/intl-localematcher': 0.5.7 tslib: 2.7.0 dev: false - /@formatjs/intl-localematcher@0.5.6: - resolution: {integrity: sha512-roz1+Ba5e23AHX6KUAWmLEyTRZegM5YDuxuvkHCyK3RJddf/UXB2f+s7pOMm9ktfPGla0g+mQXOn5vsuYirnaA==} + /@formatjs/intl-localematcher@0.5.7: + resolution: {integrity: sha512-GGFtfHGQVFe/niOZp24Kal5b2i36eE2bNL0xi9Sg/yd0TR8aLjcteApZdHmismP5QQax1cMnZM9yWySUUjJteA==} dependencies: tslib: 2.7.0 dev: false @@ -15390,9 +15390,9 @@ packages: use-sync-external-store: 1.2.0(react@18.2.0) dev: false - github.com/theopensystemslab/planx-core/b4ca5a9(@types/react@18.2.45): - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/b4ca5a9} - id: github.com/theopensystemslab/planx-core/b4ca5a9 + github.com/theopensystemslab/planx-core/d3d933c(@types/react@18.2.45): + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/d3d933c} + id: github.com/theopensystemslab/planx-core/d3d933c name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true @@ -15400,7 +15400,7 @@ packages: dependencies: '@emotion/react': 11.13.3(@types/react@18.2.45)(react@18.3.1) '@emotion/styled': 11.13.0(@emotion/react@11.13.3)(@types/react@18.2.45)(react@18.3.1) - '@formatjs/intl-listformat': 7.7.1 + '@formatjs/intl-listformat': 7.7.4 '@mui/base': 5.0.0-beta.60(@types/react@18.2.45)(react-dom@18.3.1)(react@18.3.1) '@mui/material': 5.15.10(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(@types/react@18.2.45)(react-dom@18.3.1)(react@18.3.1) ajv: 8.17.1 From 15e3f49cd0d38944e6899e876673e4dc2c44e0ee Mon Sep 17 00:00:00 2001 From: Rory Doak <138574807+RODO94@users.noreply.github.com> Date: Tue, 5 Nov 2024 15:39:05 +0000 Subject: [PATCH 12/12] feat: ensure `application_type` is added to views as a text field (#3908) --- .../down.sql | 158 +++++++++++++++++ .../up.sql | 162 ++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 hasura.planx.uk/migrations/1730730955039_application_type_as_text_in_view/down.sql create mode 100644 hasura.planx.uk/migrations/1730730955039_application_type_as_text_in_view/up.sql diff --git a/hasura.planx.uk/migrations/1730730955039_application_type_as_text_in_view/down.sql b/hasura.planx.uk/migrations/1730730955039_application_type_as_text_in_view/down.sql new file mode 100644 index 0000000000..c11f361172 --- /dev/null +++ b/hasura.planx.uk/migrations/1730730955039_application_type_as_text_in_view/down.sql @@ -0,0 +1,158 @@ +CREATE OR REPLACE VIEW "public"."submission_services_summary" AS + WITH resumes_per_session AS ( + SELECT reconciliation_requests.session_id, + count(reconciliation_requests.id) AS number_times_resumed + FROM reconciliation_requests + GROUP BY reconciliation_requests.session_id + ), bops_agg AS ( + SELECT bops_applications.session_id, + json_agg(json_build_object('id', bops_applications.bops_id, 'submittedAt', bops_applications.created_at, 'destinationUrl', bops_applications.destination_url) ORDER BY bops_applications.created_at DESC) AS bops_applications + FROM bops_applications + GROUP BY bops_applications.session_id + ), email_agg AS ( + SELECT email_applications.session_id, + json_agg(json_build_object('id', email_applications.id, 'recipient', email_applications.recipient, 'submittedAt', email_applications.created_at) ORDER BY email_applications.created_at DESC) AS email_applications + FROM email_applications + GROUP BY email_applications.session_id + ), uniform_agg AS ( + SELECT uniform_applications.submission_reference, + json_agg(json_build_object('id', uniform_applications.idox_submission_id, 'submittedAt', uniform_applications.created_at) ORDER BY uniform_applications.created_at DESC) AS uniform_applications + FROM uniform_applications + GROUP BY uniform_applications.submission_reference + ), payment_requests_agg AS ( + SELECT payment_requests.session_id, + json_agg(json_build_object('id', payment_requests.id, 'createdAt', payment_requests.created_at, 'paidAt', payment_requests.paid_at, 'govpayPaymentId', payment_requests.govpay_payment_id) ORDER BY payment_requests.created_at DESC) AS payment_requests + FROM payment_requests + GROUP BY payment_requests.session_id + ), payment_status_agg AS ( + SELECT payment_status.session_id, + json_agg(json_build_object('govpayPaymentId', payment_status.payment_id, 'createdAt', payment_status.created_at, 'status', payment_status.status) ORDER BY payment_status.created_at DESC) AS payment_status + FROM payment_status + GROUP BY payment_status.session_id + ), s3_agg AS ( + SELECT s3_applications.session_id, + json_agg(json_build_object('id', s3_applications.id, 'submittedAt', s3_applications.created_at) ORDER BY s3_applications.created_at DESC) AS s3_applications + FROM s3_applications + GROUP BY s3_applications.session_id + ) + SELECT (ls.id)::text AS session_id, + t.slug AS team_slug, + f.slug AS service_slug, + ls.created_at, + ls.submitted_at, + ((ls.submitted_at)::date - (ls.created_at)::date) AS session_length_days, + ls.has_user_saved AS user_clicked_save, + rps.number_times_resumed, + ls.allow_list_answers, + (ls.allow_list_answers -> 'proposal.projectType'::text) AS proposal_project_type, + (ls.allow_list_answers -> 'application.declaration.connection'::text) AS application_declaration_connection, + (ls.allow_list_answers -> 'property.type'::text) AS property_type, + (ls.allow_list_answers -> 'drawBoundary.action'::text) AS draw_boundary_action, + (ls.allow_list_answers -> 'user.role'::text) AS user_role, + (ls.allow_list_answers -> 'property.constraints.planning'::text) AS property_constraints_planning, + CASE + WHEN (((pr.payment_requests)::jsonb IS NOT NULL) AND (jsonb_array_length((pr.payment_requests)::jsonb) > 0)) THEN true + ELSE false + END AS user_invited_to_pay, + pr.payment_requests, + ps.payment_status, + CASE + WHEN (((ba.bops_applications)::jsonb IS NOT NULL) AND (jsonb_array_length((ba.bops_applications)::jsonb) > 0)) THEN true + ELSE false + END AS sent_to_bops, + ba.bops_applications, + CASE + WHEN (((ua.uniform_applications)::jsonb IS NOT NULL) AND (jsonb_array_length((ua.uniform_applications)::jsonb) > 0)) THEN true + ELSE false + END AS sent_to_uniform, + ua.uniform_applications, + CASE + WHEN (((ea.email_applications)::jsonb IS NOT NULL) AND (jsonb_array_length((ea.email_applications)::jsonb) > 0)) THEN true + ELSE false + END AS sent_to_email, + ea.email_applications, + (ls.allow_list_answers -> 'findProperty.action'::text) AS find_property_action, + CASE + WHEN (((sa.s3_applications)::jsonb IS NOT NULL) AND (jsonb_array_length((sa.s3_applications)::jsonb) > 0)) THEN true + ELSE false + END AS sent_to_s3_power_automate, + sa.s3_applications, + (ls.allow_list_answers -> 'usedFOIYNPP'::text) AS used_foiynpp, + (ls.allow_list_answers -> 'propertyInformation.action'::text) AS property_information_action, + (ls.allow_list_answers -> 'planningConstraints.action'::text) AS planning_constraints_action, + (ls.allow_list_answers -> '_overrides'::text) AS overrides, + (ls.allow_list_answers -> 'rab.exitReason'::text) AS rab_exit_reason, + (ls.allow_list_answers -> 'service.type'::text) AS pre_app_service_type, + (ls.allow_list_answers -> 'application.information.harmful'::text) AS pre_app_harmful_info, + (ls.allow_list_answers -> 'application.information.sensitive'::text) AS pre_app_sensitive_info, + (ls.allow_list_answers -> 'application.type'::text) AS application_type + FROM (((((((((lowcal_sessions ls + LEFT JOIN flows f ON ((f.id = ls.flow_id))) + LEFT JOIN teams t ON ((t.id = f.team_id))) + LEFT JOIN resumes_per_session rps ON ((rps.session_id = (ls.id)::text))) + LEFT JOIN payment_requests_agg pr ON ((pr.session_id = ls.id))) + LEFT JOIN payment_status_agg ps ON ((ps.session_id = ls.id))) + LEFT JOIN bops_agg ba ON ((ba.session_id = (ls.id)::text))) + LEFT JOIN uniform_agg ua ON ((ua.submission_reference = (ls.id)::text))) + LEFT JOIN email_agg ea ON ((ea.session_id = ls.id))) + LEFT JOIN s3_agg sa ON ((sa.session_id = (ls.id)::text))) + WHERE ((f.slug IS NOT NULL) AND (t.slug IS NOT NULL)); + +CREATE OR REPLACE VIEW "public"."analytics_summary" AS + SELECT a.id AS analytics_id, + al.id AS analytics_log_id, + f.slug AS service_slug, + t.slug AS team_slug, + a.type AS analytics_type, + al.created_at AS analytics_log_created_at, + a.created_at AS analytics_created_at, + ((a.user_agent -> 'os'::text) ->> 'name'::text) AS operating_system, + ((a.user_agent -> 'browser'::text) ->> 'name'::text) AS browser, + ((a.user_agent -> 'platform'::text) ->> 'type'::text) AS platform, + a.referrer, + al.flow_direction, + (al.metadata ->> 'change'::text) AS change_metadata, + (al.metadata ->> 'back'::text) AS back_metadata, + (al.metadata ->> 'selectedUrls'::text) AS selected_urls, + (al.metadata ->> 'flag'::text) AS result_flag, + (al.metadata -> 'flagSet'::text) AS result_flagset, + ((al.metadata -> 'displayText'::text) ->> 'heading'::text) AS result_heading, + ((al.metadata -> 'displayText'::text) ->> 'description'::text) AS result_description, + (al.metadata -> 'helpTextUseful'::text) AS help_text_useful, + CASE + WHEN al.has_clicked_help THEN al.metadata + ELSE NULL::jsonb + END AS help_metadata, + al.user_exit AS is_user_exit, + al.node_type, + al.node_title, + al.has_clicked_help, + al.input_errors, + (date_part('epoch'::text, (al.next_log_created_at - al.created_at)))::numeric(10,1) AS time_spent_on_node_seconds, + a.ended_at AS analytics_ended_at, + ((date_part('epoch'::text, (a.ended_at - a.created_at)) / (60)::double precision))::numeric(10,1) AS time_spent_on_analytics_session_minutes, + al.node_id, + al.allow_list_answers, + (al.allow_list_answers -> 'proposal.projectType'::text) AS proposal_project_type, + (al.allow_list_answers -> 'application.declaration.connection'::text) AS application_declaration_connection, + (al.allow_list_answers -> 'property.type'::text) AS property_type, + (al.allow_list_answers -> 'drawBoundary.action'::text) AS draw_boundary_action, + (al.allow_list_answers -> 'user.role'::text) AS user_role, + (al.allow_list_answers -> 'property.constraints.planning'::text) AS property_constraints_planning, + (al.allow_list_answers -> 'findProperty.action'::text) AS find_property_action, + (al.allow_list_answers -> 'usedFOIYNPP'::text) AS used_foiynpp, + (al.allow_list_answers -> 'propertyInformation.action'::text) AS property_information_action, + (al.allow_list_answers -> 'planningConstraints.action'::text) AS planning_constraints_action, + (al.allow_list_answers -> '_overrides'::text) AS overrides, + (al.allow_list_answers -> 'rab.exitReason'::text) AS rab_exit_reason, + (al.allow_list_answers -> 'service.type'::text) AS pre_app_service_type, + (al.allow_list_answers -> 'application.information.harmful'::text) AS pre_app_harmful_info, + (al.allow_list_answers -> 'application.information.sensitive'::text) AS pre_app_sensitive_info, + (al.allow_list_answers -> 'application.type'::text) AS application_type + FROM (((analytics a + LEFT JOIN analytics_logs al ON ((a.id = al.analytics_id))) + LEFT JOIN flows f ON ((a.flow_id = f.id))) + LEFT JOIN teams t ON ((t.id = f.team_id))); + +GRANT SELECT ON "public"."analytics_summary" TO metabase_read_only; +GRANT SELECT ON "public"."submission_services_summary" TO metabase_read_only; \ No newline at end of file diff --git a/hasura.planx.uk/migrations/1730730955039_application_type_as_text_in_view/up.sql b/hasura.planx.uk/migrations/1730730955039_application_type_as_text_in_view/up.sql new file mode 100644 index 0000000000..99ab6b85ff --- /dev/null +++ b/hasura.planx.uk/migrations/1730730955039_application_type_as_text_in_view/up.sql @@ -0,0 +1,162 @@ +DROP VIEW IF EXISTS "public"."submission_services_summary"; + +CREATE VIEW "public"."submission_services_summary" AS + WITH resumes_per_session AS ( + SELECT reconciliation_requests.session_id, + count(reconciliation_requests.id) AS number_times_resumed + FROM reconciliation_requests + GROUP BY reconciliation_requests.session_id + ), bops_agg AS ( + SELECT bops_applications.session_id, + json_agg(json_build_object('id', bops_applications.bops_id, 'submittedAt', bops_applications.created_at, 'destinationUrl', bops_applications.destination_url) ORDER BY bops_applications.created_at DESC) AS bops_applications + FROM bops_applications + GROUP BY bops_applications.session_id + ), email_agg AS ( + SELECT email_applications.session_id, + json_agg(json_build_object('id', email_applications.id, 'recipient', email_applications.recipient, 'submittedAt', email_applications.created_at) ORDER BY email_applications.created_at DESC) AS email_applications + FROM email_applications + GROUP BY email_applications.session_id + ), uniform_agg AS ( + SELECT uniform_applications.submission_reference, + json_agg(json_build_object('id', uniform_applications.idox_submission_id, 'submittedAt', uniform_applications.created_at) ORDER BY uniform_applications.created_at DESC) AS uniform_applications + FROM uniform_applications + GROUP BY uniform_applications.submission_reference + ), payment_requests_agg AS ( + SELECT payment_requests.session_id, + json_agg(json_build_object('id', payment_requests.id, 'createdAt', payment_requests.created_at, 'paidAt', payment_requests.paid_at, 'govpayPaymentId', payment_requests.govpay_payment_id) ORDER BY payment_requests.created_at DESC) AS payment_requests + FROM payment_requests + GROUP BY payment_requests.session_id + ), payment_status_agg AS ( + SELECT payment_status.session_id, + json_agg(json_build_object('govpayPaymentId', payment_status.payment_id, 'createdAt', payment_status.created_at, 'status', payment_status.status) ORDER BY payment_status.created_at DESC) AS payment_status + FROM payment_status + GROUP BY payment_status.session_id + ), s3_agg AS ( + SELECT s3_applications.session_id, + json_agg(json_build_object('id', s3_applications.id, 'submittedAt', s3_applications.created_at) ORDER BY s3_applications.created_at DESC) AS s3_applications + FROM s3_applications + GROUP BY s3_applications.session_id + ) + SELECT (ls.id)::text AS session_id, + t.slug AS team_slug, + f.slug AS service_slug, + ls.created_at, + ls.submitted_at, + ((ls.submitted_at)::date - (ls.created_at)::date) AS session_length_days, + ls.has_user_saved AS user_clicked_save, + rps.number_times_resumed, + ls.allow_list_answers, + (ls.allow_list_answers -> 'proposal.projectType')::text AS proposal_project_type, + (ls.allow_list_answers -> 'application.declaration.connection')::text AS application_declaration_connection, + (ls.allow_list_answers -> 'property.type')::text AS property_type, + (ls.allow_list_answers -> 'drawBoundary.action')::text AS draw_boundary_action, + (ls.allow_list_answers -> 'user.role')::text AS user_role, + (ls.allow_list_answers -> 'property.constraints.planning')::text AS property_constraints_planning, + CASE + WHEN (((pr.payment_requests)::jsonb IS NOT NULL) AND (jsonb_array_length((pr.payment_requests)::jsonb) > 0)) THEN true + ELSE false + END AS user_invited_to_pay, + pr.payment_requests, + ps.payment_status, + CASE + WHEN (((ba.bops_applications)::jsonb IS NOT NULL) AND (jsonb_array_length((ba.bops_applications)::jsonb) > 0)) THEN true + ELSE false + END AS sent_to_bops, + ba.bops_applications, + CASE + WHEN (((ua.uniform_applications)::jsonb IS NOT NULL) AND (jsonb_array_length((ua.uniform_applications)::jsonb) > 0)) THEN true + ELSE false + END AS sent_to_uniform, + ua.uniform_applications, + CASE + WHEN (((ea.email_applications)::jsonb IS NOT NULL) AND (jsonb_array_length((ea.email_applications)::jsonb) > 0)) THEN true + ELSE false + END AS sent_to_email, + ea.email_applications, + (ls.allow_list_answers -> 'findProperty.action')::text AS find_property_action, + CASE + WHEN (((sa.s3_applications)::jsonb IS NOT NULL) AND (jsonb_array_length((sa.s3_applications)::jsonb) > 0)) THEN true + ELSE false + END AS sent_to_s3_power_automate, + sa.s3_applications, + (ls.allow_list_answers -> 'usedFOIYNPP')::text AS used_foiynpp, + (ls.allow_list_answers -> 'propertyInformation.action')::text AS property_information_action, + (ls.allow_list_answers -> 'planningConstraints.action')::text AS planning_constraints_action, + (ls.allow_list_answers -> '_overrides')::text AS overrides, + (ls.allow_list_answers -> 'rab.exitReason')::text AS rab_exit_reason, + (ls.allow_list_answers -> 'service.type')::text AS pre_app_service_type, + (ls.allow_list_answers -> 'application.information.harmful')::text AS pre_app_harmful_info, + (ls.allow_list_answers -> 'application.information.sensitive')::text AS pre_app_sensitive_info, + (ls.allow_list_answers -> 'application.type' -> 0)::text AS application_type + FROM (((((((((lowcal_sessions ls + LEFT JOIN flows f ON ((f.id = ls.flow_id))) + LEFT JOIN teams t ON ((t.id = f.team_id))) + LEFT JOIN resumes_per_session rps ON ((rps.session_id = (ls.id)::text))) + LEFT JOIN payment_requests_agg pr ON ((pr.session_id = ls.id))) + LEFT JOIN payment_status_agg ps ON ((ps.session_id = ls.id))) + LEFT JOIN bops_agg ba ON ((ba.session_id = (ls.id)::text))) + LEFT JOIN uniform_agg ua ON ((ua.submission_reference = (ls.id)::text))) + LEFT JOIN email_agg ea ON ((ea.session_id = ls.id))) + LEFT JOIN s3_agg sa ON ((sa.session_id = (ls.id)::text))) + WHERE ((f.slug IS NOT NULL) AND (t.slug IS NOT NULL)); + +DROP VIEW IF EXISTS "public"."analytics_summary"; + +CREATE VIEW "public"."analytics_summary" AS + SELECT a.id AS analytics_id, + al.id AS analytics_log_id, + f.slug AS service_slug, + t.slug AS team_slug, + a.type AS analytics_type, + al.created_at AS analytics_log_created_at, + a.created_at AS analytics_created_at, + ((a.user_agent -> 'os'::text) ->> 'name'::text) AS operating_system, + ((a.user_agent -> 'browser'::text) ->> 'name'::text) AS browser, + ((a.user_agent -> 'platform'::text) ->> 'type'::text) AS platform, + a.referrer, + al.flow_direction, + (al.metadata ->> 'change')::text AS change_metadata, + (al.metadata ->> 'back')::text AS back_metadata, + (al.metadata ->> 'selectedUrls')::text AS selected_urls, + (al.metadata ->> 'flag')::text AS result_flag, + (al.metadata -> 'flagSet')::text AS result_flagset, + ((al.metadata -> 'displayText'::text) ->> 'heading')::text AS result_heading, + ((al.metadata -> 'displayText'::text) ->> 'description')::text AS result_description, + (al.metadata -> 'helpTextUseful')::text AS help_text_useful, + CASE + WHEN al.has_clicked_help THEN al.metadata + ELSE NULL::jsonb + END AS help_metadata, + al.user_exit AS is_user_exit, + al.node_type, + al.node_title, + al.has_clicked_help, + al.input_errors, + (date_part('epoch'::text, (al.next_log_created_at - al.created_at)))::numeric(10,1) AS time_spent_on_node_seconds, + a.ended_at AS analytics_ended_at, + ((date_part('epoch'::text, (a.ended_at - a.created_at)) / (60)::double precision))::numeric(10,1) AS time_spent_on_analytics_session_minutes, + al.node_id, + al.allow_list_answers, + (al.allow_list_answers -> 'proposal.projectType')::text AS proposal_project_type, + (al.allow_list_answers -> 'application.declaration.connection')::text AS application_declaration_connection, + (al.allow_list_answers -> 'property.type')::text AS property_type, + (al.allow_list_answers -> 'drawBoundary.action')::text AS draw_boundary_action, + (al.allow_list_answers -> 'user.role')::text AS user_role, + (al.allow_list_answers -> 'property.constraints.planning')::text AS property_constraints_planning, + (al.allow_list_answers -> 'findProperty.action')::text AS find_property_action, + (al.allow_list_answers -> 'usedFOIYNPP')::text AS used_foiynpp, + (al.allow_list_answers -> 'propertyInformation.action')::text AS property_information_action, + (al.allow_list_answers -> 'planningConstraints.action')::text AS planning_constraints_action, + (al.allow_list_answers -> '_overrides')::text AS overrides, + (al.allow_list_answers -> 'rab.exitReason')::text AS rab_exit_reason, + (al.allow_list_answers -> 'service.type')::text AS pre_app_service_type, + (al.allow_list_answers -> 'application.information.harmful')::text AS pre_app_harmful_info, + (al.allow_list_answers -> 'application.information.sensitive')::text AS pre_app_sensitive_info, + (al.allow_list_answers -> 'application.type' -> 0)::text AS application_type + FROM (((analytics a + LEFT JOIN analytics_logs al ON ((a.id = al.analytics_id))) + LEFT JOIN flows f ON ((a.flow_id = f.id))) + LEFT JOIN teams t ON ((t.id = f.team_id))); + + GRANT SELECT ON "public"."analytics_summary" TO metabase_read_only; +GRANT SELECT ON "public"."submission_services_summary" TO metabase_read_only;