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: {
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>
)}

View File

@ -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 */

View File

@ -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(),

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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'
}

View File

@ -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()
}

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 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>
}

View File

@ -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} />

View File

@ -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')}

View File

@ -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.",