From d4e595f86e8a84cbaddc0e19c8b3d8cc4801d434 Mon Sep 17 00:00:00 2001 From: Stefan Kairinos Date: Thu, 18 Jul 2024 15:25:41 +0100 Subject: [PATCH] fix: Portal frontend#7 (#49) * allow for nested properties * fix validations * forward min and max date * show errors on submit * copy --- src/components/form/AutocompleteField.tsx | 63 +++++++++++++---------- src/components/form/CheckboxField.tsx | 17 +++--- src/components/form/DatePickerField.tsx | 40 ++++++++++---- src/components/form/RepeatField.tsx | 23 ++++++--- src/components/form/SubmitButton.tsx | 33 +++++++++--- src/components/form/TextField.tsx | 39 ++++++++------ src/utils/general.test.ts | 21 ++++++++ src/utils/general.ts | 19 +++++++ 8 files changed, 180 insertions(+), 75 deletions(-) create mode 100644 src/utils/general.test.ts diff --git a/src/components/form/AutocompleteField.tsx b/src/components/form/AutocompleteField.tsx index d6f5595f..073d12ad 100644 --- a/src/components/form/AutocompleteField.tsx +++ b/src/components/form/AutocompleteField.tsx @@ -9,6 +9,7 @@ import { Field, type FieldConfig, type FieldProps } from "formik" import { string as YupString, type ValidateOptions } from "yup" import { schemaToFieldValidator } from "../../utils/form" +import { getNestedProperty } from "../../utils/general" export interface AutocompleteFieldProps< Multiple extends boolean | undefined = false, @@ -60,6 +61,8 @@ const AutocompleteField = < >): JSX.Element => { const { name, required, ...otherTextFieldProps } = textFieldProps + const dotPath = name.split(".") + let schema = YupString().oneOf(options, "not a valid option") if (required) schema = schema.required() @@ -71,34 +74,38 @@ const AutocompleteField = < return ( - {({ form, meta }: FieldProps) => ( - ( - - )} - onChange={(_, value) => { - form.setFieldValue(name, value ?? undefined, true) - }} - onBlur={form.handleBlur} - {...otherAutocompleteProps} - /> - )} + {({ form, meta }: FieldProps) => { + const value = getNestedProperty(form.values, dotPath) + const touched = getNestedProperty(form.touched, dotPath) + const error = getNestedProperty(form.errors, dotPath) + + return ( + ( + + )} + onChange={(_, value) => { + form.setFieldValue(name, value ?? undefined, true) + }} + onBlur={form.handleBlur} + {...otherAutocompleteProps} + /> + ) + }} ) } diff --git a/src/components/form/CheckboxField.tsx b/src/components/form/CheckboxField.tsx index 09d427c7..a7070eb0 100644 --- a/src/components/form/CheckboxField.tsx +++ b/src/components/form/CheckboxField.tsx @@ -11,6 +11,7 @@ import { type FC } from "react" import { bool as YupBool, type ValidateOptions } from "yup" import { schemaToFieldValidator } from "../../utils/form" +import { getNestedProperty } from "../../utils/general" export interface CheckboxFieldProps extends Omit< @@ -31,6 +32,8 @@ const CheckboxField: FC = ({ validateOptions, ...otherCheckboxProps }) => { + const dotPath = name.split(".") + let schema = YupBool() if (required) schema = schema.oneOf([true], errorMessage) @@ -43,18 +46,22 @@ const CheckboxField: FC = ({ return ( {({ form, meta }: FieldProps) => { - const error = form.touched[name] && Boolean(form.errors[name]) + const touched = getNestedProperty(form.touched, dotPath) + const error = getNestedProperty(form.errors, dotPath) + const value = getNestedProperty(form.values, dotPath) + + const hasError = touched && Boolean(error) // https://mui.com/material-ui/react-checkbox/#formgroup return ( - + = ({ } {...formControlLabelProps} /> - {error && ( - {form.errors[name] as string} - )} + {hasError && {error as string}} ) }} diff --git a/src/components/form/DatePickerField.tsx b/src/components/form/DatePickerField.tsx index 70a67eb5..b01a2bcf 100644 --- a/src/components/form/DatePickerField.tsx +++ b/src/components/form/DatePickerField.tsx @@ -11,6 +11,7 @@ import { Field, type FieldConfig, type FieldProps } from "formik" import { date as YupDate, type ValidateOptions } from "yup" import { schemaToFieldValidator } from "../../utils/form" +import { getNestedProperty } from "../../utils/general" export interface DatePickerFieldProps< TDate extends PickerValidDate, @@ -21,8 +22,6 @@ export interface DatePickerFieldProps< > { name: string required?: boolean - min?: string | Date - max?: string | Date validateOptions?: ValidateOptions } @@ -32,18 +31,34 @@ const DatePickerField = < >({ name, required, - min, - max, + minDate, + maxDate, validateOptions, ...otherDatePickerProps }: DatePickerFieldProps< TDate, TEnableAccessibleFieldDOMStructure >): JSX.Element => { + const dotPath = name.split(".") + + function dateToString(date: Dayjs) { + return date.locale("en-gb").format("L") + } + let schema = YupDate() if (required) schema = schema.required() - if (min) schema = schema.min(min) - if (max) schema = schema.max(max) + if (minDate) { + schema = schema.min( + minDate, + `this field must be after or equal to ${dateToString(minDate)}`, + ) + } + if (maxDate) { + schema = schema.max( + maxDate, + `this field must be before or equal to ${dateToString(maxDate)}`, + ) + } const fieldConfig: FieldConfig = { name, @@ -54,7 +69,10 @@ const DatePickerField = < return ( {({ form }: FieldProps) => { - let value = form.values[name] + const error = getNestedProperty(form.errors, dotPath) + const touched = getNestedProperty(form.touched, dotPath) + let value = getNestedProperty(form.values, dotPath) + value = value ? dayjs(value) : null function handleChange(value: Dayjs | null) { @@ -73,6 +91,8 @@ const DatePickerField = < { const { form } = fieldProps + const dotPath = name.split(".") + const value = getNestedProperty(form.values, dotPath) + + const repeatDotPath = repeatName.split(".") + const repeatValue = getNestedProperty(form.values, repeatDotPath) + const repeatTouched = getNestedProperty(form.touched, repeatDotPath) + const repeatError = getNestedProperty(form.errors, repeatDotPath) + useEffect(() => { - setValue(form.values[name]) - }) + setValue(value) + }, [setValue, value]) return ( ) @@ -78,7 +85,7 @@ const RepeatField: FC = ({ }) => { const [value, setValue] = useState("") - const repeatName = `repeat_${name}` + const repeatName = `${name}_repeat` const fieldConfig: FieldConfig = { name: repeatName, diff --git a/src/components/form/SubmitButton.tsx b/src/components/form/SubmitButton.tsx index 2359f929..10fecdf5 100644 --- a/src/components/form/SubmitButton.tsx +++ b/src/components/form/SubmitButton.tsx @@ -1,21 +1,42 @@ import { Button, type ButtonProps } from "@mui/material" -import { Field, type FieldProps, type FormikProps } from "formik" +import { Field, type FieldProps } from "formik" import type { FC } from "react" export interface SubmitButtonProps - extends Omit { - disabled?: (form: FormikProps) => boolean -} + extends Omit {} const SubmitButton: FC = ({ children = "Submit", - disabled = form => !(form.dirty && form.isValid), ...otherButtonProps }) => { + function getTouched( + values: Record, + touched?: Record, + ) { + touched = touched || {} + for (const key in values) { + const value = values[key] + touched[key] = + value instanceof Object && value.constructor === Object + ? getTouched(value, touched) + : true + } + + return touched + } + return ( {({ form }: FieldProps) => ( - )} diff --git a/src/components/form/TextField.tsx b/src/components/form/TextField.tsx index af0d947c..ed6b46ce 100644 --- a/src/components/form/TextField.tsx +++ b/src/components/form/TextField.tsx @@ -7,6 +7,7 @@ import type { FC } from "react" import { type StringSchema, type ValidateOptions } from "yup" import { schemaToFieldValidator } from "../../utils/form" +import { getNestedProperty } from "../../utils/general" export type TextFieldProps = Omit< MuiTextFieldProps, @@ -33,6 +34,8 @@ const TextField: FC = ({ validateOptions, ...otherTextFieldProps }) => { + const dotPath = name.split(".") + if (required) schema = schema.required() const fieldConfig: FieldConfig = { @@ -43,22 +46,26 @@ const TextField: FC = ({ return ( - {({ form }: FieldProps) => ( - - )} + {({ form }: FieldProps) => { + const value = getNestedProperty(form.values, dotPath) + const error = getNestedProperty(form.errors, dotPath) + const touched = getNestedProperty(form.touched, dotPath) + + return ( + + ) + }} ) } diff --git a/src/utils/general.test.ts b/src/utils/general.test.ts new file mode 100644 index 00000000..681d6bb8 --- /dev/null +++ b/src/utils/general.test.ts @@ -0,0 +1,21 @@ +import { getNestedProperty } from "./general" + +const PERSON = { father: { father: { name: "John" } } } + +test("get a nested property with dot notation", () => { + const name = getNestedProperty(PERSON, "father.father.name") + + expect(name).equal("John") +}) + +test("get a nested property with string array", () => { + const name = getNestedProperty(PERSON, ["father", "father", "name"]) + + expect(name).equal("John") +}) + +test("get a nested property that doesn't exist", () => { + const name = getNestedProperty(PERSON, "mother.mother.name") + + expect(name).toBeUndefined() +}) diff --git a/src/utils/general.ts b/src/utils/general.ts index 4dba99f0..96bd2824 100644 --- a/src/utils/general.ts +++ b/src/utils/general.ts @@ -434,3 +434,22 @@ export const UK_COUNTIES = [ ] as const export type UkCounties = (typeof UK_COUNTIES)[number] + +export function getNestedProperty( + obj: Record, + dotPath: string | string[], +): any { + if (typeof dotPath === "string") dotPath = dotPath.split(".") + + let value: unknown = obj + for (let i = 0; i < dotPath.length; i++) { + value = (value as Record)[dotPath[i]] + if ( + i !== dotPath.length - 1 && + (typeof value !== "object" || value === null) + ) + return + } + + return value +}