Skip to content

Commit

Permalink
feat(map-and-label): remove individual features (#3625)
Browse files Browse the repository at this point in the history
Co-authored-by: Dafydd Llŷr Pearson <DafyddLlyr@gmail.com>
  • Loading branch information
jessicamcinchak and DafyddLlyr committed Sep 9, 2024
1 parent 579e56a commit e1bcd7a
Show file tree
Hide file tree
Showing 4 changed files with 168 additions and 65 deletions.
108 changes: 95 additions & 13 deletions editor.planx.uk/src/@planx/components/MapAndLabel/Public/Context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<SchemaUserData>;
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;
Expand All @@ -51,29 +57,28 @@ export const MapAndLabelProvider: React.FC<MapAndLabelProviderProps> = (
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<SchemaUserData>({
...formikConfig,
// The user interactions are map driven - start with no values added
initialValues: {
schemaData: previousSchemaData || [],
geoData: previousGeoData || [],
schemaData: previousFormData || [],
},
onSubmit: (values) => {
const geojson: FeatureCollection = {
type: "FeatureCollection",
features: [],
};

values.geoData?.forEach((feature, i) => {
features?.forEach((feature, i) => {
// Store user inputs as GeoJSON properties
const mergedProperties = {
...feature.properties,
Expand All @@ -93,14 +98,40 @@ export const MapAndLabelProvider: React.FC<MapAndLabelProviderProps> = (
},
});

const [activeIndex, setActiveIndex] = useState<number>(0);
const [activeIndex, setActiveIndex] = useState<number>(-1);

const [minError, setMinError] = useState<boolean>(false);
const [maxError, setMaxError] = useState<boolean>(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<number>(0);

const resetErrors = () => {
setMinError(false);
setMaxError(false);
formik.setErrors({});
};

const removeAllFeaturesFromMap = () => setFeatures(undefined);

const removeAllFeaturesFromForm = () => {
formik.setFieldValue("schemaData", []);
setActiveIndex(-1);
};

const validateAndSubmitForm = () => {
Expand All @@ -119,7 +150,18 @@ export const MapAndLabelProvider: React.FC<MapAndLabelProviderProps> = (
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;
Expand All @@ -130,24 +172,64 @@ export const MapAndLabelProvider: React.FC<MapAndLabelProviderProps> = (
if (schema.max && updatedFeatures.length > schema.max) {
setMaxError(true);
}

setActiveIndex(activeIndex + 1);
};

const copyFeature = (sourceIndex: number, destinationIndex: number) => {
const sourceFeature = formik.values.schemaData[sourceIndex];
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 <my-map />) 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 (
<MapAndLabelContext.Provider
value={{
features,
updateMapKey,
activeIndex,
schema,
mapAndLabelProps: props,
editFeature,
formik,
validateAndSubmitForm,
addFeature,
addInitialFeaturesToMap,
editFeature,
copyFeature,
removeFeature,
isFeatureInvalid,
errors: {
min: minError,
Expand Down
83 changes: 32 additions & 51 deletions editor.planx.uk/src/@planx/components/MapAndLabel/Public/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import Typography from "@mui/material/Typography";
import { SiteAddress } from "@planx/components/FindProperty/model";
import { ErrorSummaryContainer } from "@planx/components/shared/Preview/ErrorSummaryContainer";
import { SchemaFields } from "@planx/components/shared/Schema/SchemaFields";
import { Feature, FeatureCollection, GeoJsonObject } from "geojson";
import { GeoJsonObject } from "geojson";
import sortBy from "lodash/sortBy";
import { useStore } from "pages/FlowEditor/lib/store";
import React, { useEffect, useState } from "react";
import React from "react";
import { FONT_WEIGHT_SEMI_BOLD } from "theme";
import FullWidthWrapper from "ui/public/FullWidthWrapper";
import ErrorWrapper from "ui/shared/ErrorWrapper";
Expand All @@ -23,7 +23,7 @@ import CardHeader from "../../shared/Preview/CardHeader";
import { MapContainer } from "../../shared/Preview/MapContainer";
import { PublicProps } from "../../ui";
import type { MapAndLabel } from "./../model";
import { MapAndLabelProvider, useMapAndLabelContext } from "./Context";
import { MAP_ID, MapAndLabelProvider, useMapAndLabelContext } from "./Context";
import { CopyFeature } from "./CopyFeature";

type Props = PublicProps<MapAndLabel>;
Expand Down Expand Up @@ -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"]);
Expand Down Expand Up @@ -152,11 +161,7 @@ const VerticalFeatureTabs: React.FC<{ features: Feature[] }> = ({
formik={formik}
/>
<Button
onClick={() =>
console.log(
`TODO - Remove ${schema.type} ${feature.properties?.label}`,
)
}
onClick={() => removeFeature(activeIndex)}
sx={{
fontWeight: FONT_WEIGHT_SEMI_BOLD,
gap: (theme) => theme.spacing(2),
Expand Down Expand Up @@ -192,12 +197,13 @@ const PlotFeatureToBegin = () => (

const Root = () => {
const {
validateAndSubmitForm,
mapAndLabelProps,
errors,
addFeature,
features,
mapAndLabelProps,
schema,
formik,
updateMapKey,
validateAndSubmitForm,
addInitialFeaturesToMap,
} = useMapAndLabelContext();
const {
title,
Expand All @@ -216,36 +222,11 @@ const Root = () => {
previouslySubmittedData,
} = mapAndLabelProps;

const previousFeatures = previouslySubmittedData?.data?.[
fn
] as FeatureCollection;
const [features, setFeatures] = useState<Feature[] | undefined>(
previousFeatures?.features?.length > 0
? previousFeatures.features
: undefined,
);

useEffect(() => {
const geojsonChangeHandler = ({ detail: geojson }: any) => {
if (geojson["EPSG:3857"]?.features) {
setFeatures(geojson["EPSG:3857"].features);
formik.setFieldValue("geoData", geojson["EPSG:3857"].features);
addFeature();
} else {
// if the user clicks 'reset' on the map, geojson will be empty object, so set features to undefined
setFeatures(undefined);
formik.setFieldValue("geoData", undefined);
}
};

const map: HTMLElement | null =
document.getElementById("map-and-label-map");
map?.addEventListener("geojsonChange", geojsonChangeHandler);

return function cleanup() {
map?.removeEventListener("geojsonChange", geojsonChangeHandler);
};
}, [setFeatures, addFeature]);
// If coming "back" or "changing", load initial features & tabs onto the map
// Pre-populating form fields within tabs is handled via formik.initialValues in Context.tsx
if (previouslySubmittedData?.data?.[fn]?.features?.length > 0) {
addInitialFeaturesToMap(previouslySubmittedData?.data?.[fn]?.features);
}

const rootError: string =
(errors.min &&
Expand All @@ -264,12 +245,12 @@ const Root = () => {
howMeasured={howMeasured}
/>
<FullWidthWrapper>
<ErrorWrapper error={rootError}>
<ErrorWrapper error={rootError} key={updateMapKey}>
<MapContainer environment="standalone">
{/* @ts-ignore */}
<my-map
id="map-and-label-map"
data-testid="map-and-label-map"
id={MAP_ID}
data-testid={MAP_ID}
basemap={basemap}
ariaLabelOlFixedOverlay={`An interactive map for plotting and describing individual ${schemaName.toLocaleLowerCase()}`}
drawMode
Expand Down Expand Up @@ -303,7 +284,7 @@ const Root = () => {
</MapContainer>
</ErrorWrapper>
{features && features?.length > 0 ? (
<VerticalFeatureTabs features={features} />
<VerticalFeatureTabs />
) : (
<PlotFeatureToBegin />
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,6 @@ export type SchemaUserResponse = Record<
*/
export type SchemaUserData = {
schemaData: SchemaUserResponse[];
geoData?: Feature[];
};

/**
Expand Down
41 changes: 41 additions & 0 deletions editor.planx.uk/src/lib/gis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Feature } from "geojson";
import { Dispatch, SetStateAction, useEffect, useState } from "react";

type Projection = "EPSG:3857" | "EPSG:27700";

export type GeoJSONChange = Record<Projection, { features: Feature[] }>;
export type GeoJSONChangeEvent = CustomEvent<GeoJSONChange>;

const isGeoJSONChangeEvent = (event: Event): event is GeoJSONChangeEvent => {
return event instanceof CustomEvent;
};

type UseGeoJSONChange = (
mapId: string,
callback: (event: GeoJSONChangeEvent) => void,
) => [Feature[] | undefined, Dispatch<SetStateAction<Feature[] | undefined>>];

/**
* Hook for interacting with @opensystemslab/map
* Assign a callback function to be triggered on the "geojsonChange" event
*/
export const useGeoJSONChange: UseGeoJSONChange = (mapId, callback) => {
const [features, setFeatures] = useState<Feature[] | undefined>(undefined);

useEffect(() => {
const geojsonChangeHandler: EventListener = (event) => {
if (!isGeoJSONChangeEvent(event)) return;

callback(event);
};

const map: HTMLElement | null = document.getElementById(mapId);
map?.addEventListener("geojsonChange", geojsonChangeHandler);

return function cleanup() {
map?.removeEventListener("geojsonChange", geojsonChangeHandler);
};
}, [callback, mapId]);

return [features, setFeatures];
};

0 comments on commit e1bcd7a

Please sign in to comment.