diff --git a/api.planx.uk/modules/send/downloadApplicationFiles/index.ts b/api.planx.uk/modules/send/downloadApplicationFiles/index.ts index aede2c8369..2c3e0f8a68 100644 --- a/api.planx.uk/modules/send/downloadApplicationFiles/index.ts +++ b/api.planx.uk/modules/send/downloadApplicationFiles/index.ts @@ -17,10 +17,10 @@ export async function downloadApplicationFiles( try { // Confirm that the provided email matches the stored team settings for the provided localAuthority - const { notifyPersonalisation } = await getTeamEmailSettings( + const { teamSettings } = await getTeamEmailSettings( req.query.localAuthority as string, ); - if (notifyPersonalisation.sendToEmail !== req.query.email) { + if (teamSettings.submissionEmail !== req.query.email) { return next({ status: 403, message: diff --git a/api.planx.uk/modules/send/email/index.test.ts b/api.planx.uk/modules/send/email/index.test.ts index 973ff9cbbc..74c21f805a 100644 --- a/api.planx.uk/modules/send/email/index.test.ts +++ b/api.planx.uk/modules/send/email/index.test.ts @@ -49,9 +49,9 @@ describe(`sending an application by email to a planning office`, () => { data: { teams: [ { - notifyPersonalisation: { + teamSettings: { emailReplyToId: "abc123", - sendToEmail: "planning.office.example@council.gov.uk", + submissionEmail: "planning.office.example@council.gov.uk", }, }, ], @@ -154,9 +154,9 @@ describe(`sending an application by email to a planning office`, () => { data: { teams: [ { - notifyPersonalisation: { + teamSettings: { emailReplyToId: "abc123", - sendToEmail: null, + submissionEmail: null, }, }, ], @@ -206,8 +206,8 @@ describe(`downloading application data received by email`, () => { data: { teams: [ { - notifyPersonalisation: { - sendToEmail: "planning.office.example@council.gov.uk", + teamSettings: { + submissionEmail: "planning.office.example@council.gov.uk", }, }, ], diff --git a/api.planx.uk/modules/send/email/index.ts b/api.planx.uk/modules/send/email/index.ts index 4d36063ad3..cd19e6bffe 100644 --- a/api.planx.uk/modules/send/email/index.ts +++ b/api.planx.uk/modules/send/email/index.ts @@ -28,9 +28,8 @@ export async function sendToEmail( try { // Confirm this local authority (aka team) has an email configured in teams.submission_email - const { notifyPersonalisation } = - await getTeamEmailSettings(localAuthority); - if (!notifyPersonalisation.sendToEmail) { + const { teamSettings } = await getTeamEmailSettings(localAuthority); + if (!teamSettings.submissionEmail) { return next({ status: 400, message: `Send to email is not enabled for this local authority (${localAuthority})`, @@ -47,15 +46,15 @@ export async function sendToEmail( serviceName: flowName, sessionId: payload.sessionId, applicantEmail: email, - downloadLink: `${process.env.API_URL_EXT}/download-application-files/${payload.sessionId}?email=${notifyPersonalisation.sendToEmail}&localAuthority=${localAuthority}`, - ...notifyPersonalisation, + downloadLink: `${process.env.API_URL_EXT}/download-application-files/${payload.sessionId}?email=${teamSettings.submissionEmail}&localAuthority=${localAuthority}`, + ...teamSettings, }, }; // Send the email const response = await sendEmail( "submit", - notifyPersonalisation.sendToEmail, + teamSettings.submissionEmail, config, ); @@ -66,14 +65,14 @@ export async function sendToEmail( insertAuditEntry( payload.sessionId, localAuthority, - notifyPersonalisation.sendToEmail, + teamSettings.submissionEmail, config, response, ); return res.status(200).send({ message: `Successfully sent to email`, - inbox: notifyPersonalisation.sendToEmail, + inbox: teamSettings.submissionEmail, govuk_notify_template: "Submit", }); } catch (error) { diff --git a/api.planx.uk/modules/send/email/service.ts b/api.planx.uk/modules/send/email/service.ts index 2a5454bc0c..878d165856 100644 --- a/api.planx.uk/modules/send/email/service.ts +++ b/api.planx.uk/modules/send/email/service.ts @@ -1,14 +1,11 @@ import { gql } from "graphql-request"; import { $api } from "../../../client/index.js"; -import { - NotifyPersonalisation, - Session, -} from "@opensystemslab/planx-core/types"; +import { Session, TeamContactSettings } from "@opensystemslab/planx-core/types"; import { EmailSubmissionNotifyConfig } from "../../../types.js"; interface GetTeamEmailSettings { teams: { - notifyPersonalisation: NotifyPersonalisation & { sendToEmail: string }; + teamSettings: TeamContactSettings; }[]; } @@ -17,12 +14,12 @@ export async function getTeamEmailSettings(localAuthority: string) { gql` query GetTeamEmailSettings($slug: String) { teams(where: { slug: { _eq: $slug } }) { - notifyPersonalisation: team_settings { + teamSettings: team_settings { helpEmail: help_email helpPhone: help_phone emailReplyToId: email_reply_to_id helpOpeningHours: help_opening_hours - sendToEmail: submission_email + submissionEmail: submission_email } } } diff --git a/api.planx.uk/package.json b/api.planx.uk/package.json index a5c53d3fcd..297ada5bc9 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#931d4db", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#ec52620", "@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 e75edf6d12..e01b020420 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#931d4db - version: github.com/theopensystemslab/planx-core/931d4db + specifier: git+https://github.com/theopensystemslab/planx-core#ec52620 + version: github.com/theopensystemslab/planx-core/ec52620 '@types/isomorphic-fetch': specifier: ^0.0.36 version: 0.0.36 @@ -5902,8 +5902,8 @@ packages: engines: {node: '>=16'} dev: false - /type-fest@4.25.0: - resolution: {integrity: sha512-bRkIGlXsnGBRBQRAY56UXBm//9qH4bmJfFvq83gSz41N282df+fjy8ofcEgc1sM8geNt5cl6mC2g9Fht1cs8Aw==} + /type-fest@4.26.0: + resolution: {integrity: sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw==} engines: {node: '>=16'} dev: false @@ -6360,8 +6360,8 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: false - github.com/theopensystemslab/planx-core/931d4db: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/931d4db} + github.com/theopensystemslab/planx-core/ec52620: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/ec52620} name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true @@ -6387,7 +6387,7 @@ packages: prettier: 3.3.3 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - type-fest: 4.25.0 + type-fest: 4.26.0 uuid: 10.0.0 zod: 3.23.8 transitivePeerDependencies: diff --git a/e2e/README.md b/e2e/README.md index 0585a1bcc9..4bac3a74af 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -8,6 +8,14 @@ More info about e2e tests is available in our [testing approach documentation](h We use [Playwright](https://playwright.dev/docs/api/class-test) to run UI driven tests where user interactions are simulated via a web browser. +### Running UI-driven tests + +1. Navigate to `/tests/ui-driven` +2. Run `pnpm install` to install the Playwright package. +3. Run `pnpm exec playwright install` to install the Playwright test browsers. +4. Run the tests with `pnpm test` + + ## API driven tests diff --git a/e2e/tests/api-driven/package.json b/e2e/tests/api-driven/package.json index 77cd72ac3a..56008e0dcd 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#931d4db", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#ec52620", "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 f1195bf03d..ee5dcc3424 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#931d4db - version: github.com/theopensystemslab/planx-core/931d4db + specifier: git+https://github.com/theopensystemslab/planx-core#ec52620 + version: github.com/theopensystemslab/planx-core/ec52620 axios: specifier: ^1.7.4 version: 1.7.4 @@ -2719,8 +2719,8 @@ packages: engines: {node: '>=10'} dev: false - /type-fest@4.25.0: - resolution: {integrity: sha512-bRkIGlXsnGBRBQRAY56UXBm//9qH4bmJfFvq83gSz41N282df+fjy8ofcEgc1sM8geNt5cl6mC2g9Fht1cs8Aw==} + /type-fest@4.26.0: + resolution: {integrity: sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw==} engines: {node: '>=16'} dev: false @@ -2931,8 +2931,8 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: false - github.com/theopensystemslab/planx-core/931d4db: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/931d4db} + github.com/theopensystemslab/planx-core/ec52620: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/ec52620} name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true @@ -2958,7 +2958,7 @@ packages: prettier: 3.3.3 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - type-fest: 4.25.0 + type-fest: 4.26.0 uuid: 10.0.0 zod: 3.23.8 transitivePeerDependencies: diff --git a/e2e/tests/api-driven/src/globalHelpers.ts b/e2e/tests/api-driven/src/globalHelpers.ts index d0d1ba88a7..98371f73b7 100644 --- a/e2e/tests/api-driven/src/globalHelpers.ts +++ b/e2e/tests/api-driven/src/globalHelpers.ts @@ -1,4 +1,4 @@ -import { TEST_EMAIL } from "../../ui-driven/src/globalHelpers"; +import { TEST_EMAIL } from "../../ui-driven/src/helpers/globalHelpers"; import { $admin } from "./client"; export function createTeam( diff --git a/e2e/tests/api-driven/src/invite-to-pay/helpers.ts b/e2e/tests/api-driven/src/invite-to-pay/helpers.ts index a15293b7c3..d78807bb85 100644 --- a/e2e/tests/api-driven/src/invite-to-pay/helpers.ts +++ b/e2e/tests/api-driven/src/invite-to-pay/helpers.ts @@ -1,20 +1,20 @@ -import { CustomWorld } from "./steps"; -import axios from "axios"; -import { readFileSync } from "node:fs"; import type { FlowGraph, PaymentRequest, } from "@opensystemslab/planx-core/types"; +import axios from "axios"; +import gql from "graphql-tag"; +import { readFileSync } from "node:fs"; +import { TEST_EMAIL } from "../../../ui-driven/src/helpers/globalHelpers"; +import { $admin } from "../client"; +import { createTeam, createUser } from "../globalHelpers"; import { inviteToPayFlowGraph, - sendNodeWithDestination, mockBreadcrumbs, mockPassport, + sendNodeWithDestination, } from "./mocks"; -import { $admin } from "../client"; -import { TEST_EMAIL } from "../../../ui-driven/src/globalHelpers"; -import { createTeam, createUser } from "../globalHelpers"; -import gql from "graphql-tag"; +import { CustomWorld } from "./steps"; export async function setUpMocks() { const serverMockFile = readFileSync(`${__dirname}/mocks/server-mocks.yaml`); diff --git a/e2e/tests/ui-driven/package.json b/e2e/tests/ui-driven/package.json index 89043a4a02..971eea4a6e 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#931d4db", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#ec52620", "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 d7442aa1a9..5844006a8a 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#931d4db - version: github.com/theopensystemslab/planx-core/931d4db + specifier: git+https://github.com/theopensystemslab/planx-core#ec52620 + version: github.com/theopensystemslab/planx-core/ec52620 axios: specifier: ^1.7.4 version: 1.7.4 @@ -2501,8 +2501,8 @@ packages: engines: {node: '>=12.20'} dev: false - /type-fest@4.25.0: - resolution: {integrity: sha512-bRkIGlXsnGBRBQRAY56UXBm//9qH4bmJfFvq83gSz41N282df+fjy8ofcEgc1sM8geNt5cl6mC2g9Fht1cs8Aw==} + /type-fest@4.26.0: + resolution: {integrity: sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw==} engines: {node: '>=16'} dev: false @@ -2669,8 +2669,8 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: false - github.com/theopensystemslab/planx-core/931d4db: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/931d4db} + github.com/theopensystemslab/planx-core/ec52620: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/ec52620} name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true @@ -2696,7 +2696,7 @@ packages: prettier: 3.3.3 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - type-fest: 4.25.0 + type-fest: 4.26.0 uuid: 10.0.0 zod: 3.23.8 transitivePeerDependencies: diff --git a/e2e/tests/ui-driven/src/create-flow/create-flow.spec.ts b/e2e/tests/ui-driven/src/create-flow/create-flow.spec.ts index d30b6f269d..c7a9cc726b 100644 --- a/e2e/tests/ui-driven/src/create-flow/create-flow.spec.ts +++ b/e2e/tests/ui-driven/src/create-flow/create-flow.spec.ts @@ -1,16 +1,26 @@ -import { test, expect, Browser } from "@playwright/test"; +import { Browser, expect, test } from "@playwright/test"; +import { + createAddressInput, + createChecklist, + createContactInput, + createDateInput, + createNotice, + createNumberInput, + createQuestionWithOptions, + createTextInput, +} from "../helpers/addComponent"; +import type { Context } from "../helpers/context"; import { contextDefaults, setUpTestContext, tearDownTestContext, -} from "../context"; +} from "../helpers/context"; +import { getTeamPage } from "../helpers/getPage"; import { createAuthenticatedSession, - answerQuestion, - clickContinue, -} from "../globalHelpers"; -import type { Context } from "../context"; -import { getTeamPage, isGetUserRequest } from "./helpers"; + isGetUserRequest, +} from "../helpers/globalHelpers"; +import { answerQuestion, clickContinue } from "../helpers/userActions"; test.describe("Navigation", () => { let context: Context = { @@ -102,47 +112,79 @@ test.describe("Navigation", () => { // update context to allow flow to be torn down context.flow = { ...serviceProps }; - await page.locator("li.hanger > a").click(); - await page.getByRole("dialog").waitFor(); + const firstNode = page.locator("li.hanger > a").first(); const questionText = "Is this a test?"; - await page.getByPlaceholder("Text").fill(questionText); - - await page.locator("button").filter({ hasText: "add new" }).click(); - await page.getByPlaceholder("Option").fill("Yes"); - - await page.locator("button").filter({ hasText: "add new" }).click(); - await page.getByPlaceholder("Option").nth(1).fill("No"); - - await page.locator("button").filter({ hasText: "Create question" }).click(); + await createQuestionWithOptions(page, firstNode, questionText, [ + "Yes", + "No", + ]); await expect( page.locator("a").filter({ hasText: questionText }), ).toBeVisible(); // Add a notice to the "Yes" path const yesBranch = page.locator("#flow .card .options .option").nth(0); - await yesBranch.locator(".hanger > a").click(); - await page.getByRole("dialog").waitFor(); - await page.locator("select").selectOption({ label: "Notice" }); const yesBranchNoticeText = "Yes! this is a test"; - await page.getByPlaceholder("Notice").fill(yesBranchNoticeText); - await page.locator("button").filter({ hasText: "Create notice" }).click(); + await createNotice( + page, + yesBranch.locator(".hanger > a"), + yesBranchNoticeText, + ); // Add a notice to the "No" path const noBranch = page.locator("#flow .card .options .option").nth(1); - await noBranch.locator(".hanger > a").click(); - await page.getByRole("dialog").waitFor(); - - await page.locator("select").selectOption({ label: "Notice" }); const noBranchNoticeText = "Sorry, this is a test"; - await page.getByPlaceholder("Notice").fill(noBranchNoticeText); - await page.locator("button").filter({ hasText: "Create notice" }).click(); + await createNotice( + page, + noBranch.locator(".hanger > a"), + noBranchNoticeText, + ); + + // TODO: find a nicer way to find the next node + let nextNode = page.locator(".hanger > a").nth(5); + await createChecklist(page, nextNode, "A checklist title", [ + "Checklist item 1", + "Second checklist item", + "The third checklist item", + ]); + + nextNode = page.locator(".hanger > a").nth(7); + await createTextInput(page, nextNode, "Tell us about your trees."); + + nextNode = page.locator(".hanger > a").nth(8); + await createNumberInput(page, nextNode, "How old are you?", "years"); + + nextNode = page.locator(".hanger > a").nth(9); + await createDateInput(page, nextNode, "When is your birthday?"); + + nextNode = page.locator(".hanger > a").nth(10); + await createAddressInput( + page, + nextNode, + "What is your address?", + "some data field", + ); + + nextNode = page.locator(".hanger > a").nth(11); + await createContactInput( + page, + nextNode, + "What is your contact info?", + "some data field", + ); const nodes = page.locator(".card"); await expect(nodes.getByText(questionText)).toBeVisible(); await expect(nodes.getByText(yesBranchNoticeText)).toBeVisible(); await expect(nodes.getByText(noBranchNoticeText)).toBeVisible(); + await expect(nodes.getByText("Checklist item 1")).toBeVisible(); + await expect(nodes.getByText("Tell us about your trees.")).toBeVisible(); + await expect(nodes.getByText("How old are you?")).toBeVisible(); + await expect(nodes.getByText("When is your birthday?")).toBeVisible(); + await expect(nodes.getByText("What is your address?")).toBeVisible(); + await expect(nodes.getByText("What is your contact info?")).toBeVisible(); }); test("Cannot preview an unpublished flow", async ({ diff --git a/e2e/tests/ui-driven/src/helpers/addComponent.ts b/e2e/tests/ui-driven/src/helpers/addComponent.ts new file mode 100644 index 0000000000..9411c2d955 --- /dev/null +++ b/e2e/tests/ui-driven/src/helpers/addComponent.ts @@ -0,0 +1,160 @@ +import { Locator, Page } from "@playwright/test"; + +const createBaseInput = async ( + page: Page, + locatingNode: Locator, + type: string, + title?: string, + options?: string[], +) => { + await locatingNode.click(); + await page.getByRole("dialog").waitFor(); + await page.locator("select").selectOption({ label: type }); + + switch (type) { + case "Question": + await page.getByPlaceholder("Text").fill(title || ""); + if (options) { + await createOptions(options, "add new", page); + } + break; + case "Notice": + await page.getByPlaceholder("Notice").fill(title || ""); + break; + case "Checklist": + await page.getByPlaceholder("Text").fill(title || ""); + if (options) { + await createOptions(options, "add new option", page); + } + break; + case "Text Input": + await page.getByPlaceholder("Title").fill(title || ""); + break; + case "Number Input": + await page.getByPlaceholder("Title").fill(title || ""); + await page.getByPlaceholder("eg square metres").fill(options?.[0] || ""); + break; + case "Date Input": + await page.getByPlaceholder("Title").fill(title || ""); + // fill with hardcoded dates for now + await page.locator("id=undefined-min-day").fill("01"); + await page.locator("id=undefined-min-month").fill("01"); + await page.locator("id=undefined-min-year").fill("1800"); + await page.locator("id=undefined-max-day").fill("31"); + await page.locator("id=undefined-max-month").fill("12"); + await page.locator("id=undefined-max-year").fill("2199"); + break; + case "Address Input": + await page.getByPlaceholder("Title").fill(title || ""); + await page.getByPlaceholder("Data Field").fill(options?.[0] || ""); + break; + case "Contact Input": + await page.getByPlaceholder("Title").fill(title || ""); + await page.getByPlaceholder("Data Field").fill(options?.[0] || ""); + break; + default: + throw new Error(`Unsupported type: ${type}`); + } + + // convert type name to lowercase, with dashes if there are spaces + const buttonName = type.toLowerCase().replace(/\s/g, "-"); + await page + .locator("button") + .filter({ + hasText: `Create ${buttonName}`, + }) + .click(); +}; + +export const createQuestionWithOptions = async ( + page: Page, + locatingNode: Locator, + questionText: string, + options: string[], +) => { + await createBaseInput(page, locatingNode, "Question", questionText, options); +}; + +export const createNotice = async ( + page: Page, + locatingNode: Locator, + noticeText: string, +) => { + await createBaseInput(page, locatingNode, "Notice", noticeText); +}; + +export const createChecklist = async ( + page: Page, + locatingNode: Locator, + checklistTitle: string, + checklistOptions: string[], +) => { + createBaseInput( + page, + locatingNode, + "Checklist", + checklistTitle, + checklistOptions, + ); +}; + +export const createTextInput = async ( + page: Page, + locatingNode: Locator, + inputTitle: string, +) => { + await createBaseInput(page, locatingNode, "Text Input", inputTitle); +}; + +export const createNumberInput = async ( + page: Page, + locatingNode: Locator, + inputTitle: string, + inputUnits: string, +) => { + await createBaseInput(page, locatingNode, "Number Input", inputTitle, [ + inputUnits, + ]); +}; + +export const createDateInput = async ( + page: Page, + locatingNode: Locator, + inputTitle: string, +) => { + await createBaseInput(page, locatingNode, "Date Input", inputTitle); +}; + +export const createAddressInput = async ( + page: Page, + locatingNode: Locator, + inputTitle: string, + inputDataField: string, +) => { + await createBaseInput(page, locatingNode, "Address Input", inputTitle, [ + inputDataField, + ]); +}; + +export const createContactInput = async ( + page: Page, + locatingNode: Locator, + inputTitle: string, + inputDataField: string, +) => { + await createBaseInput(page, locatingNode, "Contact Input", inputTitle, [ + inputDataField, + ]); +}; +async function createOptions( + options: string[], + buttonText: string, + page: Page, +) { + let index = 0; + for (const option of options) { + await page.locator("button").filter({ hasText: buttonText }).click(); + await page.getByPlaceholder("Option").nth(index).fill(option); + index++; + } +} diff --git a/e2e/tests/ui-driven/src/context.ts b/e2e/tests/ui-driven/src/helpers/context.ts similarity index 99% rename from e2e/tests/ui-driven/src/context.ts rename to e2e/tests/ui-driven/src/helpers/context.ts index 3ef2a4f3cf..c94783189e 100644 --- a/e2e/tests/ui-driven/src/context.ts +++ b/e2e/tests/ui-driven/src/helpers/context.ts @@ -1,8 +1,8 @@ -import assert from "node:assert"; -import { log } from "./globalHelpers"; -import { sign } from "jsonwebtoken"; import { CoreDomainClient } from "@opensystemslab/planx-core"; import { GraphQLClient, gql } from "graphql-request"; +import { sign } from "jsonwebtoken"; +import assert from "node:assert"; +import { log } from "./globalHelpers"; type NewTeam = Parameters[0]; @@ -60,7 +60,7 @@ export async function setUpTestContext( name: context.team.name, settings: { homepage: context.team.settings?.homepage, - submissionEmail: context.team.submissionEmail, + submissionEmail: context.team.settings?.submissionEmail, }, }); } diff --git a/e2e/tests/ui-driven/src/create-flow/helpers.ts b/e2e/tests/ui-driven/src/helpers/getPage.ts similarity index 74% rename from e2e/tests/ui-driven/src/create-flow/helpers.ts rename to e2e/tests/ui-driven/src/helpers/getPage.ts index 842cf6e430..8d9880c12e 100644 --- a/e2e/tests/ui-driven/src/create-flow/helpers.ts +++ b/e2e/tests/ui-driven/src/helpers/getPage.ts @@ -1,8 +1,5 @@ -import { Browser, Page, Request } from "@playwright/test"; -import { createAuthenticatedSession } from "../globalHelpers"; - -export const isGetUserRequest = (req: Request) => - req.url().includes("/user/me"); +import { Browser, Page } from "@playwright/test"; +import { createAuthenticatedSession } from "./globalHelpers"; export async function getAdminPage({ browser, diff --git a/e2e/tests/ui-driven/src/helpers/globalHelpers.ts b/e2e/tests/ui-driven/src/helpers/globalHelpers.ts new file mode 100644 index 0000000000..29e00f7e64 --- /dev/null +++ b/e2e/tests/ui-driven/src/helpers/globalHelpers.ts @@ -0,0 +1,142 @@ +import { FlowGraph } from "@opensystemslab/planx-core/types"; +import type { Browser, Page, Request } from "@playwright/test"; +import { gql } from "graphql-request"; +import type { Context } from "./context"; +import { generateAuthenticationToken, getGraphQLClient } from "./context"; + +// Test card numbers to be used in gov.uk sandbox environment +// reference: https://docs.payments.service.gov.uk/testing_govuk_pay/#if-you-39-re-using-a-test-39-sandbox-39-account +export const cards = { + successful_card_number: "4444333322221111", + invalid_card_number: "4000000000000002", +}; + +// Gov.uk Notify requests testing service use smoke test email addresses +// see https://docs.notifications.service.gov.uk/rest-api.html#smoke-testing +export const TEST_EMAIL = + "simulate-delivered@notifications.service.gov.uk" as const; + +// utility functions + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function log(...args: any[]) { + process.env.DEBUG_LOG && console.log(...args); +} + +// a collection of useful playwright actions +// these could evolve into fixtures: https://playwright.dev/docs/test-fixtures + +export function debugPageConsole(page: Page) { + page.on("console", (msg) => console.log(msg.text())); +} + +// used to detect `{ "setItem": ... }`, `{"getItem": ... }` +// and "section state updated" debug messages on state transitions +export async function waitForDebugLog(page: Page) { + return new Promise((resolve) => { + page.on("console", (msg) => { + if (msg.type() == "debug") { + resolve(true); + } + }); + }); +} + +export async function createAuthenticatedSession({ + browser, + userId, +}: { + browser: Browser; + userId: number; +}): Promise { + const browserContext = await browser.newContext(); + const page = await browserContext.newPage(); + const token = generateAuthenticationToken(`${userId}`); + await browserContext.addCookies([ + { + name: "jwt", + domain: "localhost", + path: "/", + value: token, + }, + { + name: "auth", + domain: "localhost", + path: "/", + value: JSON.stringify({ loggedIn: true }), + }, + ]); + return page; +} + +export async function setFeatureFlag(page: Page, featureFlag: string) { + await page.addInitScript( + (featureFlag: string) => + window.localStorage.setItem( + "FEATURE_FLAGS", + JSON.stringify([featureFlag]), + ), + featureFlag, + ); +} + +export async function getSessionId(page: Page): Promise { + // @ts-expect-error - Property api does not exist on type Window & typeof globalThis + const sessionId = page.evaluate(() => window.api.getState().sessionId); + if (!sessionId) throw Error("Session ID missing from window"); + return sessionId; +} + +export async function addSessionToContext(page: Page, context: Context) { + const sessionId = await getSessionId(page); + context.sessionIds!.push(sessionId); + return sessionId; +} + +export async function waitForPaymentResponse( + page: Page, + context: Context, +): Promise<{ paymentId: string; state?: { status: string } }> { + const { payment_id: paymentId, state } = await page + .waitForResponse((response) => { + return response.url().includes(`pay/${context.team!.slug!}`); + }) + .then((req) => req.json()); + if (!paymentId) throw new Error("Bad payment response"); + return { paymentId, state }; +} + +export async function modifyFlow({ + context, + modifiedFlow, +}: { + context: Context; + modifiedFlow: FlowGraph; +}) { + const adminGQLClient = getGraphQLClient(); + if (!context.flow?.id || !context.user?.id) { + throw new Error("context must have a flow and user"); + } + await adminGQLClient.request( + gql` + mutation UpdateTestFlow($flowId: uuid!, $userId: Int!, $data: jsonb!) { + update_flows_by_pk(pk_columns: { id: $flowId }, _set: { data: $data }) { + id + data + } + insert_published_flows_one( + object: { flow_id: $flowId, data: $data, publisher_id: $userId } + ) { + id + } + } + `, + { + flowId: context.flow!.id, + userId: context.user!.id, + data: modifiedFlow, + }, + ); +} +export const isGetUserRequest = (req: Request) => + req.url().includes("/user/me"); diff --git a/e2e/tests/ui-driven/src/globalHelpers.ts b/e2e/tests/ui-driven/src/helpers/userActions.ts similarity index 58% rename from e2e/tests/ui-driven/src/globalHelpers.ts rename to e2e/tests/ui-driven/src/helpers/userActions.ts index 56331da06b..bb2a048125 100644 --- a/e2e/tests/ui-driven/src/globalHelpers.ts +++ b/e2e/tests/ui-driven/src/helpers/userActions.ts @@ -1,76 +1,9 @@ -import { mockOSPlacesResponse } from "./mocks/osPlacesResponse"; +import type { Locator, Page } from "@playwright/test"; import { expect } from "@playwright/test"; -import type { Page, Browser, Locator } from "@playwright/test"; -import { findSessionId, generateAuthenticationToken } from "./context"; +import { mockOSPlacesResponse } from "../mocks/osPlacesResponse"; import type { Context } from "./context"; -import { getGraphQLClient } from "./context"; -import { gql } from "graphql-request"; -import { FlowGraph } from "@opensystemslab/planx-core/types"; - -// Test card numbers to be used in gov.uk sandbox environment -// reference: https://docs.payments.service.gov.uk/testing_govuk_pay/#if-you-39-re-using-a-test-39-sandbox-39-account -export const cards = { - successful_card_number: "4444333322221111", - invalid_card_number: "4000000000000002", -}; - -// Gov.uk Notify requests testing service use smoke test email addresses -// see https://docs.notifications.service.gov.uk/rest-api.html#smoke-testing -export const TEST_EMAIL = - "simulate-delivered@notifications.service.gov.uk" as const; - -// utility functions - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function log(...args: any[]) { - process.env.DEBUG_LOG && console.log(...args); -} - -// a collection of useful playwright actions -// these could evolve into fixtures: https://playwright.dev/docs/test-fixtures - -export function debugPageConsole(page: Page) { - page.on("console", (msg) => console.log(msg.text())); -} - -// used to detect `{ "setItem": ... }`, `{"getItem": ... }` -// and "section state updated" debug messages on state transitions -export async function waitForDebugLog(page: Page) { - return new Promise((resolve) => { - page.on("console", (msg) => { - if (msg.type() == "debug") { - resolve(true); - } - }); - }); -} - -export async function createAuthenticatedSession({ - browser, - userId, -}: { - browser: Browser; - userId: number; -}): Promise { - const browserContext = await browser.newContext(); - const page = await browserContext.newPage(); - const token = generateAuthenticationToken(`${userId}`); - await browserContext.addCookies([ - { - name: "jwt", - domain: "localhost", - path: "/", - value: token, - }, - { - name: "auth", - domain: "localhost", - path: "/", - value: JSON.stringify({ loggedIn: true }), - }, - ]); - return page; -} +import { findSessionId, getGraphQLClient } from "./context"; +import { TEST_EMAIL, log, waitForDebugLog } from "./globalHelpers"; export async function saveSession({ page, @@ -299,73 +232,3 @@ export async function answerContactInput( await page.getByLabel("Phone number").fill(phoneNumber); await page.getByLabel("Email address").fill(email); } - -export async function setFeatureFlag(page: Page, featureFlag: string) { - await page.addInitScript( - (featureFlag: string) => - window.localStorage.setItem( - "FEATURE_FLAGS", - JSON.stringify([featureFlag]), - ), - featureFlag, - ); -} - -export async function getSessionId(page: Page): Promise { - // @ts-expect-error - Property api does not exist on type Window & typeof globalThis - const sessionId = page.evaluate(() => window.api.getState().sessionId); - if (!sessionId) throw Error("Session ID missing from window"); - return sessionId; -} - -export async function addSessionToContext(page: Page, context: Context) { - const sessionId = await getSessionId(page); - context.sessionIds!.push(sessionId); - return sessionId; -} - -export async function waitForPaymentResponse( - page: Page, - context: Context, -): Promise<{ paymentId: string; state?: { status: string } }> { - const { payment_id: paymentId, state } = await page - .waitForResponse((response) => { - return response.url().includes(`pay/${context.team!.slug!}`); - }) - .then((req) => req.json()); - if (!paymentId) throw new Error("Bad payment response"); - return { paymentId, state }; -} - -export async function modifyFlow({ - context, - modifiedFlow, -}: { - context: Context; - modifiedFlow: FlowGraph; -}) { - const adminGQLClient = getGraphQLClient(); - if (!context.flow?.id || !context.user?.id) { - throw new Error("context must have a flow and user"); - } - await adminGQLClient.request( - gql` - mutation UpdateTestFlow($flowId: uuid!, $userId: Int!, $data: jsonb!) { - update_flows_by_pk(pk_columns: { id: $flowId }, _set: { data: $data }) { - id - data - } - insert_published_flows_one( - object: { flow_id: $flowId, data: $data, publisher_id: $userId } - ) { - id - } - } - `, - { - flowId: context.flow!.id, - userId: context.user!.id, - data: modifiedFlow, - }, - ); -} diff --git a/e2e/tests/ui-driven/src/invite-to-pay/agent.spec.ts b/e2e/tests/ui-driven/src/invite-to-pay/agent.spec.ts index 7442170cf2..23d94b2609 100644 --- a/e2e/tests/ui-driven/src/invite-to-pay/agent.spec.ts +++ b/e2e/tests/ui-driven/src/invite-to-pay/agent.spec.ts @@ -1,13 +1,18 @@ -import { test, expect, Page, BrowserContext } from "@playwright/test"; -import { addSessionToContext, modifyFlow } from "../globalHelpers"; -import inviteToPayFlow from "../mocks/flows/invite-to-pay-flow"; +import { BrowserContext, Page, expect, test } from "@playwright/test"; import { Context, contextDefaults, getGraphQLClient, setUpTestContext, tearDownTestContext, -} from "../context"; +} from "../helpers/context"; +import { addSessionToContext, modifyFlow } from "../helpers/globalHelpers"; +import { + clickContinue, + returnToSession, + saveSession, +} from "../helpers/userActions"; +import inviteToPayFlow from "../mocks/flows/invite-to-pay-flow"; import { answerInviteToPayForm, getPaymentRequestBySessionId, @@ -15,7 +20,6 @@ import { navigateToPayComponent, } from "./helpers"; import { mockPaymentRequest, modifiedInviteToPayFlow } from "./mocks"; -import { saveSession, returnToSession, clickContinue } from "../globalHelpers"; let context: Context = { ...contextDefaults, diff --git a/e2e/tests/ui-driven/src/invite-to-pay/helpers.ts b/e2e/tests/ui-driven/src/invite-to-pay/helpers.ts index 36e60c26ec..10347d7a81 100644 --- a/e2e/tests/ui-driven/src/invite-to-pay/helpers.ts +++ b/e2e/tests/ui-driven/src/invite-to-pay/helpers.ts @@ -1,16 +1,14 @@ +import { PaymentRequest } from "@opensystemslab/planx-core/dist/types"; +import type { Page } from "@playwright/test"; +import { GraphQLClient, gql } from "graphql-request"; +import { Context } from "../helpers/context"; +import { TEST_EMAIL, addSessionToContext, log } from "../helpers/globalHelpers"; import { answerChecklist, - log, - answerFindProperty, answerContactInput, - addSessionToContext, - TEST_EMAIL, -} from "../globalHelpers"; -import type { Page } from "@playwright/test"; -import { gql, GraphQLClient } from "graphql-request"; -import { fillInEmail } from "../globalHelpers"; -import { PaymentRequest } from "@opensystemslab/planx-core/dist/types"; -import { Context } from "../context"; + answerFindProperty, + fillInEmail, +} from "../helpers/userActions"; /** * Navigates to pay component whilst completing the minimum requirements for an Invite to Pay flow diff --git a/e2e/tests/ui-driven/src/invite-to-pay/mocks.ts b/e2e/tests/ui-driven/src/invite-to-pay/mocks.ts index f9f64e1956..0006a617a5 100644 --- a/e2e/tests/ui-driven/src/invite-to-pay/mocks.ts +++ b/e2e/tests/ui-driven/src/invite-to-pay/mocks.ts @@ -4,8 +4,8 @@ import { PaymentRequest, SessionData, } from "@opensystemslab/planx-core/types"; +import { TEST_EMAIL } from "../helpers/globalHelpers"; import inviteToPayFlow from "../mocks/flows/invite-to-pay-flow"; -import { TEST_EMAIL } from "../globalHelpers"; export const mockPaymentRequest: Partial = { payeeEmail: TEST_EMAIL, diff --git a/e2e/tests/ui-driven/src/invite-to-pay/nominee.spec.ts b/e2e/tests/ui-driven/src/invite-to-pay/nominee.spec.ts index 1d2276a048..bd78120699 100644 --- a/e2e/tests/ui-driven/src/invite-to-pay/nominee.spec.ts +++ b/e2e/tests/ui-driven/src/invite-to-pay/nominee.spec.ts @@ -1,18 +1,19 @@ -import { test, expect, Page, APIRequestContext } from "@playwright/test"; +import { PaymentRequest, Session } from "@opensystemslab/planx-core/types"; +import { APIRequestContext, Page, expect, test } from "@playwright/test"; +import { GraphQLClient, gql } from "graphql-request"; import { v4 as uuidV4 } from "uuid"; -import { fillGovUkCardDetails, cards } from "../globalHelpers"; -import inviteToPayFlow from "../mocks/flows/invite-to-pay-flow"; import { Context, contextDefaults, getGraphQLClient, setUpTestContext, tearDownTestContext, -} from "../context"; -import { mockPaymentRequestDetails, mockSessionData } from "./mocks"; -import { GraphQLClient, gql } from "graphql-request"; -import { PaymentRequest, Session } from "@opensystemslab/planx-core/types"; +} from "../helpers/context"; +import { cards } from "../helpers/globalHelpers"; +import { fillGovUkCardDetails } from "../helpers/userActions"; +import inviteToPayFlow from "../mocks/flows/invite-to-pay-flow"; import { getPaymentRequestBySessionId } from "./helpers"; +import { mockPaymentRequestDetails, mockSessionData } from "./mocks"; let context: Context = { ...contextDefaults, diff --git a/e2e/tests/ui-driven/src/login.spec.ts b/e2e/tests/ui-driven/src/login.spec.ts index 62481fc98c..23bfbecf83 100644 --- a/e2e/tests/ui-driven/src/login.spec.ts +++ b/e2e/tests/ui-driven/src/login.spec.ts @@ -1,11 +1,11 @@ -import { test, expect } from "@playwright/test"; -import { createAuthenticatedSession } from "./globalHelpers"; +import { expect, test } from "@playwright/test"; +import type { Context } from "./helpers/context"; import { contextDefaults, setUpTestContext, tearDownTestContext, -} from "./context"; -import type { Context } from "./context"; +} from "./helpers/context"; +import { createAuthenticatedSession } from "./helpers/globalHelpers"; test.describe("Login", () => { let context: Context = { diff --git a/e2e/tests/ui-driven/src/pay.spec.ts b/e2e/tests/ui-driven/src/pay.spec.ts index d7e6e9b56a..edf63ffdb1 100644 --- a/e2e/tests/ui-driven/src/pay.spec.ts +++ b/e2e/tests/ui-driven/src/pay.spec.ts @@ -1,23 +1,22 @@ -import { test, expect } from "@playwright/test"; -import { - cards, - fillGovUkCardDetails, - getSessionId, - log, - submitCardDetails, - waitForPaymentResponse, -} from "./globalHelpers"; -import type { Page } from "@playwright/test"; -import payFlow from "./mocks/flows/pay-flow.json"; -import { gql, GraphQLClient } from "graphql-request"; import type { SessionData } from "@opensystemslab/planx-core/types"; -import type { Context } from "./context"; +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; +import { GraphQLClient, gql } from "graphql-request"; +import type { Context } from "./helpers/context"; import { contextDefaults, getGraphQLClient, setUpTestContext, tearDownTestContext, -} from "./context"; +} from "./helpers/context"; +import { + cards, + getSessionId, + log, + waitForPaymentResponse, +} from "./helpers/globalHelpers"; +import { fillGovUkCardDetails, submitCardDetails } from "./helpers/userActions"; +import payFlow from "./mocks/flows/pay-flow.json"; let context: Context = { ...contextDefaults, diff --git a/e2e/tests/ui-driven/src/save-and-return.spec.ts b/e2e/tests/ui-driven/src/save-and-return.spec.ts index 162615bad5..c2d01b954e 100644 --- a/e2e/tests/ui-driven/src/save-and-return.spec.ts +++ b/e2e/tests/ui-driven/src/save-and-return.spec.ts @@ -1,23 +1,23 @@ -import { test, expect } from "@playwright/test"; -import { - simpleSendFlow, - modifiedSimpleSendFlow, -} from "./mocks/flows/save-and-return-flows"; +import { expect, test } from "@playwright/test"; +import type { Context } from "./helpers/context"; import { contextDefaults, setUpTestContext, tearDownTestContext, -} from "./context"; +} from "./helpers/context"; +import { modifyFlow } from "./helpers/globalHelpers"; import { - fillInEmail, + answerQuestion, clickContinue, + fillInEmail, findQuestion, - answerQuestion, returnToSession, saveSession, - modifyFlow, -} from "./globalHelpers"; -import type { Context } from "./context"; +} from "./helpers/userActions"; +import { + modifiedSimpleSendFlow, + simpleSendFlow, +} from "./mocks/flows/save-and-return-flows"; test.describe("Save and return", () => { let context: Context = { diff --git a/e2e/tests/ui-driven/src/sections.spec.ts b/e2e/tests/ui-driven/src/sections.spec.ts index 2a44985e8a..0aca98272b 100644 --- a/e2e/tests/ui-driven/src/sections.spec.ts +++ b/e2e/tests/ui-driven/src/sections.spec.ts @@ -1,26 +1,26 @@ +import type { FlowGraph } from "@opensystemslab/planx-core/types"; import { expect, test } from "@playwright/test"; -import { flow, updatedQuestionAnswers } from "./mocks/flows/sections-flow"; +import { gql } from "graphql-request"; +import type { Context } from "./helpers/context"; import { contextDefaults, + getGraphQLClient, setUpTestContext, tearDownTestContext, - getGraphQLClient, -} from "./context"; +} from "./helpers/context"; import { - fillInEmail, - answerQuestion, answerChecklist, - clickContinue, + answerQuestion, clickBack, + clickContinue, + expectConfirmation, expectNotice, expectSections, - expectConfirmation, - saveSession, + fillInEmail, returnToSession, -} from "./globalHelpers"; -import { gql } from "graphql-request"; -import type { Context } from "./context"; -import type { FlowGraph } from "@opensystemslab/planx-core/types"; + saveSession, +} from "./helpers/userActions"; +import { flow, updatedQuestionAnswers } from "./mocks/flows/sections-flow"; // TODO: move this type to planx-core // also defined in editor.planx.uk/src/types.ts diff --git a/editor.planx.uk/package.json b/editor.planx.uk/package.json index 9f16b6549e..932dfd8f44 100644 --- a/editor.planx.uk/package.json +++ b/editor.planx.uk/package.json @@ -14,8 +14,8 @@ "@mui/lab": "5.0.0-alpha.170", "@mui/material": "^5.15.10", "@mui/utils": "^5.15.11", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#931d4db", - "@opensystemslab/map": "1.0.0-alpha.2", + "@opensystemslab/map": "1.0.0-alpha.3", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#ec52620", "@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 014ad884f5..ac10529f31 100644 --- a/editor.planx.uk/pnpm-lock.yaml +++ b/editor.planx.uk/pnpm-lock.yaml @@ -44,11 +44,11 @@ dependencies: specifier: ^5.15.11 version: 5.15.11(@types/react@18.2.45)(react@18.2.0) '@opensystemslab/map': - specifier: 1.0.0-alpha.2 - version: 1.0.0-alpha.2 + specifier: 1.0.0-alpha.3 + version: 1.0.0-alpha.3 '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#931d4db - version: github.com/theopensystemslab/planx-core/931d4db(@types/react@18.2.45) + specifier: git+https://github.com/theopensystemslab/planx-core#ec52620 + version: github.com/theopensystemslab/planx-core/ec52620(@types/react@18.2.45) '@tiptap/core': specifier: ^2.4.0 version: 2.4.0(@tiptap/pm@2.0.3) @@ -5610,8 +5610,8 @@ packages: resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} dev: true - /@opensystemslab/map@1.0.0-alpha.2: - resolution: {integrity: sha512-S7amFaUpI3C+/8UKc/EO2JTysV2S300ANFDVUzSb2KWyZSPyQUBOgiy+RWJhrOQmjTozbopLwCbaZY+sAURXLQ==} + /@opensystemslab/map@1.0.0-alpha.3: + resolution: {integrity: sha512-mwOBKiq0DWNOvYv7XiSkTYbBFdoRZD6jXnk1Kh9FPSFeP2FftDDusJzSK25k0rSMd7bqnnQ0O0l7X9hBucBzIg==} dependencies: '@turf/union': 7.0.0 accessible-autocomplete: 2.0.4 @@ -9260,7 +9260,7 @@ packages: dev: true /batch@0.6.1: - resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==} + resolution: {integrity: sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY=} dev: true /bfj@7.1.0: @@ -9400,7 +9400,7 @@ packages: dev: true /bytes@3.0.0: - resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + resolution: {integrity: sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=} engines: {node: '>= 0.8'} dev: true @@ -19994,8 +19994,8 @@ packages: engines: {node: '>=16'} dev: false - /type-fest@4.25.0: - resolution: {integrity: sha512-bRkIGlXsnGBRBQRAY56UXBm//9qH4bmJfFvq83gSz41N282df+fjy8ofcEgc1sM8geNt5cl6mC2g9Fht1cs8Aw==} + /type-fest@4.26.0: + resolution: {integrity: sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw==} engines: {node: '>=16'} dev: false @@ -21379,9 +21379,9 @@ packages: use-sync-external-store: 1.2.0(react@18.2.0) dev: false - github.com/theopensystemslab/planx-core/931d4db(@types/react@18.2.45): - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/931d4db} - id: github.com/theopensystemslab/planx-core/931d4db + github.com/theopensystemslab/planx-core/ec52620(@types/react@18.2.45): + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/ec52620} + id: github.com/theopensystemslab/planx-core/ec52620 name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true @@ -21407,7 +21407,7 @@ packages: prettier: 3.3.3 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - type-fest: 4.25.0 + type-fest: 4.26.0 uuid: 10.0.0 zod: 3.23.8 transitivePeerDependencies: diff --git a/editor.planx.uk/src/@planx/components/List/Editor.tsx b/editor.planx.uk/src/@planx/components/List/Editor.tsx index 563c9b6c7d..1771b8b36a 100644 --- a/editor.planx.uk/src/@planx/components/List/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/List/Editor.tsx @@ -25,6 +25,8 @@ import { ProtectedSpaceGLA } from "./schemas/GLA/ProtectedSpace"; import { MaterialDetails } from "./schemas/Materials"; import { Parking } from "./schemas/Parking"; import { ResidentialUnitsExisting } from "./schemas/ResidentialUnits/Existing"; +import { ResidentialUnitsGLAGained } from "./schemas/ResidentialUnits/GLA/Gained"; +import { ResidentialUnitsGLALost } from "./schemas/ResidentialUnits/GLA/Lost"; import { ResidentialUnitsGLANew } from "./schemas/ResidentialUnits/GLA/New"; import { ResidentialUnitsGLARebuilt } from "./schemas/ResidentialUnits/GLA/Rebuilt"; import { ResidentialUnitsGLARemoved } from "./schemas/ResidentialUnits/GLA/Removed"; @@ -54,6 +56,14 @@ export const SCHEMAS = [ name: "Residential units (GLA) - Retained", schema: ResidentialUnitsGLARetained, }, + { + name: "Residential units (GLA) - Lost", + schema: ResidentialUnitsGLALost, + }, + { + name: "Residential units (GLA) - Gained", + schema: ResidentialUnitsGLAGained, + }, { name: "Non-residential floorspace", schema: NonResidentialFloorspace }, { name: "Existing and proposed uses (GLA)", @@ -67,12 +77,8 @@ export const SCHEMAS = [ { name: "Proposed advertisements", schema: ProposedAdvertisements }, { name: "Parking details", schema: Parking }, { name: "Parking details (GLA)", schema: ParkingGLA }, - ...(hasFeatureFlag("TREES") - ? [ - { name: "Trees", schema: Trees }, - { name: "Trees (Map first)", schema: TreesMapFirst }, - ] - : []), + { name: "Trees", schema: Trees }, + { name: "Trees (Map first)", schema: TreesMapFirst }, ]; function ListComponent(props: Props) { diff --git a/editor.planx.uk/src/@planx/components/List/Public/index.tsx b/editor.planx.uk/src/@planx/components/List/Public/index.tsx index 03a801c7c2..8b581068bc 100644 --- a/editor.planx.uk/src/@planx/components/List/Public/index.tsx +++ b/editor.planx.uk/src/@planx/components/List/Public/index.tsx @@ -15,6 +15,7 @@ import { PublicProps } from "@planx/components/ui"; import { useStore } from "pages/FlowEditor/lib/store"; import React, { useEffect, useRef } from "react"; import { FONT_WEIGHT_SEMI_BOLD } from "theme"; +import FullWidthWrapper from "ui/public/FullWidthWrapper"; import ErrorWrapper from "ui/shared/ErrorWrapper"; import Card from "../../shared/Preview/Card"; @@ -26,18 +27,30 @@ import { ListProvider, useListContext } from "./Context"; export type Props = PublicProps; const ListCard = styled(Box)(({ theme }) => ({ - padding: theme.spacing(2), + padding: theme.spacing(2.5), backgroundColor: theme.palette.background.paper, - border: "1px solid darkgray", + border: `1px solid ${theme.palette.border.main}`, display: "flex", flexDirection: "column", - gap: theme.spacing(2), + gap: theme.spacing(3), marginBottom: theme.spacing(2), + "& label, & table": { + maxWidth: theme.breakpoints.values.formWrap, + }, })); const CardButton = styled(Button)(({ theme }) => ({ - fontWeight: FONT_WEIGHT_SEMI_BOLD, + gap: theme.spacing(1), + background: theme.palette.common.white, +})); + +const InactiveListCardLayout = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", gap: theme.spacing(2), + [theme.breakpoints.up("md")]: { + flexDirection: "row", + }, })); const ActiveListCard: React.FC<{ @@ -92,12 +105,12 @@ const ActiveListCard: React.FC<{ Save {!isPageComponent && !isInitialCard && ( - + )} @@ -111,39 +124,56 @@ const InactiveListCard: React.FC<{ const { schema, formik, removeItem, editItem, isPageComponent } = useListContext(); + const mapPreview = schema.fields.find((field) => field.type === "map"); + return ( {schema.type} {!isPageComponent && ` ${i + 1}`} - - - {schema.fields.map((field, j) => ( - - - {field.data.title} - - - {formatSchemaDisplayValue( - formik.values.schemaData[i][field.data.fn], - schema.fields[j], - )} - - - ))} - -
+ + {mapPreview && ( + + {formatSchemaDisplayValue( + formik.values.schemaData[i][mapPreview.data.fn], + mapPreview, + )} + + )} + + + {schema.fields.map( + (field, j) => + field.type !== "map" && ( + + + {field.data.title} + + + {formatSchemaDisplayValue( + formik.values.schemaData[i][field.data.fn], + schema.fields[j], + )} + + + ), + )} + +
+
removeItem(i)}> Remove editItem(i)}> - {/* TODO: Is primary colour really right here? */} - + Edit @@ -201,6 +231,40 @@ const Root = () => { ); } + const listContent = ( + + <> + {formik.values.schemaData.map((_, i) => + i === activeIndex ? ( + + ) : ( + + ), + )} + {shouldShowAddAnotherButton && ( + + + + )} + + + ); + return ( { policyRef={policyRef} howMeasured={howMeasured} /> - - <> - {formik.values.schemaData.map((_, i) => - i === activeIndex ? ( - - ) : ( - - ), - )} - {shouldShowAddAnotherButton && ( - - - - )} - - + {hasMapField ? ( + {listContent} + ) : ( + listContent + )} ); }; diff --git a/editor.planx.uk/src/@planx/components/List/schemas/ResidentialUnits/Existing.ts b/editor.planx.uk/src/@planx/components/List/schemas/ResidentialUnits/Existing.ts index fe76b8d0e0..bc8d4353b9 100644 --- a/editor.planx.uk/src/@planx/components/List/schemas/ResidentialUnits/Existing.ts +++ b/editor.planx.uk/src/@planx/components/List/schemas/ResidentialUnits/Existing.ts @@ -3,6 +3,27 @@ import { Schema } from "@planx/components/shared/Schema/model"; export const ResidentialUnitsExisting: Schema = { type: "Existing residential unit type", fields: [ + { + type: "question", + data: { + title: "What best describes the type of this unit?", + fn: "type", + options: [ + { id: "house", data: { text: "House", val: "house" } }, + { + id: "flat", + data: { text: "Flat, apartment or maisonette", val: "flat" }, + }, + { + id: "sheltered", + data: { text: "Sheltered housing", val: "sheltered" }, + }, + { id: "studio", data: { text: "Studio or bedsit", val: "studio" } }, + { id: "cluster", data: { text: "Cluster flat", val: "cluster" } }, + { id: "other", data: { text: "Other", val: "other" } }, + ], + }, + }, { type: "question", data: { @@ -30,27 +51,6 @@ export const ResidentialUnitsExisting: Schema = { ], }, }, - { - type: "question", - data: { - title: "What best describes the type of this unit?", - fn: "type", - options: [ - { id: "house", data: { text: "House", val: "house" } }, - { - id: "flat", - data: { text: "Flat, apartment or maisonette", val: "flat" }, - }, - { - id: "sheltered", - data: { text: "Sheltered housing", val: "sheltered" }, - }, - { id: "studio", data: { text: "Studio or bedsit", val: "studio" } }, - { id: "cluster", data: { text: "Cluster flat", val: "cluster" } }, - { id: "other", data: { text: "Other", val: "other" } }, - ], - }, - }, { type: "number", data: { @@ -62,7 +62,7 @@ export const ResidentialUnitsExisting: Schema = { { type: "number", data: { - title: "How many existing units fit the descriptions above?", + title: "How many units of the type described above exist on the site?", fn: "identicalUnits", allowNegatives: false, }, diff --git a/editor.planx.uk/src/@planx/components/List/schemas/ResidentialUnits/GLA/Gained.ts b/editor.planx.uk/src/@planx/components/List/schemas/ResidentialUnits/GLA/Gained.ts new file mode 100644 index 0000000000..0c67dcbe81 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/List/schemas/ResidentialUnits/GLA/Gained.ts @@ -0,0 +1,215 @@ +import { Schema } from "@planx/components/shared/Schema/model"; + +export const ResidentialUnitsGLAGained: Schema = { + type: "Gained residential unit type", + fields: [ + { + type: "question", + data: { + title: "What best describes the type of this unit?", + fn: "type", + options: [ + { id: "terraced", data: { text: "Terraced home", val: "terraced" } }, + { + id: "semiDetached", + data: { text: "Semi detached home", val: "semiDetached" }, + }, + { id: "detached", data: { text: "Detached home", val: "detached" } }, + { + id: "flat", + data: { text: "Flat/apartment or maisonette", val: "flat" }, + }, + { id: "LW", data: { text: "Live/work unit", val: "LW" } }, + { id: "cluster", data: { text: "Cluster flat", val: "cluster" } }, + { id: "studio", data: { text: "Studio or bedsit", val: "studio" } }, + { id: "coLiving", data: { text: "Co living unit", val: "coLiving" } }, + { id: "hostel", data: { text: "Hostel room", val: "hostel" } }, + { id: "HMO", data: { text: "HMO", val: "HMO" } }, + { + id: "student", + data: { text: "Student accommodation", val: "student" }, + }, + { id: "other", data: { text: "Other", val: "other" } }, + ], + }, + }, + { + type: "number", + data: { + title: "What will be the Gross Internal Floor Area (GIA) of this unit?", + units: "m²", + fn: "area", + allowNegatives: false, + }, + }, + { + type: "number", + data: { + title: "How many habitable rooms will this unit have?", + fn: "habitable", + allowNegatives: false, + }, + }, + { + type: "number", + data: { + title: "How many bedrooms will this unit have?", + fn: "bedrooms", + allowNegatives: false, + }, + }, + { + type: "question", + data: { + title: "Which best describes the tenure of this unit?", + fn: "tenure", + options: [ + { id: "LAR", data: { text: "London Affordable Rent", val: "LAR" } }, + { + id: "AR", + data: { + text: "Affordable rent (not at LAR benchmark rents)", + val: "AR", + }, + }, + { id: "SR", data: { text: "Social rent", val: "SR" } }, + { id: "LRR", data: { text: "London Living Rent", val: "LRR" } }, + { + id: "sharedEquity", + data: { text: "Shared equity", val: "sharedEquity" }, + }, + { id: "LSO", data: { text: "London Shared Ownership", val: "LSO" } }, + { id: "DMS", data: { text: "Discount market sale", val: "DMS" } }, + { id: "DMR", data: { text: "Discount market rent", val: "DMR" } }, + { + id: "DMRLLR", + data: { + text: "Discount market rent (charged at London Living Rents)", + val: "DMRLLR", + }, + }, + { + id: "marketForRent", + data: { text: "Market for rent", val: "marketForRent" }, + }, + { id: "SH", data: { text: "Starter homes", val: "SH" } }, + { + id: "selfCustomBuild", + data: { + text: "Self-build and custom build", + val: "selfCustomBuild", + }, + }, + { + id: "marketForSale", + data: { text: "Market for sale", val: "marketForSale" }, + }, + { id: "other", data: { text: "Other", val: "other" } }, + ], + }, + }, + { + type: "question", + data: { + title: "What best describes the provider of this unit?", + fn: "provider", + options: [ + { id: "private", data: { text: "Private", val: "private" } }, + { + id: "privateRented", + data: { text: "Private rented sector", val: "privateRented" }, + }, + { id: "HA", data: { text: "Housing association", val: "HA" } }, + { id: "LA", data: { text: "Local authority", val: "LA" } }, + { + id: "publicAuthority", + data: { text: "Other public authority", val: "publicAuthority" }, + }, + { + id: "councilDelivery", + data: { text: "Council delivery company", val: "councilDelivery" }, + }, + { + id: "councilBuildToRent", + data: { + text: "Council delivered build to rent", + val: "councilBuildToRent", + }, + }, + { + id: "affordableHousing", + data: { + text: "Other affordable housing provider", + val: "affordableHousing", + }, + }, + { id: "selfBuild", data: { text: "Self-build", val: "selfBuild" } }, + ], + }, + }, + { + type: "checklist", + data: { + title: "Will this unit be compliant with any of the following?", + fn: "compliance", + options: [ + { + id: "m42", + data: { text: "Part M4(2) of the Building Regulations 2010" }, + }, + { + id: "m432a", + data: { text: "Part M4(3)(2a) of the Building Regulations 2010" }, + }, + { + id: "m432b", + data: { text: "Part M4(3)(2b) of the Building Regulations 2010" }, + }, + { id: "none", data: { text: "None of these" } }, + ], + }, + }, + { + type: "question", + data: { + title: "Will this unit be built on garden land?", + fn: "garden", + options: [ + { id: "true", data: { text: "Yes", val: "true" } }, + { id: "false", data: { text: "No", val: "false" } }, + ], + }, + }, + { + type: "question", + data: { + title: "Will this unit provide sheltered accommodation?", + fn: "sheltered", + options: [ + { id: "true", data: { text: "Yes", val: "true" } }, + { id: "false", data: { text: "No", val: "false" } }, + ], + }, + }, + { + type: "question", + data: { + title: "Is this unit specifically designed for older people?", + fn: "olderPersons", + options: [ + { id: "true", data: { text: "Yes", val: "true" } }, + { id: "false", data: { text: "No", val: "false" } }, + ], + }, + }, + { + type: "number", + data: { + title: "How many units of the type described above are gained?", + fn: "identicalUnits", + allowNegatives: false, + }, + }, + ], + min: 1, +} as const; diff --git a/editor.planx.uk/src/@planx/components/List/schemas/ResidentialUnits/GLA/Lost.ts b/editor.planx.uk/src/@planx/components/List/schemas/ResidentialUnits/GLA/Lost.ts new file mode 100644 index 0000000000..0816a00e71 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/List/schemas/ResidentialUnits/GLA/Lost.ts @@ -0,0 +1,215 @@ +import { Schema } from "@planx/components/shared/Schema/model"; + +export const ResidentialUnitsGLALost: Schema = { + type: "Lost residential unit type", + fields: [ + { + type: "question", + data: { + title: "What best describes the type of this unit?", + fn: "type", + options: [ + { id: "terraced", data: { text: "Terraced home", val: "terraced" } }, + { + id: "semiDetached", + data: { text: "Semi detached home", val: "semiDetached" }, + }, + { id: "detached", data: { text: "Detached home", val: "detached" } }, + { + id: "flat", + data: { text: "Flat/apartment or maisonette", val: "flat" }, + }, + { id: "LW", data: { text: "Live/work unit", val: "LW" } }, + { id: "cluster", data: { text: "Cluster flat", val: "cluster" } }, + { id: "studio", data: { text: "Studio or bedsit", val: "studio" } }, + { id: "coLiving", data: { text: "Co living unit", val: "coLiving" } }, + { id: "hostel", data: { text: "Hostel room", val: "hostel" } }, + { id: "HMO", data: { text: "HMO", val: "HMO" } }, + { + id: "student", + data: { text: "Student accommodation", val: "student" }, + }, + { id: "other", data: { text: "Other", val: "other" } }, + ], + }, + }, + { + type: "number", + data: { + title: "What is the Gross Internal Floor Area (GIA) of this unit?", + units: "m²", + fn: "area", + allowNegatives: false, + }, + }, + { + type: "number", + data: { + title: "How many habitable rooms does this unit have?", + fn: "habitable", + allowNegatives: false, + }, + }, + { + type: "number", + data: { + title: "How many bedrooms does this unit have?", + fn: "bedrooms", + allowNegatives: false, + }, + }, + { + type: "question", + data: { + title: "Which best describes the tenure of this unit?", + fn: "tenure", + options: [ + { id: "LAR", data: { text: "London Affordable Rent", val: "LAR" } }, + { + id: "AR", + data: { + text: "Affordable rent (not at LAR benchmark rents)", + val: "AR", + }, + }, + { id: "SR", data: { text: "Social rent", val: "SR" } }, + { id: "LRR", data: { text: "London Living Rent", val: "LRR" } }, + { + id: "sharedEquity", + data: { text: "Shared equity", val: "sharedEquity" }, + }, + { id: "LSO", data: { text: "London Shared Ownership", val: "LSO" } }, + { id: "DMS", data: { text: "Discount market sale", val: "DMS" } }, + { id: "DMR", data: { text: "Discount market rent", val: "DMR" } }, + { + id: "DMRLLR", + data: { + text: "Discount market rent (charged at London Living Rents)", + val: "DMRLLR", + }, + }, + { + id: "marketForRent", + data: { text: "Market for rent", val: "marketForRent" }, + }, + { id: "SH", data: { text: "Starter homes", val: "SH" } }, + { + id: "selfCustomBuild", + data: { + text: "Self-build and custom build", + val: "selfCustomBuild", + }, + }, + { + id: "marketForSale", + data: { text: "Market for sale", val: "marketForSale" }, + }, + { id: "other", data: { text: "Other", val: "other" } }, + ], + }, + }, + { + type: "question", + data: { + title: "What best describes the provider of this unit?", + fn: "provider", + options: [ + { id: "private", data: { text: "Private", val: "private" } }, + { + id: "privateRented", + data: { text: "Private rented sector", val: "privateRented" }, + }, + { id: "HA", data: { text: "Housing association", val: "HA" } }, + { id: "LA", data: { text: "Local authority", val: "LA" } }, + { + id: "publicAuthority", + data: { text: "Other public authority", val: "publicAuthority" }, + }, + { + id: "councilDelivery", + data: { text: "Council delivery company", val: "councilDelivery" }, + }, + { + id: "councilBuildToRent", + data: { + text: "Council delivered build to rent", + val: "councilBuildToRent", + }, + }, + { + id: "affordableHousing", + data: { + text: "Other affordable housing provider", + val: "affordableHousing", + }, + }, + { id: "selfBuild", data: { text: "Self-build", val: "selfBuild" } }, + ], + }, + }, + { + type: "checklist", + data: { + title: "Is this unit compliant with any of the following?", + fn: "compliance", + options: [ + { + id: "m42", + data: { text: "Part M4(2) of the Building Regulations 2010" }, + }, + { + id: "m432a", + data: { text: "Part M4(3)(2a) of the Building Regulations 2010" }, + }, + { + id: "m432b", + data: { text: "Part M4(3)(2b) of the Building Regulations 2010" }, + }, + { id: "none", data: { text: "None of these" } }, + ], + }, + }, + { + type: "question", + data: { + title: "Is this unit built on garden land?", + fn: "garden", + options: [ + { id: "true", data: { text: "Yes", val: "true" } }, + { id: "false", data: { text: "No", val: "false" } }, + ], + }, + }, + { + type: "question", + data: { + title: "Does this unit provide sheltered accommodation?", + fn: "sheltered", + options: [ + { id: "true", data: { text: "Yes", val: "true" } }, + { id: "false", data: { text: "No", val: "false" } }, + ], + }, + }, + { + type: "question", + data: { + title: "Is this unit specifically designed for older people?", + fn: "olderPersons", + options: [ + { id: "true", data: { text: "Yes", val: "true" } }, + { id: "false", data: { text: "No", val: "false" } }, + ], + }, + }, + { + type: "number", + data: { + title: "How many units of the type described above are lost?", + fn: "identicalUnits", + allowNegatives: false, + }, + }, + ], + min: 1, +} as const; diff --git a/editor.planx.uk/src/@planx/components/List/schemas/ResidentialUnits/Proposed.ts b/editor.planx.uk/src/@planx/components/List/schemas/ResidentialUnits/Proposed.ts index fdd295110b..a62a70cfef 100644 --- a/editor.planx.uk/src/@planx/components/List/schemas/ResidentialUnits/Proposed.ts +++ b/editor.planx.uk/src/@planx/components/List/schemas/ResidentialUnits/Proposed.ts @@ -3,6 +3,27 @@ import { Schema } from "@planx/components/shared/Schema/model"; export const ResidentialUnitsProposed: Schema = { type: "Proposed residential unit type", fields: [ + { + type: "question", + data: { + title: "What best describes the type of this unit?", + fn: "type", + options: [ + { id: "house", data: { text: "House", val: "house" } }, + { + id: "flat", + data: { text: "Flat, apartment or maisonette", val: "flat" }, + }, + { + id: "sheltered", + data: { text: "Sheltered housing", val: "sheltered" }, + }, + { id: "studio", data: { text: "Studio or bedsit", val: "studio" } }, + { id: "cluster", data: { text: "Cluster flat", val: "cluster" } }, + { id: "other", data: { text: "Other", val: "other" } }, + ], + }, + }, { type: "question", data: { @@ -30,27 +51,6 @@ export const ResidentialUnitsProposed: Schema = { ], }, }, - { - type: "question", - data: { - title: "What best describes the type of this unit?", - fn: "type", - options: [ - { id: "house", data: { text: "House", val: "house" } }, - { - id: "flat", - data: { text: "Flat, apartment or maisonette", val: "flat" }, - }, - { - id: "sheltered", - data: { text: "Sheltered housing", val: "sheltered" }, - }, - { id: "studio", data: { text: "Studio or bedsit", val: "studio" } }, - { id: "cluster", data: { text: "Cluster flat", val: "cluster" } }, - { id: "other", data: { text: "Other", val: "other" } }, - ], - }, - }, { type: "number", data: { @@ -62,7 +62,9 @@ export const ResidentialUnitsProposed: Schema = { { type: "number", data: { - title: "How many proposed units fit the descriptions above?", + title: "How many units of the type described above are you proposing?", + description: + "This is the total number of units of this type that will be on the site after completion of the project.", fn: "identicalUnits", allowNegatives: false, }, diff --git a/editor.planx.uk/src/@planx/components/MapAndLabel/Public/Context.tsx b/editor.planx.uk/src/@planx/components/MapAndLabel/Public/Context.tsx index 82440d9edd..3e7f049db7 100644 --- a/editor.planx.uk/src/@planx/components/MapAndLabel/Public/Context.tsx +++ b/editor.planx.uk/src/@planx/components/MapAndLabel/Public/Context.tsx @@ -9,7 +9,8 @@ import { makeData, } from "@planx/components/shared/utils"; import { FormikProps, useFormik } from "formik"; -import { FeatureCollection } from "geojson"; +import { Feature, FeatureCollection } from "geojson"; +import { GeoJSONChange, GeoJSONChangeEvent, useGeoJSONChange } from "lib/gis"; import { get } from "lodash"; import React, { createContext, @@ -20,15 +21,20 @@ import React, { import { PresentationalProps } from "."; +export const MAP_ID = "map-and-label-map"; + interface MapAndLabelContextValue { schema: Schema; + features?: Feature[]; + updateMapKey: number; activeIndex: number; - editFeature: (index: number) => void; formik: FormikProps; validateAndSubmitForm: () => void; isFeatureInvalid: (index: number) => boolean; - addFeature: () => void; + addInitialFeaturesToMap: (features: Feature[]) => void; + editFeature: (index: number) => void; copyFeature: (sourceIndex: number, destinationIndex: number) => void; + removeFeature: (index: number) => void; mapAndLabelProps: PresentationalProps; errors: { min: boolean; @@ -51,21 +57,20 @@ export const MapAndLabelProvider: React.FC = ( previousValues: getPreviouslySubmittedData(props), }); - // Deconstruct GeoJSON saved to passport back into schemaData & geoData + // Deconstruct GeoJSON saved to passport back into form data and map data const previousGeojson = previouslySubmittedData?.data?.[ fn ] as FeatureCollection; - const previousSchemaData = previousGeojson?.features.map( + const previousFormData = previousGeojson?.features.map( (feature) => feature.properties, ) as SchemaUserResponse[]; - const previousGeoData = previousGeojson?.features; + const _previousMapData = previousGeojson?.features; const formik = useFormik({ ...formikConfig, // The user interactions are map driven - start with no values added initialValues: { - schemaData: previousSchemaData || [], - geoData: previousGeoData || [], + schemaData: previousFormData || [], }, onSubmit: (values) => { const geojson: FeatureCollection = { @@ -73,7 +78,7 @@ export const MapAndLabelProvider: React.FC = ( features: [], }; - values.geoData?.forEach((feature, i) => { + features?.forEach((feature, i) => { // Store user inputs as GeoJSON properties const mergedProperties = { ...feature.properties, @@ -93,14 +98,40 @@ export const MapAndLabelProvider: React.FC = ( }, }); - const [activeIndex, setActiveIndex] = useState(0); + const [activeIndex, setActiveIndex] = useState(-1); const [minError, setMinError] = useState(false); const [maxError, setMaxError] = useState(false); + const handleGeoJSONChange = (event: GeoJSONChangeEvent) => { + // If the user clicks 'reset' on the map, geojson will be empty object + const userHitsReset = !event.detail["EPSG:3857"]; + + if (userHitsReset) { + removeAllFeaturesFromMap(); + removeAllFeaturesFromForm(); + return; + } + + addFeatureToMap(event.detail); + addFeatureToForm(); + }; + + const [features, setFeatures] = useGeoJSONChange(MAP_ID, handleGeoJSONChange); + + const [updateMapKey, setUpdateMapKey] = useState(0); + const resetErrors = () => { setMinError(false); setMaxError(false); + formik.setErrors({}); + }; + + const removeAllFeaturesFromMap = () => setFeatures(undefined); + + const removeAllFeaturesFromForm = () => { + formik.setFieldValue("schemaData", []); + setActiveIndex(-1); }; const validateAndSubmitForm = () => { @@ -119,7 +150,18 @@ export const MapAndLabelProvider: React.FC = ( const isFeatureInvalid = (index: number) => Boolean(get(formik.errors, ["schemaData", index])); - const addFeature = () => { + const addFeatureToMap = (geojson: GeoJSONChange) => { + resetErrors(); + setFeatures(geojson["EPSG:3857"].features); + setActiveIndex((features && features?.length - 2) || activeIndex + 1); + }; + + const addInitialFeaturesToMap = (features: Feature[]) => { + setFeatures(features); + // TODO: setActiveIndex ? + }; + + const addFeatureToForm = () => { resetErrors(); const currentFeatures = formik.values.schemaData; @@ -130,6 +172,8 @@ export const MapAndLabelProvider: React.FC = ( if (schema.max && updatedFeatures.length > schema.max) { setMaxError(true); } + + setActiveIndex(activeIndex + 1); }; const copyFeature = (sourceIndex: number, destinationIndex: number) => { @@ -137,17 +181,55 @@ export const MapAndLabelProvider: React.FC = ( formik.setFieldValue(`schemaData[${destinationIndex}]`, sourceFeature); }; + const removeFeatureFromForm = (index: number) => { + formik.setFieldValue( + "schemaData", + formik.values.schemaData.filter((_, i) => i !== index), + ); + }; + + const removeFeatureFromMap = (index: number) => { + // Order of features can vary by change/modification, filter on label not array position + const label = `${index + 1}`; + const filteredFeatures = features?.filter( + (f) => f.properties?.label !== label, + ); + + // Shift any feature labels that are larger than the removed feature label so they remain incremental + filteredFeatures?.map((f) => { + if (f.properties && Number(f.properties?.label) > Number(label)) { + const newLabel = Number(f.properties.label) - 1; + Object.assign(f, { properties: { label: `${newLabel}` } }); + } + }); + setFeatures(filteredFeatures); + + // `updateMapKey` is set as a unique `key` prop on the map container to force a re-render of its children (aka ) on change + setUpdateMapKey(updateMapKey + 1); + }; + + const removeFeature = (index: number) => { + resetErrors(); + removeFeatureFromForm(index); + removeFeatureFromMap(index); + // Set active index as highest tab after removal, so that when you "add" a new feature the tabs increment correctly + setActiveIndex((features && features.length - 2) || activeIndex - 1); + }; + return ( ; @@ -55,11 +55,20 @@ const StyledTab = styled((props: TabProps) => ( }, })) as typeof Tab; -const VerticalFeatureTabs: React.FC<{ features: Feature[] }> = ({ - features, -}) => { - const { schema, activeIndex, formik, editFeature, isFeatureInvalid } = - useMapAndLabelContext(); +const VerticalFeatureTabs: React.FC = () => { + const { + schema, + activeIndex, + formik, + features, + editFeature, + isFeatureInvalid, + removeFeature, + } = useMapAndLabelContext(); + + if (!features) { + throw new Error("Cannot render MapAndLabel tabs without features"); + } // Features is inherently sorted by recently added/modified, order tabs by stable labels const sortedFeatures = sortBy(features, ["properties.label"]); @@ -152,11 +161,7 @@ const VerticalFeatureTabs: React.FC<{ features: Feature[] }> = ({ formik={formik} />