mirror of
https://github.com/enso-org/enso.git
synced 2025-01-03 19:21:54 +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: {
|
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>
|
||||||
)}
|
)}
|
||||||
|
@ -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 */
|
||||||
|
@ -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(),
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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'
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
|
@ -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 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>
|
||||||
}
|
}
|
||||||
|
@ -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} />
|
||||||
|
@ -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')}
|
||||||
|
@ -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.",
|
||||||
|
Loading…
Reference in New Issue
Block a user