diff --git a/.github/workflows/cron-deploy-to-production.yml b/.github/workflows/cron-deploy-to-production.yml index ee1b7b8791..9b53c8aed1 100644 --- a/.github/workflows/cron-deploy-to-production.yml +++ b/.github/workflows/cron-deploy-to-production.yml @@ -17,7 +17,7 @@ jobs: # Requires a Personal Access Token generated by an OSL GitHub org admin # Allows this action to trigger the push-production.yml action # PAT requires the "Read and write" permissions on repository "Contents" - # XXX: Expires 23/11/24, see docs here on generating a new one - https://bit.ly/3YNr5sM + # Note: Jess generated latest token, expires 23/11/25, see docs here on generating a new one - https://bit.ly/3YNr5sM PERSONAL_ACCESS_TOKEN: ${{ secrets.PROD_DEPLOY_PAT }} run: | set -xe diff --git a/api.planx.uk/.env.test.example b/api.planx.uk/.env.test.example index b2356fd300..ec234e54f9 100644 --- a/api.planx.uk/.env.test.example +++ b/api.planx.uk/.env.test.example @@ -45,3 +45,7 @@ ORDNANCE_SURVEY_API_KEY=👻 IDOX_NEXUS_TOKEN_URL=👻 IDOX_NEXUS_SUBMISSION_URL=👻 + +# Analytics +METABASE_API_KEY=👻 +METABASE_URL_EXT=metabase.example.com \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 8569cc75dd..ad5c1ee0ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -145,6 +145,8 @@ services: IDOX_NEXUS_TOKEN_URL: ${IDOX_NEXUS_TOKEN_URL} JWT_SECRET: ${JWT_SECRET} MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN} + METABASE_API_KEY: ${METABASE_API_KEY} + METABASE_URL_EXT: ${METABASE_URL_EXT} MINIO_PORT: ${MINIO_PORT} ORDNANCE_SURVEY_API_KEY: ${ORDNANCE_SURVEY_API_KEY} PORT: ${API_PORT} diff --git a/e2e/tests/ui-driven/package.json b/e2e/tests/ui-driven/package.json index 03e2203caf..91c2c8e9db 100644 --- a/e2e/tests/ui-driven/package.json +++ b/e2e/tests/ui-driven/package.json @@ -21,7 +21,7 @@ }, "packageManager": "pnpm@8.6.6", "devDependencies": { - "@playwright/test": "^1.40.1", + "@playwright/test": "^1.49.0", "@types/node": "18.16.1", "eslint-plugin-playwright": "^0.20.0" } diff --git a/e2e/tests/ui-driven/pnpm-lock.yaml b/e2e/tests/ui-driven/pnpm-lock.yaml index 9620816eb7..4e3fc3e0fe 100644 --- a/e2e/tests/ui-driven/pnpm-lock.yaml +++ b/e2e/tests/ui-driven/pnpm-lock.yaml @@ -38,8 +38,8 @@ dependencies: devDependencies: '@playwright/test': - specifier: ^1.40.1 - version: 1.40.1 + specifier: ^1.49.0 + version: 1.49.0 '@types/node': specifier: 18.16.1 version: 18.16.1 @@ -635,13 +635,12 @@ packages: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 - /@playwright/test@1.40.1: - resolution: {integrity: sha512-EaaawMTOeEItCRvfmkI9v6rBkF1svM8wjl/YPRrg2N2Wmp+4qJYkWtJsbew1szfKKDm6fPLy4YAanBhIlf9dWw==} - engines: {node: '>=16'} - deprecated: Please update to the latest version of Playwright to test up-to-date browsers. + /@playwright/test@1.49.0: + resolution: {integrity: sha512-DMulbwQURa8rNIQrf94+jPJQ4FmOVdpE5ZppRNvWVjvhC+6sOeo28r8MgIpQRYouXRtt/FCCXU7zn20jnHR4Qw==} + engines: {node: '>=18'} hasBin: true dependencies: - playwright: 1.40.1 + playwright: 1.49.0 dev: true /@popperjs/core@2.11.8: @@ -2046,18 +2045,18 @@ packages: engines: {node: '>=12'} dev: false - /playwright-core@1.40.1: - resolution: {integrity: sha512-+hkOycxPiV534c4HhpfX6yrlawqVUzITRKwHAmYfmsVreltEl6fAZJ3DPfLMOODw0H3s1Itd6MDCWmP1fl/QvQ==} - engines: {node: '>=16'} + /playwright-core@1.49.0: + resolution: {integrity: sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA==} + engines: {node: '>=18'} hasBin: true dev: true - /playwright@1.40.1: - resolution: {integrity: sha512-2eHI7IioIpQ0bS1Ovg/HszsN/XKNwEG1kbzSDDmADpclKc7CyqkHw7Mg2JCz/bbCxg25QUPcjksoMW7JcIFQmw==} - engines: {node: '>=16'} + /playwright@1.49.0: + resolution: {integrity: sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A==} + engines: {node: '>=18'} hasBin: true dependencies: - playwright-core: 1.40.1 + playwright-core: 1.49.0 optionalDependencies: fsevents: 2.3.2 dev: true diff --git a/editor.planx.uk/src/@planx/components/PlanningConstraints/Public.test.tsx b/editor.planx.uk/src/@planx/components/PlanningConstraints/Public.test.tsx index 96a6c8f898..8646fbd0de 100644 --- a/editor.planx.uk/src/@planx/components/PlanningConstraints/Public.test.tsx +++ b/editor.planx.uk/src/@planx/components/PlanningConstraints/Public.test.tsx @@ -4,6 +4,7 @@ import React from "react"; import { act } from "react-dom/test-utils"; import { ErrorBoundary } from "react-error-boundary"; import swr from "swr"; +import useSWR from "swr"; import { setup } from "testUtils"; import { vi } from "vitest"; import { axe } from "vitest-axe"; @@ -21,15 +22,13 @@ const { setState } = useStore; beforeEach(() => vi.clearAllMocks()); -const swrMock = (swr as jest.Mock).mock; - vi.mock("swr", () => ({ default: vi.fn((url: () => string) => { const isGISRequest = url()?.startsWith( - `${import.meta.env.VITE_APP_API_URL}/gis` + `${import.meta.env.VITE_APP_API_URL}/gis`, ); const isRoadsRequest = url()?.startsWith( - `${import.meta.env.VITE_APP_API_URL}/roads` + `${import.meta.env.VITE_APP_API_URL}/roads`, ); if (isGISRequest) return { data: digitalLandResponseMock }; @@ -50,7 +49,7 @@ describe("error state", () => { disclaimer="This page does not include information about historic planning conditions that may apply to this property." handleSubmit={vi.fn()} /> - + , ); expect(getByTestId("error-summary-invalid-graph")).toBeInTheDocument(); @@ -66,7 +65,7 @@ describe("error state", () => { fn="property.constraints.planning" disclaimer="This page does not include information about historic planning conditions that may apply to this property." /> - + , ); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -82,7 +81,7 @@ describe("following a FindProperty component", () => { teamIntegrations: { hasPlanningData: true, }, - }) + }), ); }); @@ -96,11 +95,11 @@ describe("following a FindProperty component", () => { fn="property.constraints.planning" disclaimer="This page does not include information about historic planning conditions that may apply to this property." handleSubmit={handleSubmit} - /> + />, ); expect( - getByRole("heading", { name: "Planning constraints" }) + getByRole("heading", { name: "Planning constraints" }), ).toBeInTheDocument(); await user.click(getByTestId("continue-button")); @@ -115,7 +114,7 @@ describe("following a FindProperty component", () => { description="Things that might affect your project" fn="property.constraints.planning" disclaimer="This page does not include information about historic planning conditions that may apply to this property." - /> + />, ); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -129,14 +128,14 @@ describe("following a FindProperty component", () => { fn="property.constraints.planning" disclaimer="This page does not include information about historic planning conditions that may apply to this property." handleSubmit={vi.fn()} - /> + />, ); expect(swr).toHaveBeenCalled(); // Planning data is called first - const swrURL = swrMock.calls[0][0](); - const swrResponse = swrMock.results[0].value; + const swrURL = (vi.mocked(useSWR).mock.calls[0][0] as () => {})(); + const swrResponse = vi.mocked(useSWR).mock.results[0].value; expect(swrURL).toContain("/gis"); expect(swrResponse).toEqual({ data: digitalLandResponseMock }); @@ -150,14 +149,14 @@ describe("following a FindProperty component", () => { fn="property.constraints.planning" disclaimer="This page does not include information about historic planning conditions that may apply to this property." handleSubmit={vi.fn()} - /> + />, ); expect(swr).toHaveBeenCalled(); // Classified roads are called second - const swrURL = swrMock.calls[1][0](); - const swrResponse = swrMock.results[1].value; + const swrURL = (vi.mocked(useSWR).mock.calls[1][0] as () => {})(); + const swrResponse = vi.mocked(useSWR).mock.results[1].value; expect(swrURL).toContain("/roads"); expect(swrResponse).toEqual({ data: classifiedRoadsResponseMock }); @@ -171,7 +170,7 @@ describe("following a FindProperty component", () => { teamIntegrations: { hasPlanningData: true, }, - }) + }), ); setup( @@ -181,14 +180,16 @@ describe("following a FindProperty component", () => { fn="property.constraints.planning" disclaimer="This page does not include information about historic planning conditions that may apply to this property." handleSubmit={vi.fn()} - /> + />, ); expect(swr).toHaveBeenCalled(); // Planning constraints API still called - const planingConstraintsURL = swrMock.calls[0][0](); - const planingConstraintsResponse = swrMock.results[0].value; + const planingConstraintsURL = ( + vi.mocked(useSWR).mock.calls[0][0] as () => {} + )(); + const planingConstraintsResponse = vi.mocked(useSWR).mock.results[0].value; expect(planingConstraintsURL).toContain("/gis"); expect(planingConstraintsResponse).toEqual({ @@ -196,8 +197,8 @@ describe("following a FindProperty component", () => { }); // Classified roads API not called due to missing USRN - const swrURL = swrMock.calls[1][0](); - const swrResponse = swrMock.results[1].value; + const swrURL = (vi.mocked(useSWR).mock.calls[1][0] as () => {})(); + const swrResponse = vi.mocked(useSWR).mock.results[1].value; expect(swrURL).toBeNull(); expect(swrResponse).toEqual({ data: null }); @@ -211,12 +212,12 @@ describe("following a FindProperty component", () => { fn="property.constraints.planning" disclaimer="This page does not include information about historic planning conditions that may apply to this property." handleSubmit={vi.fn()} - /> + />, ); // Positive constraints visible by default expect( - getByRole("heading", { name: /These are the planning constraints/ }) + getByRole("heading", { name: /These are the planning constraints/ }), ).toBeVisible(); expect(getByRole("button", { name: /Parks and gardens/ })).toBeVisible(); @@ -227,7 +228,7 @@ describe("following a FindProperty component", () => { expect(showNegativeConstraintsButton).toBeVisible(); const negativeConstraintsContainer = getByTestId( - "negative-constraints-list" + "negative-constraints-list", ); expect(negativeConstraintsContainer).not.toBeVisible(); @@ -248,12 +249,12 @@ describe("following a FindProperty component", () => { description="Things that might affect your project" fn="property.constraints.planning" handleSubmit={vi.fn()} - /> + />, ); expect( queryByText( - "This page does not include information about historic planning conditions that may apply to this property." - ) + "This page does not include information about historic planning conditions that may apply to this property.", + ), ).toBeVisible(); }); }); @@ -268,7 +269,7 @@ describe("demo state", () => { hasPlanningData: false, }, teamSlug: "demo", - }) + }), ); }); it("should render an error when teamSlug is demo", async () => { @@ -282,21 +283,21 @@ describe("demo state", () => { disclaimer="This page does not include information about historic planning conditions that may apply to this property." handleSubmit={handleSubmit} /> - + , ); const errorMessage = queryByText( - "Planning Constraints are not enabled for demo users" + "Planning Constraints are not enabled for demo users", ); expect(errorMessage).toBeVisible(); // Check planning constraints has not rendered // reused positive constraints from basic layout test expect( - queryByRole("heading", { name: /These are the planning constraints/ }) + queryByRole("heading", { name: /These are the planning constraints/ }), ).not.toBeInTheDocument(); expect( - queryByRole("button", { name: /Parks and gardens/ }) + queryByRole("button", { name: /Parks and gardens/ }), ).not.toBeInTheDocument(); // Ensure a demo user can continue on in the application diff --git a/editor.planx.uk/src/@planx/components/Send/Public.test.tsx b/editor.planx.uk/src/@planx/components/Send/Public.test.tsx index 7feb633b89..004b2c3c16 100644 --- a/editor.planx.uk/src/@planx/components/Send/Public.test.tsx +++ b/editor.planx.uk/src/@planx/components/Send/Public.test.tsx @@ -5,7 +5,7 @@ import { FullStore, useStore } from "pages/FlowEditor/lib/store"; import React from "react"; import { act } from "react-dom/test-utils"; import { setup } from "testUtils"; -import { vi } from "vitest"; +import { it,vi } from "vitest"; import { axe } from "vitest-axe"; import hasuraEventsResponseMock from "./mocks/hasuraEventsResponseMock"; @@ -179,29 +179,34 @@ describe("Uniform overrides for Buckinghamshire", () => { }); }); -it("generates a valid breadcrumb", async () => { - const handleSubmit = vi.fn(); +it( + "generates a valid breadcrumb", + async () => { + const handleSubmit = vi.fn(); - setup( - , - ); + setup( + , + ); - await waitFor(() => expect(mockAxios.post).toHaveBeenCalledTimes(1)); - expect(handleSubmit).toHaveBeenCalledTimes(1); + await waitFor(() => expect(mockAxios.post).toHaveBeenCalledTimes(1)); + expect(handleSubmit).toHaveBeenCalledTimes(1); - const breadcrumb = handleSubmit.mock.calls[0][0]; + const breadcrumb = handleSubmit.mock.calls[0][0]; - expect(breadcrumb.data).toEqual( - expect.objectContaining({ - bopsSendEventId: hasuraEventsResponseMock.bops.event_id, - uniformSendEventId: hasuraEventsResponseMock.uniform.event_id, - }), - ); -}); + expect(breadcrumb.data).toEqual( + expect.objectContaining({ + bopsSendEventId: hasuraEventsResponseMock.bops.event_id, + uniformSendEventId: hasuraEventsResponseMock.uniform.event_id, + }), + ); + // Flaky test in CI + }, + { retry: 1 }, +); it("should not have any accessibility violations", async () => { const { container } = setup( diff --git a/editor.planx.uk/src/components/EditorNavMenu.test.tsx b/editor.planx.uk/src/components/EditorNavMenu.test.tsx new file mode 100644 index 0000000000..6b83c7355f --- /dev/null +++ b/editor.planx.uk/src/components/EditorNavMenu.test.tsx @@ -0,0 +1,205 @@ +import { within } from "@testing-library/react"; +import { useStore } from "pages/FlowEditor/lib/store"; +import React from "react"; +import * as ReactNavi from "react-navi"; +import { setup } from "testUtils"; +import { Mocked, vi } from "vitest"; + +import EditorNavMenu from "./EditorNavMenu"; + +vi.mock("react-navi", () => ({ + useCurrentRoute: vi.fn(), + useNavigation: () => ({ navigate: vi.fn() }), + // Mock completed loading process + useLoadingRoute: () => undefined, +})); + +const mockNavi = ReactNavi as Mocked; + +let mockTeamName: string | undefined = undefined; +let mockFlowName: string | undefined = undefined; +let mockAnalyticsLink: string | undefined = undefined; +const mockGetUserRoleForCurrentTeam = vi.fn(); + +vi.mock("pages/FlowEditor/lib/store", async () => ({ + useStore: vi.fn(() => [ + mockTeamName, + mockFlowName, + mockAnalyticsLink, + mockGetUserRoleForCurrentTeam(), + ]), +})); + +describe("globalLayoutRoutes", () => { + beforeEach(() => { + mockNavi.useCurrentRoute.mockReturnValue({ + url: { href: "/" }, + } as ReturnType); + }); + + it("does not display for teamEditors", () => { + mockGetUserRoleForCurrentTeam.mockReturnValue("teamEditor"); + + const { queryAllByRole } = setup(); + const menuItems = queryAllByRole("listitem"); + expect(menuItems).toHaveLength(0); + }); + + it("displays for platformAdmins", () => { + mockGetUserRoleForCurrentTeam.mockReturnValue("platformAdmin"); + + const { getAllByRole } = setup(); + const menuItems = getAllByRole("listitem"); + expect(menuItems).toHaveLength(3); + expect(within(menuItems[0]).getByText("Select a team")).toBeInTheDocument(); + }); +}); + +describe("teamLayoutRoutes", () => { + beforeEach(() => { + mockNavi.useCurrentRoute.mockReturnValue({ + url: { href: "/test-team" }, + } as ReturnType); + mockTeamName = "test-team"; + }); + + it("does not display for teamViewers", () => { + mockGetUserRoleForCurrentTeam.mockReturnValue("teamViewer"); + + const { queryAllByRole } = setup(); + const menuItems = queryAllByRole("listitem"); + expect(menuItems).toHaveLength(0); + }); + + it("displays for teamEditors", () => { + mockGetUserRoleForCurrentTeam.mockReturnValue("teamEditor"); + + const { getAllByRole } = setup(); + const menuItems = getAllByRole("listitem"); + expect(menuItems).toHaveLength(4); + expect(within(menuItems[0]).getByText("Services")).toBeInTheDocument(); + }); + + it("displays for platformAdmins", () => { + mockGetUserRoleForCurrentTeam.mockReturnValue("platformAdmin"); + + const { getAllByRole } = setup(); + const menuItems = getAllByRole("listitem"); + expect(menuItems).toHaveLength(4); + expect(within(menuItems[0]).getByText("Services")).toBeInTheDocument(); + }); +}); + +describe("flowLayoutRoutes", () => { + beforeEach(() => { + mockNavi.useCurrentRoute.mockReturnValue({ + url: { href: "/test-team/test-flow" }, + } as ReturnType); + mockTeamName = "test-team"; + mockFlowName = "test-flow"; + }); + + it("does not display for teamViewers", () => { + mockGetUserRoleForCurrentTeam.mockReturnValue("teamViewer"); + + const { queryAllByRole } = setup(); + const menuItems = queryAllByRole("listitem"); + expect(menuItems).toHaveLength(0); + }); + + it("displays for teamEditors", () => { + mockGetUserRoleForCurrentTeam.mockReturnValue("teamEditor"); + + const { getAllByRole, getByLabelText } = setup(); + const menuItems = getAllByRole("listitem"); + expect(menuItems).toHaveLength(5); + expect(getByLabelText("Submissions log")).toBeInTheDocument(); + }); + + it("displays for platformAdmins", () => { + mockGetUserRoleForCurrentTeam.mockReturnValue("platformAdmin"); + + const { getAllByRole, getByLabelText } = setup(); + const menuItems = getAllByRole("listitem"); + expect(menuItems).toHaveLength(5); + expect(getByLabelText("Submissions log")).toBeInTheDocument(); + }); +}); + +describe("flowAnalyticsRoute", () => { + beforeEach(() => { + mockNavi.useCurrentRoute.mockReturnValue({ + url: { href: "/test-team/test-flow" }, + } as ReturnType); + mockTeamName = "test-team"; + mockFlowName = "test-flow"; + mockGetUserRoleForCurrentTeam.mockReturnValue("teamEditor"); + }); + + it("is disabled without an analytics link", () => { + const { getByRole } = setup(); + expect(getByRole("button", { name: /Analytics/ })).toBeDisabled(); + }); + + it("is enabled with an analytics link", () => { + mockAnalyticsLink = "https://link-to-metabase"; + + const { getByRole } = setup(); + expect(getByRole("button", { name: /Analytics/ })).not.toBeDisabled(); + }); +}); + +describe("layout", () => { + it("displays in a full mode on global routes", () => { + mockNavi.useCurrentRoute.mockReturnValue({ + url: { href: "/" }, + } as ReturnType); + mockGetUserRoleForCurrentTeam.mockReturnValue("platformAdmin"); + + const { queryAllByRole, queryByLabelText } = setup(); + const menuItems = queryAllByRole("listitem"); + + // Tooltip not present + expect(queryByLabelText("Select a team")).not.toBeInTheDocument(); + + // Full text present + expect(within(menuItems[0]).getByText("Select a team")).toBeInTheDocument(); + }); + + it("displays in a full mode on team routes", () => { + mockNavi.useCurrentRoute.mockReturnValue({ + url: { href: "/test-team" }, + } as ReturnType); + mockGetUserRoleForCurrentTeam.mockReturnValue("platformAdmin"); + mockTeamName = "test-team"; + + const { queryAllByRole, queryByLabelText } = setup(); + const menuItems = queryAllByRole("listitem"); + + // Tooltip not present + expect(queryByLabelText("Services")).not.toBeInTheDocument(); + + // Full text present + expect(within(menuItems[0]).getByText("Services")).toBeInTheDocument(); + }); + + it("displays in a compact mode on flow routes", () => { + mockNavi.useCurrentRoute.mockReturnValue({ + url: { href: "/test-team/test-flow" }, + } as ReturnType); + mockGetUserRoleForCurrentTeam.mockReturnValue("platformAdmin"); + mockTeamName = "test-team"; + mockFlowName = "test-flow"; + + const { queryAllByRole, getByLabelText } = setup(); + const menuItems = queryAllByRole("listitem"); + + // Tooltip present + expect(getByLabelText("Submissions log")).toBeInTheDocument(); + + // Full text present + expect( + within(menuItems[0]).queryByText("Submissions log"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/editor.planx.uk/src/components/EditorNavMenu.tsx b/editor.planx.uk/src/components/EditorNavMenu.tsx index 05ad74e1a5..ff7d56fb3b 100644 --- a/editor.planx.uk/src/components/EditorNavMenu.tsx +++ b/editor.planx.uk/src/components/EditorNavMenu.tsx @@ -95,13 +95,12 @@ function EditorNavMenu() { const { navigate } = useNavigation(); const { url } = useCurrentRoute(); const isRouteLoading = useLoadingRoute(); - const [teamSlug, flowSlug, flowAnalyticsLink, role] = - useStore((state) => [ - state.teamSlug, - state.flowSlug, - state.flowAnalyticsLink, - state.getUserRoleForCurrentTeam() - ]); + const [teamSlug, flowSlug, flowAnalyticsLink, role] = useStore((state) => [ + state.teamSlug, + state.flowSlug, + state.flowAnalyticsLink, + state.getUserRoleForCurrentTeam(), + ]); const isActive = (route: string) => url.href.endsWith(route); @@ -242,7 +241,9 @@ function EditorNavMenu() { const { routes, compact } = getRoutesForUrl(url.href); - const visibleRoutes = routes.filter(({ accessibleBy }) => role && accessibleBy.includes(role)); + 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; @@ -256,6 +257,7 @@ function EditorNavMenu() { = (props) => { return ( <> + ); }; @@ -141,19 +141,22 @@ const InternalPortal: React.FC = (props) => { return ( <>