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

chore(refactor): Split rwjs/forms up into several smaller logical units #10428

Merged
merged 3 commits into from
Apr 7, 2024
Merged
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
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
Loading