diff --git a/.eslintrc.json b/.eslintrc.json index 2d7b80193..59d12b8e2 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,6 +3,7 @@ "extends": ["@appfolio/eslint-config-appfolio-react", "prettier"], "plugins": ["no-only-tests", "react-hooks", "@typescript-eslint"], "rules": { + "camelcase": ["error", { "allow": "^experimental_" }], "curly": ["error", "all"], "react/jsx-props-no-spreading": "off", "react/static-property-placement": "off", diff --git a/package.json b/package.json index 5f7eecb23..f276a2489 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@appfolio/react-gears", - "version": "7.9.0", + "version": "7.10.0-hook-form.8", "description": "React-based version of Gears", "author": "Appfolio, Inc.", "repository": { @@ -12,6 +12,21 @@ "main": "lib/index.js", "module": "esm/index.js", "types": "esm/index.d.ts", + "exports": { + ".": { + "require": "./lib/index.js", + "default": "./esm/index.js" + }, + "./hook-form": { + "require": "./lib/components/HookForm/index.js", + "default": "./esm/components/HookForm/index.js" + } + }, + "typesVersions": { + "*": { + "hook-form": ["esm/components/HookForm/index.d.ts"] + } + }, "sideEffects": false, "files": [ "lib", @@ -67,6 +82,7 @@ "lodash.without": "^4.4.0", "memoize-one": "^5.1.1", "prop-types": "^15.7.2", + "react-hook-form": "^7.39.0", "react-imask": "^6.2.2", "react-resize-detector": "^4.2.3", "react-select-plus": "1.2.0", diff --git a/src/components/Form/FormRow.tsx b/src/components/Form/FormRow.tsx index 9678def33..bc4cc398b 100644 --- a/src/components/Form/FormRow.tsx +++ b/src/components/Form/FormRow.tsx @@ -17,7 +17,7 @@ const gearsInputs = { type PropOrDefault = K extends keyof T ? T[K] : D; type ReactStrapInputTypes = NonNullable['type']>; -type InputTypes = ReactStrapInputTypes | keyof typeof gearsInputs; +export type InputTypes = ReactStrapInputTypes | keyof typeof gearsInputs; function getInputByType(type: T) { return ( diff --git a/src/components/HookForm/Form.stories.tsx b/src/components/HookForm/Form.stories.tsx new file mode 100755 index 000000000..7fd668f1b --- /dev/null +++ b/src/components/HookForm/Form.stories.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import Button from '../Button/Button'; +import FormGroup from '../Form/FormGroup'; +import Label from '../Label/Label'; +import Form from './Form'; +import FormFeedback from './FormFeedback'; +import FormLabelGroup from './FormLabelGroup'; +import FormRow from './FormRow'; +import Input from './Input'; +import { SubmitHandler } from './types'; + +export default { + title: 'react-hook-form', +}; + +interface FormInputs { + email: string; + age: string; + select: string; + address: { + line1: string; + line2: string; + state: string; + zipCode: string; + }; +} + +export const FormWithValidations = () => { + const handleSubmit: SubmitHandler = (formData, { setError }) => { + // make api calss + // if fails then call setError + setError('address.line1', { + message: 'something went wrong with line1', + }); + console.log(formData); + }; + + return ( +
+ {({ reset, formState: { errors, dirtyFields } }) => { + console.log('render'); + console.log(errors); + return ( + <> +
+ + + + + + +
+
+ Address + + + + + + + + + +
+
+ + + {errors.age?.message} +
+
+ + + + + + + + + {errors.select?.message} +
+
+ + + + + + + + + +
+
+ + + + +
+
+ Radio Buttons + + {' '} + + + + {' '} + + +
+ + + + ); + }} +
+ ); +}; + +interface FormValues { + email: string; +} + +export const SimpleFormNoValidation = () => { + const handleSubmit: SubmitHandler = (formData) => { + console.log(formData); + }; + + return ( +
+ {({ formState: { isValid } }) => ( + <> + + + + )} + + ); +}; + +export const FormDemo = () => { + const handleSubmit: SubmitHandler = (formData, { reset }) => { + console.log(formData); + reset(); + }; + + return ( +
+ + + + + + + +
+ ); +}; diff --git a/src/components/HookForm/Form.tsx b/src/components/HookForm/Form.tsx new file mode 100644 index 000000000..412ace896 --- /dev/null +++ b/src/components/HookForm/Form.tsx @@ -0,0 +1,72 @@ +import React, { ReactNode, ComponentProps } from 'react'; +import { + useForm, + FormProvider, + SubmitHandler as HookFormSubmitHandler, + UseFormProps, + UseFormReturn, + FieldValues, +} from 'react-hook-form'; +import GearsForm from '../Form/Form'; +import { SubmitHandler } from './types'; + +type BaseFormProps = { + onSubmit: SubmitHandler; + children: ((useFormReturn: UseFormReturn) => ReactNode) | ReactNode; +}; + +type FormProps = Omit< + ComponentProps, + keyof BaseFormProps +> & + UseFormProps & + BaseFormProps; + +const Form = ({ + children, + action, + method, + onSubmit = () => undefined, + mode, + reValidateMode, + defaultValues, + resolver, + context, + criteriaMode, + shouldFocusError, + shouldUnregister, + shouldUseNativeValidation, + delayError, + ...gearsFormProps +}: FormProps) => { + const useFormReturn = useForm({ + mode, + reValidateMode, + defaultValues, + resolver, + context, + criteriaMode, + shouldFocusError, + shouldUnregister, + shouldUseNativeValidation, + delayError, + }); + + const handleFormSubmit: HookFormSubmitHandler = async (formData, event) => { + return await onSubmit(formData, { ...useFormReturn }, event); + }; + + return ( + + + {typeof children === 'function' ? children(useFormReturn) : children} + + + ); +}; + +export default Form; diff --git a/src/components/HookForm/FormFeedback.tsx b/src/components/HookForm/FormFeedback.tsx new file mode 100644 index 000000000..d52d9e6b2 --- /dev/null +++ b/src/components/HookForm/FormFeedback.tsx @@ -0,0 +1,71 @@ +import React, { + ComponentProps, + ReactNode, + Children, + cloneElement, + isValidElement, + ReactElement, +} from 'react'; +import { useFormState } from 'react-hook-form'; +import GearsFormFeedback from '../Form/FormFeedback'; +import Input, { InputProps } from './Input'; + +export type InputChildProps = { + id?: string; + name: string; +}; + +type BaseFormFeedbackProps = { + name?: string; +}; + +type FormFeedbackProps = Omit< + ComponentProps, + keyof BaseFormFeedbackProps +> & + BaseFormFeedbackProps; + +const isValidChildWithNameProp = (child: ReactNode): child is ReactElement => + isValidElement(child) && typeof child.props.name === 'string'; + +export const findChildWithNameProp = (children: ReactNode) => { + return Children.toArray(children).find(isValidChildWithNameProp); +}; + +const FormFeedback = ({ name, children, ...gearsFormFeedbackProps }: FormFeedbackProps) => { + const { errors } = useFormState(); + + if (name) { + const error = errors[name]; + return ( + <> + {error && ( + + {error.message} + + )} + + ); + } + + const childWithNameProp = findChildWithNameProp(children); + if (!childWithNameProp) { + return {children}; + } + + const error = errors[childWithNameProp.props.name]; + + return ( + <> + {Children.map(children, (child) => { + if (isValidElement(child) && child.type === Input) { + return cloneElement(child as ReactElement, { invalid: !!error }); + } + return child; + })} + {error && {error.message}} + + ); +}; + +export default FormFeedback; diff --git a/src/components/HookForm/FormLabelGroup.tsx b/src/components/HookForm/FormLabelGroup.tsx new file mode 100644 index 000000000..5b250b538 --- /dev/null +++ b/src/components/HookForm/FormLabelGroup.tsx @@ -0,0 +1,55 @@ +import React, { + ComponentProps, + ReactNode, + Children, + cloneElement, + isValidElement, + ReactElement, +} from 'react'; +import { useFormState } from 'react-hook-form'; +import GearsFormLabelGroup from '../Form/FormLabelGroup'; +import { findChildWithNameProp } from './FormFeedback'; +import Input, { InputProps } from './Input'; + +type FormLabelGroupProps = ComponentProps< + typeof GearsFormLabelGroup +>; + +const FormLabelGroup = ({ + children, + ...gearsFormLabelGroupProps +}: FormLabelGroupProps) => { + const { errors } = useFormState(); + const childWithNameProp = findChildWithNameProp(children); + + if (!childWithNameProp) { + return ( + + {children} + + ); + } + + const { name, id } = childWithNameProp.props; + const inputError = errors[name]; + + return ( + + {Children.map(children, (child) => { + if (isValidElement(child) && child.type === Input) { + return cloneElement( + child as ReactElement, + { invalid: !!inputError } + ); + } + return child; + })} + + ); +}; + +export default FormLabelGroup; diff --git a/src/components/HookForm/FormRow.tsx b/src/components/HookForm/FormRow.tsx new file mode 100644 index 000000000..717f1527e --- /dev/null +++ b/src/components/HookForm/FormRow.tsx @@ -0,0 +1,51 @@ +import React, { ComponentProps, useCallback } from 'react'; +import { useController, UseControllerProps } from 'react-hook-form'; +import GearsFormRow, { InputTypes } from '../Form/FormRow'; + +type BaseFormRowProps = { + // showInvalidFeedback?: boolean; + // showValidFeedback?: boolean; +}; + +type ExcludedGearsFormRowProps = 'feedback' | 'validFeedback'; + +type FormRowProps = Omit< + ComponentProps>, + keyof BaseFormRowProps | ExcludedGearsFormRowProps +> & + Omit & + BaseFormRowProps; + +const FormRow = ({ + defaultValue, + name, + onChange: formRowOnChange, + rules, + ...gearsFormRowProps +}: FormRowProps) => { + const { + field: { onChange, onBlur }, + fieldState: { error }, + } = useController({ name, defaultValue, rules }); + + const handleChange = useCallback( + (e: any) => { + console.log(e); + onChange(e); + formRowOnChange?.(e); + }, + [onChange, formRowOnChange] + ); + + return ( + + ); +}; + +export default FormRow; diff --git a/src/components/HookForm/Input.tsx b/src/components/HookForm/Input.tsx new file mode 100644 index 000000000..7988b9be5 --- /dev/null +++ b/src/components/HookForm/Input.tsx @@ -0,0 +1,68 @@ +import React, { ComponentProps } from 'react'; +import { useFormContext, RegisterOptions, Validate, ValidationRule } from 'react-hook-form'; +import GearsInput from '../Input/Input'; + +type ValueAsNumber = boolean | undefined; +type ValueAsDate = boolean | undefined; + +type DetermineValidateValue< + TValueAsNumber extends ValueAsNumber, + TValueAsDate extends ValueAsDate +> = TValueAsNumber extends true ? number : TValueAsDate extends true ? Date : string; + +export type InputProps< + TValueAsNumber extends ValueAsNumber = undefined, + TValueAsDate extends ValueAsDate = undefined +> = Omit, keyof RegisterOptions> & + Omit & { + name: string; + validate?: + | Validate> + | Record>>; + valueAsNumber?: TValueAsNumber; + valueAsDate?: TValueAsDate; + }; + +const extractValue = ( + objOrValue?: ValidationRule +) => (typeof objOrValue === 'object' ? objOrValue.value : objOrValue); + +const Input = < + TValueAsNumber extends ValueAsNumber = undefined, + TValueAsDate extends ValueAsDate = undefined +>({ + name, + valueAsNumber, + valueAsDate, + validate, + max, + maxLength, + min, + minLength, + pattern, + required, + ...restProps +}: InputProps) => { + const { register } = useFormContext(); + const { ref, ...restRegister } = register(name, { + valueAsNumber, + valueAsDate, + validate, + max, + min, + pattern, + required, + }); + const gearsInputProps = { + ...restProps, + max: extractValue(max), + maxLength: extractValue(maxLength), + min: extractValue(min), + minLength: extractValue(minLength), + required: !!required, + }; + + return ; +}; + +export default Input; diff --git a/src/components/HookForm/index.tsx b/src/components/HookForm/index.tsx new file mode 100644 index 000000000..3bbbb5aff --- /dev/null +++ b/src/components/HookForm/index.tsx @@ -0,0 +1,5 @@ +export { default as experimental_Form } from './Form'; +export { default as experimental_Input } from './Input'; +export { default as experimental_FormFeedback } from './FormFeedback'; +export { default as experimental_FormLabelGroup } from './FormLabelGroup'; +export * from './types'; diff --git a/src/components/HookForm/types.tsx b/src/components/HookForm/types.tsx new file mode 100644 index 000000000..51407e18e --- /dev/null +++ b/src/components/HookForm/types.tsx @@ -0,0 +1,8 @@ +import { BaseSyntheticEvent } from 'react'; +import { UseFormReturn, FieldValues } from 'react-hook-form'; + +export type SubmitHandler = ( + data: TFieldValues, + useFormReturn: UseFormReturn, + event?: BaseSyntheticEvent +) => any | Promise; diff --git a/yarn.lock b/yarn.lock index 64bad7c95..c619f4333 100644 --- a/yarn.lock +++ b/yarn.lock @@ -140,6 +140,7 @@ __metadata: raf-stub: ^3.0.0 react: ^16.14.0 react-dom: ^16.14.0 + react-hook-form: ^7.39.0 react-imask: ^6.2.2 react-resize-detector: ^4.2.3 react-select-plus: 1.2.0 @@ -14718,6 +14719,15 @@ __metadata: languageName: node linkType: hard +"react-hook-form@npm:^7.39.0": + version: 7.39.0 + resolution: "react-hook-form@npm:7.39.0" + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + checksum: f0f9a081ee634d1125ddde6e8896717d1416da95e1c07c57814a6421b9ab4cc8c057e8431c879c921dffde3d5cb1531cd8dfaa203f0a87e8a0da371a4f381a26 + languageName: node + linkType: hard + "react-imask@npm:^6.2.2": version: 6.4.2 resolution: "react-imask@npm:6.4.2"