Improve Form typings (#10119)

This PR improves ergonimics and typesafety when using new Form component.
Also it passes validation state into react-aria-components
This commit is contained in:
Sergei Garin 2024-06-03 12:03:12 +03:00 committed by GitHub
parent d08cb704b0
commit 6adf590d9f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 370 additions and 155 deletions

View File

@ -63,7 +63,7 @@ export const BUTTON_STYLES = twv.tv({
isFocused: { isFocused: {
true: 'focus:outline-none focus-visible:outline focus-visible:outline-primary', true: 'focus:outline-none focus-visible:outline focus-visible:outline-primary',
}, },
loading: { true: { base: 'cursor-wait', content: 'opacity-0' } }, loading: { true: { base: 'cursor-wait' } },
fullWidth: { true: 'w-full' }, fullWidth: { true: 'w-full' },
size: { size: {
custom: '', custom: '',
@ -111,9 +111,8 @@ export const BUTTON_STYLES = twv.tv({
slots: { slots: {
extraClickZone: 'flex relative after:inset-[-12px] after:absolute', extraClickZone: 'flex relative after:inset-[-12px] after:absolute',
wrapper: 'relative block', wrapper: 'relative block',
loader: loader: 'absolute inset-0 flex items-center justify-center',
'animate-appear-delayed absolute inset-0 flex items-center justify-center duration-1000', content: 'flex items-center gap-[0.5em]',
content: 'flex items-center gap-[0.5em] delay-1000 duration-0',
icon: 'h-[1.5em] flex-none', icon: 'h-[1.5em] flex-none',
}, },
defaultVariants: { defaultVariants: {
@ -146,7 +145,7 @@ export const Button = React.forwardRef(function Button(
variant, variant,
icon, icon,
loading = false, loading = false,
isDisabled: disabled, isDisabled,
showIconOnHover, showIconOnHover,
iconPosition, iconPosition,
size, size,
@ -160,6 +159,8 @@ export const Button = React.forwardRef(function Button(
const focusChildProps = focusHooks.useFocusChild() const focusChildProps = focusHooks.useFocusChild()
const [implicitlyLoading, setImplicitlyLoading] = React.useState(false) const [implicitlyLoading, setImplicitlyLoading] = React.useState(false)
const contentRef = React.useRef<HTMLSpanElement>(null)
const loaderRef = React.useRef<HTMLSpanElement>(null)
const isLink = ariaProps.href != null const isLink = ariaProps.href != null
@ -173,16 +174,41 @@ export const Button = React.forwardRef(function Button(
const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null
const isLoading = loading || implicitlyLoading const isLoading = loading || implicitlyLoading
const isDisabled = disabled || isLoading
React.useLayoutEffect(() => {
const delay = 350
if (isLoading) {
const loaderAnimation = loaderRef.current?.animate(
[{ opacity: 0 }, { opacity: 0, offset: 1 }, { opacity: 1 }],
{ duration: delay, easing: 'linear', delay: 0, fill: 'forwards' }
)
const contentAnimation = contentRef.current?.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: 0,
easing: 'linear',
delay,
fill: 'forwards',
})
return () => {
loaderAnimation?.cancel()
contentAnimation?.cancel()
}
} else {
return () => {}
}
}, [isLoading])
const handlePress = (event: aria.PressEvent): void => { const handlePress = (event: aria.PressEvent): void => {
const result = onPress(event) if (!isLoading) {
const result = onPress(event)
if (result instanceof Promise) { if (result instanceof Promise) {
setImplicitlyLoading(true) setImplicitlyLoading(true)
void result.finally(() => { void result.finally(() => {
setImplicitlyLoading(false) setImplicitlyLoading(false)
}) })
}
} }
} }
@ -240,10 +266,12 @@ export const Button = React.forwardRef(function Button(
)} )}
> >
<span className={wrapper()}> <span className={wrapper()}>
<span className={content()}>{childrenFactory()}</span> <span ref={contentRef} className={content()}>
{childrenFactory()}
</span>
{isLoading && ( {isLoading && (
<span className={loader()}> <span ref={loaderRef} className={loader()}>
<Spinner state={spinnerModule.SpinnerState.loadingMedium} size={16} /> <Spinner state={spinnerModule.SpinnerState.loadingMedium} size={16} />
</span> </span>
)} )}

View File

@ -10,7 +10,10 @@ import * as reactHookForm from 'react-hook-form'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as components from './components' import * as components from './components'
import * as styles from './styles'
import type * as types from './types' import type * as types from './types'
/** /**
@ -24,11 +27,14 @@ import type * as types from './types'
// There is no way to avoid type casting here // There is no way to avoid type casting here
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
export const Form = React.forwardRef(function Form< export const Form = React.forwardRef(function Form<
TFieldValues extends reactHookForm.FieldValues, Schema extends components.TSchema,
// This type is defined on library level and we can't change it TFieldValues extends components.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
TTransformedValues extends reactHookForm.FieldValues | undefined = undefined, TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
>(props: types.FormProps<TFieldValues, TTransformedValues>, ref: React.Ref<HTMLFormElement>) { >(
props: types.FormProps<Schema, TFieldValues, TTransformedValues>,
ref: React.Ref<HTMLFormElement>
) {
const formId = React.useId() const formId = React.useId()
const { const {
@ -45,15 +51,22 @@ export const Form = React.forwardRef(function Form<
id = formId, id = formId,
testId, testId,
schema, schema,
defaultValues,
gap,
...formProps ...formProps
} = props } = props
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const innerForm = components.useForm<TFieldValues, TTransformedValues>( if (defaultValues) {
formOptions.defaultValues = defaultValues
}
const innerForm = components.useForm(
form ?? { form ?? {
shouldFocusError: true,
...formOptions, ...formOptions,
...(schema ? { schema } : {}), schema,
} }
) )
@ -83,41 +96,89 @@ export const Form = React.forwardRef(function Form<
// There is no way to avoid type casting here // 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 // 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 formOnSubmit = innerForm.handleSubmit(formMutation.mutateAsync as any)
const { formState, clearErrors, getValues, setValue, setError, register, unregister } = innerForm const {
const formStateRenderProps: types.FormStateRenderProps<TFieldValues> = {
formState, formState,
clearErrors,
getValues,
setValue,
setError,
register, register,
unregister, unregister,
setFocus,
reset,
} = innerForm
const formStateRenderProps: types.FormStateRenderProps<Schema, TFieldValues> = {
formState,
register: (name, options) => {
const registered = register(name, options)
const onChange: types.UseFormRegisterReturn<Schema, TFieldValues>['onChange'] = value => {
if (typeof value === 'object' && value != null && 'target' in value && 'type' in value) {
return registered.onChange(value)
} else {
return registered.onChange({ target: { event: value } })
}
}
const result: types.UseFormRegisterReturn<Schema, TFieldValues, typeof name> = {
...registered,
...(registered.disabled != null ? { isDisabled: registered.disabled } : {}),
...(registered.required != null ? { isRequired: registered.required } : {}),
isInvalid: !!formState.errors[name],
onChange,
}
return result
},
unregister,
setError, setError,
clearErrors, clearErrors,
getValues, getValues,
setValue, setValue,
setFocus,
reset,
} }
const base = styles.FORM_STYLES({
className: typeof className === 'function' ? className(formStateRenderProps) : className,
gap,
})
// eslint-disable-next-line no-restricted-syntax
const errors = Object.fromEntries(
Object.entries(formState.errors).map(([key, error]) => {
const message = error?.message ?? getText('arbitraryFormErrorMessage')
return [key, message]
})
) as Record<keyof TFieldValues, string>
return ( return (
<form <form
id={id} id={id}
ref={ref} ref={ref}
onSubmit={formOnSubmit} onSubmit={formOnSubmit}
className={typeof className === 'function' ? className(formStateRenderProps) : className} className={base}
style={typeof style === 'function' ? style(formStateRenderProps) : style} style={typeof style === 'function' ? style(formStateRenderProps) : style}
noValidate noValidate
data-testid={testId} data-testid={testId}
{...formProps} {...formProps}
> >
<reactHookForm.FormProvider {...innerForm}> <aria.FormValidationContext.Provider value={errors}>
{typeof children === 'function' ? children(formStateRenderProps) : children} <reactHookForm.FormProvider {...innerForm}>
</reactHookForm.FormProvider> {typeof children === 'function' ? children(formStateRenderProps) : children}
</reactHookForm.FormProvider>
</aria.FormValidationContext.Provider>
</form> </form>
) )
}) as unknown as (< }) as unknown as (<
TFieldValues extends reactHookForm.FieldValues, Schema extends components.TSchema,
// The type is defined on library level and we can't change it TFieldValues extends components.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
TTransformedValues extends reactHookForm.FieldValues | undefined = undefined, TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
>( >(
props: React.RefAttributes<HTMLFormElement> & types.FormProps<TFieldValues, TTransformedValues> props: React.RefAttributes<HTMLFormElement> &
types.FormProps<Schema, TFieldValues, TTransformedValues>
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
) => React.JSX.Element) & { ) => React.JSX.Element) & {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */

View File

@ -18,8 +18,8 @@ import type * as types from '../types'
* Props for the FormError component. * Props for the FormError component.
*/ */
export interface FormErrorProps< export interface FormErrorProps<
TFieldValues extends types.FieldValues, TFieldValues extends types.FieldValues<never>,
TTransformedFieldValues extends types.FieldValues, TTransformedFieldValues extends types.FieldValues<never>,
> extends Omit<reactAriaComponents.AlertProps, 'children'> { > extends Omit<reactAriaComponents.AlertProps, 'children'> {
readonly form?: reactHookForm.UseFormReturn<TFieldValues, unknown, TTransformedFieldValues> readonly form?: reactHookForm.UseFormReturn<TFieldValues, unknown, TTransformedFieldValues>
} }
@ -28,8 +28,8 @@ export interface FormErrorProps<
* Form error component. * Form error component.
*/ */
export function FormError< export function FormError<
TFieldValues extends types.FieldValues, TFieldValues extends types.FieldValues<never>,
TTransformedFieldValues extends types.FieldValues, TTransformedFieldValues extends types.FieldValues<never>,
>(props: FormErrorProps<TFieldValues, TTransformedFieldValues>) { >(props: FormErrorProps<TFieldValues, TTransformedFieldValues>) {
const { const {
form = reactHookForm.useFormContext(), form = reactHookForm.useFormContext(),

View File

@ -8,6 +8,8 @@ import * as React from 'react'
import * as reactHookForm from 'react-hook-form' import * as reactHookForm from 'react-hook-form'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents' import * as ariaComponents from '#/components/AriaComponents'
/** /**
@ -43,7 +45,10 @@ export function Submit(props: SubmitProps): React.JSX.Element {
variant = 'submit', variant = 'submit',
size = 'medium', size = 'medium',
testId = 'form-submit-button', testId = 'form-submit-button',
children,
} = props } = props
const { getText } = textProvider.useText()
const { formState } = form const { formState } = form
return ( return (
@ -54,6 +59,8 @@ export function Submit(props: SubmitProps): React.JSX.Element {
size={size} size={size}
loading={formState.isSubmitting} loading={formState.isSubmitting}
testId={testId} testId={testId}
/> >
{children ?? getText('submit')}
</ariaComponents.Button>
) )
} }

View File

@ -9,22 +9,30 @@ import type * as z from 'zod'
/** /**
* Field Values type. * Field Values type.
*/ */
export type FieldValues = reactHookForm.FieldValues export type FieldValues<Schema extends TSchema> = [Schema] extends [never]
? reactHookForm.FieldValues
: z.infer<Schema>
/**
* Schema type
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type TSchema = z.ZodObject<any>
/** /**
* Props for the useForm hook. * Props for the useForm hook.
*/ */
export interface UseFormProps<T extends FieldValues> export interface UseFormProps<Schema extends TSchema, TFieldValues extends FieldValues<Schema>>
extends Omit<reactHookForm.UseFormProps<T>, 'resetOptions' | 'resolver'> { extends Omit<reactHookForm.UseFormProps<TFieldValues>, 'resetOptions' | 'resolver'> {
// eslint-disable-next-line no-restricted-syntax readonly schema: Schema
readonly schema?: z.ZodObject<T>
} }
/** /**
* Return type of the useForm hook. * Return type of the useForm hook.
*/ */
export type UseFormReturn< export type UseFormReturn<
TFieldValues extends Record<string, unknown>, Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
TTransformedValues extends Record<string, unknown> | undefined = undefined, TTransformedValues extends Record<string, unknown> | undefined = undefined,
> = reactHookForm.UseFormReturn<TFieldValues, unknown, TTransformedValues> > = reactHookForm.UseFormReturn<TFieldValues, unknown, TTransformedValues>
@ -32,4 +40,7 @@ export type UseFormReturn<
/** /**
* Form State type. * Form State type.
*/ */
export type FormState<TFieldValues extends FieldValues> = reactHookForm.FormState<TFieldValues> export type FormState<
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
> = reactHookForm.FormState<TFieldValues>

View File

@ -26,12 +26,15 @@ import type * as types from './types'
* Otherwise you'll be fired * Otherwise you'll be fired
*/ */
export function useForm< export function useForm<
T extends types.FieldValues, Schema extends types.TSchema,
TFieldValues extends types.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
TTransformedValues extends types.FieldValues | undefined = undefined, TTransformedValues extends types.FieldValues<Schema> | undefined = undefined,
>( >(
optionsOrFormInstance: types.UseFormProps<T> | types.UseFormReturn<T, TTransformedValues> optionsOrFormInstance:
): types.UseFormReturn<T, TTransformedValues> { | types.UseFormProps<Schema, TFieldValues>
| types.UseFormReturn<Schema, TFieldValues, TTransformedValues>
): types.UseFormReturn<Schema, TFieldValues, TTransformedValues> {
const initialTypePassed = React.useRef(getArgsType(optionsOrFormInstance)) const initialTypePassed = React.useRef(getArgsType(optionsOrFormInstance))
const argsType = getArgsType(optionsOrFormInstance) const argsType = getArgsType(optionsOrFormInstance)
@ -49,9 +52,9 @@ export function useForm<
} else { } else {
const { schema, ...options } = optionsOrFormInstance const { schema, ...options } = optionsOrFormInstance
return reactHookForm.useForm({ return reactHookForm.useForm<TFieldValues, unknown, TTransformedValues>({
...options, ...options,
...(schema ? { resolver: zodResolver.zodResolver(schema, { async: true }) } : {}), resolver: zodResolver.zodResolver(schema, { async: true }),
}) })
} }
} }
@ -60,9 +63,14 @@ export function useForm<
* Get the type of arguments passed to the useForm hook * Get the type of arguments passed to the useForm hook
*/ */
function getArgsType< function getArgsType<
T extends Record<string, unknown>, Schema extends types.TSchema,
TFieldValues extends types.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
TTransformedValues extends Record<string, unknown> | undefined = undefined, TTransformedValues extends types.FieldValues<Schema> | undefined = undefined,
>(args: types.UseFormProps<T> | types.UseFormReturn<T, TTransformedValues>) { >(
args:
| types.UseFormProps<Schema, TFieldValues>
| types.UseFormReturn<Schema, TFieldValues, TTransformedValues>
) {
return 'formState' in args ? 'formInstance' : 'formOptions' return 'formState' in args ? 'formInstance' : 'formOptions'
} }

View File

@ -12,9 +12,14 @@ import type * as types from './types'
/** /**
* Hook to create a form schema. * Hook to create a form schema.
*/ */
export function useFormSchema<T extends types.FieldValues>( export function useFormSchema<Schema extends types.TSchema, T extends types.FieldValues<Schema>>(
callback: (schema: typeof schemaComponent.schema) => schemaComponent.schema.ZodObject<T> callback: (schema: typeof schemaComponent.schema) => schemaComponent.schema.ZodObject<T>
): schemaComponent.schema.ZodObject<T> { ) {
const callbackEvent = callbackEventHooks.useEventCallback(callback) const callbackEvent = callbackEventHooks.useEventCallback(callback)
return React.useMemo(() => callbackEvent(schemaComponent.schema), [callbackEvent]) const res = React.useMemo(() => callbackEvent(schemaComponent.schema), [callbackEvent])
// We assume that all the fields are optional by default
// This is because we don't want to force the user to provide a value for every field
// But if the user wants to make a field required, they can do so by providing a default value for the field
return res.partial()
} }

View File

@ -0,0 +1,26 @@
/**
* @file
*
* Styles for form components.
*/
import * as twv from 'tailwind-variants'
/**
* Props for form components.
*/
export type FormStyleProps = twv.VariantProps<typeof FORM_STYLES>
export const FORM_STYLES = twv.tv({
base: 'flex flex-col items-start',
variants: {
gap: {
custom: '',
none: 'gap-0',
small: 'gap-2',
medium: 'gap-4',
large: 'gap-6',
},
},
defaultVariants: {
gap: 'medium',
},
})

View File

@ -6,9 +6,9 @@
import type * as React from 'react' import type * as React from 'react'
import type * as reactHookForm from 'react-hook-form' import type * as reactHookForm from 'react-hook-form'
import type * as z from 'zod'
import type * as components from './components' import type * as components from './components'
import type * as styles from './styles'
export type * from './components' export type * from './components'
@ -16,40 +16,54 @@ export type * from './components'
* Props for the Form component * Props for the Form component
*/ */
export type FormProps< export type FormProps<
TFieldValues extends components.FieldValues, Schema extends components.TSchema,
// This type is defined on library level and we can't change it TFieldValues extends components.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues | undefined = undefined, TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
> = BaseFormProps<TFieldValues, TTransformedValues> & > = BaseFormProps<Schema, TFieldValues, TTransformedValues> &
(FormPropsWithOptions<TFieldValues> | FormPropsWithParentForm<TFieldValues, TTransformedValues>) (
| FormPropsWithOptions<Schema, TFieldValues>
| FormPropsWithParentForm<Schema, TFieldValues, TTransformedValues>
)
/** /**
* Base props for the Form component. * Base props for the Form component.
*/ */
interface BaseFormProps< interface BaseFormProps<
TFieldValues extends components.FieldValues, Schema extends components.TSchema,
// This type is defined on library level and we can't change it TFieldValues extends components.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues | undefined = undefined, TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
> extends Omit< > extends Omit<
React.HTMLProps<HTMLFormElement>, React.HTMLProps<HTMLFormElement>,
'children' | 'className' | 'form' | 'onSubmit' | 'onSubmitCapture' | 'style' 'children' | 'className' | 'form' | 'onSubmit' | 'onSubmitCapture' | 'style'
> { >,
readonly className?: string | ((props: FormStateRenderProps<TFieldValues>) => string) styles.FormStyleProps {
/**
* The default values for the form fields
*
* __Note:__ Even though this is optional,
* it is recommended to provide default values and specify all fields defined in the schema.
* Otherwise Typescript fails to infer the correct type for the form values.
* This is a known limitation and we are working on a solution.
*/
readonly defaultValues?: reactHookForm.UseFormProps<TFieldValues>['defaultValues']
readonly onSubmit: ( readonly onSubmit: (
values: TFieldValues, values: TFieldValues,
form: components.UseFormReturn<TFieldValues, TTransformedValues> form: components.UseFormReturn<Schema, TFieldValues, TTransformedValues>
) => unknown ) => unknown
readonly style?: readonly style?:
| React.CSSProperties | React.CSSProperties
| ((props: FormStateRenderProps<TFieldValues>) => React.CSSProperties) | ((props: FormStateRenderProps<Schema, TFieldValues>) => React.CSSProperties)
readonly children: readonly children:
| React.ReactNode | React.ReactNode
| ((props: FormStateRenderProps<TFieldValues>) => React.ReactNode) | ((props: FormStateRenderProps<Schema, TFieldValues>) => React.ReactNode)
readonly formRef?: React.MutableRefObject< readonly formRef?: React.MutableRefObject<
components.UseFormReturn<TFieldValues, TTransformedValues> components.UseFormReturn<Schema, TFieldValues, TTransformedValues>
> >
readonly className?: string | ((props: FormStateRenderProps<Schema, TFieldValues>) => string)
readonly onSubmitFailed?: (error: unknown) => Promise<void> | void readonly onSubmitFailed?: (error: unknown) => Promise<void> | void
readonly onSubmitSuccess?: () => Promise<void> | void readonly onSubmitSuccess?: () => Promise<void> | void
readonly onSubmitted?: () => Promise<void> | void readonly onSubmitted?: () => Promise<void> | void
@ -62,12 +76,12 @@ interface BaseFormProps<
* or if form is passed as a prop. * or if form is passed as a prop.
*/ */
interface FormPropsWithParentForm< interface FormPropsWithParentForm<
TFieldValues extends components.FieldValues, Schema extends components.TSchema,
// This type is defined on library level and we can't change it TFieldValues extends components.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues | undefined = undefined, TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
> { > {
readonly form?: components.UseFormReturn<TFieldValues, TTransformedValues> readonly form: components.UseFormReturn<Schema, TFieldValues, TTransformedValues>
readonly schema?: never readonly schema?: never
readonly formOptions?: never readonly formOptions?: never
} }
@ -76,25 +90,60 @@ interface FormPropsWithParentForm<
* Props for the Form component with schema and form options. * Props for the Form component with schema and form options.
* Creates a new form instance. This is the default way to use the form. * Creates a new form instance. This is the default way to use the form.
*/ */
interface FormPropsWithOptions<TFieldValues extends components.FieldValues> { interface FormPropsWithOptions<
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
> {
readonly schema: Schema
readonly form?: never readonly form?: never
readonly schema?: z.ZodObject<TFieldValues> readonly formOptions?: Omit<components.UseFormProps<Schema, TFieldValues>, 'resolver' | 'schema'>
readonly formOptions?: Omit<components.UseFormProps<TFieldValues>, 'resolver'> }
/**
*
*/
export type UseFormRegister<
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
> = <
TFieldName extends reactHookForm.FieldPath<TFieldValues> = reactHookForm.FieldPath<TFieldValues>,
>(
name: TFieldName,
options?: reactHookForm.RegisterOptions<TFieldValues, TFieldName>
// eslint-disable-next-line no-restricted-syntax
) => UseFormRegisterReturn<Schema, TFieldValues, TFieldName>
/**
* UseFormRegister return type.
*/
export interface UseFormRegisterReturn<
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
TFieldName extends reactHookForm.FieldPath<TFieldValues> = reactHookForm.FieldPath<TFieldValues>,
> extends Omit<reactHookForm.UseFormRegisterReturn<TFieldName>, 'onChange'> {
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
readonly onChange: <Value>(value: Value) => Promise<boolean | void> | void
readonly isDisabled?: boolean
readonly isRequired?: boolean
readonly isInvalid?: boolean
} }
/** /**
* Form Render Props. * Form Render Props.
*/ */
export interface FormStateRenderProps<TFieldValues extends components.FieldValues> { export interface FormStateRenderProps<
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
> {
/** /**
* The form state. Contains the current values of the form fields. * The form state. Contains the current values of the form fields.
*/ */
readonly formState: components.FormState<TFieldValues> readonly formState: components.FormState<Schema, TFieldValues>
/** /**
* The form register function. * The form register function.
* Adds a field to the form state. * Adds a field to the form state.
*/ */
readonly register: reactHookForm.UseFormRegister<TFieldValues> readonly register: UseFormRegister<Schema, TFieldValues>
/** /**
* The form unregister function. * The form unregister function.
* Removes a field from the form state. * Removes a field from the form state.
@ -104,4 +153,6 @@ export interface FormStateRenderProps<TFieldValues extends components.FieldValue
readonly getValues: reactHookForm.UseFormGetValues<TFieldValues> readonly getValues: reactHookForm.UseFormGetValues<TFieldValues>
readonly setError: reactHookForm.UseFormSetError<TFieldValues> readonly setError: reactHookForm.UseFormSetError<TFieldValues>
readonly clearErrors: reactHookForm.UseFormClearErrors<TFieldValues> readonly clearErrors: reactHookForm.UseFormClearErrors<TFieldValues>
readonly setFocus: reactHookForm.UseFormSetFocus<TFieldValues>
readonly reset: reactHookForm.UseFormReset<TFieldValues>
} }

View File

@ -66,54 +66,58 @@ export function SetOrganizationNameModal() {
hideCloseButton hideCloseButton
modalProps={{ isOpen: shouldShowModal }} modalProps={{ isOpen: shouldShowModal }}
> >
<aria.Form <ariaComponents.Form
onSubmit={event => { gap="medium"
event.preventDefault() defaultValues={{ organization: '' }}
const name = new FormData(event.currentTarget).get('organization') schema={ariaComponents.Form.useFormSchema(z =>
z.object({
if (typeof name === 'string') { organization: z
submit.mutate(name) .string()
} .min(1, getText('arbitraryFieldRequired'))
}} // eslint-disable-next-line @typescript-eslint/no-magic-numbers
> .max(255, getText('arbitraryFieldTooLong')),
<aria.TextField })
name="organization"
isRequired
autoFocus
inputMode="text"
autoComplete="off"
className="flex w-full flex-col"
>
<aria.Label className="mb-1 ml-0.5 block text-sm">{getText('organization')}</aria.Label>
<aria.Input
className={values =>
clsx('rounded-md border border-gray-300 p-1.5 text-sm transition-[outline]', {
// eslint-disable-next-line @typescript-eslint/naming-convention
'outline outline-2 outline-primary': values.isFocused || values.isFocusVisible,
// eslint-disable-next-line @typescript-eslint/naming-convention
'border-red-500 outline-red-500': values.isInvalid,
})
}
/>
<aria.FieldError className="text-sm text-red-500" />
</aria.TextField>
{submit.error && (
<ariaComponents.Alert variant="error" size="medium" className="mt-4">
{submit.error.message}
</ariaComponents.Alert>
)} )}
onSubmit={({ organization }) => submit.mutateAsync(organization)}
>
{({ register, formState }) => {
return (
<>
<aria.TextField
autoFocus
inputMode="text"
autoComplete="off"
className="flex w-full flex-col"
{...register('organization')}
>
<aria.Label className="mb-1 ml-0.5 block text-sm">
{getText('organization')}
</aria.Label>
<ariaComponents.Button <aria.Input
className="mt-4" className={values =>
type="submit" clsx('rounded-md border border-gray-300 p-1.5 text-sm transition-[outline]', {
variant="submit" // eslint-disable-next-line @typescript-eslint/naming-convention
size="medium" 'outline outline-2 outline-primary':
loading={submit.isPending} values.isFocused || values.isFocusVisible,
> // eslint-disable-next-line @typescript-eslint/naming-convention
{getText('submit')} 'border-red-500 outline-red-500': values.isInvalid,
</ariaComponents.Button> })
</aria.Form> }
/>
<aria.FieldError className="text-sm text-red-500">
{formState.errors.organization?.message}
</aria.FieldError>
</aria.TextField>
<ariaComponents.Form.FormError />
<ariaComponents.Form.Submit />
</>
)
}}
</ariaComponents.Form>
</ariaComponents.Dialog> </ariaComponents.Dialog>
<router.Outlet context={session} /> <router.Outlet context={session} />

View File

@ -28,6 +28,7 @@ declare module '#/utilities/LocalStorage' {
readonly termsOfService: z.infer<typeof TERMS_OF_SERVICE_SCHEMA> | null readonly termsOfService: z.infer<typeof TERMS_OF_SERVICE_SCHEMA> | null
} }
} }
const TERMS_OF_SERVICE_SCHEMA = z.object({ versionHash: z.string() }) const TERMS_OF_SERVICE_SCHEMA = z.object({ versionHash: z.string() })
LocalStorage.registerKey('termsOfService', { schema: TERMS_OF_SERVICE_SCHEMA }) LocalStorage.registerKey('termsOfService', { schema: TERMS_OF_SERVICE_SCHEMA })
@ -70,6 +71,16 @@ export function TermsOfServiceModal() {
const isAccepted = localVersionHash != null const isAccepted = localVersionHash != null
const shouldDisplay = !(isAccepted && isLatest) const shouldDisplay = !(isAccepted && isLatest)
const formSchema = ariaComponents.Form.useFormSchema(schema =>
schema.object({
agree: schema
.boolean()
// we accept only true
.refine(value => value, getText('licenseAgreementCheckboxError')),
hash: schema.string(),
})
)
if (shouldDisplay) { if (shouldDisplay) {
return ( return (
<> <>
@ -83,43 +94,32 @@ export function TermsOfServiceModal() {
id="terms-of-service-modal" id="terms-of-service-modal"
> >
<ariaComponents.Form <ariaComponents.Form
schema={formSchema}
defaultValues={{ agree: false, hash: latestVersionHash }}
testId="terms-of-service-form" testId="terms-of-service-form"
schema={ariaComponents.Form.schema.object({ onSubmit={({ hash }) => {
agree: ariaComponents.Form.schema localStorage.set('termsOfService', { versionHash: hash })
.boolean()
// we accept only true
.refine(value => value, getText('licenseAgreementCheckboxError')),
})}
onSubmit={() => {
localStorage.set('termsOfService', { versionHash: latestVersionHash })
}} }}
> >
{({ register, formState }) => { {({ register, formState }) => {
const agreeError = formState.errors.agree const agreeError = formState.errors.agree
const hasError = formState.errors.agree != null const hasError = agreeError != null
const checkboxRegister = register('agree')
return ( return (
<> <>
<div className="pb-6 pt-2"> <div>
<div className="mb-1"> <div className="mb-1">
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<div className="mt-0"> <aria.Input
<aria.Input type="checkbox"
type="checkbox" className={twMerge.twMerge(
className={twMerge.twMerge( `flex size-4 cursor-pointer overflow-clip rounded-lg border border-primary outline-primary focus-visible:outline focus-visible:outline-2 ${hasError ? 'border-red-700 text-red-500 outline-red-500' : ''}`
`flex size-4 cursor-pointer overflow-clip rounded-lg border border-primary outline-primary focus-visible:outline focus-visible:outline-2 ${hasError ? 'border-red-700 text-red-500 outline-red-500' : ''}` )}
)} id={checkboxId}
id={checkboxId} aria-invalid={hasError}
aria-invalid={hasError} data-testid="terms-of-service-checkbox"
{...checkboxRegister} {...register('agree')}
onInput={event => { />
void checkboxRegister.onChange(event)
}}
data-testid="terms-of-service-checkbox"
/>
</div>
<aria.Label htmlFor={checkboxId} className="text-sm"> <aria.Label htmlFor={checkboxId} className="text-sm">
{getText('licenseAgreementCheckbox')} {getText('licenseAgreementCheckbox')}

View File

@ -611,6 +611,20 @@
"appNameCloudEdition": "Enso Cloud Edition", "appNameCloudEdition": "Enso Cloud Edition",
"tryAgain": "Try again", "tryAgain": "Try again",
"arbitraryFieldRequired": "This field is required",
"arbitraryFieldInvalid": "This field is invalid",
"arbitraryFieldTooShort": "This field is too short",
"arbitraryFieldTooLong": "This field is too long",
"arbitraryFieldTooSmall": "This field is too small",
"arbitraryFieldTooLarge": "This field is too large",
"arbitraryFieldNotEqual": "This field is not equal to another field",
"arbitraryFieldNotMatch": "This field does not match the pattern",
"arbitraryFieldNotMatchAny": "This field does not match any of the patterns",
"arbitraryFieldNotMatchAll": "This field does not match all of the patterns",
"arbitraryFieldNotContain": "This field does not contain another field",
"arbitraryFieldNotContainAny": "This field does not contain any of the fields",
"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.", "arbitraryFormErrorMessage": "Something went wrong while submitting the form. Please try again or contact the administrators.",