diff --git a/.gitignore b/.gitignore index 147f72b877..b95f376eae 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ hasura.planx.uk/.env.test /playwright-report/ /playwright/.cache/ api.planx.uk/tmp/ +.python-version +__pycache__ # Ignore certificate files **/*.chain diff --git a/e2e/tests/ui-driven/src/create-flow-with-geospatial.spec.ts b/e2e/tests/ui-driven/src/create-flow-with-geospatial.spec.ts index 8bc6fe181b..02ecfbc29e 100644 --- a/e2e/tests/ui-driven/src/create-flow-with-geospatial.spec.ts +++ b/e2e/tests/ui-driven/src/create-flow-with-geospatial.spec.ts @@ -19,11 +19,24 @@ import { } from "./helpers/navigateAndPublish"; import { TestContext } from "./helpers/types"; import { serviceProps } from "./helpers/serviceData"; -import { checkGeoJsonContent } from "./helpers/geospatialChecks"; import { - mockMapGeoJson, + alterDrawGeoJson, + checkGeoJsonContent, + checkUploadFileAltRoute, + getMapProperties, + resetMapBoundary, + waitForMapComponent, +} from "./helpers/geospatialChecks"; +import { + GeoJsonChangeHandler, + mockChangedMapGeoJson, mockPropertyTypeOptions, + mockTitleBoundaryGeoJson, } from "./mocks/geospatialMocks"; +import { + setupOSMapsStyles, + setupOSMapsVectorTiles, +} from "./mocks/osMapsResponse"; test.describe("Flow creation, publish and preview", () => { let context: TestContext = { @@ -72,8 +85,7 @@ test.describe("Flow creation, publish and preview", () => { await editor.createInternalPortal(); await editor.populateInternalPortal(); await page.getByRole("link", { name: "start" }).click(); // return to main flow - await editor.createUploadAndLabel(); - // TODO: editor.createPropertyInfo() + // await editor.createUploadAndLabel(); await editor.createDrawBoundary(); await editor.createPlanningConstraints(); // await editor.createFileUpload(); @@ -81,7 +93,6 @@ test.describe("Flow creation, publish and preview", () => { await expect(editor.nodeList).toContainText([ "Find property", "an internal portalEdit Portal", - "Upload and label", "Confirm your location plan", "Planning constraints", // "File upload", @@ -119,6 +130,9 @@ test.describe("Flow creation, publish and preview", () => { `/${context.team.slug}/${serviceProps.slug}/published?analytics=false`, ); + setupOSMapsStyles(page); + setupOSMapsVectorTiles(page); + await expect( page.locator("h1", { hasText: "Find the property" }), ).toBeVisible(); @@ -130,7 +144,7 @@ test.describe("Flow creation, publish and preview", () => { ).toBeVisible(); // Check map component has geoJson content - await checkGeoJsonContent(page, mockMapGeoJson); + await checkGeoJsonContent(page, "geojsondata", mockTitleBoundaryGeoJson); // Check property info is being shown await expect(page.getByText("Test Street, Testville")).toBeVisible(); @@ -169,7 +183,49 @@ test.describe("Flow creation, publish and preview", () => { ).toBeVisible(); await clickContinue({ page }); + const drawBoundaryTitle = page.getByRole("heading", { + name: "Confirm your location plan", + }); + await expect(drawBoundaryTitle).toBeVisible(); + + await checkGeoJsonContent( + page, + "drawgeojsondata", + mockTitleBoundaryGeoJson, + ); + + const area = "The property boundary you have drawn is 490.37"; + + await expect(page.getByText(area)).toBeVisible(); + + // navigate to upload file page and back + await checkUploadFileAltRoute(page); + + await expect( + drawBoundaryTitle, + "We have navigated back to the map component", + ).toBeVisible(); + + // ensure map has loaded correctly + await waitForMapComponent(page); + + await resetMapBoundary(page); + + await alterDrawGeoJson(page); + + // extract new GeoJSON data + const newGeoJson = await getMapProperties(page, "drawgeojsondata"); + const parsedJson: GeoJsonChangeHandler = JSON.parse(newGeoJson!); + + // check it matches our static mock + await checkGeoJsonContent(page, "drawgeojsondata", mockChangedMapGeoJson); + + await expect( + page.getByText(`${parsedJson.properties!["area.squareMetres"]}`), + "The correct value for area comes from the map properties ", + ).toBeVisible(); + // TODO: answer uploadAndLabel - // TODO: answerPropertyInfo, answerDrawBoundary, answerPlanningConstraints + // TODO: answerPropertyInfo, answerPlanningConstraints }); }); diff --git a/e2e/tests/ui-driven/src/helpers/geospatialChecks.ts b/e2e/tests/ui-driven/src/helpers/geospatialChecks.ts index 7b55e01997..ea13894975 100644 --- a/e2e/tests/ui-driven/src/helpers/geospatialChecks.ts +++ b/e2e/tests/ui-driven/src/helpers/geospatialChecks.ts @@ -1,12 +1,87 @@ import { expect, Page } from "@playwright/test"; import { Feature } from "geojson"; -export const checkGeoJsonContent = async (page: Page, geoJson: Feature) => { +export const waitForMapComponent = async (page: Page) => { + await page.waitForFunction(() => { + const map = document.getElementById("draw-boundary-map"); + return map; + }); +}; + +export const getMapProperties = async ( + page: Page, + attribute: "geojsondata" | "drawgeojsondata", +) => { + const mapComponent = await page.waitForSelector("my-map"); + return await mapComponent.getAttribute(attribute); +}; + +export const alterDrawGeoJson = async (page: Page) => { + const map = page.getByTestId("map-test-id"); + + await map.click({ button: "left", position: { x: 100, y: 200 } }); + await map.click({ button: "left", position: { x: 150, y: 250 } }); + await map.click({ button: "left", position: { x: 200, y: 250 } }); + await map.click({ button: "left", position: { x: 100, y: 200 } }); +}; + +export const resetMapBoundary = async (page: Page) => { + const resetButton = page.getByLabel("Reset map view"); + await resetButton.click(); + + const resetGeoJson = await getMapProperties(page, "drawgeojsondata"); + + expect(resetGeoJson, "drawGeoJsonData should be reset").toEqual(null); +}; + +export const checkGeoJsonContent = async ( + page: Page, + attribute: "geojsondata" | "drawgeojsondata", + geoJson: Feature, +) => { // Wait for the map component to be present const mapComponent = await page.waitForSelector("my-map"); + await page.waitForFunction(() => customElements.get("my-map")); + await expect( + page.getByTestId("map-test-id"), + "Check we can see the map", + ).toBeVisible(); + // Get the geojsonData attribute - const geojsonData = await mapComponent.getAttribute("geojsondata"); + const geojsonData = await mapComponent.getAttribute(attribute); + + expect( + JSON.parse(geojsonData!), + "map attribute matches expected mock attribute", + ).toEqual(geoJson); +}; + +export const checkUploadFileAltRoute = async (page: Page) => { + const uploadButton = page.getByTestId("upload-file-button"); + + await expect( + uploadButton, + "We can see a button to upload a file instead", + ).toBeVisible(); + + await uploadButton.click(); + + await expect( + page.getByRole("heading", { name: "Upload a location plan" }), + "Should be in a page for uploading a file", + ).toBeVisible(); + + await expect( + page.getByRole("button", { name: "Drop file here or choose" }), + "A button for uploading files is visible", + ).toBeVisible(); + + await page.getByTestId("continue-button").click(); + + await page.getByTestId("error-message-upload-location-plan").isVisible(); + + const useMapButton = page.getByTestId("use-map-button"); - expect(JSON.parse(geojsonData!)).toEqual(geoJson); + await useMapButton.click(); }; diff --git a/e2e/tests/ui-driven/src/mocks/geospatialMocks.ts b/e2e/tests/ui-driven/src/mocks/geospatialMocks.ts index fb26308b1d..e923545f46 100644 --- a/e2e/tests/ui-driven/src/mocks/geospatialMocks.ts +++ b/e2e/tests/ui-driven/src/mocks/geospatialMocks.ts @@ -1,13 +1,21 @@ import { OptionWithDataValues } from "../helpers/types"; +import { Feature, Polygon } from "geojson"; + +type ChangeHandlerProperties = { + label: string; + "area.squareMetres": number; + "area.hectares": number; +}; + +export type GeoJsonChangeHandler = Feature; export const mockPropertyTypeOptions: OptionWithDataValues[] = [ { optionText: "Residential", dataValue: "residential" }, { optionText: "Commercial", dataValue: "commercial" }, ]; -import { Feature } from "geojson"; - -export const mockMapGeoJson: Feature = { +export const mockTitleBoundaryGeoJson: Feature = { + type: "Feature", geometry: { type: "MultiPolygon", coordinates: [ @@ -23,7 +31,6 @@ export const mockMapGeoJson: Feature = { ], ], }, - type: "Feature", properties: { "entry-date": "2024-05-06", "start-date": "2010-05-12", @@ -37,3 +44,23 @@ export const mockMapGeoJson: Feature = { "organisation-entity": "13", }, }; + +export const mockChangedMapGeoJson: GeoJsonChangeHandler = { + type: "Feature", + geometry: { + type: "Polygon", + coordinates: [ + [ + [-0.6341888375038146, 51.60562241658701], + [-0.6341217822784424, 51.605580770520504], + [-0.63405472705307, 51.605580770520504], + [-0.6341888375038146, 51.60562241658701], + ], + ], + }, + properties: { + label: "1", + "area.squareMetres": 10.72, + "area.hectares": 0.001072, + }, +}; diff --git a/e2e/tests/ui-driven/src/mocks/osMapsMockData.ts b/e2e/tests/ui-driven/src/mocks/osMapsMockData.ts new file mode 100644 index 0000000000..b99f14f4d3 --- /dev/null +++ b/e2e/tests/ui-driven/src/mocks/osMapsMockData.ts @@ -0,0 +1,22 @@ +export const osMapsStylesResponse = { + version: 8, + sprite: + "https://api.os.uk/maps/vector/v1/vts/resources/sprites/sprite?key=YOUR_KEY&srs=3857", + glyphs: + "https://api.os.uk/maps/vector/v1/vts/resources/fonts/{fontstack}/{range}.pbf?key=YOUR_KEY&srs=3857", + sources: { + esri: { + type: "vector", + url: "https://api.os.uk/maps/vector/v1/vts?key=YOUR_KEY&srs=3857", + }, + }, + layers: [ + { + id: "background", + type: "background", + paint: { + "background-color": "#0437F2", + }, + }, + ], +}; diff --git a/e2e/tests/ui-driven/src/mocks/osMapsResponse.ts b/e2e/tests/ui-driven/src/mocks/osMapsResponse.ts new file mode 100644 index 0000000000..ccd6f6de6f --- /dev/null +++ b/e2e/tests/ui-driven/src/mocks/osMapsResponse.ts @@ -0,0 +1,27 @@ +import { Page } from "@playwright/test"; +import { osMapsStylesResponse } from "./osMapsMockData"; + +export async function setupOSMapsStyles(page: Page) { + const ordnanceSurveyMapsStyles = new RegExp( + /\/proxy\/ordnance-survey\/maps\/vector\/v1\/vts\/resources\/styles.*/, + ); + await page.route(ordnanceSurveyMapsStyles, async (route) => { + await route.fulfill({ + status: 200, + body: JSON.stringify(osMapsStylesResponse), + }); + }); +} + +export async function setupOSMapsVectorTiles(page: Page) { + const ordnanceSurveyVectorTiles = new RegExp( + /\/proxy\/ordnance-survey\/maps\/vector\/v1\/vts\/tile/, + ); + + await page.route(ordnanceSurveyVectorTiles, async (route) => { + await route.fulfill({ + status: 200, + body: Buffer.from([]), + }); + }); +} diff --git a/editor.planx.uk/src/@planx/components/Checklist/Public/Public.tsx b/editor.planx.uk/src/@planx/components/Checklist/Public/Public.tsx index fa60443d44..e7e5420911 100644 --- a/editor.planx.uk/src/@planx/components/Checklist/Public/Public.tsx +++ b/editor.planx.uk/src/@planx/components/Checklist/Public/Public.tsx @@ -1,6 +1,7 @@ import { useStore } from "pages/FlowEditor/lib/store"; import React from "react"; +import { Option } from "../../shared"; import { Props } from "../types"; import { AutoAnsweredChecklist } from "./components/AutoAnsweredChecklist"; import { VisibleChecklist } from "./components/VisibleChecklist"; @@ -25,4 +26,4 @@ const ChecklistComponent: React.FC = (props) => { return ; }; -export default ChecklistComponent; +export default ChecklistComponent; \ No newline at end of file diff --git a/editor.planx.uk/src/@planx/components/List/Editor.tsx b/editor.planx.uk/src/@planx/components/List/Editor.tsx index 72b01dc3b7..339c08ca9c 100644 --- a/editor.planx.uk/src/@planx/components/List/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/List/Editor.tsx @@ -17,6 +17,9 @@ import { ICONS } from "../shared/icons"; import { EditorProps } from "../shared/types"; import { List, parseContent, validationSchema } from "./model"; import { ProposedAdvertisements } from "./schemas/Adverts"; +import { ExistingBuildingsCIL } from "./schemas/CIL/ExistingCIL"; +import { MezzanineCIL } from "./schemas/CIL/MezzanineCIL"; +import { UnoccupiedBuildingsCIL } from "./schemas/CIL/UnoccupiedCIL"; import { NonResidentialFloorspace } from "./schemas/Floorspace"; import { BuildingDetailsGLA } from "./schemas/GLA/BuildingDetails"; import { CommunalSpaceGLA } from "./schemas/GLA/CommunalSpace"; @@ -69,6 +72,9 @@ export const SCHEMAS = [ { name: "Proposed advertisements", schema: ProposedAdvertisements }, { name: "Parking details", schema: Parking }, { name: "Parking details (GLA)", schema: ParkingGLA }, + { name: "Existing buildings (CIL)", schema: ExistingBuildingsCIL }, + { name: "Unoccupied buildings (CIL)", schema: UnoccupiedBuildingsCIL }, + { name: "Mezzanine floors (CIL)", schema: MezzanineCIL }, { name: "Trees", schema: Trees }, { name: "Trees (Map first)", schema: TreesMapFirst }, ]; diff --git a/editor.planx.uk/src/@planx/components/List/schemas/CIL/ExistingCIL.ts b/editor.planx.uk/src/@planx/components/List/schemas/CIL/ExistingCIL.ts new file mode 100644 index 0000000000..3f69e83302 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/List/schemas/CIL/ExistingCIL.ts @@ -0,0 +1,65 @@ +import { Schema } from "@planx/components/shared/Schema/model"; +import { TextInputType } from "@planx/components/TextInput/model"; + +export const ExistingBuildingsCIL: Schema = { + type: "Existing building or part of building", + fields: [ + { + type: "text", + data: { + title: "Describe the existing building or part", + fn: "descriptionExisting", + type: TextInputType.Short, + }, + }, + { + type: "number", + data: { + title: "How much of its floorspace will be retained?", + units: "m²", + fn: "area.retained", + allowNegatives: false, + }, + }, + { + type: "text", + data: { + title: "What will the retained floorspace be used for?", + description: "This can be identical to its current use.", + fn: "descriptionProposed", + type: TextInputType.Short, + }, + }, + { + type: "number", + data: { + title: "How much of its floorspace will be lost?", + units: "m²", + fn: "area.loss", + allowNegatives: false, + }, + }, + { + type: "question", + data: { + title: + "Has the building or part been lawfully occupied for 6 continuous months in the past 36 months?", + fn: "continuousOccupation", + options: [ + { id: "true", data: { text: "Yes", val: "true" } }, + { id: "false", data: { text: "No", val: "false" } }, + ], + }, + }, + { + type: "text", + data: { + title: "When was it last occupied for its lawful use?", + description: "Please enter a date or whether it is still in use.", + fn: "lastOccupation", + type: TextInputType.Short, + }, + }, + ], + min: 1, +} as const; diff --git a/editor.planx.uk/src/@planx/components/List/schemas/CIL/MezzanineCIL.ts b/editor.planx.uk/src/@planx/components/List/schemas/CIL/MezzanineCIL.ts new file mode 100644 index 0000000000..3c216892d6 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/List/schemas/CIL/MezzanineCIL.ts @@ -0,0 +1,27 @@ +import { Schema } from "@planx/components/shared/Schema/model"; +import { TextInputType } from "@planx/components/TextInput/model"; + +export const MezzanineCIL: Schema = { + type: "New mezzanine floor", + fields: [ + { + type: "text", + data: { + title: "Describe the use of the mezzanine", + fn: "description", + type: TextInputType.Short, + }, + }, + { + type: "number", + data: { + title: + "What will be the Gross Internal Floor Area (GIA) of the mezzanine?", + units: "m²", + fn: "area", + allowNegatives: false, + }, + }, + ], + min: 1, +} as const; diff --git a/editor.planx.uk/src/@planx/components/List/schemas/CIL/UnoccupiedCIL.ts b/editor.planx.uk/src/@planx/components/List/schemas/CIL/UnoccupiedCIL.ts new file mode 100644 index 0000000000..83126000b9 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/List/schemas/CIL/UnoccupiedCIL.ts @@ -0,0 +1,44 @@ +import { Schema } from "@planx/components/shared/Schema/model"; +import { TextInputType } from "@planx/components/TextInput/model"; + +export const UnoccupiedBuildingsCIL: Schema = { + type: "Building not meant for occupation or temporarily permitted", + fields: [ + { + type: "text", + data: { + title: "Describe the existing building", + fn: "descriptionExisting", + type: TextInputType.Short, + }, + }, + { + type: "number", + data: { + title: "How much of its floorspace will be retained?", + units: "m²", + fn: "area.retained", + allowNegatives: false, + }, + }, + { + type: "text", + data: { + title: "What will the retained floorspace be used for?", + description: "This can be identical to its current use.", + fn: "descriptionProposed", + type: TextInputType.Short, + }, + }, + { + type: "number", + data: { + title: "How much of its floorspace will be lost?", + units: "m²", + fn: "area.loss", + allowNegatives: false, + }, + }, + ], + min: 1, +} as const; diff --git a/editor.planx.uk/src/@planx/components/PropertyInformation/model.ts b/editor.planx.uk/src/@planx/components/PropertyInformation/model.ts index 1d4fc94794..ebb955a5e2 100644 --- a/editor.planx.uk/src/@planx/components/PropertyInformation/model.ts +++ b/editor.planx.uk/src/@planx/components/PropertyInformation/model.ts @@ -10,9 +10,9 @@ export const parseContent = ( data: Record | undefined, ): PropertyInformation => ({ title: data?.title || "About the property", - description: - data?.description || - "This is the information we currently have about the property, including its title boundary shown in blue from the Land Registry", + description: data?.description || defaultDescription, showPropertyTypeOverride: data?.showPropertyTypeOverride || false, ...parseBaseNodeData(data), }); + +const defaultDescription = "

This is the information we currently have about the property.

The blue line shows the outline of the property (known as the title boundary). If this looks incorrect, go back a step and check you have selected the correct address.

We use this outline to create the site boundary where the project will take place. If your project covers a different area, you can change or redraw the site boundary on the next page.

"; diff --git a/editor.planx.uk/src/pages/Team.tsx b/editor.planx.uk/src/pages/Team.tsx index ad0c8b9d0b..37c10d6d37 100644 --- a/editor.planx.uk/src/pages/Team.tsx +++ b/editor.planx.uk/src/pages/Team.tsx @@ -258,59 +258,57 @@ const FlowItem: React.FC = ({ }; const GetStarted: React.FC<{ flows: FlowSummary[] }> = ({ flows }) => ( - ({ - mt: 4, - backgroundColor: theme.palette.background.paper, - borderRadius: "8px", - display: "flex", - flexDirection: "column", + ({ + mt: 4, + backgroundColor: theme.palette.background.paper, + borderRadius: "8px", + display: "flex", + flexDirection: "column", alignItems: "center", - gap: 2, - padding: 2 - })}> - No services found - Get started by creating your first service - + gap: 2, + padding: 2, + })} + > + No services found + Get started by creating your first service + -) +); const AddFlowButton: React.FC<{ flows: FlowSummary[] }> = ({ flows }) => { const { navigate } = useNavigation(); - const { teamId, createFlow, teamSlug } = useStore() + const { teamId, createFlow, teamSlug } = useStore(); const addFlow = async () => { const newFlowName = prompt("Service name"); if (!newFlowName) return; const newFlowSlug = slugify(newFlowName); - const duplicateFlowName = flows?.find( - (flow) => flow.slug === newFlowSlug, - ); + const duplicateFlowName = flows?.find((flow) => flow.slug === newFlowSlug); if (duplicateFlowName) { alert( `The flow "${newFlowName}" already exists. Enter a unique flow name to continue`, ); + return; } const newId = await createFlow(teamId, newFlowSlug, newFlowName); navigate(`/${teamSlug}/${newId}`); - } + }; - return( - - Add a new service - - ) -} + return Add a new service; +}; const Team: React.FC = () => { - const [{ id: teamId, slug }, canUserEditTeam, getFlows] = useStore((state) => [state.getTeam(), state.canUserEditTeam, state.getFlows ]); + const [{ id: teamId, slug }, canUserEditTeam, getFlows] = useStore( + (state) => [state.getTeam(), state.canUserEditTeam, state.getFlows], + ); const [flows, setFlows] = useState(null); const fetchFlows = useCallback(() => { - getFlows(teamId) - .then((flows) => { + getFlows(teamId).then((flows) => { // Copy the array and sort by most recently edited desc using last associated operation.createdAt, not flow.updatedAt const sortedFlows = flows.toSorted((a, b) => b.operations[0]["createdAt"].localeCompare( @@ -325,7 +323,7 @@ const Team: React.FC = () => { fetchFlows(); }, [fetchFlows]); - const teamHasFlows = flows && Boolean(flows.length) + const teamHasFlows = flows && Boolean(flows.length); const showAddFlowButton = teamHasFlows && canUserEditTeam(slug); return ( @@ -349,15 +347,9 @@ const Team: React.FC = () => { Services - {canUserEditTeam(slug) ? ( - - ) : ( - - )} + {canUserEditTeam(slug) ? : } - {showAddFlowButton && ( - - )} + {showAddFlowButton && } {teamHasFlows && ( @@ -373,9 +365,9 @@ const Team: React.FC = () => { }} /> ))} - ) - } - { flows && !flows.length && } + + )} + {flows && !flows.length && } ); }; diff --git a/infrastructure/application/services/hasura.ts b/infrastructure/application/services/hasura.ts index eef540216d..c4b3f63028 100644 --- a/infrastructure/application/services/hasura.ts +++ b/infrastructure/application/services/hasura.ts @@ -98,6 +98,7 @@ export const createHasuraService = async ({ name: "HASURA_GRAPHQL_DATABASE_URL", value: dbRootUrl, }, + { name: "HASURA_GRAPHQL_MIGRATIONS_SERVER_TIMEOUT", value: "300" }, { name: "HASURA_PLANX_API_URL", value: `https://api.${DOMAIN}`, diff --git a/infrastructure/data/index.ts b/infrastructure/data/index.ts index 78fe9270bd..7046441910 100644 --- a/infrastructure/data/index.ts +++ b/infrastructure/data/index.ts @@ -16,7 +16,7 @@ const db = new aws.rds.Instance("app", { // $ aws rds describe-db-engine-versions --default-only --engine postgres engineVersion: "12.17", // Available instance types: https://aws.amazon.com/rds/instance-types/ - instanceClass: env === "production" ? "db.t3.medium" : "db.t3.micro", + instanceClass: env === "production" ? "db.t3.medium" : "db.t3.small", allocatedStorage: env === "production" ? 100 : 20, allowMajorVersionUpgrade: true, dbSubnetGroupName: networking.requireOutput("subnetId"),