From 32e6278ce1e9458e732b8eff00102b21ecadb984 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Fri, 10 May 2024 18:54:51 +0100 Subject: [PATCH 01/10] fix(a11y): always enable continue button on FileUpload and DrawBoundary upload page (#3137) --- .../DrawBoundary/Public/Public.test.tsx | 8 +- .../components/DrawBoundary/Public/index.tsx | 137 ++++++++++++------ .../components/FileUpload/Public.test.tsx | 18 ++- .../@planx/components/FileUpload/Public.tsx | 8 +- 4 files changed, 117 insertions(+), 54 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/DrawBoundary/Public/Public.test.tsx b/editor.planx.uk/src/@planx/components/DrawBoundary/Public/Public.test.tsx index 7d39c9f59f..bce6b9989d 100644 --- a/editor.planx.uk/src/@planx/components/DrawBoundary/Public/Public.test.tsx +++ b/editor.planx.uk/src/@planx/components/DrawBoundary/Public/Public.test.tsx @@ -139,7 +139,13 @@ test("shows the file upload option by default and requires user data to continue // Navigate to upload a file screen await user.click(screen.getByTestId("upload-file-button")); expect(screen.getByText("Upload a file")).toBeInTheDocument(); - expect(screen.getByTestId("continue-button")).toBeDisabled(); + + // Continue is enabled by default, but requires data to proceed + expect(screen.getByTestId("continue-button")).toBeEnabled(); + await user.click(screen.getByTestId("continue-button")); + expect( + screen.getByTestId("error-message-upload-location-plan"), + ).toBeInTheDocument(); }); test("hides the upload option and allows user to continue without drawing if editor specifies", async () => { diff --git a/editor.planx.uk/src/@planx/components/DrawBoundary/Public/index.tsx b/editor.planx.uk/src/@planx/components/DrawBoundary/Public/index.tsx index 84ecd9d1e8..0ee776900f 100644 --- a/editor.planx.uk/src/@planx/components/DrawBoundary/Public/index.tsx +++ b/editor.planx.uk/src/@planx/components/DrawBoundary/Public/index.tsx @@ -14,11 +14,13 @@ import { PrivateFileUpload } from "@planx/components/shared/PrivateFileUpload/Pr import { squareMetresToHectares } from "@planx/components/shared/utils"; import type { PublicProps } from "@planx/components/ui"; import buffer from "@turf/buffer"; -import { type Feature, point } from "@turf/helpers"; +import { type Feature,point } from "@turf/helpers"; import { Store, useStore } from "pages/FlowEditor/lib/store"; import React, { useEffect, useRef, useState } from "react"; import { FONT_WEIGHT_SEMI_BOLD } from "theme"; import FullWidthWrapper from "ui/public/FullWidthWrapper"; +import ErrorWrapper from "ui/shared/ErrorWrapper"; +import { array } from "yup"; import { DrawBoundary, @@ -31,6 +33,21 @@ export type Props = PublicProps; export type Boundary = Feature | undefined; +const slotsSchema = array() + .required() + .test({ + name: "nonUploading", + message: "Upload a location plan.", + test: (slots?: Array) => { + return Boolean( + slots && + slots.length === 1 && + !slots.some((slot) => slot.status === "uploading") && + slots.every((slot) => slot.url && slot.status === "success"), + ); + }, + }); + export default function Component(props: Props) { const isMounted = useRef(false); const passport = useStore((state) => state.computePassport()); @@ -52,7 +69,9 @@ export default function Component(props: Props) { props.previouslySubmittedData?.data?.[PASSPORT_UPLOAD_KEY]; const startPage = previousFile ? "upload" : "draw"; const [page, setPage] = useState<"draw" | "upload">(startPage); + const [slots, setSlots] = useState(previousFile ?? []); + const [fileValidationError, setFileValidationError] = useState(); const addressPoint = passport?.data?._address?.longitude && @@ -94,44 +113,69 @@ export default function Component(props: Props) { }; }, [page, setArea, setBoundary, setSlots]); - return ( - { - const newPassportData: Store.userData["data"] = {}; + /** + * Declare a ref to hold a mutable copy the up-to-date validation error. + * The intention is to prevent frequent unnecessary update loops that clears the + * validation error state if it is already empty. + */ + const validationErrorRef = useRef(fileValidationError); + useEffect(() => { + validationErrorRef.current = fileValidationError; + }, [fileValidationError]); + + useEffect(() => { + if (validationErrorRef.current) { + setFileValidationError(undefined); + } + }, [slots]); - // Used the map - if (page === "draw" && boundary && props.dataFieldBoundary) { - newPassportData[props.dataFieldBoundary] = boundary; - newPassportData[`${props.dataFieldBoundary}.buffered`] = buffer( - boundary, - bufferInMeters, - { units: "meters" }, - ); + const validateAndSubmit = () => { + const newPassportData: Store.userData["data"] = {}; - if (area && props.dataFieldArea) { - newPassportData[props.dataFieldArea] = area; - newPassportData[`${props.dataFieldArea}.hectares`] = - squareMetresToHectares(area); - } + // Used the map + if (page === "draw") { + if (boundary && props.dataFieldBoundary) { + newPassportData[props.dataFieldBoundary] = boundary; + newPassportData[`${props.dataFieldBoundary}.buffered`] = buffer( + boundary, + bufferInMeters, + { units: "meters" }, + ); - // Track the type of map interaction - if ( - boundary?.geometry === - passport.data?.["property.boundary.title"]?.geometry - ) { - newPassportData[PASSPORT_COMPONENT_ACTION_KEY] = - DrawBoundaryUserAction.Accept; - } else if (boundary?.properties?.dataset === "title-boundary") { - newPassportData[PASSPORT_COMPONENT_ACTION_KEY] = - DrawBoundaryUserAction.Amend; - } else { - newPassportData[PASSPORT_COMPONENT_ACTION_KEY] = - DrawBoundaryUserAction.Draw; - } + if (area && props.dataFieldArea) { + newPassportData[props.dataFieldArea] = area; + newPassportData[`${props.dataFieldArea}.hectares`] = + squareMetresToHectares(area); + } + + // Track the type of map interaction + if ( + boundary?.geometry === + passport.data?.["property.boundary.title"]?.geometry + ) { + newPassportData[PASSPORT_COMPONENT_ACTION_KEY] = + DrawBoundaryUserAction.Accept; + } else if (boundary?.properties?.dataset === "title-boundary") { + newPassportData[PASSPORT_COMPONENT_ACTION_KEY] = + DrawBoundaryUserAction.Amend; + } else { + newPassportData[PASSPORT_COMPONENT_ACTION_KEY] = + DrawBoundaryUserAction.Draw; } - // Uploaded a file - if (page === "upload" && slots.length) { + props.handleSubmit?.({ data: { ...newPassportData } }); + } + + if (props.hideFileUpload && !boundary) { + props.handleSubmit?.({ data: { ...newPassportData } }); + } + } + + // Uploaded a file + if (page === "upload") { + slotsSchema + .validate(slots, { context: { slots } }) + .then(() => { newPassportData[PASSPORT_UPLOAD_KEY] = slots; newPassportData[PASSPORT_COMPONENT_ACTION_KEY] = DrawBoundaryUserAction.Upload; @@ -146,24 +190,27 @@ export default function Component(props: Props) { recommended, optional, }; - } - props.handleSubmit?.({ data: { ...newPassportData } }); - }} + props.handleSubmit?.({ data: { ...newPassportData } }); + }) + .catch((err) => setFileValidationError(err?.message)); + } + }; + + return ( + - {getBody(bufferInMeters)} + {getBody(bufferInMeters, fileValidationError)} ); - function getBody(bufferInMeters: number) { + function getBody(bufferInMeters: number, fileValidationError?: string) { if (page === "draw") { return ( <> @@ -257,7 +304,9 @@ export default function Component(props: Props) { howMeasured={props.howMeasured} definitionImg={props.definitionImg} /> - + + + { +test("renders correctly", async () => { const handleSubmit = jest.fn(); setup(); - expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Continue" })).toBeEnabled(); expect(handleSubmit).toHaveBeenCalledTimes(0); }); +test("shows error if user tries to continue before adding files", async () => { + const handleSubmit = jest.fn(); + + const { user } = setup( + , + ); + + await user.click(screen.getByTestId("continue-button")); + expect(screen.getByText("Upload at least one file.")).toBeInTheDocument(); + + // Blocked by validation error + expect(handleSubmit).toHaveBeenCalledTimes(0); +}); + test("recovers previously submitted files when clicking the back button", async () => { const handleSubmit = jest.fn(); const componentId = uniqueId(); diff --git a/editor.planx.uk/src/@planx/components/FileUpload/Public.tsx b/editor.planx.uk/src/@planx/components/FileUpload/Public.tsx index aa2008a1f0..fb5a867508 100644 --- a/editor.planx.uk/src/@planx/components/FileUpload/Public.tsx +++ b/editor.planx.uk/src/@planx/components/FileUpload/Public.tsx @@ -115,13 +115,7 @@ const FileUpload: React.FC = (props) => { }, [slots]); return ( - 0 && - slots.every((slot) => slot.url && slot.status === "success") - } - handleSubmit={handleSubmit} - > + Date: Mon, 13 May 2024 09:39:13 +0100 Subject: [PATCH 02/10] chore: set & track `findProperty.action` in analytics allow lists (#3135) --- .../service/analyzeSessions/operations.ts | 7 +- .../FindProperty/Public/Public.test.tsx | 2 + .../components/FindProperty/Public/index.tsx | 12 +- .../@planx/components/FindProperty/model.ts | 7 + .../FlowEditor/lib/analytics/provider.tsx | 7 +- .../down.sql | 124 +++++++++++++++++ .../up.sql | 126 ++++++++++++++++++ 7 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 hasura.planx.uk/migrations/1715340505747_add_find_property_action_analytics_views/down.sql create mode 100644 hasura.planx.uk/migrations/1715340505747_add_find_property_action_analytics_views/up.sql diff --git a/api.planx.uk/modules/webhooks/service/analyzeSessions/operations.ts b/api.planx.uk/modules/webhooks/service/analyzeSessions/operations.ts index 2b1e13b689..ca9ec158e2 100644 --- a/api.planx.uk/modules/webhooks/service/analyzeSessions/operations.ts +++ b/api.planx.uk/modules/webhooks/service/analyzeSessions/operations.ts @@ -12,12 +12,13 @@ import { Operation } from "../sanitiseApplicationData/types"; */ const ALLOW_LIST = [ - "proposal.projectType", "application.declaration.connection", - "property.type", "drawBoundary.action", - "user.role", + "findProperty.action", "property.constraints.planning", + "property.type", + "proposal.projectType", + "user.role", ]; export const getAnalyzeSessionOperations = (): Operation[] => [ diff --git a/editor.planx.uk/src/@planx/components/FindProperty/Public/Public.test.tsx b/editor.planx.uk/src/@planx/components/FindProperty/Public/Public.test.tsx index 5ce9bef4b8..343e6d7990 100644 --- a/editor.planx.uk/src/@planx/components/FindProperty/Public/Public.test.tsx +++ b/editor.planx.uk/src/@planx/components/FindProperty/Public/Public.test.tsx @@ -69,6 +69,7 @@ const osAddressProps = { "organisation-entity": "13", }, }, + "findProperty.action": "Selected an existing address", }; const proposedAddressProps = { @@ -117,6 +118,7 @@ const proposedAddressProps = { "organisation-entity": "13", }, }, + "findProperty.action": "Proposed a new address", }; jest.spyOn(SWR, "default").mockImplementation((url: any) => { diff --git a/editor.planx.uk/src/@planx/components/FindProperty/Public/index.tsx b/editor.planx.uk/src/@planx/components/FindProperty/Public/index.tsx index 762391b272..02307ff308 100644 --- a/editor.planx.uk/src/@planx/components/FindProperty/Public/index.tsx +++ b/editor.planx.uk/src/@planx/components/FindProperty/Public/index.tsx @@ -16,7 +16,12 @@ import ExternalPlanningSiteDialog, { DialogPurpose, } from "ui/public/ExternalPlanningSiteDialog"; -import { FindProperty, SiteAddress } from "../model"; +import { + FindProperty, + FindPropertyUserAction, + PASSPORT_COMPONENT_ACTION_KEY, + SiteAddress, +} from "../model"; import PickOSAddress from "./Autocomplete"; import PlotNewAddress from "./Map"; @@ -223,6 +228,11 @@ function Component(props: Props) { squareMetresToHectares(areaSquareMetres); } + newPassportData[PASSPORT_COMPONENT_ACTION_KEY] = + address?.source === "os" + ? FindPropertyUserAction.Existing + : FindPropertyUserAction.New; + props.handleSubmit?.({ data: { ...newPassportData } }); } }} diff --git a/editor.planx.uk/src/@planx/components/FindProperty/model.ts b/editor.planx.uk/src/@planx/components/FindProperty/model.ts index b9bf931e67..81b7d1f9b9 100644 --- a/editor.planx.uk/src/@planx/components/FindProperty/model.ts +++ b/editor.planx.uk/src/@planx/components/FindProperty/model.ts @@ -1,5 +1,12 @@ import { MoreInformation, parseMoreInformation } from "../shared"; +export enum FindPropertyUserAction { + Existing = "Selected an existing address", + New = "Proposed a new address", +} + +export const PASSPORT_COMPONENT_ACTION_KEY = "findProperty.action"; + export interface FindProperty extends MoreInformation { title: string; description: string; diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/analytics/provider.tsx b/editor.planx.uk/src/pages/FlowEditor/lib/analytics/provider.tsx index 37dc35a4f4..5ba9bbf4ed 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/analytics/provider.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/lib/analytics/provider.tsx @@ -37,12 +37,13 @@ import { * extract it into it's own column. */ export const ALLOW_LIST = [ - "proposal.projectType", "application.declaration.connection", - "property.type", "drawBoundary.action", - "user.role", + "findProperty.action", "property.constraints.planning", + "property.type", + "proposal.projectType", + "user.role", ] as const; let lastVisibleNodeAnalyticsLogId: number | undefined = undefined; diff --git a/hasura.planx.uk/migrations/1715340505747_add_find_property_action_analytics_views/down.sql b/hasura.planx.uk/migrations/1715340505747_add_find_property_action_analytics_views/down.sql new file mode 100644 index 0000000000..023b85d79b --- /dev/null +++ b/hasura.planx.uk/migrations/1715340505747_add_find_property_action_analytics_views/down.sql @@ -0,0 +1,124 @@ +CREATE OR REPLACE VIEW "public"."analytics_summary" AS + SELECT a.id AS analytics_id, + al.id AS analytics_log_id, + f.slug AS service_slug, + t.slug AS team_slug, + a.type AS analytics_type, + al.created_at AS analytics_log_created_at, + a.created_at AS analytics_created_at, + ((a.user_agent -> 'os'::text) ->> 'name'::text) AS operating_system, + ((a.user_agent -> 'browser'::text) ->> 'name'::text) AS browser, + ((a.user_agent -> 'platform'::text) ->> 'type'::text) AS platform, + a.referrer, + al.flow_direction, + (al.metadata ->> 'change'::text) AS change_metadata, + (al.metadata ->> 'back'::text) AS back_metadata, + (al.metadata ->> 'selectedUrls'::text) AS selected_urls, + (al.metadata ->> 'flag'::text) AS result_flag, + (al.metadata -> 'flagSet'::text) AS result_flagset, + ((al.metadata -> 'displayText'::text) ->> 'heading'::text) AS result_heading, + ((al.metadata -> 'displayText'::text) ->> 'description'::text) AS result_description, + (al.metadata -> 'helpTextUseful'::text) AS help_text_useful, + CASE + WHEN al.has_clicked_help THEN al.metadata + ELSE NULL::jsonb + END AS help_metadata, + al.user_exit AS is_user_exit, + al.node_type, + al.node_title, + al.has_clicked_help, + al.input_errors, + (date_part('epoch'::text, (al.next_log_created_at - al.created_at)))::numeric(10,1) AS time_spent_on_node_seconds, + a.ended_at AS analytics_ended_at, + ((date_part('epoch'::text, (a.ended_at - a.created_at)) / (60)::double precision))::numeric(10,1) AS time_spent_on_analytics_session_minutes, + al.node_id, + al.allow_list_answers, + (al.allow_list_answers -> 'proposal.projectType'::text) AS proposal_project_type, + (al.allow_list_answers -> 'application.declaration.connection'::text) AS application_declaration_connection, + (al.allow_list_answers -> 'property.type'::text) AS property_type, + (al.allow_list_answers -> 'drawBoundary.action'::text) AS draw_boundary_action, + (al.allow_list_answers -> 'user.role'::text) AS user_role, + (al.allow_list_answers -> 'property.constraints.planning'::text) AS property_constraints_planning + FROM (((analytics a + LEFT JOIN analytics_logs al ON ((a.id = al.analytics_id))) + LEFT JOIN flows f ON ((a.flow_id = f.id))) + LEFT JOIN teams t ON ((t.id = f.team_id))); + +CREATE OR REPLACE VIEW "public"."submission_services_summary" AS + WITH resumes_per_session AS ( + SELECT reconciliation_requests.session_id, + count(reconciliation_requests.id) AS number_times_resumed + FROM reconciliation_requests + GROUP BY reconciliation_requests.session_id + ), bops_agg AS ( + SELECT bops_applications.session_id, + json_agg(json_build_object('id', bops_applications.bops_id, 'submittedAt', bops_applications.created_at, 'destinationUrl', bops_applications.destination_url) ORDER BY bops_applications.created_at DESC) AS bops_applications + FROM bops_applications + GROUP BY bops_applications.session_id + ), email_agg AS ( + SELECT email_applications.session_id, + json_agg(json_build_object('id', email_applications.id, 'recipient', email_applications.recipient, 'submittedAt', email_applications.created_at) ORDER BY email_applications.created_at DESC) AS email_applications + FROM email_applications + GROUP BY email_applications.session_id + ), uniform_agg AS ( + SELECT uniform_applications.submission_reference, + json_agg(json_build_object('id', uniform_applications.idox_submission_id, 'submittedAt', uniform_applications.created_at) ORDER BY uniform_applications.created_at DESC) AS uniform_applications + FROM uniform_applications + GROUP BY uniform_applications.submission_reference + ), payment_requests_agg AS ( + SELECT payment_requests.session_id, + json_agg(json_build_object('id', payment_requests.id, 'createdAt', payment_requests.created_at, 'paidAt', payment_requests.paid_at, 'govpayPaymentId', payment_requests.govpay_payment_id) ORDER BY payment_requests.created_at DESC) AS payment_requests + FROM payment_requests + GROUP BY payment_requests.session_id + ), payment_status_agg AS ( + SELECT payment_status.session_id, + json_agg(json_build_object('govpayPaymentId', payment_status.payment_id, 'createdAt', payment_status.created_at, 'status', payment_status.status) ORDER BY payment_status.created_at DESC) AS payment_status + FROM payment_status + GROUP BY payment_status.session_id + ) + SELECT (ls.id)::text AS session_id, + t.slug AS team_slug, + f.slug AS service_slug, + ls.created_at, + ls.submitted_at, + ((ls.submitted_at)::date - (ls.created_at)::date) AS session_length_days, + ls.has_user_saved AS user_clicked_save, + rps.number_times_resumed, + ls.allow_list_answers, + (ls.allow_list_answers -> 'proposal.projectType'::text) AS proposal_project_type, + (ls.allow_list_answers -> 'application.declaration.connection'::text) AS application_declaration_connection, + (ls.allow_list_answers -> 'property.type'::text) AS property_type, + (ls.allow_list_answers -> 'drawBoundary.action'::text) AS draw_boundary_action, + (ls.allow_list_answers -> 'user.role'::text) AS user_role, + (ls.allow_list_answers -> 'property.constraints.planning'::text) AS property_constraints_planning, + CASE + WHEN (((pr.payment_requests)::jsonb IS NOT NULL) AND (jsonb_array_length((pr.payment_requests)::jsonb) > 0)) THEN true + ELSE false + END AS user_invited_to_pay, + pr.payment_requests, + ps.payment_status, + CASE + WHEN (((ba.bops_applications)::jsonb IS NOT NULL) AND (jsonb_array_length((ba.bops_applications)::jsonb) > 0)) THEN true + ELSE false + END AS sent_to_bops, + ba.bops_applications, + CASE + WHEN (((ua.uniform_applications)::jsonb IS NOT NULL) AND (jsonb_array_length((ua.uniform_applications)::jsonb) > 0)) THEN true + ELSE false + END AS sent_to_uniform, + ua.uniform_applications, + CASE + WHEN (((ea.email_applications)::jsonb IS NOT NULL) AND (jsonb_array_length((ea.email_applications)::jsonb) > 0)) THEN true + ELSE false + END AS sent_to_email, + ea.email_applications + FROM ((((((((lowcal_sessions ls + LEFT JOIN flows f ON ((f.id = ls.flow_id))) + LEFT JOIN teams t ON ((t.id = f.team_id))) + LEFT JOIN resumes_per_session rps ON ((rps.session_id = (ls.id)::text))) + LEFT JOIN payment_requests_agg pr ON ((pr.session_id = ls.id))) + LEFT JOIN payment_status_agg ps ON ((ps.session_id = ls.id))) + LEFT JOIN bops_agg ba ON ((ba.session_id = (ls.id)::text))) + LEFT JOIN uniform_agg ua ON ((ua.submission_reference = (ls.id)::text))) + LEFT JOIN email_agg ea ON ((ea.session_id = ls.id))) + WHERE ((f.slug IS NOT NULL) AND (t.slug IS NOT NULL)); diff --git a/hasura.planx.uk/migrations/1715340505747_add_find_property_action_analytics_views/up.sql b/hasura.planx.uk/migrations/1715340505747_add_find_property_action_analytics_views/up.sql new file mode 100644 index 0000000000..bbbff4b550 --- /dev/null +++ b/hasura.planx.uk/migrations/1715340505747_add_find_property_action_analytics_views/up.sql @@ -0,0 +1,126 @@ +CREATE OR REPLACE VIEW "public"."analytics_summary" AS + SELECT a.id AS analytics_id, + al.id AS analytics_log_id, + f.slug AS service_slug, + t.slug AS team_slug, + a.type AS analytics_type, + al.created_at AS analytics_log_created_at, + a.created_at AS analytics_created_at, + ((a.user_agent -> 'os'::text) ->> 'name'::text) AS operating_system, + ((a.user_agent -> 'browser'::text) ->> 'name'::text) AS browser, + ((a.user_agent -> 'platform'::text) ->> 'type'::text) AS platform, + a.referrer, + al.flow_direction, + (al.metadata ->> 'change'::text) AS change_metadata, + (al.metadata ->> 'back'::text) AS back_metadata, + (al.metadata ->> 'selectedUrls'::text) AS selected_urls, + (al.metadata ->> 'flag'::text) AS result_flag, + (al.metadata -> 'flagSet'::text) AS result_flagset, + ((al.metadata -> 'displayText'::text) ->> 'heading'::text) AS result_heading, + ((al.metadata -> 'displayText'::text) ->> 'description'::text) AS result_description, + (al.metadata -> 'helpTextUseful'::text) AS help_text_useful, + CASE + WHEN al.has_clicked_help THEN al.metadata + ELSE NULL::jsonb + END AS help_metadata, + al.user_exit AS is_user_exit, + al.node_type, + al.node_title, + al.has_clicked_help, + al.input_errors, + (date_part('epoch'::text, (al.next_log_created_at - al.created_at)))::numeric(10,1) AS time_spent_on_node_seconds, + a.ended_at AS analytics_ended_at, + ((date_part('epoch'::text, (a.ended_at - a.created_at)) / (60)::double precision))::numeric(10,1) AS time_spent_on_analytics_session_minutes, + al.node_id, + al.allow_list_answers, + (al.allow_list_answers -> 'proposal.projectType'::text) AS proposal_project_type, + (al.allow_list_answers -> 'application.declaration.connection'::text) AS application_declaration_connection, + (al.allow_list_answers -> 'property.type'::text) AS property_type, + (al.allow_list_answers -> 'drawBoundary.action'::text) AS draw_boundary_action, + (al.allow_list_answers -> 'user.role'::text) AS user_role, + (al.allow_list_answers -> 'property.constraints.planning'::text) AS property_constraints_planning, + (al.allow_list_answers -> 'findProperty.action'::text) AS find_property_action + FROM (((analytics a + LEFT JOIN analytics_logs al ON ((a.id = al.analytics_id))) + LEFT JOIN flows f ON ((a.flow_id = f.id))) + LEFT JOIN teams t ON ((t.id = f.team_id))); + +CREATE OR REPLACE VIEW "public"."submission_services_summary" AS + WITH resumes_per_session AS ( + SELECT reconciliation_requests.session_id, + count(reconciliation_requests.id) AS number_times_resumed + FROM reconciliation_requests + GROUP BY reconciliation_requests.session_id + ), bops_agg AS ( + SELECT bops_applications.session_id, + json_agg(json_build_object('id', bops_applications.bops_id, 'submittedAt', bops_applications.created_at, 'destinationUrl', bops_applications.destination_url) ORDER BY bops_applications.created_at DESC) AS bops_applications + FROM bops_applications + GROUP BY bops_applications.session_id + ), email_agg AS ( + SELECT email_applications.session_id, + json_agg(json_build_object('id', email_applications.id, 'recipient', email_applications.recipient, 'submittedAt', email_applications.created_at) ORDER BY email_applications.created_at DESC) AS email_applications + FROM email_applications + GROUP BY email_applications.session_id + ), uniform_agg AS ( + SELECT uniform_applications.submission_reference, + json_agg(json_build_object('id', uniform_applications.idox_submission_id, 'submittedAt', uniform_applications.created_at) ORDER BY uniform_applications.created_at DESC) AS uniform_applications + FROM uniform_applications + GROUP BY uniform_applications.submission_reference + ), payment_requests_agg AS ( + SELECT payment_requests.session_id, + json_agg(json_build_object('id', payment_requests.id, 'createdAt', payment_requests.created_at, 'paidAt', payment_requests.paid_at, 'govpayPaymentId', payment_requests.govpay_payment_id) ORDER BY payment_requests.created_at DESC) AS payment_requests + FROM payment_requests + GROUP BY payment_requests.session_id + ), payment_status_agg AS ( + SELECT payment_status.session_id, + json_agg(json_build_object('govpayPaymentId', payment_status.payment_id, 'createdAt', payment_status.created_at, 'status', payment_status.status) ORDER BY payment_status.created_at DESC) AS payment_status + FROM payment_status + GROUP BY payment_status.session_id + ) + SELECT (ls.id)::text AS session_id, + t.slug AS team_slug, + f.slug AS service_slug, + ls.created_at, + ls.submitted_at, + ((ls.submitted_at)::date - (ls.created_at)::date) AS session_length_days, + ls.has_user_saved AS user_clicked_save, + rps.number_times_resumed, + ls.allow_list_answers, + (ls.allow_list_answers -> 'proposal.projectType'::text) AS proposal_project_type, + (ls.allow_list_answers -> 'application.declaration.connection'::text) AS application_declaration_connection, + (ls.allow_list_answers -> 'property.type'::text) AS property_type, + (ls.allow_list_answers -> 'drawBoundary.action'::text) AS draw_boundary_action, + (ls.allow_list_answers -> 'user.role'::text) AS user_role, + (ls.allow_list_answers -> 'property.constraints.planning'::text) AS property_constraints_planning, + CASE + WHEN (((pr.payment_requests)::jsonb IS NOT NULL) AND (jsonb_array_length((pr.payment_requests)::jsonb) > 0)) THEN true + ELSE false + END AS user_invited_to_pay, + pr.payment_requests, + ps.payment_status, + CASE + WHEN (((ba.bops_applications)::jsonb IS NOT NULL) AND (jsonb_array_length((ba.bops_applications)::jsonb) > 0)) THEN true + ELSE false + END AS sent_to_bops, + ba.bops_applications, + CASE + WHEN (((ua.uniform_applications)::jsonb IS NOT NULL) AND (jsonb_array_length((ua.uniform_applications)::jsonb) > 0)) THEN true + ELSE false + END AS sent_to_uniform, + ua.uniform_applications, + CASE + WHEN (((ea.email_applications)::jsonb IS NOT NULL) AND (jsonb_array_length((ea.email_applications)::jsonb) > 0)) THEN true + ELSE false + END AS sent_to_email, + ea.email_applications, + (ls.allow_list_answers -> 'findProperty.action'::text) AS find_property_action + FROM ((((((((lowcal_sessions ls + LEFT JOIN flows f ON ((f.id = ls.flow_id))) + LEFT JOIN teams t ON ((t.id = f.team_id))) + LEFT JOIN resumes_per_session rps ON ((rps.session_id = (ls.id)::text))) + LEFT JOIN payment_requests_agg pr ON ((pr.session_id = ls.id))) + LEFT JOIN payment_status_agg ps ON ((ps.session_id = ls.id))) + LEFT JOIN bops_agg ba ON ((ba.session_id = (ls.id)::text))) + LEFT JOIN uniform_agg ua ON ((ua.submission_reference = (ls.id)::text))) + LEFT JOIN email_agg ea ON ((ea.session_id = ls.id))) + WHERE ((f.slug IS NOT NULL) AND (t.slug IS NOT NULL)); From d946db8921156b2bcdee405f944b47cd7ae5262e Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Mon, 13 May 2024 09:47:58 +0100 Subject: [PATCH 03/10] fix(a11y): always enabled continue button on DrawBoundary map page (#3138) --- .../DrawBoundary/Public/Public.test.tsx | 9 +- .../components/DrawBoundary/Public/index.tsx | 139 ++++++++++-------- .../components/FileUpload/Public.test.tsx | 2 +- .../@planx/components/FileUpload/Public.tsx | 2 +- 4 files changed, 87 insertions(+), 65 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/DrawBoundary/Public/Public.test.tsx b/editor.planx.uk/src/@planx/components/DrawBoundary/Public/Public.test.tsx index bce6b9989d..570a387cbb 100644 --- a/editor.planx.uk/src/@planx/components/DrawBoundary/Public/Public.test.tsx +++ b/editor.planx.uk/src/@planx/components/DrawBoundary/Public/Public.test.tsx @@ -117,7 +117,7 @@ it("should not have any accessibility violations", async () => { expect(results).toHaveNoViolations(); }); -test("shows the file upload option by default and requires user data to continue", async () => { +test("shows the file upload option by default and requires user data to continue from either page", async () => { const handleSubmit = jest.fn(); const { user } = setup( @@ -134,7 +134,12 @@ test("shows the file upload option by default and requires user data to continue // Draw a boundary screen expect(screen.getByTestId("upload-file-button")).toBeInTheDocument(); - expect(screen.getByTestId("continue-button")).toBeDisabled(); + expect(screen.getByTestId("continue-button")).toBeEnabled(); + + await user.click(screen.getByTestId("continue-button")); + expect( + screen.getByTestId("error-message-draw-boundary-map"), + ).toBeInTheDocument(); // Navigate to upload a file screen await user.click(screen.getByTestId("upload-file-button")); diff --git a/editor.planx.uk/src/@planx/components/DrawBoundary/Public/index.tsx b/editor.planx.uk/src/@planx/components/DrawBoundary/Public/index.tsx index 0ee776900f..3b827d2fdc 100644 --- a/editor.planx.uk/src/@planx/components/DrawBoundary/Public/index.tsx +++ b/editor.planx.uk/src/@planx/components/DrawBoundary/Public/index.tsx @@ -37,7 +37,7 @@ const slotsSchema = array() .required() .test({ name: "nonUploading", - message: "Upload a location plan.", + message: "Upload a location plan", test: (slots?: Array) => { return Boolean( slots && @@ -60,6 +60,7 @@ export default function Component(props: Props) { passport.data?.["property.boundary.title.area"]; const [boundary, setBoundary] = useState(previousBoundary); const [area, setArea] = useState(previousArea); + const [mapValidationError, setMapValidationError] = useState(); // Buffer applied to the address point to clip this map extent // and applied to the site boundary and written to the passport to later clip the map extent in overview documents @@ -114,26 +115,45 @@ export default function Component(props: Props) { }, [page, setArea, setBoundary, setSlots]); /** - * Declare a ref to hold a mutable copy the up-to-date validation error. + * Declare refs to hold a mutable copy the up-to-date validation errors * The intention is to prevent frequent unnecessary update loops that clears the * validation error state if it is already empty. */ - const validationErrorRef = useRef(fileValidationError); + const fileValidationErrorRef = useRef(fileValidationError); useEffect(() => { - validationErrorRef.current = fileValidationError; + fileValidationErrorRef.current = fileValidationError; }, [fileValidationError]); useEffect(() => { - if (validationErrorRef.current) { + if (fileValidationErrorRef.current) { setFileValidationError(undefined); } }, [slots]); + const mapValidationErrorRef = useRef(mapValidationError); + useEffect(() => { + mapValidationErrorRef.current = mapValidationError; + }, [mapValidationError]); + + useEffect(() => { + if (mapValidationErrorRef.current) { + setMapValidationError(undefined); + } + }, [boundary]); + const validateAndSubmit = () => { const newPassportData: Store.userData["data"] = {}; // Used the map if (page === "draw") { + if (!props.hideFileUpload && !boundary) { + setMapValidationError("Draw a boundary"); + } + + if (props.hideFileUpload && !boundary) { + props.handleSubmit?.({ data: { ...newPassportData } }); + } + if (boundary && props.dataFieldBoundary) { newPassportData[props.dataFieldBoundary] = boundary; newPassportData[`${props.dataFieldBoundary}.buffered`] = buffer( @@ -165,10 +185,6 @@ export default function Component(props: Props) { props.handleSubmit?.({ data: { ...newPassportData } }); } - - if (props.hideFileUpload && !boundary) { - props.handleSubmit?.({ data: { ...newPassportData } }); - } } // Uploaded a file @@ -198,19 +214,16 @@ export default function Component(props: Props) { }; return ( - - {getBody(bufferInMeters, fileValidationError)} + + {getBody(bufferInMeters, mapValidationError, fileValidationError)} ); - function getBody(bufferInMeters: number, fileValidationError?: string) { + function getBody( + bufferInMeters: number, + mapValidationError?: string, + fileValidationError?: string, + ) { if (page === "draw") { return ( <> @@ -223,50 +236,54 @@ export default function Component(props: Props) { definitionImg={props.definitionImg} /> - -

- An interactive map centred on your address, pre-populated with a - red boundary that includes the entire property, using - information from the Land Registry. You can accept this boundary - as your location plan by continuing, you can amend it by - clicking and dragging the points, or you can erase it by - clicking the reset button and draw a new custom boundary. -

- {!props.hideFileUpload && ( + +

- If you prefer to upload a file instead of using the - interactive map, please click "Upload a location plan instead" - below to navigate to the file upload. + An interactive map centred on your address, pre-populated with + a red boundary that includes the entire property, using + information from the Land Registry. You can accept this + boundary as your location plan by continuing, you can amend it + by clicking and dragging the points, or you can erase it by + clicking the reset button and draw a new custom boundary.

- )} - {/* @ts-ignore */} - Title boundary subject to Crown copyright and database rights ${new Date().getFullYear()} OS (0)100026316`} - collapseAttributions={self.innerWidth < 500 ? true : undefined} - /> -
+ {!props.hideFileUpload && ( +

+ If you prefer to upload a file instead of using the + interactive map, please click "Upload a location plan + instead" below to navigate to the file upload. +

+ )} + {/* @ts-ignore */} + Title boundary subject to Crown copyright and database rights ${new Date().getFullYear()} OS (0)100026316`} + collapseAttributions={ + self.innerWidth < 500 ? true : undefined + } + /> +
+ The property boundary you have drawn is{" "} diff --git a/editor.planx.uk/src/@planx/components/FileUpload/Public.test.tsx b/editor.planx.uk/src/@planx/components/FileUpload/Public.test.tsx index c67a340123..0adf0e6c0a 100644 --- a/editor.planx.uk/src/@planx/components/FileUpload/Public.test.tsx +++ b/editor.planx.uk/src/@planx/components/FileUpload/Public.test.tsx @@ -24,7 +24,7 @@ test("shows error if user tries to continue before adding files", async () => { ); await user.click(screen.getByTestId("continue-button")); - expect(screen.getByText("Upload at least one file.")).toBeInTheDocument(); + expect(screen.getByText("Upload at least one file")).toBeInTheDocument(); // Blocked by validation error expect(handleSubmit).toHaveBeenCalledTimes(0); diff --git a/editor.planx.uk/src/@planx/components/FileUpload/Public.tsx b/editor.planx.uk/src/@planx/components/FileUpload/Public.tsx index fb5a867508..c3780cbcaf 100644 --- a/editor.planx.uk/src/@planx/components/FileUpload/Public.tsx +++ b/editor.planx.uk/src/@planx/components/FileUpload/Public.tsx @@ -34,7 +34,7 @@ const slotsSchema = array() .required() .test({ name: "nonUploading", - message: "Upload at least one file.", + message: "Upload at least one file", test: (slots?: Array) => { return Boolean( slots && From 14acc58879c5ae95f9abe8447e7f2aacc5ef5447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Mon, 13 May 2024 15:46:06 +0100 Subject: [PATCH 04/10] fix(a11y): Add support for Windows high contrast mode (#3139) --- editor.planx.uk/src/theme.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/editor.planx.uk/src/theme.ts b/editor.planx.uk/src/theme.ts index f1b4cf53f3..d42b363b88 100644 --- a/editor.planx.uk/src/theme.ts +++ b/editor.planx.uk/src/theme.ts @@ -260,6 +260,13 @@ const getThemeOptions = ({ padding: 0, border: 0, }, + img: { + // a11y: Ensure images are visible in Windows high contrast mode + "@media (forced-colors: active)": { + forcedColorAdjust: "none", + backgroundColor: "white", + }, + }, }, }, MuiButtonBase: { From 4960828efae2f1d7f4e0cca4e9af44c0aa7134db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Mon, 13 May 2024 15:46:45 +0100 Subject: [PATCH 05/10] chore: Bump `planx-core` (#3140) --- api.planx.uk/package.json | 2 +- api.planx.uk/pnpm-lock.yaml | 9 +++++---- e2e/tests/api-driven/package.json | 2 +- e2e/tests/api-driven/pnpm-lock.yaml | 8 ++++---- e2e/tests/ui-driven/package.json | 2 +- e2e/tests/ui-driven/pnpm-lock.yaml | 8 ++++---- editor.planx.uk/package.json | 2 +- editor.planx.uk/pnpm-lock.yaml | 10 +++++----- 8 files changed, 22 insertions(+), 21 deletions(-) diff --git a/api.planx.uk/package.json b/api.planx.uk/package.json index 63eac94acf..fd2bcca1b7 100644 --- a/api.planx.uk/package.json +++ b/api.planx.uk/package.json @@ -4,7 +4,7 @@ "private": true, "dependencies": { "@airbrake/node": "^2.1.8", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#d8acbea", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#2732b6e", "@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 ffc2f2d677..50ebefe1e6 100644 --- a/api.planx.uk/pnpm-lock.yaml +++ b/api.planx.uk/pnpm-lock.yaml @@ -12,8 +12,8 @@ dependencies: specifier: ^2.1.8 version: 2.1.8 '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#d8acbea - version: github.com/theopensystemslab/planx-core/d8acbea + specifier: git+https://github.com/theopensystemslab/planx-core#2732b6e + version: github.com/theopensystemslab/planx-core/2732b6e '@types/isomorphic-fetch': specifier: ^0.0.36 version: 0.0.36 @@ -5717,6 +5717,7 @@ packages: chalk: 3.0.0 diff-match-patch: 1.0.5 dev: false + bundledDependencies: [] /jsonwebtoken@9.0.2: resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} @@ -8353,8 +8354,8 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: false - github.com/theopensystemslab/planx-core/d8acbea: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/d8acbea} + github.com/theopensystemslab/planx-core/2732b6e: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/2732b6e} name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true diff --git a/e2e/tests/api-driven/package.json b/e2e/tests/api-driven/package.json index f1c7246f45..6a0cf886a7 100644 --- a/e2e/tests/api-driven/package.json +++ b/e2e/tests/api-driven/package.json @@ -6,7 +6,7 @@ }, "dependencies": { "@cucumber/cucumber": "^9.3.0", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#d8acbea", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#2732b6e", "axios": "^1.6.8", "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 cc27faa949..3569a80e19 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#d8acbea - version: github.com/theopensystemslab/planx-core/d8acbea + specifier: git+https://github.com/theopensystemslab/planx-core#2732b6e + version: github.com/theopensystemslab/planx-core/2732b6e axios: specifier: ^1.6.8 version: 1.6.8 @@ -2933,8 +2933,8 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: false - github.com/theopensystemslab/planx-core/d8acbea: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/d8acbea} + github.com/theopensystemslab/planx-core/2732b6e: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/2732b6e} name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true diff --git a/e2e/tests/ui-driven/package.json b/e2e/tests/ui-driven/package.json index db3e047d2f..6ddf6383ce 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#d8acbea", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#2732b6e", "axios": "^1.6.8", "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 d00b13321e..9c1c01205c 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#d8acbea - version: github.com/theopensystemslab/planx-core/d8acbea + specifier: git+https://github.com/theopensystemslab/planx-core#2732b6e + version: github.com/theopensystemslab/planx-core/2732b6e axios: specifier: ^1.6.8 version: 1.6.8 @@ -2682,8 +2682,8 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: false - github.com/theopensystemslab/planx-core/d8acbea: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/d8acbea} + github.com/theopensystemslab/planx-core/2732b6e: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/2732b6e} name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true diff --git a/editor.planx.uk/package.json b/editor.planx.uk/package.json index f527cb1af7..e04706974e 100644 --- a/editor.planx.uk/package.json +++ b/editor.planx.uk/package.json @@ -12,7 +12,7 @@ "@mui/material": "^5.15.2", "@mui/utils": "^5.15.2", "@opensystemslab/map": "^0.8.2", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#d8acbea", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#2732b6e", "@tiptap/core": "^2.0.3", "@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 009ade247f..5b20d29999 100644 --- a/editor.planx.uk/pnpm-lock.yaml +++ b/editor.planx.uk/pnpm-lock.yaml @@ -38,8 +38,8 @@ dependencies: specifier: ^0.8.2 version: 0.8.2 '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#d8acbea - version: github.com/theopensystemslab/planx-core/d8acbea(@types/react@18.2.45) + specifier: git+https://github.com/theopensystemslab/planx-core#2732b6e + version: github.com/theopensystemslab/planx-core/2732b6e(@types/react@18.2.45) '@tiptap/core': specifier: ^2.0.3 version: 2.0.3(@tiptap/pm@2.0.3) @@ -21295,9 +21295,9 @@ packages: use-sync-external-store: 1.2.0(react@18.2.0) dev: false - github.com/theopensystemslab/planx-core/d8acbea(@types/react@18.2.45): - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/d8acbea} - id: github.com/theopensystemslab/planx-core/d8acbea + github.com/theopensystemslab/planx-core/2732b6e(@types/react@18.2.45): + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/2732b6e} + id: github.com/theopensystemslab/planx-core/2732b6e name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true From 31c14bfa51f7cc1fc9309d4cfde817a33372fa31 Mon Sep 17 00:00:00 2001 From: augustlindemer <118665588+augustlindemer@users.noreply.github.com> Date: Mon, 13 May 2024 18:44:20 +0100 Subject: [PATCH 06/10] feat: Hook up granular A4 GIS API variables for Epsom and Ewell (#3142) --- .../modules/gis/service/digitalLand.ts | 1 + .../metadata/epsomAndEwell.ts | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 api.planx.uk/modules/gis/service/local_authorities/metadata/epsomAndEwell.ts diff --git a/api.planx.uk/modules/gis/service/digitalLand.ts b/api.planx.uk/modules/gis/service/digitalLand.ts index 0768711f96..4e13f3ca48 100644 --- a/api.planx.uk/modules/gis/service/digitalLand.ts +++ b/api.planx.uk/modules/gis/service/digitalLand.ts @@ -26,6 +26,7 @@ const localAuthorityMetadata: Record = { camden: require("./local_authorities/metadata/camden"), canterbury: require("./local_authorities/metadata/canterbury"), doncaster: require("./local_authorities/metadata/doncaster"), + "epsom-and-ewell": require("./local_authorities/metadata/epsomAndEwell"), gateshead: require("./local_authorities/metadata/gateshead"), lambeth: require("./local_authorities/metadata/lambeth"), medway: require("./local_authorities/metadata/medway"), diff --git a/api.planx.uk/modules/gis/service/local_authorities/metadata/epsomAndEwell.ts b/api.planx.uk/modules/gis/service/local_authorities/metadata/epsomAndEwell.ts new file mode 100644 index 0000000000..0880301d9b --- /dev/null +++ b/api.planx.uk/modules/gis/service/local_authorities/metadata/epsomAndEwell.ts @@ -0,0 +1,49 @@ +/* +LAD20CD: E07000208 +LAD20NM: Epsom and Ewell +LAD20NMW: +FID: + +https://www.planning.data.gov.uk/entity/?dataset=article-4-direction-area&geometry_curie=statistical-geography%3AE07000208&entry_date_day=&entry_date_month=&entry_date_year= +https://docs.google.com/spreadsheets/d/1BzYZ_YvJjOrY2afxPGWbPvV2g_FLBr0H/edit#gid=125611981 +*/ + +import { LocalAuthorityMetadata } from "../../digitalLand"; + +const planningConstraints: LocalAuthorityMetadata["planningConstraints"] = { + article4: { + // Planx granular values link to Digital Land article-4-direction and entity.reference + records: { + "article4.epsomAndEwell.burghheath": "11/00003/ART4", + "article4.epsomAndEwell.churchstreet": "11/00004/ART4", + "article4.epsomAndEwell.college": "11/00005/ART4", + "article4.epsomAndEwell.downs": "11/00006/ART4", + "article4.epsomAndEwell.ewellvillage": "11/00002/ART4", + "article4.epsomAndEwell.highergreenlongdown": "05/00002/ART4", + "article4.epsomAndEwell.lintons": "11/00007/ART4", + "article4.epsomAndEwell.pikes": "12/00002/ART4", + "article4.epsomAndEwell.pikesPart": "11/00008/ART4", + "article4.epsomAndEwell.stamfordgreen": "05/00001/ART4", + "article4.epsomAndEwell.greenewelldowns": "05/00004/ART4", + "article4.epsomAndEwell.worple": "11/00009/ART4", + "article4.epsomAndEwell.adelphi": "16/00008/ART4", + "article4.epsomAndEwell.aplanhouse": "16/00013/ART4", + "article4.epsomAndEwell.bradfordhouse": "16/00013/ART4", + "article4.epsomAndEwell.eastleighhouse": "16/00005/ART4", + "article4.epsomAndEwell.emeraldhouse": "16/00009/ART4", + "article4.epsomAndEwell.epsomchase": "16/00004/ART4", + "article4.epsomAndEwell.epsomgateway": "16/00015/ART4", + "article4.epsomAndEwell.globalhouse": "16/00016/ART4", + "article4.epsomAndEwell.horizonhouse": "16/00012/ART4", + "article4.epsomAndEwell.newplanhouse": "16/00006/ART4", + "article4.epsomAndEwell.nightingalehouse": "16/00007/ART4", + "article4.epsomAndEwell.oakshouse": "16/00014/ART4", + "article4.epsomAndEwell.parksidehouse": "16/00017/ART4", + "article4.epsomAndEwell.sollishouse": "16/00011/ART4", + "article4.epsomAndEwell.thekirkgate": "16/00019/ART4", + "article4.epsomAndEwell.thewells": "16/00018/ART4", + }, + }, +}; + +export { planningConstraints }; From 711553cf5cb8d3c17c16d209b0341222bce8ca05 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Tue, 14 May 2024 13:37:59 +0100 Subject: [PATCH 07/10] fix(a11y): always enable continue button on FindProperty map page (#3141) --- .../components/FindProperty/Public/Map.tsx | 133 ++++++++++------- .../FindProperty/Public/Public.test.tsx | 15 +- .../components/FindProperty/Public/index.tsx | 136 +++++++++--------- 3 files changed, 161 insertions(+), 123 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/FindProperty/Public/Map.tsx b/editor.planx.uk/src/@planx/components/FindProperty/Public/Map.tsx index 90978ee9d6..1ad63c2512 100644 --- a/editor.planx.uk/src/@planx/components/FindProperty/Public/Map.tsx +++ b/editor.planx.uk/src/@planx/components/FindProperty/Public/Map.tsx @@ -11,10 +11,10 @@ import { MapContainer, MapFooter, } from "@planx/components/shared/Preview/MapContainer"; -import { GeoJSONObject } from "@turf/helpers"; import { useStore } from "pages/FlowEditor/lib/store"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import InputLabel from "ui/public/InputLabel"; +import ErrorWrapper from "ui/shared/ErrorWrapper"; import Input from "ui/shared/Input"; import { DEFAULT_NEW_ADDRESS_LABEL, SiteAddress } from "../model"; @@ -23,10 +23,15 @@ interface PlotNewAddressProps { setAddress: React.Dispatch>; setPage: React.Dispatch>; initialProposedAddress?: SiteAddress; - boundary?: GeoJSONObject | undefined; id?: string; description?: string; descriptionLabel?: string; + mapValidationError?: string; + setMapValidationError: React.Dispatch< + React.SetStateAction + >; + showSiteDescriptionError: boolean; + setShowSiteDescriptionError: React.Dispatch>; } type Coordinates = { @@ -55,8 +60,6 @@ export default function PlotNewAddress(props: PlotNewAddressProps): FCReturn { const [siteDescription, setSiteDescription] = useState( props.initialProposedAddress?.title ?? null, ); - const [showSiteDescriptionError, setShowSiteDescriptionError] = - useState(false); const [environment, boundaryBBox] = useStore((state) => [ state.previewEnvironment, @@ -85,6 +88,17 @@ export default function PlotNewAddress(props: PlotNewAddressProps): FCReturn { }; }, [setCoordinates]); + const mapValidationErrorRef = useRef(props.mapValidationError); + useEffect(() => { + mapValidationErrorRef.current = props.mapValidationError; + }, [props.mapValidationError]); + + useEffect(() => { + if (mapValidationErrorRef.current) { + props.setMapValidationError(undefined); + } + }, [coordinates]); + useEffect(() => { // when we have all required address parts, call setAddress to enable the "Continue" button if (siteDescription && coordinates) { @@ -99,7 +113,7 @@ export default function PlotNewAddress(props: PlotNewAddressProps): FCReturn { }, [coordinates, siteDescription]); const handleSiteDescriptionCheck = () => { - if (!siteDescription) setShowSiteDescriptionError(true); + if (!siteDescription) props.setShowSiteDescriptionError(true); }; const handleSiteDescriptionInputChange = ( @@ -111,54 +125,65 @@ export default function PlotNewAddress(props: PlotNewAddressProps): FCReturn { return ( <> - -

- An interactive map centred on the local authority district, showing - the Ordnance Survey basemap. Click to place a point representing your - proposed site location. -

- {/* @ts-ignore */} - - + + +

+ An interactive map centred on the local authority district, showing + the Ordnance Survey basemap. Click to place a point representing + your proposed site location. +

+ {/* @ts-ignore */} + +
+
+ + + The coordinate location of your address point is:{" "} + + {`${ + (coordinates?.x && Math.round(coordinates.x)) || 0 + } Easting (X), ${ + (coordinates?.y && Math.round(coordinates.y)) || 0 + } Northing (Y)`} + + + { + props.setPage("os-address"); + props.setAddress(undefined); + }} + > - The coordinate location of your address point is:{" "} - - {`${ - (coordinates?.x && Math.round(coordinates.x)) || 0 - } Easting (X), ${ - (coordinates?.y && Math.round(coordinates.y)) || 0 - } Northing (Y)`} - + I want to select an existing address - { - props.setPage("os-address"); - props.setAddress(undefined); - }} - > - - I want to select an existing address - - - -
+ +
{ const descriptionInput = screen.getByTestId("new-address-input"); expect(descriptionInput).toBeInTheDocument(); - // expect continue to be disabled because an address has not been selected - expect(screen.getByTestId("continue-button")).toBeDisabled(); + // Continue button is always enabled, but validation prevents submit until we have complete address details + expect(screen.getByTestId("continue-button")).toBeEnabled(); + await user.click(screen.getByTestId("continue-button")); expect(handleSubmit).not.toHaveBeenCalled(); }); @@ -416,9 +417,15 @@ describe("plotting a new address that does not have a uprn yet", () => { screen.getByText(`Enter a site description such as "Land at..."`), ).toBeInTheDocument(); - // expect continue to be disabled because we have incomplete address details - expect(screen.getByTestId("continue-button")).toBeDisabled(); + // Continue button is always enabled, but validation prevents submit until we have complete address details + expect(screen.getByTestId("continue-button")).toBeEnabled(); + await user.click(screen.getByTestId("continue-button")); expect(handleSubmit).not.toHaveBeenCalled(); + + // continue should trigger map error wrapper too + expect( + screen.getByTestId("error-message-plot-new-address-map"), + ).toBeInTheDocument(); }); it("recovers previously submitted address when clicking the back button and lands on the map page", async () => { diff --git a/editor.planx.uk/src/@planx/components/FindProperty/Public/index.tsx b/editor.planx.uk/src/@planx/components/FindProperty/Public/index.tsx index 02307ff308..10bdab7330 100644 --- a/editor.planx.uk/src/@planx/components/FindProperty/Public/index.tsx +++ b/editor.planx.uk/src/@planx/components/FindProperty/Public/index.tsx @@ -7,9 +7,9 @@ import QuestionHeader from "@planx/components/shared/Preview/QuestionHeader"; import { squareMetresToHectares } from "@planx/components/shared/utils"; import { PublicProps } from "@planx/components/ui"; import area from "@turf/area"; -import { Feature, GeoJSONObject } from "@turf/helpers"; +import { Feature } from "@turf/helpers"; import DelayedLoadingIndicator from "components/DelayedLoadingIndicator"; -import { Store, useStore } from "pages/FlowEditor/lib/store"; +import { Store } from "pages/FlowEditor/lib/store"; import React, { useEffect, useState } from "react"; import useSWR from "swr"; import ExternalPlanningSiteDialog, { @@ -51,6 +51,10 @@ function Component(props: Props) { : "os-address"; const [page, setPage] = useState<"os-address" | "new-address">(startPage); + const [mapValidationError, setMapValidationError] = useState(); + const [showSiteDescriptionError, setShowSiteDescriptionError] = + useState(false); + const [address, setAddress] = useState( previouslySubmittedData?._address, ); @@ -59,9 +63,6 @@ function Component(props: Props) { >(); const [regions, setRegions] = useState(); const [titleBoundary, setTitleBoundary] = useState(); - const [boundary, setBoundary] = useState(); - - const teamSettings = useStore((state) => state.teamSettings); // Use the address point to fetch the Local Authority District(s) & region via Digital Land const options = new URLSearchParams({ @@ -89,21 +90,6 @@ function Component(props: Props) { }, ); - // if allowNewAddresses is on, fetch the boundary geojson for this team to position the map view or default to London - // example value for team.settings.boundary is https://www.planning.data.gov.uk/entity/8600093.geojson - const { data: geojson } = useSWR( - () => - props.allowNewAddresses && teamSettings?.boundary - ? teamSettings.boundary - : null, - fetcher, - { - shouldRetryOnError: true, - errorRetryInterval: 500, - errorRetryCount: 1, - }, - ); - useEffect(() => { if (address && data?.features?.length > 0) { const lads: string[] = []; @@ -124,11 +110,62 @@ function Component(props: Props) { } }, [data, address]); - useEffect(() => { - if (geojson) setBoundary(geojson); - }, [geojson]); + const validateAndSubmit = () => { + // TODO `if (isValidating)` on either page, wrap Continue button in error mesage? + + if (page === "new-address") { + if (address?.x === undefined && address?.y === undefined) + setMapValidationError("Click or tap to place a point on the map"); + + if (address?.title === undefined) setShowSiteDescriptionError(true); + } + + if (address) { + const newPassportData: Store.userData["data"] = {}; + newPassportData["_address"] = address; + if (address?.planx_value) { + newPassportData["property.type"] = [address.planx_value]; + } + + if (localAuthorityDistricts) { + newPassportData["property.localAuthorityDistrict"] = + localAuthorityDistricts; + } + if (regions) { + newPassportData["property.region"] = regions; + } + if (titleBoundary) { + const areaSquareMetres = + Math.round(area(titleBoundary as Feature) * 100) / 100; + newPassportData["property.boundary.title"] = titleBoundary; + newPassportData["property.boundary.title.area"] = areaSquareMetres; + newPassportData["property.boundary.title.area.hectares"] = + squareMetresToHectares(areaSquareMetres); + } + + newPassportData[PASSPORT_COMPONENT_ACTION_KEY] = + address?.source === "os" + ? FindPropertyUserAction.Existing + : FindPropertyUserAction.New; + + props.handleSubmit?.({ data: { ...newPassportData } }); + } + }; + + return ( + + {getBody()} + + ); - function getPageBody() { + function getBody() { if (props.allowNewAddresses && page === "new-address") { return ( <> @@ -143,11 +180,20 @@ function Component(props: Props) { previouslySubmittedData?._address?.source === "proposed" && previouslySubmittedData?._address } - boundary={boundary} id={props.id} description={props.newAddressDescription || ""} descriptionLabel={props.newAddressDescriptionLabel || ""} + mapValidationError={mapValidationError} + setMapValidationError={setMapValidationError} + showSiteDescriptionError={showSiteDescriptionError} + setShowSiteDescriptionError={setShowSiteDescriptionError} /> + {Boolean(address) && isValidating && ( + + )} ); } else { @@ -200,44 +246,4 @@ function Component(props: Props) { ); } } - - return ( - { - if (address) { - const newPassportData: Store.userData["data"] = {}; - newPassportData["_address"] = address; - if (address?.planx_value) { - newPassportData["property.type"] = [address.planx_value]; - } - - if (localAuthorityDistricts) { - newPassportData["property.localAuthorityDistrict"] = - localAuthorityDistricts; - } - if (regions) { - newPassportData["property.region"] = regions; - } - if (titleBoundary) { - const areaSquareMetres = - Math.round(area(titleBoundary as Feature) * 100) / 100; - newPassportData["property.boundary.title"] = titleBoundary; - newPassportData["property.boundary.title.area"] = areaSquareMetres; - newPassportData["property.boundary.title.area.hectares"] = - squareMetresToHectares(areaSquareMetres); - } - - newPassportData[PASSPORT_COMPONENT_ACTION_KEY] = - address?.source === "os" - ? FindPropertyUserAction.Existing - : FindPropertyUserAction.New; - - props.handleSubmit?.({ data: { ...newPassportData } }); - } - }} - > - {getPageBody()} - - ); } From c81d65043e5f6f3e9f1cd160f1ad59f7cfefc406 Mon Sep 17 00:00:00 2001 From: augustlindemer <118665588+augustlindemer@users.noreply.github.com> Date: Tue, 14 May 2024 15:20:41 +0100 Subject: [PATCH 08/10] feat: Hook up granular A4 GIS API variables for West Berkshire (#3145) --- .../modules/gis/service/digitalLand.ts | 1 + .../metadata/westBerkshire.ts | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 api.planx.uk/modules/gis/service/local_authorities/metadata/westBerkshire.ts diff --git a/api.planx.uk/modules/gis/service/digitalLand.ts b/api.planx.uk/modules/gis/service/digitalLand.ts index 4e13f3ca48..209714e447 100644 --- a/api.planx.uk/modules/gis/service/digitalLand.ts +++ b/api.planx.uk/modules/gis/service/digitalLand.ts @@ -34,6 +34,7 @@ const localAuthorityMetadata: Record = { southwark: require("./local_authorities/metadata/southwark"), "st-albans": require("./local_authorities/metadata/stAlbans"), tewkesbury: require("./local_authorities/metadata/tewkesbury"), + "west-berkshire": require("./local_authorities/metadata/westBerkshire"), }; /** diff --git a/api.planx.uk/modules/gis/service/local_authorities/metadata/westBerkshire.ts b/api.planx.uk/modules/gis/service/local_authorities/metadata/westBerkshire.ts new file mode 100644 index 0000000000..3b46bd7dc3 --- /dev/null +++ b/api.planx.uk/modules/gis/service/local_authorities/metadata/westBerkshire.ts @@ -0,0 +1,30 @@ +/* +LAD20CD: E06000037 +LAD20NM: West Berkshire +LAD20NMW: +FID: + +https://www.planning.data.gov.uk/entity/?dataset=article-4-direction-area&geometry_curie=statistical-geography%3AE06000037&entry_date_day=&entry_date_month=&entry_date_year=#51.47483223935322,-1.0401689836322703,17.030900256454515z +https://docs.google.com/spreadsheets/d/1dRTb8xhcJgsQB8zIFregenm5aEzrZGU_/edit#gid=322896440 +*/ + +import { LocalAuthorityMetadata } from "../../digitalLand"; + +const planningConstraints: LocalAuthorityMetadata["planningConstraints"] = { + article4: { + // Planx granular values link to Digital Land article-4-direction and entity.reference + records: { + "article4.westBerkshire.oxfordroad": "23/00295/ART4", + "article4.westBerkshire.theobalddrive": "23/00294/ART4", + "article4.westBerkshire.hollies": "23/00293/ART4", + "article4.westBerkshire.bridleway": "23/00292/ART4", + "article4.westBerkshire.sawmills": "22/00011/ART4", + "article4.westBerkshire.fordsfarmestate": "23/00291/ART4", + "article4.westBerkshire.hollybushlane": "22/00012/ART4", + "article4.westBerkshire.shawroad": "23/00296/ART4", + "article4.westBerkshire.eastgarston": "22/00010/ART4", + }, + }, +}; + +export { planningConstraints }; From 1a73dcb838e979d23e6ed2f9fc2530c289d13447 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Wed, 15 May 2024 09:40:37 +0100 Subject: [PATCH 09/10] feat: create `submission_services_log` view (#3144) --- hasura.planx.uk/metadata/tables.yaml | 3 ++ .../down.sql | 1 + .../up.sql | 45 +++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 hasura.planx.uk/migrations/1715689232003_create view submission services log /down.sql create mode 100644 hasura.planx.uk/migrations/1715689232003_create view submission services log /up.sql diff --git a/hasura.planx.uk/metadata/tables.yaml b/hasura.planx.uk/metadata/tables.yaml index 5eaac0e3f0..1227515ef1 100644 --- a/hasura.planx.uk/metadata/tables.yaml +++ b/hasura.planx.uk/metadata/tables.yaml @@ -1360,6 +1360,9 @@ - locked_at: _is_null: true check: null +- table: + name: submission_services_log + schema: public - table: name: submission_services_summary schema: public diff --git a/hasura.planx.uk/migrations/1715689232003_create view submission services log /down.sql b/hasura.planx.uk/migrations/1715689232003_create view submission services log /down.sql new file mode 100644 index 0000000000..753d1262be --- /dev/null +++ b/hasura.planx.uk/migrations/1715689232003_create view submission services log /down.sql @@ -0,0 +1 @@ +drop view if exists public.submission_services_log cascade; diff --git a/hasura.planx.uk/migrations/1715689232003_create view submission services log /up.sql b/hasura.planx.uk/migrations/1715689232003_create view submission services log /up.sql new file mode 100644 index 0000000000..fe0efbb9db --- /dev/null +++ b/hasura.planx.uk/migrations/1715689232003_create view submission services log /up.sql @@ -0,0 +1,45 @@ +create or replace view public.submission_services_log as +with payments as ( + select + session_id, + payment_id::text as event_id, + 'Pay' as event_type, + initcap(status) as status, + '{}'::jsonb as response, + created_at + from payment_status + where status != 'created' + and created_at >= '2024-01-01' +), submissions as ( + select + (seil.request -> 'payload' -> 'payload' ->> 'sessionId')::uuid as session_id, + se.id as event_id, + case + when se.webhook_conf::text like '%bops%' then 'Submit to BOPS' + when se.webhook_conf::text like '%uniform%' then 'Submit to Uniform' + when se.webhook_conf::text like '%email-submission%' then 'Send to email' + when se.webhook_conf::text like '%upload-submission%' then 'Upload to AWS S3' + else se.webhook_conf::text + end as event_type, + case + when seil.status = 200 then 'Success' + else format('Failed (%s)', seil.status) + end as status, + seil.response::jsonb, + seil.created_at + from hdb_catalog.hdb_scheduled_events se + left join hdb_catalog.hdb_scheduled_event_invocation_logs seil on seil.event_id = se.id + where se.webhook_conf::text not like '%email/%' + and seil.created_at >= '2024-01-01' +), all_events as ( + select * from payments + union all + select * from submissions +) +SELECT + ls.flow_id, + ae.* +FROM all_events ae + left join public.lowcal_sessions ls on ls.id = ae.session_id +WHERE ls.flow_id is not null +order by ae.created_at desc; From e71a0f9097ab40047ccef3369fbd7d15aa1e4992 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Wed, 15 May 2024 11:37:18 +0100 Subject: [PATCH 10/10] fix: temporary redirect for Bucks building control service (#3147) --- editor.planx.uk/src/routes/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/editor.planx.uk/src/routes/index.tsx b/editor.planx.uk/src/routes/index.tsx index 1f15826a56..df205af886 100644 --- a/editor.planx.uk/src/routes/index.tsx +++ b/editor.planx.uk/src/routes/index.tsx @@ -71,6 +71,12 @@ export default isPreviewOnlyDomain }) : mount({ "/:team/:flow/published": lazy(() => import("./published")), // loads current published flow if exists, or throws Not Found if unpublished + "buckinghamshire/apply-for-building-regulations-applications/preview": + map(async (req) => + redirect( + `/buckinghamshire/apply-for-building-regulations-applications/published${req?.search}`, + ), + ), // temporary redirect while Bucks works with internal IT to update advertised service links "/:team/:flow/preview": lazy(() => import("./preview")), // loads current draft flow and latest published external portals, or throws Not Found if any external portal is unpublished "/:team/:flow/draft": lazy(() => import("./draft")), // loads current draft flow and draft external portals "/:team/:flow/pay": mountPayRoutes(),