diff --git a/doc/how-to/how-to-add-a-list-component-schema.md b/doc/how-to/how-to-add-a-list-component-schema.md index fc1b712f72..fe40164956 100644 --- a/doc/how-to/how-to-add-a-list-component-schema.md +++ b/doc/how-to/how-to-add-a-list-component-schema.md @@ -12,7 +12,7 @@ The ideal maintainers of these schemas are still the services team though, rathe 2. **GitHub** - In the `.ts` file, ensure the schema has this basic structure: ```ts -import { Schema } from "@planx/components/List/model"; +import { Schema } from "@planx/component/shared/Schema/model"; export const YourSchemasName: Schema = { type: "Title (singular if no max, plural if max = 1)", diff --git a/editor.planx.uk/src/@planx/components/List/Public/Context.tsx b/editor.planx.uk/src/@planx/components/List/Public/Context.tsx index 8d7f033828..bf50c5f481 100644 --- a/editor.planx.uk/src/@planx/components/List/Public/Context.tsx +++ b/editor.planx.uk/src/@planx/components/List/Public/Context.tsx @@ -1,3 +1,9 @@ +import { useSchema } from "@planx/components/shared/Schema/hook"; +import { + Schema, + SchemaUserData, + SchemaUserResponse, +} from "@planx/components/shared/Schema/model"; import { getPreviouslySubmittedData, makeData, @@ -11,14 +17,7 @@ import React, { useState, } from "react"; -import { - generateInitialValues, - generateValidationSchema, - List, - Schema, - UserData, - UserResponse, -} from "../model"; +import { List } from "../model"; import { flatten, sumIdenticalUnits, @@ -33,7 +32,7 @@ interface ListContextValue { removeItem: (index: number) => void; editItem: (index: number) => void; cancelEditItem: () => void; - formik: FormikProps; + formik: FormikProps; validateAndSubmitForm: () => void; listProps: PublicProps; /** @@ -58,13 +57,57 @@ const ListContext = createContext(undefined); export const ListProvider: React.FC = (props) => { const { schema, children, handleSubmit } = props; + const { formikConfig, initialValues } = useSchema({ + schema, + previousValues: getPreviouslySubmittedData(props), + }); + + const formik = useFormik({ + ...formikConfig, + onSubmit: (values) => { + // defaultPassportData (array) is used when coming "back" + const defaultPassportData = makeData(props, values.schemaData)?.["data"]; + + // flattenedPassportData makes individual list items compatible with Calculate components + const flattenedPassportData = flatten(defaultPassportData, { depth: 2 }); + + // basic example of general summary stats we can add onSubmit: + // 1. count of items/responses + // 2. if the schema includes a field that sets fn = "identicalUnits", sum of total units + // 3. if the schema includes a field that sets fn = "development" & fn = "identicalUnits", sum of total units by development "val" + const totalUnits = sumIdenticalUnits(props.fn, defaultPassportData); + const totalUnitsByDevelopmentType = sumIdenticalUnitsByDevelopmentType( + props.fn, + defaultPassportData, + ); + + const summaries = { + [`${props.fn}.total.listItems`]: + defaultPassportData[`${props.fn}`].length, + ...(totalUnits > 0 && { + [`${props.fn}.total.units`]: totalUnits, + }), + ...(totalUnits > 0 && + Object.keys(totalUnitsByDevelopmentType).length > 0 && + totalUnitsByDevelopmentType), + }; + + handleSubmit?.({ + data: { + ...defaultPassportData, + ...flattenedPassportData, + ...summaries, + }, + }); + }, + }); const [activeIndex, setActiveIndex] = useState( props.previouslySubmittedData ? -1 : 0, ); const [activeItemInitialState, setActiveItemInitialState] = useState< - UserResponse | undefined + SchemaUserResponse | undefined >(undefined); const [addItemError, setAddItemError] = useState(false); @@ -85,21 +128,21 @@ export const ListProvider: React.FC = (props) => { if (activeIndex !== -1) return setAddItemError(true); // Do not allow new item to be added if it will exceed max - if (schema.max && formik.values.userData.length === schema.max) { + if (schema.max && formik.values.schemaData.length === schema.max) { return setMaxError(true); } // Add new item, and set to active setAddItemError(false); - formik.values.userData.push(generateInitialValues(schema)); - setActiveIndex(formik.values.userData.length - 1); + formik.values.schemaData.push(initialValues); + setActiveIndex(formik.values.schemaData.length - 1); }; const saveItem = async () => { resetErrors(); const errors = await formik.validateForm(); - const isValid = !errors.userData?.length; + const isValid = !errors.schemaData?.length; if (isValid) { exitEditMode(); setAddItemError(false); @@ -114,10 +157,10 @@ export const ListProvider: React.FC = (props) => { setActiveIndex((prev) => (prev === -1 ? 0 : prev - 1)); } - // Remove item from userData + // Remove item from schemaData formik.setFieldValue( - "userData", - formik.values.userData.filter((_, i) => i !== index), + "schemaData", + formik.values.schemaData.filter((_, i) => i !== index), ); }; @@ -126,7 +169,7 @@ export const ListProvider: React.FC = (props) => { if (activeIndex !== -1) return setUnsavedItemError(true); // Manually validate minimum number of items - if (formik.values.userData.length < schema.min) { + if (formik.values.schemaData.length < schema.min) { return setMinError(true); } @@ -144,69 +187,17 @@ export const ListProvider: React.FC = (props) => { }; const editItem = (index: number) => { - setActiveItemInitialState(formik.values.userData[index]); + setActiveItemInitialState(formik.values.schemaData[index]); setActiveIndex(index); }; - const getInitialValues = () => { - const previousValues = getPreviouslySubmittedData(props); - if (previousValues) return previousValues; - - return schema.min ? [generateInitialValues(schema)] : []; - }; - const exitEditMode = () => setActiveIndex(-1); const resetItemToPreviousState = () => - formik.setFieldValue(`userData[${activeIndex}]`, activeItemInitialState); + formik.setFieldValue(`schemaData[${activeIndex}]`, activeItemInitialState); const isPageComponent = schema.max === 1; - const formik = useFormik({ - initialValues: { - userData: getInitialValues(), - }, - onSubmit: (values) => { - // defaultPassportData (array) is used when coming "back" - const defaultPassportData = makeData(props, values.userData)?.["data"]; - - // flattenedPassportData makes individual list items compatible with Calculate components - const flattenedPassportData = flatten(defaultPassportData, { depth: 2 }); - - // basic example of general summary stats we can add onSubmit: - // 1. count of items/responses - // 2. if the schema includes a field that sets fn = "identicalUnits", sum of total units - // 3. if the schema includes a field that sets fn = "development" & fn = "identicalUnits", sum of total units by development "val" - const totalUnits = sumIdenticalUnits(props.fn, defaultPassportData); - const totalUnitsByDevelopmentType = sumIdenticalUnitsByDevelopmentType( - props.fn, - defaultPassportData, - ); - - const summaries = { - [`${props.fn}.total.listItems`]: - defaultPassportData[`${props.fn}`].length, - ...(totalUnits > 0 && { - [`${props.fn}.total.units`]: totalUnits, - }), - ...(totalUnits > 0 && - Object.keys(totalUnitsByDevelopmentType).length > 0 && - totalUnitsByDevelopmentType), - }; - - handleSubmit?.({ - data: { - ...defaultPassportData, - ...flattenedPassportData, - ...summaries, - }, - }); - }, - validateOnBlur: false, - validateOnChange: false, - validationSchema: generateValidationSchema(schema), - }); - return ( = T & { id: string }; - -export const TextFieldInput: React.FC> = ({ id, data }) => { - const { formik, activeIndex } = useListContext(); - - return ( - - { - if (type === "email") return "email"; - else if (type === "phone") return "tel"; - return "text"; - })(data.type)} - multiline={data.type && ["long", "extraLong"].includes(data.type)} - bordered - value={formik.values.userData[activeIndex][data.fn]} - onChange={formik.handleChange} - errorMessage={get(formik.errors, ["userData", activeIndex, data.fn])} - id={id} - rows={ - data.type && ["long", "extraLong"].includes(data.type) ? 5 : undefined - } - name={`userData[${activeIndex}]['${data.fn}']`} - required - inputProps={{ - "aria-describedby": [ - data.description ? DESCRIPTION_TEXT : "", - get(formik.errors, ["userData", activeIndex, data.fn]) - ? `${ERROR_MESSAGE}-${id}` - : "", - ] - .filter(Boolean) - .join(" "), - }} - /> - - ); -}; - -export const NumberFieldInput: React.FC> = ({ - id, - data, -}) => { - const { formik, activeIndex } = useListContext(); - - return ( - - - - {data.units && {data.units}} - - - ); -}; - -export const RadioFieldInput: React.FC> = (props) => { - const { formik, activeIndex } = useListContext(); - const { id, data } = props; - - return ( - - ({ - color: theme.palette.text.primary, - "&.Mui-focused": { - color: theme.palette.text.primary, - }, - })} - > - {data.title} - - - - {data.options.map(({ id, data }) => ( - - ))} - - - - ); -}; - -export const SelectFieldInput: React.FC> = (props) => { - const { formik, activeIndex } = useListContext(); - const { id, data } = props; - - return ( - - - - {data.options.map((option) => ( - - {option.data.text} - - ))} - - - - ); -}; - -export const ChecklistFieldInput: React.FC> = (props) => { - const { formik, activeIndex } = useListContext(); - const { - id, - data: { options, title, fn }, - } = props; - - const changeCheckbox = - (id: string) => - async ( - _checked: React.MouseEvent | undefined, - ) => { - let newCheckedIds; - - if (formik.values.userData[activeIndex][fn].includes(id)) { - newCheckedIds = ( - formik.values.userData[activeIndex][fn] as string[] - ).filter((x) => x !== id); - } else { - newCheckedIds = [...formik.values.userData[activeIndex][fn], id]; - } - - await formik.setFieldValue( - `userData[${activeIndex}]['${fn}']`, - newCheckedIds, - ); - }; - - return ( - - - - {title} - {options.map((option) => ( - - ))} - - - - ); -}; - -export const DateFieldInput: React.FC> = ({ id, data }) => { - const { formik, activeIndex } = useListContext(); - - return ( - - - { - formik.setFieldValue( - `userData[${activeIndex}]['${data.fn}']`, - paddedDate(newDate, eventType), - ); - }} - error={get(formik.errors, ["userData", activeIndex, data.fn])} - id={id} - /> - - - ); -}; - -export const MapFieldInput: React.FC> = (props) => { - const { formik, activeIndex, schema } = useListContext(); - const { - id, - data: { title, fn, mapOptions }, - } = props; - - const teamSettings = useStore.getState().teamSettings; - const passport = useStore((state) => state.computePassport()); - - const [_features, setFeatures] = useState(undefined); - - useEffect(() => { - const geojsonChangeHandler = async ({ detail: geojson }: any) => { - if (geojson["EPSG:3857"]?.features) { - setFeatures(geojson["EPSG:3857"].features); - await formik.setFieldValue( - `userData[${activeIndex}]['${fn}']`, - geojson["EPSG:3857"].features, - ); - } else { - // if the user clicks 'reset' on the map, geojson will be empty object, so set features to undefined - setFeatures(undefined); - await formik.setFieldValue( - `userData[${activeIndex}]['${fn}']`, - undefined, - ); - } - }; - - const map: any = document.getElementById(id); - - map?.addEventListener("geojsonChange", geojsonChangeHandler); - - return function cleanup() { - map?.removeEventListener("geojsonChange", geojsonChangeHandler); - }; - }, [setFeatures]); - - return ( - - - - {/* @ts-ignore */} - - - - - ); -}; diff --git a/editor.planx.uk/src/@planx/components/List/Public/index.tsx b/editor.planx.uk/src/@planx/components/List/Public/index.tsx index 56cfe6984d..e12ee74d90 100644 --- a/editor.planx.uk/src/@planx/components/List/Public/index.tsx +++ b/editor.planx.uk/src/@planx/components/List/Public/index.tsx @@ -8,26 +8,17 @@ import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; import TableRow from "@mui/material/TableRow"; import Typography from "@mui/material/Typography"; +import { SchemaFields } from "@planx/components/shared/Schema/SchemaFields"; import { PublicProps } from "@planx/components/ui"; import React, { useEffect, useRef } from "react"; import { FONT_WEIGHT_SEMI_BOLD } from "theme"; import ErrorWrapper from "ui/shared/ErrorWrapper"; -import InputRow from "ui/shared/InputRow"; import Card from "../../shared/Preview/Card"; import CardHeader from "../../shared/Preview/CardHeader"; -import type { Field, List } from "../model"; +import type { List } from "../model"; import { formatSchemaDisplayValue } from "../utils"; import { ListProvider, useListContext } from "./Context"; -import { - ChecklistFieldInput, - MapFieldInput, - DateFieldInput, - NumberFieldInput, - RadioFieldInput, - SelectFieldInput, - TextFieldInput, -} from "./Fields"; export type Props = PublicProps; @@ -46,35 +37,10 @@ const CardButton = styled(Button)(({ theme }) => ({ gap: theme.spacing(2), })); -/** - * Controller to return correct user input for field in schema - */ -const InputField: React.FC = (props) => { - const inputFieldId = `input-${props.type}-${props.data.fn}`; - - switch (props.type) { - case "text": - return ; - case "number": - return ; - case "question": - if (props.data.options.length === 2) { - return ; - } - return ; - case "checklist": - return ; - case "date": - return ; - case "map": - return ; - } -}; - const ActiveListCard: React.FC<{ index: number; }> = ({ index: i }) => { - const { schema, saveItem, cancelEditItem, errors, isPageComponent } = + const { schema, saveItem, cancelEditItem, errors, isPageComponent, formik, activeIndex } = useListContext(); const ref = useRef(null); @@ -93,11 +59,7 @@ const ActiveListCard: React.FC<{ {schema.type} {!isPageComponent && ` ${i + 1}`} - {schema.fields.map((field, i) => ( - - - - ))} +