From 0254c030205ff4e8b75f06aec132c797dad6d052 Mon Sep 17 00:00:00 2001 From: Steven Than Date: Tue, 1 Nov 2022 13:14:52 -0700 Subject: [PATCH 1/3] feat: convert to using exports field --- package.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/package.json b/package.json index 5f7eecb23..d7457f042 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,12 @@ "main": "lib/index.js", "module": "esm/index.js", "types": "esm/index.d.ts", + "exports": { + ".": { + "require": "./lib/index.js", + "default": "./esm/index.js" + } + }, "sideEffects": false, "files": [ "lib", From 1ec016fddd001e754e47eacaeb2375f6322e83c5 Mon Sep 17 00:00:00 2001 From: Steven Than Date: Fri, 4 Nov 2022 05:40:53 -0700 Subject: [PATCH 2/3] chore: wip --- package.json | 10 +++ src/components/HookForm/Form.stories.tsx | 100 +++++++++++++++++++++++ src/components/HookForm/Form.tsx | 33 ++++++++ src/components/HookForm/Input.tsx | 68 +++++++++++++++ src/components/HookForm/index.tsx | 2 + yarn.lock | 10 +++ 6 files changed, 223 insertions(+) create mode 100755 src/components/HookForm/Form.stories.tsx create mode 100644 src/components/HookForm/Form.tsx create mode 100644 src/components/HookForm/Input.tsx create mode 100644 src/components/HookForm/index.tsx diff --git a/package.json b/package.json index d7457f042..5d0b3b2af 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,15 @@ ".": { "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, @@ -73,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/HookForm/Form.stories.tsx b/src/components/HookForm/Form.stories.tsx new file mode 100755 index 000000000..80019f69e --- /dev/null +++ b/src/components/HookForm/Form.stories.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { SubmitHandler } from 'react-hook-form'; +import { FormFeedback } from 'reactstrap'; +import Button from '../Button/Button'; +import FormGroup from '../Form/FormGroup'; +import Label from '../Label/Label'; +import Form from './Form'; +import Input from './Input'; + +export default { + title: 'react-hook-form', +}; + +interface FormInputs { + email: string; + age: string; + select: string; +} + +export const LiveExample = () => { + const handleSubmit: SubmitHandler = (formData) => { + console.log(formData); + }; + + return ( +
+ {({ formState: { errors, dirtyFields } }) => ( + <> +
+ + value === 'email' || 'incorrect'} + /> + {errors.email?.message} + Looks good! +
+
+ + + {errors.age?.message} +
+
+ + + + + + + + + {errors.select?.message} +
+
+ Radio Buttons + + {' '} + + + + {' '} + + +
+ + + )} +
+ ); +}; + +export const TestExample = () => { + const handleSubmit: SubmitHandler = (formData) => { + console.log(formData); + }; + + return ( +
+
+ + + Looks good! +
+
+ ); +}; diff --git a/src/components/HookForm/Form.tsx b/src/components/HookForm/Form.tsx new file mode 100644 index 000000000..a0086ed11 --- /dev/null +++ b/src/components/HookForm/Form.tsx @@ -0,0 +1,33 @@ +import React, { ReactNode } from 'react'; +import { + useForm, + FormProvider, + SubmitHandler, + UseFormProps, + UseFormReturn, + FieldValues, +} from 'react-hook-form'; +import GearsForm from '../Form/Form'; + +interface FormProps extends UseFormProps { + onSubmit: SubmitHandler; + children: ((useFormReturn: UseFormReturn) => ReactNode) | ReactNode; +} + +const Form = ({ + children, + onSubmit, + ...useFormProps +}: FormProps) => { + const useFormReturn = useForm(useFormProps); + + return ( + + + {typeof children === 'function' ? children(useFormReturn) : children} + + + ); +}; + +export default Form; diff --git a/src/components/HookForm/Input.tsx b/src/components/HookForm/Input.tsx new file mode 100644 index 000000000..f64b6abe9 --- /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; + +type InputProps = Omit< + ComponentProps, + 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..2777192a1 --- /dev/null +++ b/src/components/HookForm/index.tsx @@ -0,0 +1,2 @@ +export { default as Form } from './Form'; +export { default as Input } from './Input'; 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" From ce95b0cd7ecbe2290d4007c4eb0d9ad70ec60662 Mon Sep 17 00:00:00 2001 From: Steven Than Date: Thu, 17 Nov 2022 12:10:53 -0800 Subject: [PATCH 3/3] chore: wip --- .eslintrc.json | 1 + package.json | 2 +- src/components/Form/FormRow.tsx | 2 +- src/components/HookForm/Form.stories.tsx | 202 ++++++++++++++------- src/components/HookForm/Form.tsx | 57 +++++- src/components/HookForm/FormFeedback.tsx | 71 ++++++++ src/components/HookForm/FormLabelGroup.tsx | 55 ++++++ src/components/HookForm/FormRow.tsx | 51 ++++++ src/components/HookForm/Input.tsx | 8 +- src/components/HookForm/index.tsx | 7 +- src/components/HookForm/types.tsx | 8 + 11 files changed, 383 insertions(+), 81 deletions(-) create mode 100644 src/components/HookForm/FormFeedback.tsx create mode 100644 src/components/HookForm/FormLabelGroup.tsx create mode 100644 src/components/HookForm/FormRow.tsx create mode 100644 src/components/HookForm/types.tsx 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 5d0b3b2af..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": { 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 index 80019f69e..7fd668f1b 100755 --- a/src/components/HookForm/Form.stories.tsx +++ b/src/components/HookForm/Form.stories.tsx @@ -1,11 +1,13 @@ import React from 'react'; -import { SubmitHandler } from 'react-hook-form'; -import { FormFeedback } from 'reactstrap'; 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', @@ -15,86 +17,158 @@ interface FormInputs { email: string; age: string; select: string; + address: { + line1: string; + line2: string; + state: string; + zipCode: string; + }; } -export const LiveExample = () => { - const handleSubmit: SubmitHandler = (formData) => { +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 (
- {({ formState: { errors, dirtyFields } }) => ( + {({ 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 } }) => ( <> -
- - value === 'email' || 'incorrect'} - /> - {errors.email?.message} - Looks good! -
-
- - - {errors.age?.message} -
-
- - - - - - - - - {errors.select?.message} -
-
- Radio Buttons - - {' '} - - - - {' '} - - -
- + + )} ); }; -export const TestExample = () => { - const handleSubmit: SubmitHandler = (formData) => { +export const FormDemo = () => { + const handleSubmit: SubmitHandler = (formData, { reset }) => { console.log(formData); + reset(); }; return ( -
-
- - - Looks good! -
+ + + + + + + +
); }; diff --git a/src/components/HookForm/Form.tsx b/src/components/HookForm/Form.tsx index a0086ed11..412ace896 100644 --- a/src/components/HookForm/Form.tsx +++ b/src/components/HookForm/Form.tsx @@ -1,29 +1,68 @@ -import React, { ReactNode } from 'react'; +import React, { ReactNode, ComponentProps } from 'react'; import { useForm, FormProvider, - SubmitHandler, + SubmitHandler as HookFormSubmitHandler, UseFormProps, UseFormReturn, FieldValues, } from 'react-hook-form'; import GearsForm from '../Form/Form'; +import { SubmitHandler } from './types'; -interface FormProps extends UseFormProps { +type BaseFormProps = { onSubmit: SubmitHandler; children: ((useFormReturn: UseFormReturn) => ReactNode) | ReactNode; -} +}; + +type FormProps = Omit< + ComponentProps, + keyof BaseFormProps +> & + UseFormProps & + BaseFormProps; -const Form = ({ +const Form = ({ children, - onSubmit, - ...useFormProps + action, + method, + onSubmit = () => undefined, + mode, + reValidateMode, + defaultValues, + resolver, + context, + criteriaMode, + shouldFocusError, + shouldUnregister, + shouldUseNativeValidation, + delayError, + ...gearsFormProps }: FormProps) => { - const useFormReturn = useForm(useFormProps); + 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} 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 index f64b6abe9..7988b9be5 100644 --- a/src/components/HookForm/Input.tsx +++ b/src/components/HookForm/Input.tsx @@ -10,10 +10,10 @@ type DetermineValidateValue< TValueAsDate extends ValueAsDate > = TValueAsNumber extends true ? number : TValueAsDate extends true ? Date : string; -type InputProps = Omit< - ComponentProps, - keyof RegisterOptions -> & +export type InputProps< + TValueAsNumber extends ValueAsNumber = undefined, + TValueAsDate extends ValueAsDate = undefined +> = Omit, keyof RegisterOptions> & Omit & { name: string; validate?: diff --git a/src/components/HookForm/index.tsx b/src/components/HookForm/index.tsx index 2777192a1..3bbbb5aff 100644 --- a/src/components/HookForm/index.tsx +++ b/src/components/HookForm/index.tsx @@ -1,2 +1,5 @@ -export { default as Form } from './Form'; -export { default as Input } from './Input'; +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;