From 6adf590d9f7dd5080291a5bcc2752ea55338f832 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Mon, 3 Jun 2024 12:03:12 +0300 Subject: [PATCH] Improve Form typings (#10119) This PR improves ergonimics and typesafety when using new Form component. Also it passes validation state into react-aria-components --- .../AriaComponents/Button/Button.tsx | 56 +++++++--- .../components/AriaComponents/Form/Form.tsx | 95 +++++++++++++--- .../Form/components/FormError.tsx | 8 +- .../AriaComponents/Form/components/Submit.tsx | 9 +- .../AriaComponents/Form/components/types.ts | 25 +++-- .../AriaComponents/Form/components/useForm.ts | 26 +++-- .../Form/components/useFormSchema.tsx | 11 +- .../components/AriaComponents/Form/styles.ts | 26 +++++ .../components/AriaComponents/Form/types.ts | 105 +++++++++++++----- .../src/modals/SetOrganizationNameModal.tsx | 96 ++++++++-------- .../src/modals/TermsOfServiceModal.tsx | 54 ++++----- .../lib/dashboard/src/text/english.json | 14 +++ 12 files changed, 370 insertions(+), 155 deletions(-) create mode 100644 app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/styles.ts diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx index 92e098df3bd..5a9c37af90d 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx @@ -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(null) + const loaderRef = React.useRef(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 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 result = onPress(event) + if (!isLoading) { + const result = onPress(event) - if (result instanceof Promise) { - setImplicitlyLoading(true) - void result.finally(() => { - setImplicitlyLoading(false) - }) + if (result instanceof Promise) { + setImplicitlyLoading(true) + void result.finally(() => { + setImplicitlyLoading(false) + }) + } } } @@ -240,10 +266,12 @@ export const Button = React.forwardRef(function Button( )} > - {childrenFactory()} + + {childrenFactory()} + {isLoading && ( - + )} diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/Form.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/Form.tsx index a2566391156..06670442358 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/Form.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/Form.tsx @@ -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, // eslint-disable-next-line no-restricted-syntax - TTransformedValues extends reactHookForm.FieldValues | undefined = undefined, ->(props: types.FormProps, ref: React.Ref) { + TTransformedValues extends components.FieldValues | undefined = undefined, +>( + props: types.FormProps, + ref: React.Ref +) { 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( + 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 = { + const { formState, + clearErrors, + getValues, + setValue, + setError, register, unregister, + setFocus, + reset, + } = innerForm + + const formStateRenderProps: types.FormStateRenderProps = { + formState, + register: (name, options) => { + const registered = register(name, options) + + const onChange: types.UseFormRegisterReturn['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 = { + ...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 + return (
- - {typeof children === 'function' ? children(formStateRenderProps) : children} - + + + {typeof children === 'function' ? children(formStateRenderProps) : children} + +
) }) 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, // eslint-disable-next-line no-restricted-syntax - TTransformedValues extends reactHookForm.FieldValues | undefined = undefined, + TTransformedValues extends components.FieldValues | undefined = undefined, >( - props: React.RefAttributes & types.FormProps + props: React.RefAttributes & + types.FormProps // eslint-disable-next-line no-restricted-syntax ) => React.JSX.Element) & { /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/FormError.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/FormError.tsx index a089cecaeb5..300c5988647 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/FormError.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/FormError.tsx @@ -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, + TTransformedFieldValues extends types.FieldValues, > extends Omit { readonly form?: reactHookForm.UseFormReturn } @@ -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, + TTransformedFieldValues extends types.FieldValues, >(props: FormErrorProps) { const { form = reactHookForm.useFormContext(), diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/Submit.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/Submit.tsx index 03343402fd1..99575fbc96e 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/Submit.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/Submit.tsx @@ -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')} + ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/types.ts b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/types.ts index e94f1899c45..de46845e690 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/types.ts +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/types.ts @@ -9,22 +9,30 @@ import type * as z from 'zod' /** * Field Values type. */ -export type FieldValues = reactHookForm.FieldValues +export type FieldValues = [Schema] extends [never] + ? reactHookForm.FieldValues + : z.infer + +/** + * Schema type + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type TSchema = z.ZodObject /** * Props for the useForm hook. */ -export interface UseFormProps - extends Omit, 'resetOptions' | 'resolver'> { - // eslint-disable-next-line no-restricted-syntax - readonly schema?: z.ZodObject +export interface UseFormProps> + extends Omit, 'resetOptions' | 'resolver'> { + readonly schema: Schema } /** * Return type of the useForm hook. */ export type UseFormReturn< - TFieldValues extends Record, + Schema extends TSchema, + TFieldValues extends FieldValues, // eslint-disable-next-line no-restricted-syntax TTransformedValues extends Record | undefined = undefined, > = reactHookForm.UseFormReturn @@ -32,4 +40,7 @@ export type UseFormReturn< /** * Form State type. */ -export type FormState = reactHookForm.FormState +export type FormState< + Schema extends TSchema, + TFieldValues extends FieldValues, +> = reactHookForm.FormState diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/useForm.ts b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/useForm.ts index 091627f6346..fd995e4ee3a 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/useForm.ts +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/useForm.ts @@ -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, // eslint-disable-next-line no-restricted-syntax - TTransformedValues extends types.FieldValues | undefined = undefined, + TTransformedValues extends types.FieldValues | undefined = undefined, >( - optionsOrFormInstance: types.UseFormProps | types.UseFormReturn -): types.UseFormReturn { + optionsOrFormInstance: + | types.UseFormProps + | types.UseFormReturn +): types.UseFormReturn { 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({ ...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, + Schema extends types.TSchema, + TFieldValues extends types.FieldValues, // eslint-disable-next-line no-restricted-syntax - TTransformedValues extends Record | undefined = undefined, ->(args: types.UseFormProps | types.UseFormReturn) { + TTransformedValues extends types.FieldValues | undefined = undefined, +>( + args: + | types.UseFormProps + | types.UseFormReturn +) { return 'formState' in args ? 'formInstance' : 'formOptions' } diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/useFormSchema.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/useFormSchema.tsx index f5c0327dc73..eaf8c78fcb2 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/useFormSchema.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/useFormSchema.tsx @@ -12,9 +12,14 @@ import type * as types from './types' /** * Hook to create a form schema. */ -export function useFormSchema( +export function useFormSchema>( callback: (schema: typeof schemaComponent.schema) => schemaComponent.schema.ZodObject -): schemaComponent.schema.ZodObject { +) { 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() } diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/styles.ts b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/styles.ts new file mode 100644 index 00000000000..c3b2f6097be --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/styles.ts @@ -0,0 +1,26 @@ +/** + * @file + * + * Styles for form components. + */ +import * as twv from 'tailwind-variants' + +/** + * Props for form components. + */ +export type FormStyleProps = twv.VariantProps +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', + }, +}) diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/types.ts b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/types.ts index 387a7d75f44..82db6638f4f 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/types.ts +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/types.ts @@ -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, // eslint-disable-next-line no-restricted-syntax - TTransformedValues extends components.FieldValues | undefined = undefined, -> = BaseFormProps & - (FormPropsWithOptions | FormPropsWithParentForm) + TTransformedValues extends components.FieldValues | undefined = undefined, +> = BaseFormProps & + ( + | FormPropsWithOptions + | FormPropsWithParentForm + ) /** * 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, // eslint-disable-next-line no-restricted-syntax - TTransformedValues extends components.FieldValues | undefined = undefined, + TTransformedValues extends components.FieldValues | undefined = undefined, > extends Omit< - React.HTMLProps, - 'children' | 'className' | 'form' | 'onSubmit' | 'onSubmitCapture' | 'style' - > { - readonly className?: string | ((props: FormStateRenderProps) => string) + React.HTMLProps, + 'children' | 'className' | 'form' | 'onSubmit' | 'onSubmitCapture' | 'style' + >, + 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['defaultValues'] readonly onSubmit: ( values: TFieldValues, - form: components.UseFormReturn + form: components.UseFormReturn ) => unknown readonly style?: | React.CSSProperties - | ((props: FormStateRenderProps) => React.CSSProperties) + | ((props: FormStateRenderProps) => React.CSSProperties) readonly children: | React.ReactNode - | ((props: FormStateRenderProps) => React.ReactNode) + | ((props: FormStateRenderProps) => React.ReactNode) readonly formRef?: React.MutableRefObject< - components.UseFormReturn + components.UseFormReturn > + readonly className?: string | ((props: FormStateRenderProps) => string) + readonly onSubmitFailed?: (error: unknown) => Promise | void readonly onSubmitSuccess?: () => Promise | void readonly onSubmitted?: () => Promise | 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, // eslint-disable-next-line no-restricted-syntax - TTransformedValues extends components.FieldValues | undefined = undefined, + TTransformedValues extends components.FieldValues | undefined = undefined, > { - readonly form?: components.UseFormReturn + readonly form: components.UseFormReturn 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 { +interface FormPropsWithOptions< + Schema extends components.TSchema, + TFieldValues extends components.FieldValues, +> { + readonly schema: Schema readonly form?: never - readonly schema?: z.ZodObject - readonly formOptions?: Omit, 'resolver'> + readonly formOptions?: Omit, 'resolver' | 'schema'> +} + +/** + * + */ +export type UseFormRegister< + Schema extends components.TSchema, + TFieldValues extends components.FieldValues, +> = < + TFieldName extends reactHookForm.FieldPath = reactHookForm.FieldPath, +>( + name: TFieldName, + options?: reactHookForm.RegisterOptions + // eslint-disable-next-line no-restricted-syntax +) => UseFormRegisterReturn + +/** + * UseFormRegister return type. + */ +export interface UseFormRegisterReturn< + Schema extends components.TSchema, + TFieldValues extends components.FieldValues, + TFieldName extends reactHookForm.FieldPath = reactHookForm.FieldPath, +> extends Omit, 'onChange'> { + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + readonly onChange: (value: Value) => Promise | void + readonly isDisabled?: boolean + readonly isRequired?: boolean + readonly isInvalid?: boolean } /** * Form Render Props. */ -export interface FormStateRenderProps { +export interface FormStateRenderProps< + Schema extends components.TSchema, + TFieldValues extends components.FieldValues, +> { /** * The form state. Contains the current values of the form fields. */ - readonly formState: components.FormState + readonly formState: components.FormState /** * The form register function. * Adds a field to the form state. */ - readonly register: reactHookForm.UseFormRegister + readonly register: UseFormRegister /** * The form unregister function. * Removes a field from the form state. @@ -104,4 +153,6 @@ export interface FormStateRenderProps readonly setError: reactHookForm.UseFormSetError readonly clearErrors: reactHookForm.UseFormClearErrors + readonly setFocus: reactHookForm.UseFormSetFocus + readonly reset: reactHookForm.UseFormReset } diff --git a/app/ide-desktop/lib/dashboard/src/modals/SetOrganizationNameModal.tsx b/app/ide-desktop/lib/dashboard/src/modals/SetOrganizationNameModal.tsx index 05ae66c3fb3..5eb9af99313 100644 --- a/app/ide-desktop/lib/dashboard/src/modals/SetOrganizationNameModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/modals/SetOrganizationNameModal.tsx @@ -66,54 +66,58 @@ export function SetOrganizationNameModal() { hideCloseButton modalProps={{ isOpen: shouldShowModal }} > - { - event.preventDefault() - const name = new FormData(event.currentTarget).get('organization') - - if (typeof name === 'string') { - submit.mutate(name) - } - }} - > - - {getText('organization')} - - 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, - }) - } - /> - - - - {submit.error && ( - - {submit.error.message} - + + 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 ( + <> + + + {getText('organization')} + - - {getText('submit')} - - + + 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, + }) + } + /> + + + {formState.errors.organization?.message} + + + + + + + + ) + }} + diff --git a/app/ide-desktop/lib/dashboard/src/modals/TermsOfServiceModal.tsx b/app/ide-desktop/lib/dashboard/src/modals/TermsOfServiceModal.tsx index 121275a6fb4..e80d8083584 100644 --- a/app/ide-desktop/lib/dashboard/src/modals/TermsOfServiceModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/modals/TermsOfServiceModal.tsx @@ -28,6 +28,7 @@ declare module '#/utilities/LocalStorage' { readonly termsOfService: z.infer | 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,43 +94,32 @@ export function TermsOfServiceModal() { id="terms-of-service-modal" > 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 ( <> -
+
-
- { - void checkboxRegister.onChange(event) - }} - data-testid="terms-of-service-checkbox" - /> -
+ {getText('licenseAgreementCheckbox')} diff --git a/app/ide-desktop/lib/dashboard/src/text/english.json b/app/ide-desktop/lib/dashboard/src/text/english.json index b0f01613192..397b6e072cf 100644 --- a/app/ide-desktop/lib/dashboard/src/text/english.json +++ b/app/ide-desktop/lib/dashboard/src/text/english.json @@ -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.",