Skip to content

Commit

Permalink
chore(refactor): Split rwjs/forms up into several smaller logical uni…
Browse files Browse the repository at this point in the history
…ts (#10428)
  • Loading branch information
Tobbe authored Apr 7, 2024
1 parent 06d5ccd commit 7a2a135
Show file tree
Hide file tree
Showing 14 changed files with 1,028 additions and 967 deletions.
63 changes: 63 additions & 0 deletions packages/forms/src/CheckboxField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { ForwardedRef } from 'react'
import React, { forwardRef } from 'react'

import type { FieldProps } from './FieldProps'
import { useErrorStyles } from './useErrorStyles'
import { useRegister } from './useRegister'

export interface CheckboxFieldProps
extends Omit<FieldProps<HTMLInputElement>, 'type' | 'emptyAs'>,
Omit<React.ComponentPropsWithRef<'input'>, 'name' | 'type'> {}

/** Renders an `<input type="checkbox">` field */
export const CheckboxField = forwardRef(
(
{
name,
id,
// for useErrorStyles
errorClassName,
errorStyle,
className,
style,
// for useRegister
validation,
onBlur,
onChange,
...rest
}: CheckboxFieldProps,
ref: ForwardedRef<HTMLInputElement>,
) => {
const styles = useErrorStyles({
name,
errorClassName,
errorStyle,
className,
style,
})

const type = 'checkbox'

const useRegisterReturn = useRegister(
{
name,
validation,
onBlur,
onChange,
type,
},
ref,
)

return (
<input
id={id || name}
{...rest}
/** This order ensures type="checkbox" */
type={type}
{...styles}
{...useRegisterReturn}
/>
)
},
)
72 changes: 72 additions & 0 deletions packages/forms/src/FieldError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React from 'react'

import { get, useFormContext } from 'react-hook-form'

export interface FieldErrorProps
extends React.ComponentPropsWithoutRef<'span'> {
/**
* The name of the field the `<FieldError>`'s associated with.
*/
name: string
}

const DEFAULT_MESSAGES = {
required: 'is required',
pattern: 'is not formatted correctly',
minLength: 'is too short',
maxLength: 'is too long',
min: 'is too low',
max: 'is too high',
validate: 'is not valid',
}

/**
* Renders a `<span>` with an error message if there's a validation error on the corresponding field.
* If no error message is provided, a default one is used based on the type of validation that caused the error.
*
* @example Displaying a validation error message with `<FieldError>`
*
* `<FieldError>` doesn't render (i.e. returns `null`) when there's no error on `<TextField>`.
*
* ```jsx
* <Label name="name" errorClassName="error">
* Name
* </Label>
* <TextField
* name="name"
* validation={{ required: true }}
* errorClassName="error"
* />
* <FieldError name="name" className="error" />
* ```
*
* @see {@link https://redwoodjs.com/docs/tutorial/chapter3/forms#fielderror}
*
* @privateRemarks
*
* This is basically a helper for a common pattern you see in `react-hook-form`:
*
* ```jsx
* <form onSubmit={handleSubmit(onSubmit)}>
* <input {...register("firstName", { required: true })} />
* {errors.firstName?.type === 'required' && "First name is required"}
* ```
*
* @see {@link https://react-hook-form.com/get-started#Handleerrors}
*/
export const FieldError = ({ name, ...rest }: FieldErrorProps) => {
const {
formState: { errors },
} = useFormContext()

const validationError = get(errors, name)

const errorMessage =
validationError &&
(validationError.message ||
`${name} ${
DEFAULT_MESSAGES[validationError.type as keyof typeof DEFAULT_MESSAGES]
}`)

return validationError ? <span {...rest}>{errorMessage}</span> : null
}
31 changes: 31 additions & 0 deletions packages/forms/src/FieldProps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { EmptyAsValue, RedwoodRegisterOptions } from './coercion'

/**
* The main interface, just to have some sort of source of truth.
*
* @typeParam E - The type of element; we're only ever working with a few HTMLElements.
*
* `extends` constrains the generic while `=` provides a default.
*
* @see {@link https://www.typescriptlang.org/docs/handbook/2/generics.html#generic-constraints}
*
* @internal
*/
export interface FieldProps<
Element extends
| HTMLTextAreaElement
| HTMLSelectElement
| HTMLInputElement = HTMLInputElement,
> {
name: string
id?: string
emptyAs?: EmptyAsValue
errorClassName?: string
errorStyle?: React.CSSProperties
className?: string
style?: React.CSSProperties
validation?: RedwoodRegisterOptions
type?: string
onBlur?: React.FocusEventHandler<Element>
onChange?: React.ChangeEventHandler<Element>
}
96 changes: 96 additions & 0 deletions packages/forms/src/Form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import React, { forwardRef } from 'react'
import type { ForwardedRef } from 'react'

import { useForm, FormProvider } from 'react-hook-form'
import type { FieldValues, UseFormReturn, UseFormProps } from 'react-hook-form'

import { ServerErrorsContext } from './ServerErrorsContext'

export interface FormProps<TFieldValues extends FieldValues>
extends Omit<React.ComponentPropsWithRef<'form'>, 'onSubmit'> {
error?: any
/**
* The methods returned by `useForm`.
* This prop is only necessary if you've called `useForm` yourself to get
* access to one of its functions, like `reset`.
*
* @example
*
* ```typescript
* const formMethods = useForm<FormData>()
*
* const onSubmit = (data: FormData) => {
* sendDataToServer(data)
* formMethods.reset()
* }
*
* return (
* <Form formMethods={formMethods} onSubmit={onSubmit}>
* )
* ```
*/
formMethods?: UseFormReturn<TFieldValues>
/**
* Configures how React Hook Form performs validation, among other things.
*
* @example
*
* ```jsx
* <Form config={{ mode: 'onBlur' }}>
* ```
*
* @see {@link https://react-hook-form.com/api/useform}
*/
config?: UseFormProps<TFieldValues>
onSubmit?: (value: TFieldValues, event?: React.BaseSyntheticEvent) => void
}

/**
* Renders a `<form>` with the required context.
*/
function FormInner<TFieldValues extends FieldValues>(
{
config,
error: errorProps,
formMethods: propFormMethods,
onSubmit,
children,
...rest
}: FormProps<TFieldValues>,
ref: ForwardedRef<HTMLFormElement>,
) {
const hookFormMethods = useForm<TFieldValues>(config)
const formMethods = propFormMethods || hookFormMethods

return (
<form
ref={ref}
{...rest}
onSubmit={formMethods.handleSubmit((data, event) =>
onSubmit?.(data, event),
)}
>
<ServerErrorsContext.Provider
value={
errorProps?.graphQLErrors?.[0]?.extensions?.properties?.messages || {}
}
>
<FormProvider {...formMethods}>{children}</FormProvider>
</ServerErrorsContext.Provider>
</form>
)
}

// Sorry about the `as` type assertion (type cast) here. Normally I'd redeclare
// forwardRef to only return a plain function, allowing us to use TypeScript's
// Higher-order Function Type Inference. But that gives us problems with the
// ForwardRefExoticComponent type we use for our InputComponents. So instead
// of changing that type (because it's correct) I use a type assertion here.
// forwardRef is notoriously difficult to use with UI component libs.
// Chakra-UI also says:
// > To be honest, the forwardRef type is quite complex [...] I'd recommend
// > that you cast the type
// https://github.com/chakra-ui/chakra-ui/issues/4528#issuecomment-902566185
export const Form = forwardRef(FormInner) as <TFieldValues extends FieldValues>(
props: FormProps<TFieldValues> & React.RefAttributes<HTMLFormElement>,
) => React.ReactElement | null
Loading

0 comments on commit 7a2a135

Please sign in to comment.