From e1bcd7aaed6ce14e41131e2b2d6996d5d6603670 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Mon, 9 Sep 2024 10:32:33 +0200 Subject: [PATCH] feat(map-and-label): remove individual features (#3625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Dafydd Llŷr Pearson --- .../components/MapAndLabel/Public/Context.tsx | 108 +++++++++++++++--- .../components/MapAndLabel/Public/index.tsx | 83 ++++++-------- .../@planx/components/shared/Schema/model.ts | 1 - editor.planx.uk/src/lib/gis.ts | 41 +++++++ 4 files changed, 168 insertions(+), 65 deletions(-) create mode 100644 editor.planx.uk/src/lib/gis.ts diff --git a/editor.planx.uk/src/@planx/components/MapAndLabel/Public/Context.tsx b/editor.planx.uk/src/@planx/components/MapAndLabel/Public/Context.tsx index 82440d9edd..3e7f049db7 100644 --- a/editor.planx.uk/src/@planx/components/MapAndLabel/Public/Context.tsx +++ b/editor.planx.uk/src/@planx/components/MapAndLabel/Public/Context.tsx @@ -9,7 +9,8 @@ import { makeData, } from "@planx/components/shared/utils"; import { FormikProps, useFormik } from "formik"; -import { FeatureCollection } from "geojson"; +import { Feature, FeatureCollection } from "geojson"; +import { GeoJSONChange, GeoJSONChangeEvent, useGeoJSONChange } from "lib/gis"; import { get } from "lodash"; import React, { createContext, @@ -20,15 +21,20 @@ import React, { import { PresentationalProps } from "."; +export const MAP_ID = "map-and-label-map"; + interface MapAndLabelContextValue { schema: Schema; + features?: Feature[]; + updateMapKey: number; activeIndex: number; - editFeature: (index: number) => void; formik: FormikProps; validateAndSubmitForm: () => void; isFeatureInvalid: (index: number) => boolean; - addFeature: () => void; + addInitialFeaturesToMap: (features: Feature[]) => void; + editFeature: (index: number) => void; copyFeature: (sourceIndex: number, destinationIndex: number) => void; + removeFeature: (index: number) => void; mapAndLabelProps: PresentationalProps; errors: { min: boolean; @@ -51,21 +57,20 @@ export const MapAndLabelProvider: React.FC = ( previousValues: getPreviouslySubmittedData(props), }); - // Deconstruct GeoJSON saved to passport back into schemaData & geoData + // Deconstruct GeoJSON saved to passport back into form data and map data const previousGeojson = previouslySubmittedData?.data?.[ fn ] as FeatureCollection; - const previousSchemaData = previousGeojson?.features.map( + const previousFormData = previousGeojson?.features.map( (feature) => feature.properties, ) as SchemaUserResponse[]; - const previousGeoData = previousGeojson?.features; + const _previousMapData = previousGeojson?.features; const formik = useFormik({ ...formikConfig, // The user interactions are map driven - start with no values added initialValues: { - schemaData: previousSchemaData || [], - geoData: previousGeoData || [], + schemaData: previousFormData || [], }, onSubmit: (values) => { const geojson: FeatureCollection = { @@ -73,7 +78,7 @@ export const MapAndLabelProvider: React.FC = ( features: [], }; - values.geoData?.forEach((feature, i) => { + features?.forEach((feature, i) => { // Store user inputs as GeoJSON properties const mergedProperties = { ...feature.properties, @@ -93,14 +98,40 @@ export const MapAndLabelProvider: React.FC = ( }, }); - const [activeIndex, setActiveIndex] = useState(0); + const [activeIndex, setActiveIndex] = useState(-1); const [minError, setMinError] = useState(false); const [maxError, setMaxError] = useState(false); + const handleGeoJSONChange = (event: GeoJSONChangeEvent) => { + // If the user clicks 'reset' on the map, geojson will be empty object + const userHitsReset = !event.detail["EPSG:3857"]; + + if (userHitsReset) { + removeAllFeaturesFromMap(); + removeAllFeaturesFromForm(); + return; + } + + addFeatureToMap(event.detail); + addFeatureToForm(); + }; + + const [features, setFeatures] = useGeoJSONChange(MAP_ID, handleGeoJSONChange); + + const [updateMapKey, setUpdateMapKey] = useState(0); + const resetErrors = () => { setMinError(false); setMaxError(false); + formik.setErrors({}); + }; + + const removeAllFeaturesFromMap = () => setFeatures(undefined); + + const removeAllFeaturesFromForm = () => { + formik.setFieldValue("schemaData", []); + setActiveIndex(-1); }; const validateAndSubmitForm = () => { @@ -119,7 +150,18 @@ export const MapAndLabelProvider: React.FC = ( const isFeatureInvalid = (index: number) => Boolean(get(formik.errors, ["schemaData", index])); - const addFeature = () => { + const addFeatureToMap = (geojson: GeoJSONChange) => { + resetErrors(); + setFeatures(geojson["EPSG:3857"].features); + setActiveIndex((features && features?.length - 2) || activeIndex + 1); + }; + + const addInitialFeaturesToMap = (features: Feature[]) => { + setFeatures(features); + // TODO: setActiveIndex ? + }; + + const addFeatureToForm = () => { resetErrors(); const currentFeatures = formik.values.schemaData; @@ -130,6 +172,8 @@ export const MapAndLabelProvider: React.FC = ( if (schema.max && updatedFeatures.length > schema.max) { setMaxError(true); } + + setActiveIndex(activeIndex + 1); }; const copyFeature = (sourceIndex: number, destinationIndex: number) => { @@ -137,17 +181,55 @@ export const MapAndLabelProvider: React.FC = ( formik.setFieldValue(`schemaData[${destinationIndex}]`, sourceFeature); }; + const removeFeatureFromForm = (index: number) => { + formik.setFieldValue( + "schemaData", + formik.values.schemaData.filter((_, i) => i !== index), + ); + }; + + const removeFeatureFromMap = (index: number) => { + // Order of features can vary by change/modification, filter on label not array position + const label = `${index + 1}`; + const filteredFeatures = features?.filter( + (f) => f.properties?.label !== label, + ); + + // Shift any feature labels that are larger than the removed feature label so they remain incremental + filteredFeatures?.map((f) => { + if (f.properties && Number(f.properties?.label) > Number(label)) { + const newLabel = Number(f.properties.label) - 1; + Object.assign(f, { properties: { label: `${newLabel}` } }); + } + }); + setFeatures(filteredFeatures); + + // `updateMapKey` is set as a unique `key` prop on the map container to force a re-render of its children (aka ) on change + setUpdateMapKey(updateMapKey + 1); + }; + + const removeFeature = (index: number) => { + resetErrors(); + removeFeatureFromForm(index); + removeFeatureFromMap(index); + // Set active index as highest tab after removal, so that when you "add" a new feature the tabs increment correctly + setActiveIndex((features && features.length - 2) || activeIndex - 1); + }; + return ( ; @@ -55,11 +55,20 @@ const StyledTab = styled((props: TabProps) => ( }, })) as typeof Tab; -const VerticalFeatureTabs: React.FC<{ features: Feature[] }> = ({ - features, -}) => { - const { schema, activeIndex, formik, editFeature, isFeatureInvalid } = - useMapAndLabelContext(); +const VerticalFeatureTabs: React.FC = () => { + const { + schema, + activeIndex, + formik, + features, + editFeature, + isFeatureInvalid, + removeFeature, + } = useMapAndLabelContext(); + + if (!features) { + throw new Error("Cannot render MapAndLabel tabs without features"); + } // Features is inherently sorted by recently added/modified, order tabs by stable labels const sortedFeatures = sortBy(features, ["properties.label"]); @@ -152,11 +161,7 @@ const VerticalFeatureTabs: React.FC<{ features: Feature[] }> = ({ formik={formik} />