mirror of
https://github.com/enso-org/enso.git
synced 2025-01-05 15:42:52 +03:00
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:
parent
65f28c322a
commit
1991aab19d
@ -34,26 +34,29 @@
|
|||||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
|
"@hookform/resolvers": "^3.4.0",
|
||||||
"@monaco-editor/react": "4.6.0",
|
"@monaco-editor/react": "4.6.0",
|
||||||
"@sentry/react": "^7.74.0",
|
"@sentry/react": "^7.74.0",
|
||||||
"@tanstack/react-query": "^5.27.5",
|
"@tanstack/react-query": "5.37.1",
|
||||||
"ajv": "^8.12.0",
|
"ajv": "^8.12.0",
|
||||||
"clsx": "^1.1.1",
|
"clsx": "^1.1.1",
|
||||||
"enso-common": "^1.0.0",
|
"enso-common": "^1.0.0",
|
||||||
"is-network-error": "^1.0.1",
|
"is-network-error": "^1.0.1",
|
||||||
"monaco-editor": "0.47.0",
|
"monaco-editor": "0.48.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.3.1",
|
||||||
"react-aria": "^3.32.1",
|
"react-aria": "^3.33.0",
|
||||||
"react-aria-components": "^1.1.1",
|
"react-aria-components": "^1.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.8.1",
|
"react-error-boundary": "4.0.13",
|
||||||
"react-stately": "^3.30.1",
|
"react-hook-form": "^7.51.4",
|
||||||
|
"react-router-dom": "^6.23.1",
|
||||||
|
"react-stately": "^3.31.0",
|
||||||
"react-toastify": "^9.1.3",
|
"react-toastify": "^9.1.3",
|
||||||
"tailwind-merge": "^2.2.1",
|
"tailwind-merge": "^2.3.0",
|
||||||
"tiny-invariant": "^1.3.3",
|
"tiny-invariant": "^1.3.3",
|
||||||
"ts-results": "^3.3.0",
|
"ts-results": "^3.3.0",
|
||||||
"validator": "^13.11.0",
|
"validator": "^13.12.0",
|
||||||
"react-error-boundary": "4.0.13"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/plugin-syntax-import-assertions": "^7.23.3",
|
"@babel/plugin-syntax-import-assertions": "^7.23.3",
|
||||||
@ -64,6 +67,7 @@
|
|||||||
"@playwright/experimental-ct-react": "^1.40.0",
|
"@playwright/experimental-ct-react": "^1.40.0",
|
||||||
"@playwright/test": "^1.40.0",
|
"@playwright/test": "^1.40.0",
|
||||||
"@react-types/shared": "^3.22.1",
|
"@react-types/shared": "^3.22.1",
|
||||||
|
"@tanstack/react-query-devtools": "5.37.1",
|
||||||
"@types/node": "^20.11.21",
|
"@types/node": "^20.11.21",
|
||||||
"@types/react": "^18.0.27",
|
"@types/react": "^18.0.27",
|
||||||
"@types/react-dom": "^18.0.10",
|
"@types/react-dom": "^18.0.10",
|
||||||
@ -71,7 +75,6 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "^6.7.2",
|
"@typescript-eslint/eslint-plugin": "^6.7.2",
|
||||||
"@typescript-eslint/parser": "^6.7.2",
|
"@typescript-eslint/parser": "^6.7.2",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"@tanstack/react-query-devtools": "^5.36.2",
|
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"enso-chat": "git://github.com/enso-org/enso-bot",
|
"enso-chat": "git://github.com/enso-org/enso-bot",
|
||||||
@ -88,8 +91,8 @@
|
|||||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||||
"react-toastify": "^9.1.3",
|
"react-toastify": "^9.1.3",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"tailwindcss-react-aria-components": "^1.1.1",
|
|
||||||
"tailwindcss-animate": "1.0.7",
|
"tailwindcss-animate": "1.0.7",
|
||||||
|
"tailwindcss-react-aria-components": "^1.1.1",
|
||||||
"ts-plugin-namespace-auto-import": "^1.0.0",
|
"ts-plugin-namespace-auto-import": "^1.0.0",
|
||||||
"typescript": "~5.2.2",
|
"typescript": "~5.2.2",
|
||||||
"vite": "^4.4.9",
|
"vite": "^4.4.9",
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import * as reactQuery from '@tanstack/react-query'
|
||||||
import * as reactQueryDevtools from '@tanstack/react-query-devtools'
|
import * as reactQueryDevtools from '@tanstack/react-query-devtools'
|
||||||
|
|
||||||
const ReactQueryDevtoolsProduction = React.lazy(() =>
|
const ReactQueryDevtoolsProduction = React.lazy(() =>
|
||||||
@ -19,6 +20,11 @@ const ReactQueryDevtoolsProduction = React.lazy(() =>
|
|||||||
*/
|
*/
|
||||||
export function ReactQueryDevtools() {
|
export function ReactQueryDevtools() {
|
||||||
const [showDevtools, setShowDevtools] = React.useState(false)
|
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(() => {
|
React.useEffect(() => {
|
||||||
window.toggleDevtools = () => {
|
window.toggleDevtools = () => {
|
||||||
@ -28,11 +34,11 @@ export function ReactQueryDevtools() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<reactQueryDevtools.ReactQueryDevtools />
|
<reactQueryDevtools.ReactQueryDevtools client={client} />
|
||||||
|
|
||||||
{showDevtools && (
|
{showDevtools && (
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<ReactQueryDevtoolsProduction />
|
<ReactQueryDevtoolsProduction client={client} />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -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
|
@ -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
|
||||||
|
}
|
@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
@ -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'
|
@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* @file
|
||||||
|
*
|
||||||
|
* Create a schema for a form
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * as schema from 'zod'
|
@ -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>
|
@ -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'
|
||||||
|
}
|
@ -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])
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* @file
|
||||||
|
*
|
||||||
|
* Barrel export file for Form components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './Form'
|
||||||
|
export type * from './types'
|
@ -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>
|
||||||
|
}
|
@ -6,3 +6,4 @@ export * from './Button/Button'
|
|||||||
export * from './Tooltip/Tooltip'
|
export * from './Tooltip/Tooltip'
|
||||||
export * from './Dialog'
|
export * from './Dialog'
|
||||||
export * from './Alert'
|
export * from './Alert'
|
||||||
|
export * from './Form'
|
||||||
|
@ -124,15 +124,18 @@ function RadioGroup(props: aria.RadioGroupProps, ref: React.ForwardedRef<HTMLDiv
|
|||||||
|
|
||||||
const renderProps = useRenderProps({
|
const renderProps = useRenderProps({
|
||||||
...props,
|
...props,
|
||||||
|
defaultClassName: 'react-aria-RadioGroup',
|
||||||
values: {
|
values: {
|
||||||
orientation: props.orientation || 'vertical',
|
orientation: props.orientation || 'vertical',
|
||||||
|
isInvalid: state.isInvalid,
|
||||||
isDisabled: state.isDisabled,
|
isDisabled: state.isDisabled,
|
||||||
isReadOnly: state.isReadOnly,
|
isReadOnly: state.isReadOnly,
|
||||||
isRequired: state.isRequired,
|
isRequired: state.isRequired,
|
||||||
isInvalid: state.isInvalid,
|
|
||||||
state,
|
state,
|
||||||
},
|
|
||||||
defaultClassName: 'react-aria-RadioGroup',
|
defaultClassName: 'react-aria-RadioGroup',
|
||||||
|
defaultStyle: {},
|
||||||
|
defaultChildren: null,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -577,6 +577,7 @@
|
|||||||
"tryAgain": "Try again",
|
"tryAgain": "Try again",
|
||||||
"arbitraryErrorTitle": "An error occurred",
|
"arbitraryErrorTitle": "An error occurred",
|
||||||
"arbitraryErrorSubtitle": "Please try again or contact the administrators.",
|
"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",
|
"subscribeSubmit": "Subscribe",
|
||||||
"BankCardLabel": "Bank Card",
|
"BankCardLabel": "Bank Card",
|
||||||
|
5851
package-lock.json
generated
5851
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user