diff --git a/editor.planx.uk/src/@planx/components/PlanningConstraints/List.tsx b/editor.planx.uk/src/@planx/components/PlanningConstraints/List.tsx index 86998af6dc..3e0ca1d314 100644 --- a/editor.planx.uk/src/@planx/components/PlanningConstraints/List.tsx +++ b/editor.planx.uk/src/@planx/components/PlanningConstraints/List.tsx @@ -13,16 +13,19 @@ import type { GISResponse, Metadata, } from "@opensystemslab/planx-core/types"; +import { hasFeatureFlag } from "lib/featureFlags"; import groupBy from "lodash/groupBy"; import { useStore } from "pages/FlowEditor/lib/store"; -import React, { ReactNode } from "react"; +import React, { ReactNode, useState } from "react"; import ReactHtmlParser from "react-html-parser"; import { FONT_WEIGHT_SEMI_BOLD } from "theme"; import Caret from "ui/icons/Caret"; import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml"; import { SiteAddress } from "../FindProperty/model"; +import { OverrideEntitiesModal } from "./Modal"; import { availableDatasets } from "./model"; +import { InaccurateConstraints } from "./Public"; const CATEGORY_COLORS: Record = { "General policy": "#99C1DE", @@ -68,11 +71,17 @@ const StyledAccordion = styled(Accordion, { interface ConstraintsListProps { data: Constraint[]; metadata: GISResponse["metadata"]; + inaccurateConstraints: InaccurateConstraints; + setInaccurateConstraints: ( + value: React.SetStateAction, + ) => void; } export default function ConstraintsList({ data, metadata, + inaccurateConstraints, + setInaccurateConstraints, }: ConstraintsListProps) { const groupedConstraints = groupBy(data, (constraint) => { return constraint.category; @@ -113,11 +122,14 @@ export default function ConstraintsList({ {groupedConstraints[category].map((con) => ( {metadata?.[con.fn]?.plural || ReactHtmlParser(con.text)} @@ -131,21 +143,27 @@ export default function ConstraintsList({ } interface ConstraintListItemProps { - key: Constraint["fn"]; + fn: Constraint["fn"]; value: Constraint["value"]; content: Constraint["text"]; data: Constraint["data"] | null; metadata?: Metadata; category: string; children: ReactNode; + inaccurateConstraints: InaccurateConstraints; + setInaccurateConstraints: ( + value: React.SetStateAction, + ) => void; } function ConstraintListItem({ children, ...props }: ConstraintListItemProps) { + const [showModal, setShowModal] = useState(false); + const { longitude, latitude, usrn } = (useStore( (state) => state.computePassport().data?._address, ) as SiteAddress) || {}; - const item = props.metadata?.name.replaceAll(" ", "-"); + const isSourcedFromPlanningData = props.metadata?.plural !== "Classified roads"; @@ -160,21 +178,32 @@ function ConstraintListItem({ children, ...props }: ConstraintListItemProps) { .join("&"); const planningDataMapURL = `https://www.planning.data.gov.uk/map/?${encodedMatchingDatasets}#${latitude},${longitude},17.5z`; + // If a user overrides every entity in a constraint category, then that whole category becomes inapplicable and we want to gray it out + const allEntitiesInaccurate = + props.data?.length !== 0 && + props.data?.length === + props.inaccurateConstraints?.[props.fn]?.["entities"]?.length; + return ( } sx={{ pr: 1.5, background: `rgba(255, 255, 255, 0.8)` }} > - + {children} @@ -201,23 +230,41 @@ function ConstraintListItem({ children, ...props }: ConstraintListItemProps) { key={`entity-${record.entity}-li`} dense disableGutters - sx={{ display: "list-item" }} + sx={{ + display: "list-item", + color: props.inaccurateConstraints?.[props.fn]?.[ + "entities" + ]?.includes(`${record.entity}`) + ? "GrayText" + : "inherit", + }} > {isSourcedFromPlanningData ? ( - + - {record.name || - (record["flood-risk-level"] && - `${props.metadata?.name} - Level ${record["flood-risk-level"]}`) || - `Planning Data entity #${record.entity}`} + {formatEntityName(record, props.metadata)} ) : ( {record.name} )} + {props.inaccurateConstraints?.[props.fn]?.[ + "entities" + ]?.includes(`${record.entity}`) && ( + + {` [Not applicable]`} + + )} ))} @@ -238,7 +285,7 @@ function ConstraintListItem({ children, ...props }: ConstraintListItemProps) { )} {`How is it defined`} - + + {hasFeatureFlag("OVERRIDE_CONSTRAINTS") && + props.value && + Boolean(props.data?.length) && ( + + { + event.stopPropagation(); + setShowModal(true); + }} + > + I don't think this constraint applies to this property + + + )} + ); } + +/** + * Not all Planning Data entity records populate "name", + * so configure meaningful fallback values for the list display + */ +export function formatEntityName( + entity: Record, + metadata?: Metadata, +): string { + return ( + entity.name || + (metadata?.name && + entity["flood-risk-level"] && + `${metadata.name} - Level ${entity["flood-risk-level"]}`) || + `Planning Data entity #${entity.entity}` + ); +} diff --git a/editor.planx.uk/src/@planx/components/PlanningConstraints/Modal.tsx b/editor.planx.uk/src/@planx/components/PlanningConstraints/Modal.tsx new file mode 100644 index 0000000000..1a8f2b0263 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/PlanningConstraints/Modal.tsx @@ -0,0 +1,258 @@ +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import Divider from "@mui/material/Divider"; +import Grid from "@mui/material/Grid"; +import Typography from "@mui/material/Typography"; +import visuallyHidden from "@mui/utils/visuallyHidden"; +import { Constraint, Metadata } from "@opensystemslab/planx-core/types"; +import omit from "lodash/omit"; +import React, { useState } from "react"; +import InputLabel from "ui/public/InputLabel"; +import ChecklistItem from "ui/shared/ChecklistItem"; +import ErrorWrapper from "ui/shared/ErrorWrapper"; +import Input from "ui/shared/Input"; + +import { formatEntityName } from "./List"; +import { InaccurateConstraints } from "./Public"; + +interface OverrideEntitiesModalProps { + showModal: boolean; + setShowModal: (value: React.SetStateAction) => void; + fn: Constraint["fn"]; + entities: Constraint["data"] | null; + metadata?: Metadata; + inaccurateConstraints: InaccurateConstraints; + setInaccurateConstraints: ( + value: React.SetStateAction, + ) => void; +} + +const ERROR_MESSAGES = { + checklist: "Select at least one option", + input: "Enter a value", // @todo split into empty & maxLength input errors +}; + +export const OverrideEntitiesModal = ({ + showModal, + setShowModal, + fn, + entities, + metadata, + inaccurateConstraints, + setInaccurateConstraints, +}: OverrideEntitiesModalProps) => { + const initialCheckedOptions = inaccurateConstraints?.[fn]?.["entities"]; + const [checkedOptions, setCheckedOptions] = useState( + initialCheckedOptions, + ); + const [showChecklistError, setShowChecklistError] = useState(false); + + const initialTextInput = inaccurateConstraints?.[fn]?.["reason"]; + const [textInput, setTextInput] = useState( + initialTextInput, + ); + const [showInputError, setShowInputError] = useState(false); + + const title = `Which ${ + metadata?.plural?.toLowerCase() || "entities" + } are inaccurate?`; + + const closeModal = (_event: any, reason?: string) => { + if (reason && reason == "backdropClick") { + return; + } + + // Revert any non-submitted inputs on cancel + setCheckedOptions(initialCheckedOptions); + setTextInput(initialTextInput); + + // Close modal + setShowModal(false); + }; + + const changeCheckbox = + (id: string) => + (_checked: React.MouseEvent | undefined) => { + let newCheckedIds; + + if (checkedOptions?.includes(id)) { + newCheckedIds = checkedOptions.filter((e) => e !== id); + } else { + newCheckedIds = [...(checkedOptions || []), id]; + } + + if (newCheckedIds.length > 0) { + setShowChecklistError(false); + } + setCheckedOptions(newCheckedIds); + }; + + const changeInput = (e: React.ChangeEvent) => { + if (e.target.value.length) { + setShowInputError(false); + } + setTextInput(e.target.value); + }; + + const validateAndSubmit = () => { + const invalidChecklist = !checkedOptions || checkedOptions.length === 0; + const invalidInput = !textInput || textInput.trim().length === 0; + + // All form fields are required to submit + if (invalidChecklist && invalidInput) { + // If you're re-opening the modal to remove previous answers + if (initialCheckedOptions?.length && initialTextInput) { + // Sync cleared form data to parent state + const newInaccurateConstraints = omit(inaccurateConstraints, fn); + setInaccurateConstraints(newInaccurateConstraints); + setShowModal(false); + } else { + // If the form was empty to start + setShowChecklistError(true); + setShowInputError(true); + } + } else if (invalidChecklist) { + setShowChecklistError(true); + } else if (invalidInput) { + setShowInputError(true); + } else { + // Update parent component state on valid submit + const newInaccurateConstraints = { + ...inaccurateConstraints, + ...{ [fn]: { entities: checkedOptions, reason: textInput } }, + }; + setInaccurateConstraints(newInaccurateConstraints); + setShowModal(false); + } + }; + + return ( + theme.breakpoints.values.md, + borderRadius: 0, + borderTop: (theme) => `20px solid ${theme.palette.primary.main}`, + background: "#FFF", + margin: (theme) => theme.spacing(2), + }, + }} + > + + + + I don't think this constraint applies to this property + + + Have we identified a planning constraint that you don't think + applies to this property? + + + We check constraints using a geospatial search, and minor + differences in boundaries may lead to inaccurate results such as a + constraint on an adjacent property. + + + + Select an inaccurate constraint below to proceed forward as if it + does not apply to this property. + {" "} + Your feedback will also help us improve local open data. + + + + + + + {title} + {Boolean(entities?.length) && + entities?.map((e) => ( + + ))} + + + + + + + + changeInput(e)} + /> + + + + + + + + + + + + + ); +}; diff --git a/editor.planx.uk/src/@planx/components/PlanningConstraints/PlanningConstraints.stories.tsx b/editor.planx.uk/src/@planx/components/PlanningConstraints/PlanningConstraints.stories.tsx index 5a6ad4da1c..a461789de2 100644 --- a/editor.planx.uk/src/@planx/components/PlanningConstraints/PlanningConstraints.stories.tsx +++ b/editor.planx.uk/src/@planx/components/PlanningConstraints/PlanningConstraints.stories.tsx @@ -44,6 +44,8 @@ const propsWithIntersections: PlanningConstraintsContentProps = { }, handleSubmit: () => {}, refreshConstraints: () => {}, + inaccurateConstraints: {}, + setInaccurateConstraints: () => {}, }; const propsWithoutIntersections: PlanningConstraintsContentProps = { @@ -63,6 +65,8 @@ const propsWithoutIntersections: PlanningConstraintsContentProps = { }, handleSubmit: () => {}, refreshConstraints: () => {}, + inaccurateConstraints: {}, + setInaccurateConstraints: () => {}, }; export const WithIntersections = { diff --git a/editor.planx.uk/src/@planx/components/PlanningConstraints/Public.tsx b/editor.planx.uk/src/@planx/components/PlanningConstraints/Public.tsx index 5a857226ae..aa34f4e383 100644 --- a/editor.planx.uk/src/@planx/components/PlanningConstraints/Public.tsx +++ b/editor.planx.uk/src/@planx/components/PlanningConstraints/Public.tsx @@ -12,7 +12,7 @@ import DelayedLoadingIndicator from "components/DelayedLoadingIndicator"; import capitalize from "lodash/capitalize"; import { useStore } from "pages/FlowEditor/lib/store"; import { handleSubmit } from "pages/Preview/Node"; -import React from "react"; +import React, { useState } from "react"; import useSWR, { Fetcher } from "swr"; import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml"; import { stringify } from "wkt"; @@ -30,9 +30,21 @@ import { type Props = PublicProps; +interface InaccurateConstraint { + entities: string[]; + reason: string; +} + +export type InaccurateConstraints = + | Record + | undefined; + export default Component; function Component(props: Props) { + const [inaccurateConstraints, setInaccurateConstraints] = + useState(); + const siteBoundary = useStore( (state) => state.computePassport().data?.["property.boundary.site"], ); @@ -154,6 +166,8 @@ function Component(props: Props) { if (data) _constraints.push(data as GISResponse["constraints"]); } + const _overrides = inaccurateConstraints; + const _nots: any = {}; const intersectingConstraints: IntersectingConstraints = {}; Object.entries(constraints).forEach(([key, data]) => { @@ -168,6 +182,7 @@ function Component(props: Props) { const passportData = { _constraints, + _overrides, _nots, ...intersectingConstraints, }; @@ -177,6 +192,8 @@ function Component(props: Props) { }); }} refreshConstraints={() => mutate()} + inaccurateConstraints={inaccurateConstraints} + setInaccurateConstraints={setInaccurateConstraints} /> ) : ( @@ -200,6 +217,10 @@ export type PlanningConstraintsContentProps = { metadata: GISResponse["metadata"]; handleSubmit: () => void; refreshConstraints: () => void; + inaccurateConstraints: InaccurateConstraints; + setInaccurateConstraints: ( + value: React.SetStateAction, + ) => void; }; export function PlanningConstraintsContent( @@ -212,6 +233,8 @@ export function PlanningConstraintsContent( metadata, refreshConstraints, disclaimer, + inaccurateConstraints, + setInaccurateConstraints, } = props; const error = constraints.error || undefined; const showError = error || !Object.values(constraints)?.length; @@ -238,7 +261,12 @@ export function PlanningConstraintsContent( These are the planning constraints we think apply to this property - + {negativeConstraints.length > 0 && ( - + )} @@ -275,7 +308,12 @@ export function PlanningConstraintsContent( closed: "Hide constraints that don't apply", }} > - + diff --git a/editor.planx.uk/src/lib/featureFlags.ts b/editor.planx.uk/src/lib/featureFlags.ts index 90a52dfe3f..c74697336b 100644 --- a/editor.planx.uk/src/lib/featureFlags.ts +++ b/editor.planx.uk/src/lib/featureFlags.ts @@ -1,5 +1,5 @@ // add/edit/remove feature flags in array below -const AVAILABLE_FEATURE_FLAGS = ["SEARCH"] as const; +const AVAILABLE_FEATURE_FLAGS = ["SEARCH", "OVERRIDE_CONSTRAINTS"] as const; type FeatureFlag = (typeof AVAILABLE_FEATURE_FLAGS)[number];