From d73f42ba8aa41cce154dffa823c421ec503af32a Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Thu, 26 Feb 2026 08:41:58 -0500 Subject: [PATCH 1/9] refactor: migrate forms from Formik to React Hook Form with Zod validation - Updated Textarea and ToggleInput components to use React Hook Form. - Refactored ProfileForm, SettingsForm, SignInForm, and UserForm components to implement React Hook Form with Zod for validation. - Removed Formik dependencies and validation schemas in favor of Zod schemas. - Adjusted form submission logic to align with the new form handling approach. - Enhanced user experience by ensuring form states are managed correctly during submissions. --- package-lock.json | 36 +++ package.json | 2 + src/common/components/Input/CheckboxInput.tsx | 47 ++- src/common/components/Input/DateInput.tsx | 41 +-- src/common/components/Input/DatetimeInput.tsx | 43 +-- src/common/components/Input/Input.tsx | 78 ++--- .../components/Input/RadioGroupInput.tsx | 39 +-- src/common/components/Input/RangeInput.tsx | 38 +-- src/common/components/Input/SelectInput.tsx | 44 +-- src/common/components/Input/Textarea.tsx | 90 +++--- src/common/components/Input/ToggleInput.tsx | 30 +- .../components/Profile/ProfileForm.tsx | 231 +++++++-------- .../components/Settings/SettingsForm.tsx | 270 ++++++++++-------- .../Auth/SignIn/components/SignInForm.tsx | 212 +++++++------- .../Users/components/UserAdd/UserAddModal.tsx | 4 +- .../Users/components/UserEdit/UserEdit.tsx | 4 +- .../Users/components/UserForm/UserForm.tsx | 170 +++++------ 17 files changed, 759 insertions(+), 620 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2aa550d..0ec4577 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@fortawesome/fontawesome-svg-core": "7.2.0", "@fortawesome/free-solid-svg-icons": "7.2.0", "@fortawesome/react-fontawesome": "3.2.0", + "@hookform/resolvers": "5.2.2", "@ionic/react": "8.7.17", "@ionic/react-router": "8.7.17", "@tanstack/react-query": "5.90.21", @@ -33,6 +34,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-error-boundary": "6.1.1", + "react-hook-form": "7.71.2", "react-i18next": "16.5.4", "react-router": "5.3.4", "react-router-dom": "5.3.4", @@ -3051,6 +3053,18 @@ "react": "^18.0.0 || ^19.0.0" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -4262,6 +4276,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@stencil/core": { "version": "4.38.0", "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.38.0.tgz", @@ -11871,6 +11891,22 @@ "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==", "license": "MIT" }, + "node_modules/react-hook-form": { + "version": "7.71.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", + "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-i18next": { "version": "16.5.4", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz", diff --git a/package.json b/package.json index 14789fa..ebd92f8 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@fortawesome/fontawesome-svg-core": "7.2.0", "@fortawesome/free-solid-svg-icons": "7.2.0", "@fortawesome/react-fontawesome": "3.2.0", + "@hookform/resolvers": "5.2.2", "@ionic/react": "8.7.17", "@ionic/react-router": "8.7.17", "@tanstack/react-query": "5.90.21", @@ -50,6 +51,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-error-boundary": "6.1.1", + "react-hook-form": "7.71.2", "react-i18next": "16.5.4", "react-router": "5.3.4", "react-router-dom": "5.3.4", diff --git a/src/common/components/Input/CheckboxInput.tsx b/src/common/components/Input/CheckboxInput.tsx index ee80670..4ac6db5 100644 --- a/src/common/components/Input/CheckboxInput.tsx +++ b/src/common/components/Input/CheckboxInput.tsx @@ -1,6 +1,6 @@ import { ComponentPropsWithoutRef } from 'react'; import { CheckboxCustomEvent, IonCheckbox } from '@ionic/react'; -import { useField } from 'formik'; +import { Control, FieldPath, FieldValues, useController } from 'react-hook-form'; import classNames from 'classnames'; import './CheckboxInput.scss'; @@ -11,15 +11,15 @@ import { PropsWithTestId } from '../types'; * @see {@link PropsWithTestId} * @see {@link IonCheckbox} */ -interface CheckboxInputProps - extends - PropsWithTestId, - Omit, 'name'>, - Required, 'name'>> {} +interface CheckboxInputProps + extends PropsWithTestId, Omit, 'name'> { + control: Control; + name: FieldPath; +} /** * The `CheckboxInput` component renders a standardized `IonCheckbox` which is - * integrated with Formik. + * integrated with React Hook Form. * * CheckboxInput supports two types of field values: `boolean` and `string[]`. * @@ -30,20 +30,21 @@ interface CheckboxInputProps * with the same `name` and a unique `value` property. * * @param {CheckboxInputProps} props - Component properties. - * @returns {JSX.Element} JSX */ -const CheckboxInput = ({ +const CheckboxInput = ({ className, + control, name, onIonChange, testid = 'input-checkbox', - value, ...checkboxProps -}: CheckboxInputProps) => { - const [field, meta, helpers] = useField({ +}: CheckboxInputProps) => { + const { + field, + fieldState: { error, isTouched }, + } = useController({ name, - type: 'checkbox', - value, + control, }); /** @@ -51,25 +52,21 @@ const CheckboxInput = ({ * @param {CheckboxCustomEvent} e - The event. */ const onChange = async (e: CheckboxCustomEvent): Promise => { - if (typeof meta.value === 'boolean') { - await helpers.setValue(e.detail.checked); - } else if (Array.isArray(meta.value)) { - if (e.detail.checked) { - await helpers.setValue([...meta.value, e.detail.value]); - } else { - await helpers.setValue(meta.value.filter((val) => val !== e.detail.value)); - } - } + field.onChange(!field.value); onIonChange?.(e); }; + const errorText: string | undefined = isTouched ? error?.message : undefined; + return ( ); }; diff --git a/src/common/components/Input/DateInput.tsx b/src/common/components/Input/DateInput.tsx index 9e9bf56..77dc470 100644 --- a/src/common/components/Input/DateInput.tsx +++ b/src/common/components/Input/DateInput.tsx @@ -1,7 +1,7 @@ import { ComponentPropsWithoutRef, useMemo, useState } from 'react'; import { ModalCustomEvent } from '@ionic/core'; import { DatetimeCustomEvent, IonButton, IonDatetime, IonInput, IonModal } from '@ionic/react'; -import { useField } from 'formik'; +import { Control, FieldPath, FieldValues, useController } from 'react-hook-form'; import classNames from 'classnames'; import dayjs from 'dayjs'; @@ -34,38 +34,47 @@ type DateValue = string | null; * @see {@link IonInput} * @see {@link IonModal} */ -interface DateInputProps +interface DateInputProps extends PropsWithTestId, Pick, 'label' | 'labelPlacement'>, Pick, 'onIonModalDidDismiss'>, - Omit, 'multiple' | 'name' | 'presentation'>, - Required, 'name'>> {} + Omit, 'multiple' | 'name' | 'presentation'> { + control: Control; + name: FieldPath; +} /** * The `DateInput` component renders an `IonDatetime` which is integrated with - * Formik. The form field value is displayed in an `IonInput`. When that input + * React Hook Form. The form field value is displayed in an `IonInput`. When that input * is clicked, an `IonDatetime` is presented within an `IonModal`. * * Use this component when you need to collect a date value within a form. The * date value will be set as an ISO8601 date, e.g. YYYY-MM-DD * * @param {DateInputProps} props - Component properties. - * @returns {JSX.Element} JSX */ -const DateInput = ({ +const DateInput = ({ className, + control, + name, label, labelPlacement, onIonModalDidDismiss, testid = 'input-date', ...datetimeProps -}: DateInputProps) => { - const [field, meta, helpers] = useField(datetimeProps.name); +}: DateInputProps) => { + const { + field, + fieldState: { error, isTouched }, + } = useController({ + name, + control, + }); const [isOpen, setIsOpen] = useState(false); // populate error text only if the field has been touched and has an error - const errorText: string | undefined = meta.touched ? meta.error : undefined; + const errorText: string | undefined = isTouched ? error?.message : undefined; /** * Handle change events emitted by `IonDatetime`. @@ -75,9 +84,9 @@ const DateInput = ({ const value = e.detail.value as DateValue; if (value) { const isoDate = dayjs(value).format('YYYY-MM-DD'); - await helpers.setValue(isoDate, true); + field.onChange(isoDate); } else { - await helpers.setValue(null, true); + field.onChange(null); } datetimeProps.onIonChange?.(e); }; @@ -86,7 +95,7 @@ const DateInput = ({ * Handle 'did dismiss' events emitted by `IonModal`. */ const onDidDismiss = async (e: ModalCustomEvent): Promise => { - await helpers.setTouched(true, true); + field.onBlur(); setIsOpen(false); onIonModalDidDismiss?.(e); }; @@ -117,9 +126,9 @@ const DateInput = ({ className={classNames( 'ls-date-input', className, - { 'ion-touched': meta.touched }, - { 'ion-invalid': meta.error }, - { 'ion-valid': meta.touched && !meta.error }, + { 'ion-touched': isTouched }, + { 'ion-invalid': error }, + { 'ion-valid': isTouched && !error }, )} data-testid={testid} disabled={datetimeProps.disabled} diff --git a/src/common/components/Input/DatetimeInput.tsx b/src/common/components/Input/DatetimeInput.tsx index 6318386..42467b8 100644 --- a/src/common/components/Input/DatetimeInput.tsx +++ b/src/common/components/Input/DatetimeInput.tsx @@ -1,7 +1,7 @@ import { ComponentPropsWithoutRef, useMemo, useState } from 'react'; import { ModalCustomEvent } from '@ionic/core'; import { DatetimeCustomEvent, IonButton, IonDatetime, IonInput, IonModal } from '@ionic/react'; -import { useField } from 'formik'; +import { Control, FieldPath, FieldValues, useController } from 'react-hook-form'; import classNames from 'classnames'; import dayjs from 'dayjs'; @@ -45,38 +45,47 @@ type DatetimeValue = string | null; * @see {@link IonInput} * @see {@link IonModal} */ -interface DatetimeInputProps +interface DatetimeInputProps extends PropsWithTestId, Pick, 'label' | 'labelPlacement'>, Pick, 'onIonModalDidDismiss'>, - Omit, 'multiple' | 'name' | 'presentation'>, - Required, 'name'>> {} + Omit, 'multiple' | 'name' | 'presentation'> { + control: Control; + name: FieldPath; +} /** * The `DatetimeInput` component renders an `IonDatetime` which is integrated with - * Formik. The form field value is displayed in an `IonInput`. When that input + * React Hook Form. The form field value is displayed in an `IonInput`. When that input * is clicked, an `IonDatetime` is presented within an `IonModal`. * * Use this component when you need to collect a date and time, a timestamp, * value within a form. The value will be set as an ISO8601 timestamp. * - * @param {DateInputProps} props - Component properties. - * @returns {JSX.Element} JSX + * @param {DatetimeInputProps} props - Component properties. */ -const DatetimeInput = ({ +const DatetimeInput = ({ className, + control, label, labelPlacement, + name, onIonModalDidDismiss, testid = 'input-datetime', ...datetimeProps -}: DatetimeInputProps) => { - const [field, meta, helpers] = useField(datetimeProps.name); +}: DatetimeInputProps) => { + const { + field, + fieldState: { error, isTouched }, + } = useController({ + name, + control, + }); const [isOpen, setIsOpen] = useState(false); // populate error text only if the field has been touched and has an error - const errorText: string | undefined = meta.touched ? meta.error : undefined; + const errorText: string | undefined = isTouched ? error?.message : undefined; /** * Handle change events emitted by `IonDatetime`. @@ -86,9 +95,9 @@ const DatetimeInput = ({ const value = e.detail.value as DatetimeValue; if (value) { const isoDate = dayjs(value).toISOString(); - await helpers.setValue(isoDate, true); + field.onChange(isoDate); } else { - await helpers.setValue(null, true); + field.onChange(null); } datetimeProps.onIonChange?.(e); }; @@ -97,7 +106,7 @@ const DatetimeInput = ({ * Handle 'did dismiss' events emitted by `IonModal`. */ const onDidDismiss = async (e: ModalCustomEvent): Promise => { - await helpers.setTouched(true, true); + field.onBlur(); setIsOpen(false); onIonModalDidDismiss?.(e); }; @@ -129,9 +138,9 @@ const DatetimeInput = ({ className={classNames( 'ls-datetime-input', className, - { 'ion-touched': meta.touched }, - { 'ion-invalid': meta.error }, - { 'ion-valid': meta.touched && !meta.error }, + { 'ion-touched': isTouched }, + { 'ion-invalid': error }, + { 'ion-valid': isTouched && !error }, )} data-testid={testid} disabled={datetimeProps.disabled} diff --git a/src/common/components/Input/Input.tsx b/src/common/components/Input/Input.tsx index b25084f..215cdbc 100644 --- a/src/common/components/Input/Input.tsx +++ b/src/common/components/Input/Input.tsx @@ -1,56 +1,66 @@ -import { InputInputEventDetail, IonInput } from '@ionic/react'; +import { forwardRef } from 'react'; +import { IonInput } from '@ionic/react'; +import { Control, FieldValues, useController, FieldPath } from 'react-hook-form'; import classNames from 'classnames'; import { BaseComponentProps } from '../types'; -import { useField } from 'formik'; -import { forwardRef } from 'react'; /** * Properties for the `Input` component. * @see {@link BaseComponentProps} * @see {@link IonInput} */ -interface InputProps - extends - BaseComponentProps, - Omit, 'name'>, - Required, 'name'>> {} +interface InputProps + extends BaseComponentProps, Omit, 'name'> { + control: Control; + name: FieldPath; +} /** * The `Input` component renders a standardized `IonInput` which is integrated - * with Formik. + * with React Hook Form. * * Optionally accepts a forwarded `ref` which allows the parent to manipulate * the input, performing actions programmatically such as giving focus. * * @param {InputProps} props - Component properties. * @param {ForwardedRef} [ref] - Optional. A forwarded `ref`. - * @returns {JSX.Element} JSX */ -const Input = forwardRef( - ({ className, testid = 'input', ...props }: InputProps, ref) => { - const [field, meta, helpers] = useField(props.name); - const errorText: string | undefined = meta.touched ? meta.error : undefined; +const InputComponent = ( + { className, control, name, testid = 'input', ...props }: InputProps, + ref: React.ForwardedRef, +) => { + const { + field, + fieldState: { error, isTouched }, + } = useController({ + name, + control, + }); + + const errorText: string | undefined = isTouched ? error?.message : undefined; + + return ( + + ); +}; - return ( - ) => await helpers.setValue(e.detail.value)} - data-testid={testid} - {...field} - {...props} - errorText={errorText} - ref={ref} - > - ); - }, -); -Input.displayName = 'Input'; +const Input = forwardRef(InputComponent) as ( + props: InputProps & { ref?: React.ForwardedRef }, +) => React.ReactElement; export default Input; diff --git a/src/common/components/Input/RadioGroupInput.tsx b/src/common/components/Input/RadioGroupInput.tsx index 652d8bc..abc636a 100644 --- a/src/common/components/Input/RadioGroupInput.tsx +++ b/src/common/components/Input/RadioGroupInput.tsx @@ -1,6 +1,6 @@ import { ComponentPropsWithoutRef } from 'react'; import { IonRadioGroup, IonText, RadioGroupCustomEvent } from '@ionic/react'; -import { useField } from 'formik'; +import { Control, FieldPath, FieldValues, useController } from 'react-hook-form'; import classNames from 'classnames'; import './RadioGroupInput.scss'; @@ -11,38 +11,43 @@ import { PropsWithTestId } from '../types'; * @see {@link PropsWithTestId} * @see {@link IonRadioGroup} */ -interface RadioGroupInputProps - extends - PropsWithTestId, - Omit, 'name'>, - Required, 'name'>> {} +interface RadioGroupInputProps + extends PropsWithTestId, Omit, 'name'> { + control: Control; + name: FieldPath; +} /** * The `RadioGroupInput` component renders a standardized `IonRadioGroup` which - * is integrated with Formik. + * is integrated with React Hook Form. * * Use one to many `IonRadio` components as the `children` to specify the * available options. * * @param {RadioGroupInputProps} props - Component properties. - * @returns {JSX.Element} JSX */ -const RadioGroupInput = ({ +const RadioGroupInput = ({ className, + control, name, onIonChange, testid = 'input-radiogroup', ...radioGroupProps -}: RadioGroupInputProps) => { - const [field, meta, helpers] = useField({ name }); +}: RadioGroupInputProps) => { + const { + field, + fieldState: { error }, + } = useController({ + name, + control, + }); /** * Handles changes to the field value as a result of user action. * @param {RadioGroupCustomEvent} event - The event */ const onChange = async (event: RadioGroupCustomEvent): Promise => { - await helpers.setValue(event.detail.value); - await helpers.setTouched(true); + field.onChange(event.detail.value); onIonChange?.(event); }; @@ -50,14 +55,14 @@ const RadioGroupInput = ({
- {!!meta.error && ( + {!!error && ( - {meta.error} + {error.message} )}
diff --git a/src/common/components/Input/RangeInput.tsx b/src/common/components/Input/RangeInput.tsx index e249169..ff92d4f 100644 --- a/src/common/components/Input/RangeInput.tsx +++ b/src/common/components/Input/RangeInput.tsx @@ -1,6 +1,6 @@ import { IonRange, RangeCustomEvent } from '@ionic/react'; import { ComponentPropsWithoutRef } from 'react'; -import { useField } from 'formik'; +import { Control, FieldPath, FieldValues, useController } from 'react-hook-form'; import classNames from 'classnames'; import { PropsWithTestId } from '../types'; @@ -11,38 +11,44 @@ import { PropsWithTestId } from '../types'; * @see {@link PropsWithTestId} * @see {@link IonRange} */ -interface RangeInputProps extends PropsWithTestId, ComponentPropsWithoutRef { - name: string; +interface RangeInputProps + extends PropsWithTestId, Omit, 'name'> { + control: Control; + name: FieldPath; } /** * The `RangeInput` component renders a standardized `IonRange` which is - * integrated with Formik. + * integrated with React Hook Form. * * @param {RangeInputProps} props - Component properties. * @returns {JSX.Element} JSX */ -const RangeInput = ({ className, name, onIonChange, testid = 'input-range', ...rangeProps }: RangeInputProps) => { - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - const [field, meta, helpers] = useField(name); +const RangeInput = ({ + className, + control, + name, + onIonChange, + testid = 'input-range', + ...rangeProps +}: RangeInputProps) => { + const { field } = useController({ + name, + control, + }); const onChange = async (e: RangeCustomEvent) => { - await helpers.setValue(e.detail.value as number); - // add artificial delay to ensure Formik context `values` are updated - // before proceeding; in rare instances where a form is submitted - // from a field change event, the delay is needed - setTimeout(() => { - onIonChange?.(e); - }, 100); + field.onChange(e.detail.value); + onIonChange?.(e); }; return ( ); }; diff --git a/src/common/components/Input/SelectInput.tsx b/src/common/components/Input/SelectInput.tsx index 75a7db9..22bb594 100644 --- a/src/common/components/Input/SelectInput.tsx +++ b/src/common/components/Input/SelectInput.tsx @@ -1,6 +1,6 @@ import { IonSelect, IonText, SelectCustomEvent } from '@ionic/react'; import { ComponentPropsWithoutRef } from 'react'; -import { useField } from 'formik'; +import { Control, FieldPath, FieldValues, useController } from 'react-hook-form'; import classNames from 'classnames'; import './SelectInput.scss'; @@ -11,26 +11,38 @@ import { PropsWithTestId } from '../types'; * @see {@link PropsWithTestId} * @see {@link IonSelect} */ -interface SelectInputProps - extends - PropsWithTestId, - Omit, 'name'>, - Required, 'name'>> {} +interface SelectInputProps + extends PropsWithTestId, Omit, 'name'> { + control: Control; + name: FieldPath; +} /** * The `SelectInput` component renders a standardized wrapper of the `IonSelect` - * component which is integrated with Formik. + * component which is integrated with React Hook Form. * * Accepts a collection of `IonSelectOption` components as `children`. * * @param {SelectInputProps} props - Component properties. - * @returns {JSX.Element} JSX */ -const SelectInput = ({ className, name, onIonChange, testid = 'input-select', ...selectProps }: SelectInputProps) => { - const [field, meta, helpers] = useField(name); +const SelectInput = ({ + className, + control, + name, + onIonChange, + testid = 'input-select', + ...selectProps +}: SelectInputProps) => { + const { + field, + fieldState: { error, isTouched }, + } = useController({ + name, + control, + }); const onChange = async (e: SelectCustomEvent) => { - await helpers.setValue(e.detail.value); + field.onChange(e.detail.value); onIonChange?.(e); }; @@ -40,18 +52,18 @@ const SelectInput = ({ className, name, onIonChange, testid = 'input-select', .. className={classNames( 'ls-select-input__select', className, - { 'ion-touched': meta.touched }, - { 'ion-invalid': meta.error }, - { 'ion-valid': meta.touched && !meta.error }, + { 'ion-touched': isTouched }, + { 'ion-invalid': error }, + { 'ion-valid': isTouched && !error }, )} onIonChange={onChange} data-testid={testid} {...field} {...selectProps} > - {meta.error && ( + {error && ( - {meta.error} + {error.message} )} diff --git a/src/common/components/Input/Textarea.tsx b/src/common/components/Input/Textarea.tsx index 45aed25..11db340 100644 --- a/src/common/components/Input/Textarea.tsx +++ b/src/common/components/Input/Textarea.tsx @@ -1,6 +1,6 @@ import { IonTextarea, TextareaCustomEvent } from '@ionic/react'; import { ComponentPropsWithoutRef, forwardRef } from 'react'; -import { useField } from 'formik'; +import { Control, FieldPath, FieldValues, useController } from 'react-hook-form'; import classNames from 'classnames'; import { PropsWithTestId } from '../types'; @@ -10,55 +10,65 @@ import { PropsWithTestId } from '../types'; * @see {@link PropsWithTestId} * @see {@link IonTextarea} */ -interface TextareaProps - extends - PropsWithTestId, - Omit, 'name'>, - Required, 'name'>> {} +interface TextareaProps + extends PropsWithTestId, Omit, 'name'> { + control: Control; + name: FieldPath; +} /** * The `Textarea` component renders a standardized `IonTextarea` which is - * integrated with Formik. + * integrated with React Hook Form. * * Optionally accepts a forwarded `ref` which allows the parent to manipulate * the textarea, performing actions programmatically such as giving focus. * * @param {TextareaProps} props - Component properties. - * @returns {JSX.Element} JSX */ -const Textarea = forwardRef( - ({ className, onIonInput, testid = 'textarea', ...textareaProps }: TextareaProps, ref) => { - const [field, meta, helpers] = useField(textareaProps.name); +const TextareaComponent = ( + { className, control, name, onIonInput, testid = 'textarea', ...textareaProps }: TextareaProps, + ref: React.ForwardedRef, +) => { + const { + field, + fieldState: { error, isTouched }, + } = useController({ + name, + control, + }); - /** - * Handle changes to the textarea's value. Updates the Formik field state. - * Calls the supplied `onIonInput` props function if one was provided. - * @param {TextareaCustomEvent} e - The event. - */ - const onInput = async (e: TextareaCustomEvent) => { - await helpers.setValue(e.detail.value); - onIonInput?.(e); - }; + /** + * Handle changes to the textarea's value. Updates the Formik field state. + * Calls the supplied `onIonInput` props function if one was provided. + * @param {TextareaCustomEvent} e - The event. + */ + const onInput = async (e: TextareaCustomEvent) => { + field.onChange(e.detail.value); + onIonInput?.(e); + }; - return ( - - ); - }, -); -Textarea.displayName = 'Textarea'; + return ( + + ); +}; + +const Textarea = forwardRef(TextareaComponent) as ( + props: TextareaProps & { ref?: React.ForwardedRef }, +) => React.ReactElement; export default Textarea; diff --git a/src/common/components/Input/ToggleInput.tsx b/src/common/components/Input/ToggleInput.tsx index 5e2e443..bcb9710 100644 --- a/src/common/components/Input/ToggleInput.tsx +++ b/src/common/components/Input/ToggleInput.tsx @@ -1,6 +1,6 @@ import { IonToggle, ToggleChangeEventDetail, ToggleCustomEvent } from '@ionic/react'; import { ComponentPropsWithoutRef } from 'react'; -import { useField } from 'formik'; +import { Control, FieldPath, FieldValues, useController } from 'react-hook-form'; import classNames from 'classnames'; import { PropsWithTestId } from '../types'; @@ -11,23 +11,33 @@ import { PropsWithTestId } from '../types'; * @see {@link PropsWithTestId} * @see {@link IonToggle} */ -interface ToggleInputProps extends PropsWithTestId, ComponentPropsWithoutRef { - name: string; +interface ToggleInputProps + extends PropsWithTestId, Omit, 'name'> { + control: Control; + name: FieldPath; } /** * The `ToggleInput` component renders a standardized `IonToggle` which is - * integrated with Formik. + * integrated with React Hook Form. * * @param {ToggleInputProps} props - Component properties. - * @returns {JSX.Element} JSX */ -const ToggleInput = ({ className, name, onIonChange, testid = 'input-toggle', ...toggleProps }: ToggleInputProps) => { - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - const [field, meta, helpers] = useField(name); +const ToggleInput = ({ + className, + control, + name, + onIonChange, + testid = 'input-toggle', + ...toggleProps +}: ToggleInputProps) => { + const { field } = useController({ + name, + control, + }); const onChange = async (e: ToggleCustomEvent) => { - await helpers.setValue(e.detail.checked); + field.onChange(e.detail.checked); onIonChange?.(e); }; @@ -36,8 +46,8 @@ const ToggleInput = ({ className, name, onIonChange, testid = 'input-toggle', .. className={classNames('ls-toggle-input', className)} checked={field.value} onIonChange={onChange} - data-testid={testid} {...toggleProps} + data-testid={testid} /> ); }; diff --git a/src/pages/Account/components/Profile/ProfileForm.tsx b/src/pages/Account/components/Profile/ProfileForm.tsx index 57d0477..5f2ca31 100644 --- a/src/pages/Account/components/Profile/ProfileForm.tsx +++ b/src/pages/Account/components/Profile/ProfileForm.tsx @@ -1,7 +1,8 @@ import { IonButton, useIonRouter, useIonViewDidEnter } from '@ionic/react'; import { useRef, useState } from 'react'; -import { Form, Formik } from 'formik'; -import { date, object, string } from 'yup'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; @@ -36,7 +37,6 @@ interface ProfileFormProps extends BaseComponentProps { /** * The `ProfileForm` component renders a Formik form to edit a user profile. * @param {ProfileFormProps} props - Component propeties. - * @returns {JSX.Element} JSX */ const ProfileForm = ({ className, testid = 'form-profile', profile }: ProfileFormProps) => { const focusInput = useRef(null); @@ -50,11 +50,25 @@ const ProfileForm = ({ className, testid = 'form-profile', profile }: ProfileFor /** * Profile form validation schema. */ - const validationSchema = object({ - name: string().required(t('validation.required')), - email: string().required(t('validation.required')).email(t('validation.email')), - bio: string().max(500, ({ max }) => t('validation.max', { max })), - dateOfBirth: date().required(t('validation.required')), + const profileFormSchema = z.object({ + name: z.string().min(1, { message: t('validation.required') }), + email: z.email({ message: t('validation.email') }).min(1, { message: t('validation.required') }), + bio: z + .string() + .max(500, { message: t('validation.max', { max: 500 }) }) + .optional(), + dateOfBirth: z.iso.date().optional(), + }); + + const { control, formState, handleSubmit } = useForm({ + defaultValues: { + email: profile.email, + name: profile.name, + bio: profile.bio, + dateOfBirth: profile.dateOfBirth, + }, + mode: 'all', + resolver: zodResolver(profileFormSchema), }); useIonViewDidEnter(() => { @@ -65,6 +79,30 @@ const ProfileForm = ({ className, testid = 'form-profile', profile }: ProfileFor router.goBack(); }; + const onFormSubmit = (values: ProfileFormValues) => { + setProgress(true); + setError(''); + updateProfile( + { profile: values }, + { + onSuccess: () => { + createToast({ + message: t('profile.updated-profile', { ns: 'account' }), + duration: 5000, + buttons: [DismissButton()], + }); + router.goBack(); + }, + onError: (err) => { + setError(err.message); + }, + onSettled: () => { + setProgress(false); + }, + }, + ); + }; + return (
{error && ( @@ -75,111 +113,78 @@ const ProfileForm = ({ className, testid = 'form-profile', profile }: ProfileFor /> )} - - enableReinitialize={true} - initialValues={{ - email: profile.email, - name: profile.name, - bio: profile.bio, - dateOfBirth: profile.dateOfBirth, - }} - onSubmit={(values, { setSubmitting }) => { - setProgress(true); - setError(''); - updateProfile( - { profile: values }, - { - onSuccess: () => { - createToast({ - message: t('profile.updated-profile', { ns: 'account' }), - duration: 5000, - buttons: [DismissButton()], - }); - router.goBack(); - }, - onError: (err) => { - setError(err.message); - }, - onSettled: () => { - setProgress(false); - setSubmitting(false); - }, - }, - ); - }} - validationSchema={validationSchema} - > - {({ dirty, isSubmitting }) => ( -
- - - - -