Form Component (#9995)

This PR provides initial support for `Form` component and is supposed to be a first step in the long run.
Over the next iterations, we're going to continue adding new features that support `<Form />` out of the box(inputs, checkboxes, and so on)

Current PR is focused on providing the first version of Form component

As a tech stack, we chose:
1. `react-hook-form` for being mature and feature-complete and performant
2. Zod as validation library instead of ajv(that is present in the project already) for smaller bundle size, simpler and ts-friendly configuration, and better support
This commit is contained in:
Sergei Garin 2024-05-20 16:53:38 +03:00 committed by GitHub
parent 65f28c322a
commit 1991aab19d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 4096 additions and 2353 deletions

View File

@ -34,26 +34,29 @@
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-brands-svg-icons": "^6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@hookform/resolvers": "^3.4.0",
"@monaco-editor/react": "4.6.0",
"@sentry/react": "^7.74.0",
"@tanstack/react-query": "^5.27.5",
"@tanstack/react-query": "5.37.1",
"ajv": "^8.12.0",
"clsx": "^1.1.1",
"enso-common": "^1.0.0",
"is-network-error": "^1.0.1",
"monaco-editor": "0.47.0",
"react": "^18.2.0",
"react-aria": "^3.32.1",
"react-aria-components": "^1.1.1",
"react-dom": "^18.2.0",
"react-router-dom": "^6.8.1",
"react-stately": "^3.30.1",
"monaco-editor": "0.48.0",
"react": "^18.3.1",
"react-aria": "^3.33.0",
"react-aria-components": "^1.2.0",
"react-dom": "^18.3.1",
"react-error-boundary": "4.0.13",
"react-hook-form": "^7.51.4",
"react-router-dom": "^6.23.1",
"react-stately": "^3.31.0",
"react-toastify": "^9.1.3",
"tailwind-merge": "^2.2.1",
"tailwind-merge": "^2.3.0",
"tiny-invariant": "^1.3.3",
"ts-results": "^3.3.0",
"validator": "^13.11.0",
"react-error-boundary": "4.0.13"
"validator": "^13.12.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@babel/plugin-syntax-import-assertions": "^7.23.3",
@ -64,6 +67,7 @@
"@playwright/experimental-ct-react": "^1.40.0",
"@playwright/test": "^1.40.0",
"@react-types/shared": "^3.22.1",
"@tanstack/react-query-devtools": "5.37.1",
"@types/node": "^20.11.21",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
@ -71,7 +75,6 @@
"@typescript-eslint/eslint-plugin": "^6.7.2",
"@typescript-eslint/parser": "^6.7.2",
"@vitejs/plugin-react": "^4.2.1",
"@tanstack/react-query-devtools": "^5.36.2",
"chalk": "^5.3.0",
"cross-env": "^7.0.3",
"enso-chat": "git://github.com/enso-org/enso-bot",
@ -88,8 +91,8 @@
"prettier-plugin-tailwindcss": "^0.5.11",
"react-toastify": "^9.1.3",
"tailwindcss": "^3.4.1",
"tailwindcss-react-aria-components": "^1.1.1",
"tailwindcss-animate": "1.0.7",
"tailwindcss-react-aria-components": "^1.1.1",
"ts-plugin-namespace-auto-import": "^1.0.0",
"typescript": "~5.2.2",
"vite": "^4.4.9",

View File

@ -5,6 +5,7 @@
*/
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as reactQueryDevtools from '@tanstack/react-query-devtools'
const ReactQueryDevtoolsProduction = React.lazy(() =>
@ -19,6 +20,11 @@ const ReactQueryDevtoolsProduction = React.lazy(() =>
*/
export function ReactQueryDevtools() {
const [showDevtools, setShowDevtools] = React.useState(false)
// It's safer to pass the client directly to the devtools
// since there might be a chance that we have multiple versions of react-query,
// in case we forgot to update the devtools, npm messed up the versions,
// or there are hoisting issues.
const client = reactQuery.useQueryClient()
React.useEffect(() => {
window.toggleDevtools = () => {
@ -28,11 +34,11 @@ export function ReactQueryDevtools() {
return (
<>
<reactQueryDevtools.ReactQueryDevtools />
<reactQueryDevtools.ReactQueryDevtools client={client} />
{showDevtools && (
<React.Suspense fallback={null}>
<ReactQueryDevtoolsProduction />
<ReactQueryDevtoolsProduction client={client} />
</React.Suspense>
)}
</>

View File

@ -0,0 +1,131 @@
/**
* @file
*
* Form component
*/
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as reactHookForm from 'react-hook-form'
import * as textProvider from '#/providers/TextProvider'
import * as components from './components'
import type * as types from './types'
/**
* Form component. It wraps the form and provides the form context.
* It also handles the form submission.
* Provides better error handling and form state management.
* And serves a better UX out of the box.
*
* ## Component is in BETA and will be improved in the future.
*/
// There is no way to avoid type casting here
// eslint-disable-next-line no-restricted-syntax
export const Form = React.forwardRef(function Form<
TFieldValues extends reactHookForm.FieldValues,
// This type is defined on library level and we can't change it
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends reactHookForm.FieldValues | undefined = undefined,
>(props: types.FormProps<TFieldValues, TTransformedValues>, ref: React.Ref<HTMLFormElement>) {
const formId = React.useId()
const {
children,
onSubmit,
formRef,
form,
formOptions = {},
className,
style,
onSubmitted = () => {},
onSubmitSuccess = () => {},
onSubmitFailed = () => {},
id = formId,
schema,
...formProps
} = props
const { getText } = textProvider.useText()
const innerForm = components.useForm<TFieldValues, TTransformedValues>(
form ?? {
...formOptions,
...(schema ? { schema } : {}),
}
)
React.useImperativeHandle(formRef, () => innerForm, [innerForm])
const formMutation = reactQuery.useMutation({
mutationKey: ['FormSubmit', id],
mutationFn: async (fieldValues: TFieldValues) => {
try {
await onSubmit(fieldValues, innerForm)
} catch (error) {
innerForm.setError('root.submit', {
message: error instanceof Error ? error.message : getText('arbitraryFormErrorMessage'),
})
// TODO: Should we throw the error here?
// Or should we just log it?
// eslint-disable-next-line no-restricted-syntax
throw error
}
},
onError: onSubmitFailed,
onSuccess: onSubmitSuccess,
onMutate: onSubmitted,
onSettled: onSubmitted,
})
// There is no way to avoid type casting here
// eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax,@typescript-eslint/no-unsafe-argument
const formOnSubmit = innerForm.handleSubmit(formMutation.mutateAsync as any)
const formStateRenderProps = {
formState: innerForm.formState,
register: innerForm.register,
unregister: innerForm.unregister,
}
return (
<form
id={id}
ref={ref}
onSubmit={formOnSubmit}
className={typeof className === 'function' ? className(formStateRenderProps) : className}
style={typeof style === 'function' ? style(formStateRenderProps) : style}
noValidate
{...formProps}
>
<reactHookForm.FormProvider {...innerForm}>
{typeof children === 'function' ? children(formStateRenderProps) : children}
</reactHookForm.FormProvider>
</form>
)
}) as unknown as (<
TFieldValues extends reactHookForm.FieldValues,
// The type is defined on library level and we can't change it
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends reactHookForm.FieldValues | undefined = undefined,
>(
props: React.RefAttributes<HTMLFormElement> & types.FormProps<TFieldValues, TTransformedValues>
// eslint-disable-next-line no-restricted-syntax
) => React.JSX.Element) & {
/* eslint-disable @typescript-eslint/naming-convention */
schema: typeof components.schema
useForm: typeof components.useForm
Submit: typeof components.Submit
Reset: typeof components.Reset
FormError: typeof components.FormError
useFormSchema: typeof components.useFormSchema
/* eslint-enable @typescript-eslint/naming-convention */
}
Form.schema = components.schema
Form.useForm = components.useForm
Form.Submit = components.Submit
Form.Reset = components.Reset
Form.FormError = components.FormError
Form.useFormSchema = components.useFormSchema

View File

@ -0,0 +1,74 @@
/**
* @file
*
* Form error component.
*/
import * as React from 'react'
import * as reactHookForm from 'react-hook-form'
import * as textProvider from '#/providers/TextProvider'
import * as reactAriaComponents from '#/components/AriaComponents'
import type * as types from '../types'
/**
* Props for the FormError component.
*/
export interface FormErrorProps<
TFieldValues extends types.FieldValues,
TTransformedFieldValues extends types.FieldValues,
> extends Omit<reactAriaComponents.AlertProps, 'children'> {
readonly form?: reactHookForm.UseFormReturn<TFieldValues, unknown, TTransformedFieldValues>
}
/**
* Form error component.
*/
export function FormError<
TFieldValues extends types.FieldValues,
TTransformedFieldValues extends types.FieldValues,
>(props: FormErrorProps<TFieldValues, TTransformedFieldValues>) {
const {
form = reactHookForm.useFormContext(),
size = 'medium',
variant = 'error',
...alertProps
} = props
const { formState } = form
const { errors } = formState
const { getText } = textProvider.useText()
/**
* Get the error message.
*/
const getSubmitError = (): string | null => {
const formErrors = errors.root
if (formErrors) {
const submitError = formErrors.submit
if (submitError) {
return (
submitError.message ??
getText('arbitraryErrorTitle') + '. ' + getText('arbitraryErrorSubtitle')
)
} else {
return null
}
} else {
return null
}
}
const errorMessage = getSubmitError()
return errorMessage != null ? (
<reactAriaComponents.Alert size={size} variant={variant} {...alertProps}>
{errorMessage}
</reactAriaComponents.Alert>
) : null
}

View File

@ -0,0 +1,43 @@
/**
* @file
*
* Reset button for forms.
*/
import * as React from 'react'
import * as reactHookForm from 'react-hook-form'
import * as ariaComponents from '#/components/AriaComponents'
/**
* Props for the Reset component.
*/
export interface ResetProps extends Omit<ariaComponents.ButtonProps, 'loading'> {
/**
* Connects the submit button to a form.
* If not provided, the button will use the nearest form context.
*
* This field is helpful when you need to use the submit button outside of the form.
*/
// For this component, we don't need to know the form fields
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly form?: reactHookForm.UseFormReturn<any>
}
/**
* Reset button for forms.
*/
export function Reset(props: ResetProps): React.JSX.Element {
const { form = reactHookForm.useFormContext(), variant = 'cancel', size = 'medium' } = props
const { formState } = form
return (
<ariaComponents.Button
{...props}
variant={variant}
size={size}
isDisabled={formState.isSubmitting}
onPress={form.reset}
/>
)
}

View File

@ -0,0 +1,53 @@
/**
* @file
*
* Submit button for forms.
* Manages the form state and displays a loading spinner when the form is submitting.
*/
import * as React from 'react'
import * as reactHookForm from 'react-hook-form'
import * as ariaComponents from '#/components/AriaComponents'
/**
* Additional props for the Submit component.
*/
interface SubmitButtonBaseProps {
readonly variant?: ariaComponents.ButtonProps['variant']
/**
* Connects the submit button to a form.
* If not provided, the button will use the nearest form context.
*
* This field is helpful when you need to use the submit button outside of the form.
*/
// For this component, we don't need to know the form fields
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly form?: reactHookForm.UseFormReturn<any>
}
/**
* Props for the Submit component.
*/
export type SubmitProps = Omit<ariaComponents.ButtonProps, 'loading' | 'variant'> &
SubmitButtonBaseProps
/**
* Submit button for forms.
*
* Manages the form state and displays a loading spinner when the form is submitting.
*/
export function Submit(props: SubmitProps): React.JSX.Element {
const { form = reactHookForm.useFormContext(), variant = 'submit', size = 'medium' } = props
const { formState } = form
return (
<ariaComponents.Button
{...props}
type="submit"
variant={variant}
size={size}
loading={formState.isSubmitting}
/>
)
}

View File

@ -0,0 +1,12 @@
/**
* @file
*
* Barrel file for form components.
*/
export * from './Submit'
export * from './Reset'
export * from './useForm'
export * from './FormError'
export * from './types'
export * from './useFormSchema'
export * from './schema'

View File

@ -0,0 +1,7 @@
/**
* @file
*
* Create a schema for a form
*/
export * as schema from 'zod'

View File

@ -0,0 +1,35 @@
/**
* @file
* Types for the Form component.
*/
import type * as reactHookForm from 'react-hook-form'
import type * as z from 'zod'
/**
* Field Values type.
*/
export type FieldValues = reactHookForm.FieldValues
/**
* Props for the useForm hook.
*/
export interface UseFormProps<T extends FieldValues>
extends Omit<reactHookForm.UseFormProps<T>, 'resetOptions' | 'resolver'> {
// eslint-disable-next-line no-restricted-syntax
readonly schema?: z.ZodObject<T>
}
/**
* Return type of the useForm hook.
*/
export type UseFormReturn<
TFieldValues extends Record<string, unknown>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends Record<string, unknown> | undefined = undefined,
> = reactHookForm.UseFormReturn<TFieldValues, unknown, TTransformedValues>
/**
* Form State type.
*/
export type FormState<TFieldValues extends FieldValues> = reactHookForm.FormState<TFieldValues>

View File

@ -0,0 +1,68 @@
/**
* @file
*
* A hook that returns a form instance.
*/
import * as React from 'react'
import * as zodResolver from '@hookform/resolvers/zod'
import * as reactHookForm from 'react-hook-form'
import invariant from 'tiny-invariant'
import type * as types from './types'
/**
* A hook that returns a form instance.
* @param optionsOrFormInstance - Either form options or a form instance
*
* If form instance is passed, it will be returned as is
* If form options are passed, a form instance will be created and returned
*
* ***Note:*** This hook accepts either a form instance(If form is created outside)
* or form options(and creates a form instance).
* This is useful when you want to create a form instance outside the component
* and pass it to the component.
* But be careful, You should not switch between the two types of arguments.
* Otherwise you'll be fired
*/
export function useForm<
T extends types.FieldValues,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends types.FieldValues | undefined = undefined,
>(
optionsOrFormInstance: types.UseFormProps<T> | types.UseFormReturn<T, TTransformedValues>
): types.UseFormReturn<T, TTransformedValues> {
const initialTypePassed = React.useRef(getArgsType(optionsOrFormInstance))
const argsType = getArgsType(optionsOrFormInstance)
invariant(
initialTypePassed.current === argsType,
`
Found a switch between form options and form instance. This is not allowed. Please use either form options or form instance and stick to it.\n\n
Initially passed: ${initialTypePassed.current}, Currently passed: ${argsType}.
`
)
if ('formState' in optionsOrFormInstance) {
return optionsOrFormInstance
} else {
const { schema, ...options } = optionsOrFormInstance
return reactHookForm.useForm({
...options,
...(schema ? { resolver: zodResolver.zodResolver(schema) } : {}),
})
}
}
/**
* Get the type of arguments passed to the useForm hook
*/
function getArgsType<
T extends Record<string, unknown>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends Record<string, unknown> | undefined = undefined,
>(args: types.UseFormProps<T> | types.UseFormReturn<T, TTransformedValues>) {
return 'formState' in args ? 'formInstance' : 'formOptions'
}

View File

@ -0,0 +1,20 @@
/**
* @file This file contains the useFormSchema hook for creating form schemas.
*/
import * as React from 'react'
import * as callbackEventHooks from '#/hooks/eventCallbackHooks'
import * as schemaComponent from './schema'
import type * as types from './types'
/**
* Hook to create a form schema.
*/
export function useFormSchema<T extends types.FieldValues>(
callback: (schema: typeof schemaComponent.schema) => schemaComponent.schema.ZodObject<T>
): schemaComponent.schema.ZodObject<T> {
const callbackEvent = callbackEventHooks.useEventCallback(callback)
return React.useMemo(() => callbackEvent(schemaComponent.schema), [callbackEvent])
}

View File

@ -0,0 +1,8 @@
/**
* @file
*
* Barrel export file for Form components.
*/
export * from './Form'
export type * from './types'

View File

@ -0,0 +1,99 @@
/**
* @file
* Types for the Form component.
*/
import type * as reactHookForm from 'react-hook-form'
import type * as z from 'zod'
import type * as components from './components'
export type * from './components'
/**
* Props for the Form component
*/
export type FormProps<
TFieldValues extends components.FieldValues,
// This type is defined on library level and we can't change it
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues | undefined = undefined,
> = BaseFormProps<TFieldValues, TTransformedValues> &
(FormPropsWithOptions<TFieldValues> | FormPropsWithParentForm<TFieldValues, TTransformedValues>)
/**
* Base props for the Form component.
*/
interface BaseFormProps<
TFieldValues extends components.FieldValues,
// This type is defined on library level and we can't change it
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues | undefined = undefined,
> extends Omit<
React.HTMLProps<HTMLFormElement>,
'children' | 'className' | 'form' | 'onSubmit' | 'onSubmitCapture' | 'style'
> {
readonly className?: string | ((props: FormStateRenderProps<TFieldValues>) => string)
readonly onSubmit: (
values: TFieldValues,
form: components.UseFormReturn<TFieldValues, TTransformedValues>
) => unknown
readonly style?:
| React.CSSProperties
| ((props: FormStateRenderProps<TFieldValues>) => React.CSSProperties)
readonly children:
| React.ReactNode
| ((props: FormStateRenderProps<TFieldValues>) => React.ReactNode)
readonly formRef?: React.MutableRefObject<
components.UseFormReturn<TFieldValues, TTransformedValues>
>
readonly onSubmitFailed?: (error: unknown) => Promise<void> | void
readonly onSubmitSuccess?: () => Promise<void> | void
readonly onSubmitted?: () => Promise<void> | void
}
/**
* Props for the Form component with parent form
* or if form is passed as a prop.
*/
interface FormPropsWithParentForm<
TFieldValues extends components.FieldValues,
// This type is defined on library level and we can't change it
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues | undefined = undefined,
> {
readonly form: components.UseFormReturn<TFieldValues, TTransformedValues>
readonly schema?: never
readonly formOptions?: never
}
/**
* Props for the Form component with schema and form options.
* Creates a new form instance. This is the default way to use the form.
*/
interface FormPropsWithOptions<TFieldValues extends components.FieldValues> {
readonly form?: never
readonly schema?: z.ZodObject<TFieldValues>
readonly formOptions: Omit<components.UseFormProps<TFieldValues>, 'resolver'>
}
/**
* Form Render Props.
*/
export interface FormStateRenderProps<TFieldValues extends components.FieldValues> {
/**
* The form state. Contains the current values of the form fields.
*/
readonly formState: components.FormState<TFieldValues>
/**
* The form register function.
* Adds a field to the form state.
*/
readonly register: reactHookForm.UseFormRegister<TFieldValues>
/**
* The form unregister function.
* Removes a field from the form state.
*/
readonly unregister: reactHookForm.UseFormUnregister<TFieldValues>
}

View File

@ -6,3 +6,4 @@ export * from './Button/Button'
export * from './Tooltip/Tooltip'
export * from './Dialog'
export * from './Alert'
export * from './Form'

View File

@ -124,15 +124,18 @@ function RadioGroup(props: aria.RadioGroupProps, ref: React.ForwardedRef<HTMLDiv
const renderProps = useRenderProps({
...props,
defaultClassName: 'react-aria-RadioGroup',
values: {
orientation: props.orientation || 'vertical',
isInvalid: state.isInvalid,
isDisabled: state.isDisabled,
isReadOnly: state.isReadOnly,
isRequired: state.isRequired,
isInvalid: state.isInvalid,
state,
defaultClassName: 'react-aria-RadioGroup',
defaultStyle: {},
defaultChildren: null,
},
defaultClassName: 'react-aria-RadioGroup',
})
return (

View File

@ -577,6 +577,7 @@
"tryAgain": "Try again",
"arbitraryErrorTitle": "An error occurred",
"arbitraryErrorSubtitle": "Please try again or contact the administrators.",
"arbitraryFormErrorMessage": "Something went wrong while submitting the form. Please try again or contact the administrators.",
"subscribeSubmit": "Subscribe",
"BankCardLabel": "Bank Card",

5851
package-lock.json generated

File diff suppressed because it is too large Load Diff