Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Basic validation for MapAndLabel #3589

Merged
merged 9 commits into from
Aug 30, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ListComponent {...mockZooProps} />,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -21,14 +18,13 @@ import { PresentationalProps } from ".";
interface MapAndLabelContextValue {
schema: Schema;
activeIndex: number;
saveItem: () => Promise<void>;
editItem: (index: number) => void;
cancelEditItem: () => void;
editFeature: (index: number) => void;
formik: FormikProps<SchemaUserData>;
validateAndSubmitForm: () => void;
isFeatureInvalid: (index: number) => boolean;
addFeature: () => void;
mapAndLabelProps: PresentationalProps;
errors: {
unsavedItem: boolean;
min: boolean;
max: boolean;
};
Expand All @@ -44,13 +40,15 @@ export const MapAndLabelProvider: React.FC<MapAndLabelProviderProps> = (
props,
) => {
const { schema, children, handleSubmit } = props;
const { formikConfig, initialValues: _initialValues } = useSchema({
const { formikConfig, initialValues } = useSchema({
schema,
previousValues: getPreviouslySubmittedData(props),
});

const formik = useFormik<SchemaUserData>({
...formikConfig,
// The user interactions are map driven - start with no values added
initialValues: { schemaData: [] },
onSubmit: (values) => {
const defaultPassportData = makeData(props, values.schemaData)?.["data"];

Expand All @@ -66,73 +64,55 @@ export const MapAndLabelProvider: React.FC<MapAndLabelProviderProps> = (
props.previouslySubmittedData ? -1 : 0,
);

const [activeItemInitialState, setActiveItemInitialState] = useState<
SchemaUserResponse | undefined
>(undefined);

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

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 (
<MapAndLabelContext.Provider
value={{
activeIndex,
saveItem,
schema,
mapAndLabelProps: props,
editItem,
cancelEditItem,
editFeature,
formik,
validateAndSubmitForm,
addFeature,
isFeatureInvalid,
errors: {
unsavedItem: unsavedItemError,
min: minError,
max: maxError,
},
Expand Down
158 changes: 86 additions & 72 deletions editor.planx.uk/src/@planx/components/MapAndLabel/Public/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { FONT_WEIGHT_SEMI_BOLD } from "theme";
import SelectInput from "ui/editor/SelectInput";
import FullWidthWrapper from "ui/public/FullWidthWrapper";
import InputLabel from "ui/public/InputLabel";
import ErrorWrapper from "ui/shared/ErrorWrapper";

import Card from "../../shared/Preview/Card";
import CardHeader from "../../shared/Preview/CardHeader";
Expand All @@ -43,14 +44,8 @@ function a11yProps(index: number) {
const VerticalFeatureTabs: React.FC<{ features: Feature[] }> = ({
features,
}) => {
const { schema, activeIndex, formik } = useMapAndLabelContext();
const [activeTab, setActiveTab] = useState<string>(
features[features.length - 1].properties?.label || "",
);

const handleChange = (_event: React.SyntheticEvent, newValue: string) => {
setActiveTab(newValue);
};
const { schema, activeIndex, formik, editFeature, isFeatureInvalid } =
useMapAndLabelContext();

return (
<Box
Expand All @@ -61,28 +56,36 @@ const VerticalFeatureTabs: React.FC<{ features: Feature[] }> = ({
maxHeight: "fit-content",
}}
>
<TabContext value={activeTab}>
<TabContext value={activeIndex.toString()}>
DafyddLlyr marked this conversation as resolved.
Show resolved Hide resolved
<Tabs
orientation="vertical"
variant="scrollable"
value={activeTab}
onChange={handleChange}
value={activeIndex.toString()}
onChange={(_e, newValue) => {
editFeature(parseInt(newValue, 10));
}}
// TODO!
aria-label="Vertical tabs example"
sx={{ borderRight: 1, borderColor: "divider" }}
>
{features.map((feature, i) => (
<Tab
key={`tab-${i}`}
value={feature.properties?.label}
value={i.toString()}
label={`${schema.type} ${feature.properties?.label}`}
{...a11yProps(i)}
{...(isFeatureInvalid(i) && {
sx: (theme) => ({
backgroundColor: theme.palette.action.focus,
}),
})}
DafyddLlyr marked this conversation as resolved.
Show resolved Hide resolved
/>
))}
</Tabs>
{features.map((feature, i) => (
<TabPanel
key={`tabpanel-${i}`}
value={feature.properties?.label}
value={i.toString()}
sx={{ width: "100%" }}
>
<Box
Expand All @@ -108,35 +111,36 @@ const VerticalFeatureTabs: React.FC<{ features: Feature[] }> = ({
} m²)`}
</Typography>
</Box>
<Box>
<InputLabel label="Copy from" id={`select-${i}`}>
<SelectInput
bordered
required
title={"Copy from"}
labelId={`select-label-${i}`}
value={""}
onChange={() =>
console.log(`TODO - Copy data from another tab`)
}
name={""}
style={{ width: "200px" }}
>
{features
.filter(
(feature) => feature.properties?.label !== activeTab,
)
.map((option) => (
<MenuItem
key={option.properties?.label}
value={option.properties?.label}
>
{`${schema.type} ${option.properties?.label}`}
</MenuItem>
))}
</SelectInput>
</InputLabel>
</Box>
{features.length > 1 && (
DafyddLlyr marked this conversation as resolved.
Show resolved Hide resolved
<Box>
<InputLabel label="Copy from" id={`select-${i}`}>
<SelectInput
bordered
required
title={"Copy from"}
labelId={`select-label-${i}`}
value={""}
onChange={() =>
console.log(`TODO - Copy data from another tab`)
}
name={""}
style={{ width: "200px" }}
>
{/* Iterate over all other features */}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...and only list all other features in the dropdown.

{features
.filter((_, j) => j !== i)
.map((option) => (
<MenuItem
key={option.properties?.label}
value={option.properties?.label}
>
{`${schema.type} ${option.properties?.label}`}
</MenuItem>
))}
</SelectInput>
</InputLabel>
</Box>
)}
</Box>
<SchemaFields
sx={(theme) => ({
Expand Down Expand Up @@ -171,7 +175,8 @@ const VerticalFeatureTabs: React.FC<{ features: Feature[] }> = ({
};

const Root = () => {
const { validateAndSubmitForm, mapAndLabelProps } = useMapAndLabelContext();
const { validateAndSubmitForm, mapAndLabelProps, errors } =
useMapAndLabelContext();
const {
title,
description,
Expand All @@ -188,11 +193,13 @@ const Root = () => {
} = mapAndLabelProps;

const [features, setFeatures] = useState<Feature[] | undefined>(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);
Expand All @@ -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)`) ||
"";
DafyddLlyr marked this conversation as resolved.
Show resolved Hide resolved

return (
<Card handleSubmit={validateAndSubmitForm} isValid>
Expand All @@ -218,34 +230,36 @@ const Root = () => {
howMeasured={howMeasured}
/>
<FullWidthWrapper>
<MapContainer environment="standalone">
{/* @ts-ignore */}
<my-map
id="map-and-label-map"
basemap={basemap}
ariaLabelOlFixedOverlay={`An interactive map for plotting and describing individual ${schemaName.toLocaleLowerCase()}`}
drawMode
drawMany
drawColor={drawColor}
drawType={drawType}
drawPointer="crosshair"
zoom={20}
maxZoom={23}
latitude={latitude}
longitude={longitude}
osProxyEndpoint={`${
import.meta.env.VITE_APP_API_URL
}/proxy/ordnance-survey`}
osCopyright={
basemap === "OSVectorTile"
? `© Crown copyright and database rights ${new Date().getFullYear()} OS (0)100024857`
: ``
}
clipGeojsonData={boundaryBBox && JSON.stringify(boundaryBBox)}
mapboxAccessToken={import.meta.env.VITE_APP_MAPBOX_ACCESS_TOKEN}
collapseAttributions
/>
</MapContainer>
<ErrorWrapper error={rootError}>
<MapContainer environment="standalone">
{/* @ts-ignore */}
<my-map
id="map-and-label-map"
basemap={basemap}
ariaLabelOlFixedOverlay={`An interactive map for plotting and describing individual ${schemaName.toLocaleLowerCase()}`}
drawMode
drawMany
drawColor={drawColor}
drawType={drawType}
drawPointer="crosshair"
zoom={20}
maxZoom={23}
latitude={latitude}
longitude={longitude}
osProxyEndpoint={`${
import.meta.env.VITE_APP_API_URL
}/proxy/ordnance-survey`}
osCopyright={
basemap === "OSVectorTile"
? `© Crown copyright and database rights ${new Date().getFullYear()} OS (0)100024857`
: ``
}
clipGeojsonData={boundaryBBox && JSON.stringify(boundaryBBox)}
mapboxAccessToken={import.meta.env.VITE_APP_MAPBOX_ACCESS_TOKEN}
collapseAttributions
/>
</MapContainer>
</ErrorWrapper>
{features && features?.length > 0 ? (
<VerticalFeatureTabs features={features} />
) : (
Expand Down
Loading