diff --git a/editor.planx.uk/src/@planx/components/List/Public/index.test.tsx b/editor.planx.uk/src/@planx/components/List/Public/index.test.tsx index fed2d67c66..0c0c150221 100644 --- a/editor.planx.uk/src/@planx/components/List/Public/index.test.tsx +++ b/editor.planx.uk/src/@planx/components/List/Public/index.test.tsx @@ -552,7 +552,7 @@ describe("Form validation and error handling", () => { test( "an error displays if the maximum number of items is exceeded", - { timeout: 20000 }, + { timeout: 25000 }, async () => { const { user, getAllByTestId, getByTestId, getByText } = setup( , 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 75b6aabbdf..dd6da0de94 100644 --- a/editor.planx.uk/src/@planx/components/MapAndLabel/Public/Context.tsx +++ b/editor.planx.uk/src/@planx/components/MapAndLabel/Public/Context.tsx @@ -1,14 +1,11 @@ import { useSchema } from "@planx/components/shared/Schema/hook"; -import { - Schema, - SchemaUserData, - SchemaUserResponse, -} from "@planx/components/shared/Schema/model"; +import { Schema, SchemaUserData } from "@planx/components/shared/Schema/model"; import { getPreviouslySubmittedData, makeData, } from "@planx/components/shared/utils"; import { FormikProps, useFormik } from "formik"; +import { get } from "lodash"; import React, { createContext, PropsWithChildren, @@ -21,14 +18,13 @@ import { PresentationalProps } from "."; interface MapAndLabelContextValue { schema: Schema; activeIndex: number; - saveItem: () => Promise; - editItem: (index: number) => void; - cancelEditItem: () => void; + editFeature: (index: number) => void; formik: FormikProps; validateAndSubmitForm: () => void; + isFeatureInvalid: (index: number) => boolean; + addFeature: () => void; mapAndLabelProps: PresentationalProps; errors: { - unsavedItem: boolean; min: boolean; max: boolean; }; @@ -44,13 +40,15 @@ export const MapAndLabelProvider: React.FC = ( props, ) => { const { schema, children, handleSubmit } = props; - const { formikConfig, initialValues: _initialValues } = useSchema({ + const { formikConfig, initialValues } = useSchema({ schema, previousValues: getPreviouslySubmittedData(props), }); const formik = useFormik({ ...formikConfig, + // The user interactions are map driven - start with no values added + initialValues: { schemaData: [] }, onSubmit: (values) => { const defaultPassportData = makeData(props, values.schemaData)?.["data"]; @@ -66,35 +64,16 @@ export const MapAndLabelProvider: React.FC = ( props.previouslySubmittedData ? -1 : 0, ); - const [activeItemInitialState, setActiveItemInitialState] = useState< - SchemaUserResponse | undefined - >(undefined); - - const [unsavedItemError, setUnsavedItemError] = useState(false); const [minError, setMinError] = useState(false); const [maxError, setMaxError] = useState(false); const resetErrors = () => { setMinError(false); setMaxError(false); - setUnsavedItemError(false); - }; - - const saveItem = async () => { - resetErrors(); - - const errors = await formik.validateForm(); - const isValid = !errors.schemaData?.length; - if (isValid) { - exitEditMode(); - } }; const validateAndSubmitForm = () => { - // Do not allow submissions with an unsaved item - if (activeIndex !== -1) return setUnsavedItemError(true); - - // Manually validate minimum number of items + // Manually validate minimum number of features if (formik.values.schemaData.length < schema.min) { return setMinError(true); } @@ -102,37 +81,38 @@ export const MapAndLabelProvider: React.FC = ( formik.handleSubmit(); }; - const cancelEditItem = () => { - if (activeItemInitialState) resetItemToPreviousState(); - - setActiveItemInitialState(undefined); - - exitEditMode(); - }; - - const editItem = (index: number) => { - setActiveItemInitialState(formik.values.schemaData[index]); + const editFeature = (index: number) => { setActiveIndex(index); }; - const exitEditMode = () => setActiveIndex(-1); + const isFeatureInvalid = (index: number) => + Boolean(get(formik.errors, ["schemaData", index])); + + const addFeature = () => { + resetErrors(); + + const currentFeatures = formik.values.schemaData; + const updatedFeatures = [...currentFeatures, initialValues]; + formik.setFieldValue("schemaData", updatedFeatures); - const resetItemToPreviousState = () => - formik.setFieldValue(`schemaData[${activeIndex}]`, activeItemInitialState); + // TODO: Handle more gracefully - stop user from adding new feature to map? + if (schema.max && updatedFeatures.length > schema.max) { + setMaxError(true); + } + }; return ( = ({ features, }) => { - const { schema, activeIndex, formik } = useMapAndLabelContext(); - const [activeTab, setActiveTab] = useState( - features[features.length - 1].properties?.label || "", - ); - - const handleChange = (_event: React.SyntheticEvent, newValue: string) => { - setActiveTab(newValue); - }; + const { schema, activeIndex, formik, editFeature, isFeatureInvalid } = + useMapAndLabelContext(); return ( = ({ maxHeight: "fit-content", }} > - + { + editFeature(parseInt(newValue, 10)); + }} + // TODO! aria-label="Vertical tabs example" sx={{ borderRight: 1, borderColor: "divider" }} > {features.map((feature, i) => ( ({ + backgroundColor: theme.palette.action.focus, + }), + })} /> ))} {features.map((feature, i) => ( = ({ } m²)`} - - - - console.log(`TODO - Copy data from another tab`) - } - name={""} - style={{ width: "200px" }} - > - {features - .filter( - (feature) => feature.properties?.label !== activeTab, - ) - .map((option) => ( - - {`${schema.type} ${option.properties?.label}`} - - ))} - - - + {features.length > 1 && ( + + + + console.log(`TODO - Copy data from another tab`) + } + name={""} + style={{ width: "200px" }} + > + {/* Iterate over all other features */} + {features + .filter((_, j) => j !== i) + .map((option) => ( + + {`${schema.type} ${option.properties?.label}`} + + ))} + + + + )} ({ @@ -171,7 +175,8 @@ const VerticalFeatureTabs: React.FC<{ features: Feature[] }> = ({ }; const Root = () => { - const { validateAndSubmitForm, mapAndLabelProps } = useMapAndLabelContext(); + const { validateAndSubmitForm, mapAndLabelProps, errors } = + useMapAndLabelContext(); const { title, description, @@ -188,11 +193,13 @@ const Root = () => { } = mapAndLabelProps; const [features, setFeatures] = useState(undefined); + const { addFeature, schema } = useMapAndLabelContext(); useEffect(() => { const geojsonChangeHandler = ({ detail: geojson }: any) => { if (geojson["EPSG:3857"]?.features) { setFeatures(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); @@ -206,7 +213,12 @@ const Root = () => { return function cleanup() { map?.removeEventListener("geojsonChange", geojsonChangeHandler); }; - }, [setFeatures]); + }, [setFeatures, addFeature]); + + const rootError: string = + (errors.min && `You must provide at least ${schema.min} response(s)`) || + (errors.max && `You can provide at most ${schema.max} response(s)`) || + ""; return ( @@ -218,34 +230,36 @@ const Root = () => { howMeasured={howMeasured} /> - - {/* @ts-ignore */} - - + + + {/* @ts-ignore */} + + + {features && features?.length > 0 ? ( ) : (