mirror of
https://github.com/enso-org/enso.git
synced 2025-01-03 18:38:08 +03:00
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:
parent
d08cb704b0
commit
6adf590d9f
@ -63,7 +63,7 @@ export const BUTTON_STYLES = twv.tv({
|
||||
isFocused: {
|
||||
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' },
|
||||
size: {
|
||||
custom: '',
|
||||
@ -111,9 +111,8 @@ export const BUTTON_STYLES = twv.tv({
|
||||
slots: {
|
||||
extraClickZone: 'flex relative after:inset-[-12px] after:absolute',
|
||||
wrapper: 'relative block',
|
||||
loader:
|
||||
'animate-appear-delayed absolute inset-0 flex items-center justify-center duration-1000',
|
||||
content: 'flex items-center gap-[0.5em] delay-1000 duration-0',
|
||||
loader: 'absolute inset-0 flex items-center justify-center',
|
||||
content: 'flex items-center gap-[0.5em]',
|
||||
icon: 'h-[1.5em] flex-none',
|
||||
},
|
||||
defaultVariants: {
|
||||
@ -146,7 +145,7 @@ export const Button = React.forwardRef(function Button(
|
||||
variant,
|
||||
icon,
|
||||
loading = false,
|
||||
isDisabled: disabled,
|
||||
isDisabled,
|
||||
showIconOnHover,
|
||||
iconPosition,
|
||||
size,
|
||||
@ -160,6 +159,8 @@ export const Button = React.forwardRef(function Button(
|
||||
const focusChildProps = focusHooks.useFocusChild()
|
||||
|
||||
const [implicitlyLoading, setImplicitlyLoading] = React.useState(false)
|
||||
const contentRef = React.useRef<HTMLSpanElement>(null)
|
||||
const loaderRef = React.useRef<HTMLSpanElement>(null)
|
||||
|
||||
const isLink = ariaProps.href != null
|
||||
|
||||
@ -173,9 +174,33 @@ export const Button = React.forwardRef(function Button(
|
||||
const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null
|
||||
|
||||
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 => {
|
||||
if (!isLoading) {
|
||||
const result = onPress(event)
|
||||
|
||||
if (result instanceof Promise) {
|
||||
@ -185,6 +210,7 @@ export const Button = React.forwardRef(function Button(
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
base,
|
||||
@ -240,10 +266,12 @@ export const Button = React.forwardRef(function Button(
|
||||
)}
|
||||
>
|
||||
<span className={wrapper()}>
|
||||
<span className={content()}>{childrenFactory()}</span>
|
||||
<span ref={contentRef} className={content()}>
|
||||
{childrenFactory()}
|
||||
</span>
|
||||
|
||||
{isLoading && (
|
||||
<span className={loader()}>
|
||||
<span ref={loaderRef} className={loader()}>
|
||||
<Spinner state={spinnerModule.SpinnerState.loadingMedium} size={16} />
|
||||
</span>
|
||||
)}
|
||||
|
@ -10,7 +10,10 @@ import * as reactHookForm from 'react-hook-form'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
|
||||
import * as components from './components'
|
||||
import * as styles from './styles'
|
||||
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
|
||||
// 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
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends reactHookForm.FieldValues | undefined = undefined,
|
||||
>(props: types.FormProps<TFieldValues, TTransformedValues>, ref: React.Ref<HTMLFormElement>) {
|
||||
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
props: types.FormProps<Schema, TFieldValues, TTransformedValues>,
|
||||
ref: React.Ref<HTMLFormElement>
|
||||
) {
|
||||
const formId = React.useId()
|
||||
|
||||
const {
|
||||
@ -45,15 +51,22 @@ export const Form = React.forwardRef(function Form<
|
||||
id = formId,
|
||||
testId,
|
||||
schema,
|
||||
defaultValues,
|
||||
gap,
|
||||
...formProps
|
||||
} = props
|
||||
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const innerForm = components.useForm<TFieldValues, TTransformedValues>(
|
||||
if (defaultValues) {
|
||||
formOptions.defaultValues = defaultValues
|
||||
}
|
||||
|
||||
const innerForm = components.useForm(
|
||||
form ?? {
|
||||
shouldFocusError: true,
|
||||
...formOptions,
|
||||
...(schema ? { schema } : {}),
|
||||
schema,
|
||||
}
|
||||
)
|
||||
|
||||
@ -83,41 +96,89 @@ export const Form = React.forwardRef(function Form<
|
||||
// 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 { formState, clearErrors, getValues, setValue, setError, register, unregister } = innerForm
|
||||
|
||||
const formStateRenderProps: types.FormStateRenderProps<TFieldValues> = {
|
||||
const {
|
||||
formState,
|
||||
clearErrors,
|
||||
getValues,
|
||||
setValue,
|
||||
setError,
|
||||
register,
|
||||
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,
|
||||
clearErrors,
|
||||
getValues,
|
||||
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 (
|
||||
<form
|
||||
id={id}
|
||||
ref={ref}
|
||||
onSubmit={formOnSubmit}
|
||||
className={typeof className === 'function' ? className(formStateRenderProps) : className}
|
||||
className={base}
|
||||
style={typeof style === 'function' ? style(formStateRenderProps) : style}
|
||||
noValidate
|
||||
data-testid={testId}
|
||||
{...formProps}
|
||||
>
|
||||
<aria.FormValidationContext.Provider value={errors}>
|
||||
<reactHookForm.FormProvider {...innerForm}>
|
||||
{typeof children === 'function' ? children(formStateRenderProps) : children}
|
||||
</reactHookForm.FormProvider>
|
||||
</aria.FormValidationContext.Provider>
|
||||
</form>
|
||||
)
|
||||
}) as unknown as (<
|
||||
TFieldValues extends reactHookForm.FieldValues,
|
||||
// The type is defined on library level and we can't change it
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
// 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
|
||||
) => React.JSX.Element) & {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
@ -18,8 +18,8 @@ import type * as types from '../types'
|
||||
* Props for the FormError component.
|
||||
*/
|
||||
export interface FormErrorProps<
|
||||
TFieldValues extends types.FieldValues,
|
||||
TTransformedFieldValues extends types.FieldValues,
|
||||
TFieldValues extends types.FieldValues<never>,
|
||||
TTransformedFieldValues extends types.FieldValues<never>,
|
||||
> extends Omit<reactAriaComponents.AlertProps, 'children'> {
|
||||
readonly form?: reactHookForm.UseFormReturn<TFieldValues, unknown, TTransformedFieldValues>
|
||||
}
|
||||
@ -28,8 +28,8 @@ export interface FormErrorProps<
|
||||
* Form error component.
|
||||
*/
|
||||
export function FormError<
|
||||
TFieldValues extends types.FieldValues,
|
||||
TTransformedFieldValues extends types.FieldValues,
|
||||
TFieldValues extends types.FieldValues<never>,
|
||||
TTransformedFieldValues extends types.FieldValues<never>,
|
||||
>(props: FormErrorProps<TFieldValues, TTransformedFieldValues>) {
|
||||
const {
|
||||
form = reactHookForm.useFormContext(),
|
||||
|
@ -8,6 +8,8 @@ import * as React from 'react'
|
||||
|
||||
import * as reactHookForm from 'react-hook-form'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
|
||||
/**
|
||||
@ -43,7 +45,10 @@ export function Submit(props: SubmitProps): React.JSX.Element {
|
||||
variant = 'submit',
|
||||
size = 'medium',
|
||||
testId = 'form-submit-button',
|
||||
children,
|
||||
} = props
|
||||
|
||||
const { getText } = textProvider.useText()
|
||||
const { formState } = form
|
||||
|
||||
return (
|
||||
@ -54,6 +59,8 @@ export function Submit(props: SubmitProps): React.JSX.Element {
|
||||
size={size}
|
||||
loading={formState.isSubmitting}
|
||||
testId={testId}
|
||||
/>
|
||||
>
|
||||
{children ?? getText('submit')}
|
||||
</ariaComponents.Button>
|
||||
)
|
||||
}
|
||||
|
@ -9,22 +9,30 @@ import type * as z from 'zod'
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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>
|
||||
export interface UseFormProps<Schema extends TSchema, TFieldValues extends FieldValues<Schema>>
|
||||
extends Omit<reactHookForm.UseFormProps<TFieldValues>, 'resetOptions' | 'resolver'> {
|
||||
readonly schema: Schema
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type of the useForm hook.
|
||||
*/
|
||||
export type UseFormReturn<
|
||||
TFieldValues extends Record<string, unknown>,
|
||||
Schema extends TSchema,
|
||||
TFieldValues extends FieldValues<Schema>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends Record<string, unknown> | undefined = undefined,
|
||||
> = reactHookForm.UseFormReturn<TFieldValues, unknown, TTransformedValues>
|
||||
@ -32,4 +40,7 @@ export type UseFormReturn<
|
||||
/**
|
||||
* 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>
|
||||
|
@ -26,12 +26,15 @@ import type * as types from './types'
|
||||
* Otherwise you'll be fired
|
||||
*/
|
||||
export function useForm<
|
||||
T extends types.FieldValues,
|
||||
Schema extends types.TSchema,
|
||||
TFieldValues extends types.FieldValues<Schema>,
|
||||
// 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>
|
||||
): types.UseFormReturn<T, TTransformedValues> {
|
||||
optionsOrFormInstance:
|
||||
| types.UseFormProps<Schema, TFieldValues>
|
||||
| types.UseFormReturn<Schema, TFieldValues, TTransformedValues>
|
||||
): types.UseFormReturn<Schema, TFieldValues, TTransformedValues> {
|
||||
const initialTypePassed = React.useRef(getArgsType(optionsOrFormInstance))
|
||||
|
||||
const argsType = getArgsType(optionsOrFormInstance)
|
||||
@ -49,9 +52,9 @@ export function useForm<
|
||||
} else {
|
||||
const { schema, ...options } = optionsOrFormInstance
|
||||
|
||||
return reactHookForm.useForm({
|
||||
return reactHookForm.useForm<TFieldValues, unknown, TTransformedValues>({
|
||||
...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
|
||||
*/
|
||||
function getArgsType<
|
||||
T extends Record<string, unknown>,
|
||||
Schema extends types.TSchema,
|
||||
TFieldValues extends types.FieldValues<Schema>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends Record<string, unknown> | undefined = undefined,
|
||||
>(args: types.UseFormProps<T> | types.UseFormReturn<T, TTransformedValues>) {
|
||||
TTransformedValues extends types.FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
args:
|
||||
| types.UseFormProps<Schema, TFieldValues>
|
||||
| types.UseFormReturn<Schema, TFieldValues, TTransformedValues>
|
||||
) {
|
||||
return 'formState' in args ? 'formInstance' : 'formOptions'
|
||||
}
|
||||
|
@ -12,9 +12,14 @@ import type * as types from './types'
|
||||
/**
|
||||
* 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>
|
||||
): schemaComponent.schema.ZodObject<T> {
|
||||
) {
|
||||
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()
|
||||
}
|
||||
|
@ -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',
|
||||
},
|
||||
})
|
@ -6,9 +6,9 @@
|
||||
import type * as React from 'react'
|
||||
|
||||
import type * as reactHookForm from 'react-hook-form'
|
||||
import type * as z from 'zod'
|
||||
|
||||
import type * as components from './components'
|
||||
import type * as styles from './styles'
|
||||
|
||||
export type * from './components'
|
||||
|
||||
@ -16,40 +16,54 @@ 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
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends components.FieldValues | undefined = undefined,
|
||||
> = BaseFormProps<TFieldValues, TTransformedValues> &
|
||||
(FormPropsWithOptions<TFieldValues> | FormPropsWithParentForm<TFieldValues, TTransformedValues>)
|
||||
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
|
||||
> = BaseFormProps<Schema, TFieldValues, TTransformedValues> &
|
||||
(
|
||||
| FormPropsWithOptions<Schema, TFieldValues>
|
||||
| FormPropsWithParentForm<Schema, 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
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends components.FieldValues | undefined = undefined,
|
||||
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
|
||||
> extends Omit<
|
||||
React.HTMLProps<HTMLFormElement>,
|
||||
'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: (
|
||||
values: TFieldValues,
|
||||
form: components.UseFormReturn<TFieldValues, TTransformedValues>
|
||||
form: components.UseFormReturn<Schema, TFieldValues, TTransformedValues>
|
||||
) => unknown
|
||||
readonly style?:
|
||||
| React.CSSProperties
|
||||
| ((props: FormStateRenderProps<TFieldValues>) => React.CSSProperties)
|
||||
| ((props: FormStateRenderProps<Schema, TFieldValues>) => React.CSSProperties)
|
||||
readonly children:
|
||||
| React.ReactNode
|
||||
| ((props: FormStateRenderProps<TFieldValues>) => React.ReactNode)
|
||||
| ((props: FormStateRenderProps<Schema, TFieldValues>) => React.ReactNode)
|
||||
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 onSubmitSuccess?: () => Promise<void> | void
|
||||
readonly onSubmitted?: () => Promise<void> | void
|
||||
@ -62,12 +76,12 @@ interface BaseFormProps<
|
||||
* 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
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
// 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 formOptions?: never
|
||||
}
|
||||
@ -76,25 +90,60 @@ interface FormPropsWithParentForm<
|
||||
* 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> {
|
||||
interface FormPropsWithOptions<
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
> {
|
||||
readonly schema: Schema
|
||||
readonly form?: never
|
||||
readonly schema?: z.ZodObject<TFieldValues>
|
||||
readonly formOptions?: Omit<components.UseFormProps<TFieldValues>, 'resolver'>
|
||||
readonly formOptions?: Omit<components.UseFormProps<Schema, TFieldValues>, 'resolver' | 'schema'>
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
readonly formState: components.FormState<TFieldValues>
|
||||
readonly formState: components.FormState<Schema, TFieldValues>
|
||||
/**
|
||||
* The form register function.
|
||||
* Adds a field to the form state.
|
||||
*/
|
||||
readonly register: reactHookForm.UseFormRegister<TFieldValues>
|
||||
readonly register: UseFormRegister<Schema, TFieldValues>
|
||||
/**
|
||||
* The form unregister function.
|
||||
* Removes a field from the form state.
|
||||
@ -104,4 +153,6 @@ export interface FormStateRenderProps<TFieldValues extends components.FieldValue
|
||||
readonly getValues: reactHookForm.UseFormGetValues<TFieldValues>
|
||||
readonly setError: reactHookForm.UseFormSetError<TFieldValues>
|
||||
readonly clearErrors: reactHookForm.UseFormClearErrors<TFieldValues>
|
||||
readonly setFocus: reactHookForm.UseFormSetFocus<TFieldValues>
|
||||
readonly reset: reactHookForm.UseFormReset<TFieldValues>
|
||||
}
|
||||
|
@ -66,54 +66,58 @@ export function SetOrganizationNameModal() {
|
||||
hideCloseButton
|
||||
modalProps={{ isOpen: shouldShowModal }}
|
||||
>
|
||||
<aria.Form
|
||||
onSubmit={event => {
|
||||
event.preventDefault()
|
||||
const name = new FormData(event.currentTarget).get('organization')
|
||||
|
||||
if (typeof name === 'string') {
|
||||
submit.mutate(name)
|
||||
}
|
||||
}}
|
||||
<ariaComponents.Form
|
||||
gap="medium"
|
||||
defaultValues={{ organization: '' }}
|
||||
schema={ariaComponents.Form.useFormSchema(z =>
|
||||
z.object({
|
||||
organization: z
|
||||
.string()
|
||||
.min(1, getText('arbitraryFieldRequired'))
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
.max(255, getText('arbitraryFieldTooLong')),
|
||||
})
|
||||
)}
|
||||
onSubmit={({ organization }) => submit.mutateAsync(organization)}
|
||||
>
|
||||
{({ register, formState }) => {
|
||||
return (
|
||||
<>
|
||||
<aria.TextField
|
||||
name="organization"
|
||||
isRequired
|
||||
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>
|
||||
<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,
|
||||
'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.FieldError className="text-sm text-red-500">
|
||||
{formState.errors.organization?.message}
|
||||
</aria.FieldError>
|
||||
</aria.TextField>
|
||||
|
||||
{submit.error && (
|
||||
<ariaComponents.Alert variant="error" size="medium" className="mt-4">
|
||||
{submit.error.message}
|
||||
</ariaComponents.Alert>
|
||||
)}
|
||||
<ariaComponents.Form.FormError />
|
||||
|
||||
<ariaComponents.Button
|
||||
className="mt-4"
|
||||
type="submit"
|
||||
variant="submit"
|
||||
size="medium"
|
||||
loading={submit.isPending}
|
||||
>
|
||||
{getText('submit')}
|
||||
</ariaComponents.Button>
|
||||
</aria.Form>
|
||||
<ariaComponents.Form.Submit />
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</ariaComponents.Form>
|
||||
</ariaComponents.Dialog>
|
||||
|
||||
<router.Outlet context={session} />
|
||||
|
@ -28,6 +28,7 @@ declare module '#/utilities/LocalStorage' {
|
||||
readonly termsOfService: z.infer<typeof TERMS_OF_SERVICE_SCHEMA> | null
|
||||
}
|
||||
}
|
||||
|
||||
const TERMS_OF_SERVICE_SCHEMA = z.object({ versionHash: z.string() })
|
||||
LocalStorage.registerKey('termsOfService', { schema: TERMS_OF_SERVICE_SCHEMA })
|
||||
|
||||
@ -70,6 +71,16 @@ export function TermsOfServiceModal() {
|
||||
const isAccepted = localVersionHash != null
|
||||
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) {
|
||||
return (
|
||||
<>
|
||||
@ -83,29 +94,22 @@ export function TermsOfServiceModal() {
|
||||
id="terms-of-service-modal"
|
||||
>
|
||||
<ariaComponents.Form
|
||||
schema={formSchema}
|
||||
defaultValues={{ agree: false, hash: latestVersionHash }}
|
||||
testId="terms-of-service-form"
|
||||
schema={ariaComponents.Form.schema.object({
|
||||
agree: ariaComponents.Form.schema
|
||||
.boolean()
|
||||
// we accept only true
|
||||
.refine(value => value, getText('licenseAgreementCheckboxError')),
|
||||
})}
|
||||
onSubmit={() => {
|
||||
localStorage.set('termsOfService', { versionHash: latestVersionHash })
|
||||
onSubmit={({ hash }) => {
|
||||
localStorage.set('termsOfService', { versionHash: hash })
|
||||
}}
|
||||
>
|
||||
{({ register, formState }) => {
|
||||
const agreeError = formState.errors.agree
|
||||
const hasError = formState.errors.agree != null
|
||||
|
||||
const checkboxRegister = register('agree')
|
||||
const hasError = agreeError != null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pb-6 pt-2">
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<div className="mt-0">
|
||||
<aria.Input
|
||||
type="checkbox"
|
||||
className={twMerge.twMerge(
|
||||
@ -113,13 +117,9 @@ export function TermsOfServiceModal() {
|
||||
)}
|
||||
id={checkboxId}
|
||||
aria-invalid={hasError}
|
||||
{...checkboxRegister}
|
||||
onInput={event => {
|
||||
void checkboxRegister.onChange(event)
|
||||
}}
|
||||
data-testid="terms-of-service-checkbox"
|
||||
{...register('agree')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aria.Label htmlFor={checkboxId} className="text-sm">
|
||||
{getText('licenseAgreementCheckbox')}
|
||||
|
@ -611,6 +611,20 @@
|
||||
"appNameCloudEdition": "Enso Cloud Edition",
|
||||
|
||||
"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",
|
||||
"arbitraryErrorSubtitle": "Please try again or contact the administrators.",
|
||||
"arbitraryFormErrorMessage": "Something went wrong while submitting the form. Please try again or contact the administrators.",
|
||||
|
Loading…
Reference in New Issue
Block a user