Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React Hook Form smart components #1106

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 17 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/components/Form/FormRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const gearsInputs = {

type PropOrDefault<T extends {}, K extends PropertyKey, D = never> = K extends keyof T ? T[K] : D;
type ReactStrapInputTypes = NonNullable<React.ComponentProps<typeof Input>['type']>;
type InputTypes = ReactStrapInputTypes | keyof typeof gearsInputs;
export type InputTypes = ReactStrapInputTypes | keyof typeof gearsInputs;

function getInputByType<T extends InputTypes>(type: T) {
return (
Expand Down
174 changes: 174 additions & 0 deletions src/components/HookForm/Form.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<FormInputs> = (formData, { setError }) => {
// make api calss
// if fails then call setError
setError('address.line1', {
message: 'something went wrong with line1',
});
console.log(formData);
};

return (
<Form onSubmit={handleSubmit} mode="onChange">
{({ reset, formState: { errors, dirtyFields } }) => {
console.log('render');
console.log(errors);
return (
<>
<div className="mb-3">
<Label for="email">Email</Label>
<FormFeedback>
<Input id="email" name="email" required="Can't be blank" />
</FormFeedback>

<FormFeedback name="email" />
</div>
<div className="mb-3">
<legend>Address</legend>
<Label for="line1">Address Line 1</Label>
<Input invalid={!!errors.address?.line1} id="line1" name="address.line1" />
<FormFeedback name="email" />
<Label for="line2">Address Line 2</Label>
<Input id="line2" name="address.addr2" />
<Label for="state">State</Label>
<Input id="state" name="address.state" />
<Label for="zipCode">Zip Code</Label>
<Input id="zipCode" name="address.zipCode" />
</div>
<div className="mb-3">
<Label for="age">Age</Label>
<Input
min={{ value: 1, message: 'Min is 1' }}
type="number"
invalid={!!errors.age}
id="age"
name="age"
/>
<FormFeedback invalid>{errors.age?.message}</FormFeedback>
</div>
<div className="mb-3">
<Label for="select">Select</Label>
<Input type="select" invalid={!!errors.select} id="select" name="select">
<option>1</option>
<option>2</option>
<option>3</option>
<option>4</option>
<option>5</option>
</Input>
<FormFeedback invalid>{errors.select?.message}</FormFeedback>
</div>
<div className="mb-3">
<FormLabelGroup inputId="select-multiple" label="Select multiple" stacked>
<Input type="select" id="select-multiple" name="selectMuliple" multiple>
<option>1</option>
<option>2</option>
<option>3</option>
<option>4</option>
<option>5</option>
</Input>
</FormLabelGroup>
</div>
<div className="mb-3">
<FormLabelGroup inputId="checkboxes" label="Check boxes" stacked>
<Input type="checkbox" id="checkbox1" name="checkboxes" value="Value 1" />
<Input type="checkbox" id="checkbox2" name="checkboxes" value="Value 2" />
</FormLabelGroup>
</div>
<div className="mb-3">
<legend>Radio Buttons</legend>
<FormGroup check>
<Input id="radio-option-1" name="radio" type="radio" value="radio-option-value-1" />{' '}
<Label check for="radio-option-1">
Option one is this and that—be sure to include why it‘s great
</Label>
</FormGroup>
<FormGroup check>
<Input id="radio-option-2" name="radio" type="radio" value="radio-option-value-2" />{' '}
<Label check for="radio-option-2">
Option two can be something else and selecting it will deselect option one
</Label>
</FormGroup>
</div>
<Button color="primary" type="submit">
Submit
</Button>
<Button type="button" onClick={() => reset()}>
Reset
</Button>
</>
);
}}
</Form>
);
};

interface FormValues {
email: string;
}

export const SimpleFormNoValidation = () => {
const handleSubmit: SubmitHandler<FormValues> = (formData) => {
console.log(formData);
};

return (
<Form onSubmit={handleSubmit}>
{({ formState: { isValid } }) => (
<>
<FormRow name="test" type="month" />
<Button type="submit" disabled={!isValid}>
Submit
</Button>
</>
)}
</Form>
);
};

export const FormDemo = () => {
const handleSubmit: SubmitHandler<FormValues> = (formData, { reset }) => {
console.log(formData);
reset();
};

return (
<Form onSubmit={handleSubmit}>
<FormLabelGroup label="First Name">
<Input id="first-name" name="firstName" required="Can't be blank" />
</FormLabelGroup>
<FormLabelGroup label="Last Name">
<Input id="last-name" name="lastName" />
</FormLabelGroup>
<Button color="primary" type="submit">
Submit
</Button>
</Form>
);
};
72 changes: 72 additions & 0 deletions src/components/HookForm/Form.tsx
Original file line number Diff line number Diff line change
@@ -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<TFieldValues extends FieldValues> = {
onSubmit: SubmitHandler<TFieldValues>;
children: ((useFormReturn: UseFormReturn<TFieldValues>) => ReactNode) | ReactNode;
};

type FormProps<TFieldValues extends FieldValues> = Omit<
ComponentProps<typeof GearsForm>,
keyof BaseFormProps<TFieldValues>
> &
UseFormProps<TFieldValues> &
BaseFormProps<TFieldValues>;

const Form = <TFieldValues extends FieldValues = FieldValues, TContext = any>({
children,
action,
method,
onSubmit = () => undefined,
mode,
reValidateMode,
defaultValues,
resolver,
context,
criteriaMode,
shouldFocusError,
shouldUnregister,
shouldUseNativeValidation,
delayError,
...gearsFormProps
}: FormProps<TFieldValues>) => {
const useFormReturn = useForm<TFieldValues, TContext>({
mode,
reValidateMode,
defaultValues,
resolver,
context,
criteriaMode,
shouldFocusError,
shouldUnregister,
shouldUseNativeValidation,
delayError,
});

const handleFormSubmit: HookFormSubmitHandler<TFieldValues> = async (formData, event) => {
return await onSubmit(formData, { ...useFormReturn }, event);
};

return (
<FormProvider {...useFormReturn}>
<GearsForm
noValidate
onSubmit={useFormReturn.handleSubmit(handleFormSubmit)}
{...gearsFormProps}
>
{typeof children === 'function' ? children(useFormReturn) : children}
</GearsForm>
</FormProvider>
);
};

export default Form;
71 changes: 71 additions & 0 deletions src/components/HookForm/FormFeedback.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof GearsFormFeedback>,
keyof BaseFormFeedbackProps
> &
BaseFormFeedbackProps;

const isValidChildWithNameProp = (child: ReactNode): child is ReactElement<InputChildProps> =>
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 && (
<GearsFormFeedback className="d-block" {...gearsFormFeedbackProps}>
{error.message}
</GearsFormFeedback>
)}
</>
);
}

const childWithNameProp = findChildWithNameProp(children);
if (!childWithNameProp) {
return <GearsFormFeedback {...gearsFormFeedbackProps}>{children}</GearsFormFeedback>;
}

const error = errors[childWithNameProp.props.name];

return (
<>
{Children.map(children, (child) => {
if (isValidElement(child) && child.type === Input) {
return cloneElement(child as ReactElement<InputProps>, { invalid: !!error });
}
return child;
})}
{error && <GearsFormFeedback {...gearsFormFeedbackProps}>{error.message}</GearsFormFeedback>}
</>
);
};

export default FormFeedback;
Loading