diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index a064a83de..d11ab7eee 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -12,67 +12,33 @@ import { mergeOpts, } from './utils' import { defaultValidationLogic } from './ValidationLogic' -import type { ReadonlyStore } from '@tanstack/store' -import type { DeepKeys, DeepValue, UnwrapOneLevelOfArray } from './util-types' -import type { - StandardSchemaV1, - StandardSchemaV1Issue, - TStandardSchemaValidatorValue, -} from './standardSchemaValidator' import type { + FieldAsyncValidateOrFn, + FieldErrorMapFromValidator, FieldInfo, - FormApi, - FormAsyncValidateOrFn, - FormValidateAsyncFn, - FormValidateFn, - FormValidateOrFn, -} from './FormApi' -import type { + FieldLikeAPI, + FieldLikeApiOptions, + FieldLikeMetaBase, + FieldLikeOptions, + FieldLikeState, + FieldValidateOrFn, ListenerCause, + UnwrapFieldAsyncValidateOrFn, + UnwrapFieldValidateOrFn, UpdateMetaOptions, ValidationCause, ValidationError, ValidationErrorMap, - ValidationErrorMapSource, } from './types' +import type { ReadonlyStore } from '@tanstack/store' +import type { DeepKeys, DeepValue } from './util-types' +import type { + StandardSchemaV1, + TStandardSchemaValidatorValue, +} from './standardSchemaValidator' +import type { FormAsyncValidateOrFn, FormValidateOrFn } from './FormApi' import type { AsyncValidator, SyncValidator, Updater } from './utils' -/** - * @private - */ -// TODO: Add the `Unwrap` type to the errors -type FieldErrorMapFromValidator< - TFormData, - TName extends DeepKeys, - TData extends DeepValue, - TOnMount extends undefined | FieldValidateOrFn, - TOnChange extends undefined | FieldValidateOrFn, - TOnChangeAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnBlur extends undefined | FieldValidateOrFn, - TOnBlurAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnSubmit extends undefined | FieldValidateOrFn, - TOnSubmitAsync extends - | undefined - | FieldAsyncValidateOrFn, -> = Partial< - Record< - DeepKeys, - ValidationErrorMap< - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync - > - > -> - /** * @private */ @@ -111,55 +77,6 @@ export type FieldValidateFn< > }) => unknown -/** - * @private - */ -export type FieldValidateOrFn< - TParentData, - TName extends DeepKeys, - TData extends DeepValue = DeepValue, -> = - | FieldValidateFn - | StandardSchemaV1 - -type StandardBrandedSchemaV1 = T & { __standardSchemaV1: true } - -type UnwrapFormValidateOrFnForInner< - TValidateOrFn extends undefined | FormValidateOrFn, -> = [TValidateOrFn] extends [FormValidateFn] - ? ReturnType - : [TValidateOrFn] extends [StandardSchemaV1] - ? StandardBrandedSchemaV1 - : undefined - -export type UnwrapFieldValidateOrFn< - TName extends string, - TValidateOrFn extends undefined | FieldValidateOrFn, - TFormValidateOrFn extends undefined | FormValidateOrFn, -> = - | ([TFormValidateOrFn] extends [StandardSchemaV1] - ? TName extends keyof TStandardOut - ? StandardSchemaV1Issue[] - : undefined - : undefined) - | (UnwrapFormValidateOrFnForInner extends infer TFormValidateVal - ? TFormValidateVal extends { __standardSchemaV1: true } - ? [DeepValue] extends [never] - ? undefined - : StandardSchemaV1Issue[] - : TFormValidateVal extends { fields: any } - ? TName extends keyof TFormValidateVal['fields'] - ? TFormValidateVal['fields'][TName] - : undefined - : undefined - : never) - | ([TValidateOrFn] extends [FieldValidateFn] - ? ReturnType - : [TValidateOrFn] extends [StandardSchemaV1] - ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] - StandardSchemaV1Issue[] - : undefined) - /** * @private */ @@ -199,53 +116,6 @@ export type FieldValidateAsyncFn< signal: AbortSignal }) => unknown | Promise -/** - * @private - */ -export type FieldAsyncValidateOrFn< - TParentData, - TName extends DeepKeys, - TData extends DeepValue = DeepValue, -> = - | FieldValidateAsyncFn - | StandardSchemaV1 - -type UnwrapFormAsyncValidateOrFnForInner< - TValidateOrFn extends undefined | FormAsyncValidateOrFn, -> = [TValidateOrFn] extends [FormValidateAsyncFn] - ? Awaited> - : [TValidateOrFn] extends [StandardSchemaV1] - ? StandardBrandedSchemaV1 - : undefined - -export type UnwrapFieldAsyncValidateOrFn< - TName extends string, - TValidateOrFn extends undefined | FieldAsyncValidateOrFn, - TFormValidateOrFn extends undefined | FormAsyncValidateOrFn, -> = - | ([TFormValidateOrFn] extends [StandardSchemaV1] - ? TName extends keyof TStandardOut - ? StandardSchemaV1Issue[] - : undefined - : undefined) - | (UnwrapFormAsyncValidateOrFnForInner extends infer TFormValidateVal - ? TFormValidateVal extends { __standardSchemaV1: true } - ? [DeepValue] extends [never] - ? undefined - : StandardSchemaV1Issue[] - : TFormValidateVal extends { fields: any } - ? TName extends keyof TFormValidateVal['fields'] - ? TFormValidateVal['fields'][TName] - : undefined - : undefined - : never) - | ([TValidateOrFn] extends [FieldValidateAsyncFn] - ? Awaited> - : [TValidateOrFn] extends [StandardSchemaV1] - ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] - StandardSchemaV1Issue[] - : undefined) - /** * @private */ @@ -384,12 +254,10 @@ export interface FieldListeners< onMount?: FieldListenerFn onUnmount?: FieldListenerFn onSubmit?: FieldListenerFn + onGroupSubmit?: FieldListenerFn } -/** - * An object type representing the options for a field in a form. - */ -export interface FieldOptions< +interface FieldExtraOptions< TParentData, TName extends DeepKeys, TData extends DeepValue, @@ -411,22 +279,6 @@ export interface FieldOptions< | undefined | FieldAsyncValidateOrFn, > { - /** - * The field name. The type will be `DeepKeys` to ensure your name is a deep key of the parent dataset. - */ - name: TName - /** - * An optional default value for the field. - */ - defaultValue?: NoInfer - /** - * The default time to debounce async validation if there is not a more specific debounce time passed. - */ - asyncDebounceMs?: number - /** - * If `true`, always run async validation, even if there are errors emitted during synchronous validation. - */ - asyncAlways?: boolean /** * A list of validators to pass to the field */ @@ -444,11 +296,40 @@ export interface FieldOptions< TOnDynamic, TOnDynamicAsync > + /** - * An optional object with default metadata for the field. + * A list of listeners which attach to the corresponding events */ - defaultMeta?: Partial< - FieldMeta< + listeners?: FieldListeners +} + +/** + * An object type representing the options for a field in a form. + */ +export interface FieldOptions< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FieldValidateOrFn, + TOnChange extends undefined | FieldValidateOrFn, + TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnBlur extends undefined | FieldValidateOrFn, + TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnSubmit extends undefined | FieldValidateOrFn, + TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnDynamic extends undefined | FieldValidateOrFn, + TOnDynamicAsync extends + | undefined + | FieldAsyncValidateOrFn, +> + extends + FieldExtraOptions< TParentData, TName, TData, @@ -460,32 +341,24 @@ export interface FieldOptions< TOnSubmit, TOnSubmitAsync, TOnDynamic, - TOnDynamicAsync, - any, - any, - any, - any, - any, - any, - any, - any, - any - > - > - /** - * A list of listeners which attach to the corresponding events - */ - listeners?: FieldListeners - /** - * Disable the `flat(1)` operation on `field.errors`. This is useful if you want to keep the error structure as is. Not suggested for most use-cases. - */ - disableErrorFlat?: boolean -} + TOnDynamicAsync + >, + FieldLikeOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync + > {} -/** - * An object type representing the required options for the FieldApi class. - */ -export interface FieldApiOptions< +interface FieldApiOptions< in out TParentData, in out TName extends DeepKeys, in out TData extends DeepValue, @@ -535,402 +408,47 @@ export interface FieldApiOptions< | FormAsyncValidateOrFn, in out TFormOnServer extends undefined | FormAsyncValidateOrFn, in out TParentSubmitMeta, -> extends FieldOptions< - TParentData, - TName, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync -> { - form: FormApi< - TParentData, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnDynamic, - TFormOnDynamicAsync, - TFormOnServer, - TParentSubmitMeta - > -} - -export type FieldMetaBase< - TParentData, - TName extends DeepKeys, - TData extends DeepValue, - TOnMount extends undefined | FieldValidateOrFn, - TOnChange extends undefined | FieldValidateOrFn, - TOnChangeAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnBlur extends undefined | FieldValidateOrFn, - TOnBlurAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnSubmit extends undefined | FieldValidateOrFn, - TOnSubmitAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnDynamic extends undefined | FieldValidateOrFn, - TOnDynamicAsync extends - | undefined - | FieldAsyncValidateOrFn, - TFormOnMount extends undefined | FormValidateOrFn, - TFormOnChange extends undefined | FormValidateOrFn, - TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, - TFormOnBlur extends undefined | FormValidateOrFn, - TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, - TFormOnSubmit extends undefined | FormValidateOrFn, - TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, - TFormOnDynamic extends undefined | FormValidateOrFn, - TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, -> = { - /** - * A flag indicating whether the field has been touched. - */ - isTouched: boolean - /** - * A flag indicating whether the field has been blurred. - */ - isBlurred: boolean - /** - * A flag that is `true` if the field's value has been modified by the user. Opposite of `isPristine`. - */ - isDirty: boolean - /** - * A map of errors related to the field value. - */ - errorMap: ValidationErrorMap< - UnwrapFieldValidateOrFn, - UnwrapFieldValidateOrFn, - UnwrapFieldAsyncValidateOrFn, - UnwrapFieldValidateOrFn, - UnwrapFieldAsyncValidateOrFn, - UnwrapFieldValidateOrFn, - UnwrapFieldAsyncValidateOrFn, - UnwrapFieldValidateOrFn, - UnwrapFieldAsyncValidateOrFn - > - - /** - * @private allows tracking the source of the errors in the error map - */ - errorSourceMap: ValidationErrorMapSource - /** - * A flag indicating whether the field is currently being validated. - */ - isValidating: boolean -} - -export type AnyFieldMetaBase = FieldMetaBase< - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any -> - -export type FieldMetaDerived< - TParentData, - TName extends DeepKeys, - TData extends DeepValue, - TOnMount extends undefined | FieldValidateOrFn, - TOnChange extends undefined | FieldValidateOrFn, - TOnChangeAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnBlur extends undefined | FieldValidateOrFn, - TOnBlurAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnSubmit extends undefined | FieldValidateOrFn, - TOnSubmitAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnDynamic extends undefined | FieldValidateOrFn, - TOnDynamicAsync extends - | undefined - | FieldAsyncValidateOrFn, - TFormOnMount extends undefined | FormValidateOrFn, - TFormOnChange extends undefined | FormValidateOrFn, - TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, - TFormOnBlur extends undefined | FormValidateOrFn, - TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, - TFormOnSubmit extends undefined | FormValidateOrFn, - TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, - TFormOnDynamic extends undefined | FormValidateOrFn, - TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, -> = { - /** - * An array of errors related to the field value. - */ - errors: Array< - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldAsyncValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldAsyncValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldAsyncValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldAsyncValidateOrFn< - TName, - TOnDynamicAsync, - TFormOnDynamicAsync - > - > - > - /** - * A flag that is `true` if the field's value has not been modified by the user. Opposite of `isDirty`. - */ - isPristine: boolean - /** - * A boolean indicating if the field is valid. Evaluates `true` if there are no field errors. - */ - isValid: boolean - /** - * A flag indicating whether the field's current value is the default value - */ - isDefaultValue: boolean -} - -export type AnyFieldMetaDerived = FieldMetaDerived< - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any -> - -/** - * An object type representing the metadata of a field in a form. - */ -export type FieldMeta< - TParentData, - TName extends DeepKeys, - TData extends DeepValue, - TOnMount extends undefined | FieldValidateOrFn, - TOnChange extends undefined | FieldValidateOrFn, - TOnChangeAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnBlur extends undefined | FieldValidateOrFn, - TOnBlurAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnSubmit extends undefined | FieldValidateOrFn, - TOnSubmitAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnDynamic extends undefined | FieldValidateOrFn, - TOnDynamicAsync extends - | undefined - | FieldAsyncValidateOrFn, - TFormOnMount extends undefined | FormValidateOrFn, - TFormOnChange extends undefined | FormValidateOrFn, - TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, - TFormOnBlur extends undefined | FormValidateOrFn, - TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, - TFormOnSubmit extends undefined | FormValidateOrFn, - TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, - TFormOnDynamic extends undefined | FormValidateOrFn, - TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, -> = FieldMetaBase< - TParentData, - TName, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnDynamic, - TFormOnDynamicAsync -> & - FieldMetaDerived< - TParentData, - TName, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnDynamic, - TFormOnDynamicAsync - > - -export type AnyFieldMeta = FieldMeta< - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any > - -/** - * An object type representing the state of a field. - */ -export type FieldState< - TParentData, - TName extends DeepKeys, - TData extends DeepValue, - TOnMount extends undefined | FieldValidateOrFn, - TOnChange extends undefined | FieldValidateOrFn, - TOnChangeAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnBlur extends undefined | FieldValidateOrFn, - TOnBlurAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnSubmit extends undefined | FieldValidateOrFn, - TOnSubmitAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnDynamic extends undefined | FieldValidateOrFn, - TOnDynamicAsync extends - | undefined - | FieldAsyncValidateOrFn, - TFormOnMount extends undefined | FormValidateOrFn, - TFormOnChange extends undefined | FormValidateOrFn, - TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, - TFormOnBlur extends undefined | FormValidateOrFn, - TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, - TFormOnSubmit extends undefined | FormValidateOrFn, - TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, - TFormOnDynamic extends undefined | FormValidateOrFn, - TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, -> = { - /** - * The current value of the field. - */ - value: TData - /** - * The current metadata of the field. - */ - meta: FieldMeta< - TParentData, - TName, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnDynamic, - TFormOnDynamicAsync - > -} + extends + FieldLikeApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, + FieldExtraOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync + > {} /** * @public @@ -1027,6 +545,44 @@ export class FieldApi< | FormAsyncValidateOrFn, in out TFormOnServer extends undefined | FormAsyncValidateOrFn, in out TParentSubmitMeta, +> implements FieldLikeAPI< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta, + FieldExtraOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync + > > { /** * A reference to the form API instance. @@ -1092,7 +648,7 @@ export class FieldApi< * The field state store. */ store!: ReadonlyStore< - FieldState< + FieldLikeState< TParentData, TName, TData, @@ -1171,7 +727,7 @@ export class FieldApi< this.store = createStore( ( prevVal: - | FieldState< + | FieldLikeState< TParentData, TName, TData, @@ -1221,7 +777,7 @@ export class FieldApi< return { value, meta, - } as FieldState< + } as FieldLikeState< TParentData, TName, TData, @@ -1490,7 +1046,7 @@ export class FieldApi< */ setMeta = ( updater: Updater< - FieldMetaBase< + FieldLikeMetaBase< TParentData, TName, TData, @@ -1653,6 +1209,10 @@ export class FieldApi< const linkedFields: AnyFieldApi[] = [] for (const field of fields) { if (!field.instance) continue + // TODO: How to handle FieldGroups? Do we need to? IDK. + if (!(field.instance instanceof FieldApi)) { + continue + } const { onChangeListenTo, onBlurListenTo } = field.instance.options.validators || {} if (cause === 'change' && onChangeListenTo?.includes(this.name)) { @@ -2172,6 +1732,16 @@ export class FieldApi< }) } } + + /** + * @private + */ + triggerOnSubmitListener() { + this.options.listeners?.onSubmit?.({ + value: this.state.value, + fieldApi: this, + }) + } } function normalizeError(rawError?: ValidationError) { diff --git a/packages/form-core/src/FieldGroupApi.ts b/packages/form-core/src/FieldGroupApi.ts index 6cd9d0976..92b52d4be 100644 --- a/packages/form-core/src/FieldGroupApi.ts +++ b/packages/form-core/src/FieldGroupApi.ts @@ -7,18 +7,14 @@ import type { FormAsyncValidateOrFn, FormValidateOrFn, } from './FormApi' -import type { AnyFieldMetaBase, FieldOptions } from './FieldApi' +import type { AnyFieldLikeMetaBase, FieldOptions } from './FieldApi' import type { DeepKeys, DeepKeysOfType, DeepValue, FieldsMap, } from './util-types' -import type { - FieldManipulator, - UpdateMetaOptions, - ValidationCause, -} from './types' +import type { FormLikeAPI, UpdateMetaOptions, ValidationCause } from './types' export type AnyFieldGroupApi = FieldGroupApi< any, @@ -127,7 +123,7 @@ export class FieldGroupApi< in out TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, in out TOnServer extends undefined | FormAsyncValidateOrFn, in out TSubmitMeta = never, -> implements FieldManipulator { +> implements FormLikeAPI { /** * The form that called this field group. */ diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 2317fb2b9..0e727a435 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -30,15 +30,13 @@ import type { StandardSchemaV1Issue, TStandardSchemaValidatorValue, } from './standardSchemaValidator' -import type { - AnyFieldApi, - AnyFieldMeta, - AnyFieldMetaBase, - FieldApi, -} from './FieldApi' -import type { +import type { AnyFieldApi } from './FieldApi' +import { + AnyFieldLikeMeta, + AnyFieldLikeMetaBase, ExtractGlobalFormError, - FieldManipulator, + FieldInfo, + FormLikeAPI, FormValidationError, FormValidationErrorMap, GlobalFormValidationError, @@ -503,44 +501,6 @@ export type ValidationMeta = { lastAbortController: AbortController } -/** - * An object representing the field information for a specific field within the form. - */ -export type FieldInfo = { - /** - * An instance of the FieldAPI. - */ - instance: FieldApi< - TFormData, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any - > | null - /** - * A record of field validation internal handling. - */ - validationMetaMap: Record -} - /** * An object representing the current state of the form. */ @@ -583,7 +543,7 @@ export type BaseFormState< /** * A record of field metadata for each field in the form, not including the derived properties, like `errors` and such */ - fieldMetaBase: Partial, AnyFieldMetaBase>> + fieldMetaBase: Partial, AnyFieldLikeMetaBase>> /** * A boolean indicating if the form is currently in the process of being submitted after `handleSubmit` is called. * @@ -712,7 +672,7 @@ export type DerivedFormState< /** * A record of field metadata for each field in the form. */ - fieldMeta: Partial, AnyFieldMeta>> + fieldMeta: Partial, AnyFieldLikeMeta>> } export interface FormState< @@ -851,6 +811,13 @@ export type AnyFormApi = FormApi< any > +interface ValidateOpts { + // Useful in FormGroup where validation doesn't update form error map + dontUpdateFormErrorMap?: boolean + // Filter which field names to validate, useful for FormGroup validation to filter out fields that don't start with the FormGroup name + filterFieldNames?: (fieldName: DeepKeys) => boolean +} + /** * We cannot use methods and must use arrow functions. Otherwise, our React adapters * will break due to loss of the method when using spread. @@ -876,7 +843,7 @@ export class FormApi< in out TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, in out TOnServer extends undefined | FormAsyncValidateOrFn, in out TSubmitMeta = never, -> implements FieldManipulator { +> implements FormLikeAPI { /** * The options for the form. */ @@ -944,7 +911,6 @@ export class FormApi< * A record of field information for each field in the form. */ fieldInfo: Partial, FieldInfo>> = {} - get state() { return this.store.state } @@ -1035,7 +1001,7 @@ export class FormApi< } const existingFieldMeta = baseStoreVal.fieldMetaBase[ fieldName as never - ] as AnyFieldMetaBase | undefined + ] as AnyFieldLikeMetaBase | undefined baseStoreVal.fieldMetaBase[fieldName as never] = { isTouched: false, isValidating: false, @@ -1050,7 +1016,7 @@ export class FormApi< ...(existingFieldMeta?.['errorMap'] ?? {}), [errKey as never]: fieldErr, }, - } satisfies AnyFieldMetaBase as never + } satisfies AnyFieldLikeMetaBase as never } } } @@ -1074,7 +1040,7 @@ export class FormApi< | undefined = undefined this.fieldMetaDerived = createStore( - (prevVal: Record, AnyFieldMeta> | undefined) => { + (prevVal: Record, AnyFieldLikeMeta> | undefined) => { const currBaseStore = this.baseStore.get() let originalMetaCount = 0 @@ -1098,11 +1064,11 @@ export class FormApi< ) as Array) { const currBaseMeta = currBaseStore.fieldMetaBase[ fieldName as never - ] as AnyFieldMetaBase + ] as AnyFieldLikeMetaBase const prevBaseMeta = prevBaseStore?.fieldMetaBase[ fieldName as never - ] as AnyFieldMetaBase | undefined + ] as AnyFieldLikeMetaBase | undefined const prevFieldInfo = prevVal?.[fieldName as never as keyof typeof prevVal] @@ -1160,7 +1126,7 @@ export class FormApi< isPristine: isFieldPristine, isValid: isFieldValid, isDefaultValue: isDefaultValue, - } satisfies AnyFieldMeta as AnyFieldMeta + } satisfies AnyFieldLikeMeta as AnyFieldLikeMeta } if (!Object.keys(currBaseStore.fieldMetaBase).length) return fieldMeta @@ -1215,7 +1181,7 @@ export class FormApi< // Computed state const fieldMetaValues = Object.values(currFieldMeta).filter( Boolean, - ) as AnyFieldMeta[] + ) as AnyFieldLikeMeta[] const isFieldsValidating = fieldMetaValues.some( (field) => field.isValidating, @@ -1565,7 +1531,7 @@ export class FormApi< ) // If any fields are not touched - if (!field.instance.state.meta.isTouched) { + if (!field.instance.store.state.meta.isTouched) { // Mark them as touched field.instance.setMeta((prev) => ({ ...prev, isTouched: true })) } @@ -1640,7 +1606,7 @@ export class FormApi< } // If the field is not touched (same logic as in validateAllFields) - if (!fieldInstance.state.meta.isTouched) { + if (!fieldInstance.store.state.meta.isTouched) { // Mark it as touched fieldInstance.setMeta((prev) => ({ ...prev, isTouched: true })) } @@ -1654,6 +1620,7 @@ export class FormApi< */ validateSync = ( cause: ValidationCause, + validateOpts?: ValidateOpts, ): { hasErrored: boolean fieldsErrorMap: FormErrorMapFromValidator< @@ -1709,11 +1676,17 @@ export class FormApi< const errorMapKey = getErrorMapKey(validateObj.cause) - const allFieldsToProcess = new Set([ + let allFieldsToProcess = new Set([ ...Object.keys(this.state.fieldMeta), ...Object.keys(fieldErrors || {}), ] as DeepKeys[]) + if (validateOpts?.filterFieldNames) { + allFieldsToProcess = new Set( + [...allFieldsToProcess].filter(validateOpts.filterFieldNames), + ) + } + for (const field of allFieldsToProcess) { if ( this.baseStore.state.fieldMetaBase[field] === undefined && @@ -1765,15 +1738,17 @@ export class FormApi< } } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (this.state.errorMap?.[errorMapKey] !== formError) { - this.baseStore.setState((prev) => ({ - ...prev, - errorMap: { - ...prev.errorMap, - [errorMapKey]: formError, - }, - })) + if (!validateOpts?.dontUpdateFormErrorMap) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (this.state.errorMap?.[errorMapKey] !== formError) { + this.baseStore.setState((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + [errorMapKey]: formError, + }, + })) + } } if (formError || fieldErrors) { @@ -1781,6 +1756,10 @@ export class FormApi< } } + if (validateOpts?.dontUpdateFormErrorMap) { + return + } + /** * when we have an error for onSubmit in the state, we want * to clear the error as soon as the user enters a valid value in the field @@ -1830,6 +1809,7 @@ export class FormApi< */ validateAsync = async ( cause: ValidationCause, + validateOpts?: ValidateOpts, ): Promise< FormErrorMapFromValidator< TFormData, @@ -1920,9 +1900,13 @@ export class FormApi< } const errorMapKey = getErrorMapKey(validateObj.cause) - for (const field of Object.keys( - this.state.fieldMeta, - ) as DeepKeys[]) { + let fields: DeepKeys[] = Object.keys(this.state.fieldMeta) + + if (validateOpts?.filterFieldNames) { + fields = fields.filter(validateOpts.filterFieldNames) + } + + for (const field of fields) { if (this.baseStore.state.fieldMetaBase[field] === undefined) { continue } @@ -1947,10 +1931,8 @@ export class FormApi< previousErrorValue: currentErrorMap?.[errorMapKey], }) - if ( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - currentErrorMap?.[errorMapKey] !== newErrorValue - ) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (currentErrorMap?.[errorMapKey] !== newErrorValue) { this.setFieldMeta(field, (prev) => ({ ...prev, errorMap: { @@ -1965,13 +1947,15 @@ export class FormApi< } } - this.baseStore.setState((prev) => ({ - ...prev, - errorMap: { - ...prev.errorMap, - [errorMapKey]: formError, - }, - })) + if (!validateOpts?.dontUpdateFormErrorMap) { + this.baseStore.setState((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + [errorMapKey]: formError, + }, + })) + } resolve( fieldErrorsFromFormValidators @@ -2030,6 +2014,7 @@ export class FormApi< */ validate = ( cause: ValidationCause, + validateOpts?: ValidateOpts, ): | FormErrorMapFromValidator< TFormData, @@ -2058,14 +2043,17 @@ export class FormApi< > > => { // Attempt to sync validate first - const { hasErrored, fieldsErrorMap } = this.validateSync(cause) + const { hasErrored, fieldsErrorMap } = this.validateSync( + cause, + validateOpts, + ) if (hasErrored && !this.options.asyncAlways) { return fieldsErrorMap } // No error? Attempt async validation - return this.validateAsync(cause) + return this.validateAsync(cause, validateOpts) } // Needs to edgecase in the React adapter specifically to avoid type errors @@ -2093,7 +2081,7 @@ export class FormApi< (field) => { if (!field.instance) return // If any fields are not touched - if (!field.instance.state.meta.isTouched) { + if (!field.instance.store.state.meta.isTouched) { // Mark them as touched field.instance.setMeta((prev) => ({ ...prev, isTouched: true })) } @@ -2135,8 +2123,8 @@ export class FormApi< submissionAttempt: this.state.submissionAttempts, successful: false, stage: 'validateAllFields', - errors: (Object.values(this.state.fieldMeta) as AnyFieldMeta[]) - .map((meta: AnyFieldMeta) => meta.errors) + errors: (Object.values(this.state.fieldMeta) as AnyFieldLikeMeta[]) + .map((meta) => meta.errors) .flat(), }) return @@ -2168,10 +2156,7 @@ export class FormApi< batch(() => { void (Object.values(this.fieldInfo) as FieldInfo[]).forEach( (field) => { - field.instance?.options.listeners?.onSubmit?.({ - value: field.instance.state.value, - fieldApi: field.instance, - }) + field.instance?.triggerOnSubmitListener() }, ) }) @@ -2233,7 +2218,7 @@ export class FormApi< */ getFieldMeta = >( field: TField, - ): AnyFieldMeta | undefined => { + ): AnyFieldLikeMeta | undefined => { return this.state.fieldMeta[field] } @@ -2261,7 +2246,7 @@ export class FormApi< */ setFieldMeta = >( field: TField, - updater: Updater, + updater: Updater, ) => { this.baseStore.setState((prev) => { return { @@ -2281,15 +2266,15 @@ export class FormApi< * resets every field's meta */ resetFieldMeta = >( - fieldMeta: Partial>, - ): Partial> => { + fieldMeta: Partial>, + ): Partial> => { return Object.keys(fieldMeta).reduce( (acc, key) => { const fieldKey = key as TField acc[fieldKey] = defaultFieldMeta return acc }, - {} as Partial>, + {} as Partial>, ) } @@ -2699,12 +2684,12 @@ export class FormApi< fields: Object.entries(this.state.fieldMeta).reduce( (acc, [fieldName, fieldMeta]) => { if ( - Object.keys(fieldMeta as AnyFieldMeta).length && - (fieldMeta as AnyFieldMeta).errors.length + Object.keys(fieldMeta as AnyFieldLikeMeta).length && + (fieldMeta as AnyFieldLikeMeta).errors.length ) { acc[fieldName as DeepKeys] = { - errors: (fieldMeta as AnyFieldMeta).errors, - errorMap: (fieldMeta as AnyFieldMeta).errorMap, + errors: (fieldMeta as AnyFieldLikeMeta).errors, + errorMap: (fieldMeta as AnyFieldLikeMeta).errorMap, } } diff --git a/packages/form-core/src/FormGroupApi.ts b/packages/form-core/src/FormGroupApi.ts new file mode 100644 index 000000000..66a24ef48 --- /dev/null +++ b/packages/form-core/src/FormGroupApi.ts @@ -0,0 +1,1995 @@ +import { batch, createStore } from '@tanstack/store' +import { + determineFieldLevelErrorSourceAndValue, + evaluate, + getAsyncValidatorArray, + getSyncValidatorArray, + isGlobalFormValidationError, + mergeOpts, +} from './utils' +import { defaultValidationLogic } from './ValidationLogic' +import { + isStandardSchemaValidator, + standardSchemaValidators, +} from './standardSchemaValidator' +import { defaultFieldMeta } from './metaHelper' +import { FieldApi } from './FieldApi' +import { + BaseFormState, + FormAsyncValidateOrFn, + FormState, + FormValidateOrFn, + UnwrapFormAsyncValidateOrFn, + UnwrapFormValidateOrFn, +} from './FormApi' +import type { AnyFieldApi } from './FieldApi' +import type { + StandardSchemaV1, + TStandardSchemaValidatorValue, +} from './standardSchemaValidator' +import type { AsyncValidator, SyncValidator, Updater } from './utils' +import type { ReadonlyStore, Store } from '@tanstack/store' +import { + AnyFieldLikeMeta, + FieldErrorMapFromValidator, + FieldInfo, + FieldLikeAPI, + FieldLikeApiOptions, + FieldLikeMetaBase, + FieldLikeOptions, + FieldLikeState, + ListenerCause, + UnwrapFieldAsyncValidateOrFn, + UnwrapFieldValidateOrFn, + UpdateMetaOptions, + ValidationCause, + ValidationError, + ValidationErrorMap, +} from './types' +import type { DeepKeys, DeepValue } from './util-types' + +/** + * @private + */ +export type FormGroupValidateFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = (props: { + value: TData + groupApi: FormGroupApi< + TParentData, + TName, + TData, + // This is technically an edge-type; which we try to keep non-`any`, but in this case + // It's referring to an inaccessible type from the group validate function inner types, so it's not a big deal + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > +}) => unknown + +/** + * @private + */ +export type FormGroupValidateOrFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = + | FormGroupValidateFn + | StandardSchemaV1 + +/** + * @private + */ +export type FormGroupValidateAsyncFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = (options: { + value: TData + groupApi: FormGroupApi< + TParentData, + TName, + TData, + // This is technically an edge-type; which we try to keep non-`any`, but in this case + // It's referring to an inaccessible type from the group validate function inner types, so it's not a big deal + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > + signal: AbortSignal +}) => unknown | Promise + +/** + * @private + */ +export type FormGroupAsyncValidateOrFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = + | FormGroupValidateAsyncFn + | StandardSchemaV1 + +/** + * @private + */ +export type FormGroupListenerFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = (props: { + value: TData + groupApi: FormGroupApi< + TParentData, + TName, + TData, + // This is technically an edge-type; which we try to keep non-`any`, but in this case + // It's referring to an inaccessible type from the group listener function inner types, so it's not a big deal + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > +}) => void + +export interface FormGroupValidators< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, +> { + /** + * An optional function, that runs on the mount event of input. + */ + onMount?: TOnMount + /** + * An optional function, that runs on the change event of input. + * + * @example z.string().min(1) + */ + onChange?: TOnChange + /** + * An optional property similar to `onChange` but async validation + * + * @example z.string().refine(async (val) => val.length > 3, { message: 'Testing 123' }) + */ + onChangeAsync?: TOnChangeAsync + /** + * An optional number to represent how long the `onChangeAsync` should wait before running + * + * If set to a number larger than 0, will debounce the async validation event by this length of time in milliseconds + */ + onChangeAsyncDebounceMs?: number + /** + * An optional list of field names that should trigger this field's `onChange` and `onChangeAsync` events when its value changes + */ + onChangeListenTo?: DeepKeys[] + /** + * An optional function, that runs on the blur event of input. + * + * @example z.string().min(1) + */ + onBlur?: TOnBlur + /** + * An optional property similar to `onBlur` but async validation. + * + * @example z.string().refine(async (val) => val.length > 3, { message: 'Testing 123' }) + */ + onBlurAsync?: TOnBlurAsync + + /** + * An optional number to represent how long the `onBlurAsync` should wait before running + * + * If set to a number larger than 0, will debounce the async validation event by this length of time in milliseconds + */ + onBlurAsyncDebounceMs?: number + /** + * An optional list of field names that should trigger this field's `onBlur` and `onBlurAsync` events when its value changes + */ + onBlurListenTo?: DeepKeys[] + /** + * An optional function, that runs on the submit event of form. + * + * @example z.string().min(1) + */ + onSubmit?: TOnSubmit + /** + * An optional property similar to `onSubmit` but async validation. + * + * @example z.string().refine(async (val) => val.length > 3, { message: 'Testing 123' }) + */ + onSubmitAsync?: TOnSubmitAsync + onDynamic?: TOnDynamic + onDynamicAsync?: TOnDynamicAsync + onDynamicAsyncDebounceMs?: number +} + +export interface FormGroupListeners< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> { + onChange?: FormGroupListenerFn + onChangeDebounceMs?: number + onBlur?: FormGroupListenerFn + onBlurDebounceMs?: number + onMount?: FormGroupListenerFn + onUnmount?: FormGroupListenerFn + onSubmit?: FormGroupListenerFn + onGroupSubmit?: FormGroupListenerFn +} + +interface FormGroupExtraOptions< + in out TParentData, + in out TName extends DeepKeys, + in out TData extends DeepValue, + in out TOnMount extends + | undefined + | FormGroupValidateOrFn, + in out TOnChange extends + | undefined + | FormGroupValidateOrFn, + in out TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnBlur extends + | undefined + | FormGroupValidateOrFn, + in out TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + in out TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + in out TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TSubmitMeta, + in out TFormOnMount extends undefined | FormValidateOrFn, + in out TFormOnChange extends undefined | FormValidateOrFn, + in out TFormOnChangeAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnBlur extends undefined | FormValidateOrFn, + in out TFormOnBlurAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnSubmit extends undefined | FormValidateOrFn, + in out TFormOnSubmitAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnDynamic extends undefined | FormValidateOrFn, + in out TFormOnDynamicAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnServer extends undefined | FormAsyncValidateOrFn, + in out TParentSubmitMeta, +> { + /** + * A list of validators to pass to the field + */ + validators?: FormGroupValidators< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync + > + + /** + * If true, allows the form to be submitted in an invalid state i.e. canSubmit will remain true regardless of validation errors. Defaults to undefined. + */ + canSubmitWhenInvalid?: boolean + + /** + * A list of listeners which attach to the corresponding events + */ + listeners?: FormGroupListeners + + defaultState?: FormGroupState + /** + * onSubmitMeta, the data passed from the handleSubmit handler, to the onSubmit function props + */ + onSubmitMeta?: TSubmitMeta + + /** + * A function to be called when the form is submitted, what should happen once the user submits a valid form returns `any` or a promise `Promise` + */ + onGroupSubmit?: (props: { + value: TData + groupApi: FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > + meta: TSubmitMeta + }) => any | Promise + /** + * Specify an action for scenarios where the user tries to submit an invalid form. + */ + onGroupSubmitInvalid?: (props: { + value: TData + groupApi: FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > + meta: TSubmitMeta + }) => void +} + +/** + * An object type representing the options for a field in a form. + */ +export interface FieldOptions< + in out TParentData, + in out TName extends DeepKeys, + in out TData extends DeepValue, + in out TOnMount extends + | undefined + | FormGroupValidateOrFn, + in out TOnChange extends + | undefined + | FormGroupValidateOrFn, + in out TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnBlur extends + | undefined + | FormGroupValidateOrFn, + in out TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + in out TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + in out TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TSubmitMeta, + in out TFormOnMount extends undefined | FormValidateOrFn, + in out TFormOnChange extends undefined | FormValidateOrFn, + in out TFormOnChangeAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnBlur extends undefined | FormValidateOrFn, + in out TFormOnBlurAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnSubmit extends undefined | FormValidateOrFn, + in out TFormOnSubmitAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnDynamic extends undefined | FormValidateOrFn, + in out TFormOnDynamicAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnServer extends undefined | FormAsyncValidateOrFn, + in out TParentSubmitMeta, +> + extends + FormGroupExtraOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, + FieldLikeOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync + > {} + +interface FormGroupApiOptions< + in out TParentData, + in out TName extends DeepKeys, + in out TData extends DeepValue, + in out TOnMount extends + | undefined + | FormGroupValidateOrFn, + in out TOnChange extends + | undefined + | FormGroupValidateOrFn, + in out TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnBlur extends + | undefined + | FormGroupValidateOrFn, + in out TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + in out TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + in out TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TSubmitMeta, + in out TFormOnMount extends undefined | FormValidateOrFn, + in out TFormOnChange extends undefined | FormValidateOrFn, + in out TFormOnChangeAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnBlur extends undefined | FormValidateOrFn, + in out TFormOnBlurAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnSubmit extends undefined | FormValidateOrFn, + in out TFormOnSubmitAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnDynamic extends undefined | FormValidateOrFn, + in out TFormOnDynamicAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnServer extends undefined | FormAsyncValidateOrFn, + in out TParentSubmitMeta, +> + extends + FieldLikeApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, + FormGroupExtraOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > {} + +interface FormGroupState { + /** + * A boolean indicating if the form is currently in the process of being submitted after `handleSubmit` is called. + * + * Goes back to `false` when submission completes for one of the following reasons: + * - the validation step returned errors. + * - the `onSubmit` function has completed. + * + * Note: if you're running async operations in your `onSubmit` function make sure to await them to ensure `isSubmitting` is set to `false` only when the async operation completes. + * + * This is useful for displaying loading indicators or disabling form inputs during submission. + * + */ + isSubmitting: boolean + /** + * A boolean indicating if the `onSubmit` function has completed successfully. + * + * Goes back to `false` at each new submission attempt. + * + * Note: you can use isSubmitting to check if the form is currently submitting. + */ + isSubmitted: boolean + /** + * A boolean indicating if the form or any of its fields are currently validating. + */ + isValidating: boolean + /** + * A counter for tracking the number of submission attempts. + */ + submissionAttempts: number + /** + * A boolean indicating if the last submission was successful. + */ + isSubmitSuccessful: boolean +} + +function getDefaultFormGroupState( + defaultState: Partial, +): FormGroupState { + return { + isSubmitted: defaultState.isSubmitted ?? false, + isSubmitting: defaultState.isSubmitting ?? false, + isValidating: defaultState.isValidating ?? false, + submissionAttempts: defaultState.submissionAttempts ?? 0, + isSubmitSuccessful: defaultState.isSubmitSuccessful ?? false, + } +} + +/** + * @public + * + * A type representing the FormGroup API with all generics set to `any` for convenience. + */ +export type AnyFormGroupApi = FormGroupApi< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any +> + +interface FormGroupStoreState extends AnyFieldLikeMeta { + isFieldsValidating: boolean + isFieldsValid: boolean + isGroupValid: boolean + canSubmit: boolean +} + +export class FormGroupApi< + in out TParentData, + in out TName extends DeepKeys, + in out TData extends DeepValue, + in out TOnMount extends + | undefined + | FormGroupValidateOrFn, + in out TOnChange extends + | undefined + | FormGroupValidateOrFn, + in out TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnBlur extends + | undefined + | FormGroupValidateOrFn, + in out TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + in out TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + in out TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TSubmitMeta, + in out TFormOnMount extends undefined | FormValidateOrFn, + in out TFormOnChange extends undefined | FormValidateOrFn, + in out TFormOnChangeAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnBlur extends undefined | FormValidateOrFn, + in out TFormOnBlurAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnSubmit extends undefined | FormValidateOrFn, + in out TFormOnSubmitAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnDynamic extends undefined | FormValidateOrFn, + in out TFormOnDynamicAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnServer extends undefined | FormAsyncValidateOrFn, + in out TParentSubmitMeta, +> implements FieldLikeAPI< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta, + FormGroupExtraOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > +> { + /** + * A reference to the form API instance. + */ + form: FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >['form'] + /** + * The field name. + */ + name: TName + /** + * The field options. + */ + options: FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > = {} as any + /** + * The field state store. + */ + store!: ReadonlyStore< + FieldLikeState< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync + > + > + /** + * The current field state. + */ + get state() { + return this.store.state + } + + formStateStore: Store + + get formState() { + return this.formStateStore.state + } + + timeoutIds: { + validations: Record | null> + listeners: Record | null> + formListeners: Record | null> + } + + constructor( + opts: FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, + ) { + this.form = opts.form + this.name = opts.name + this.options = opts + + this.timeoutIds = { + validations: {} as Record, + listeners: {} as Record, + formListeners: {} as Record, + } + + const formStateStoreVal: FormGroupState = getDefaultFormGroupState({ + ...(opts.defaultState as any), + }) + + this.formStateStore = createStore(formStateStoreVal) as never + + let prevMeta: AnyFieldLikeMeta | undefined = undefined + + this.store = createStore( + ( + prevVal: + | (FormGroupStoreState & + FieldLikeState< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync + >) + | undefined, + ) => { + // Temp hack to subscribe to form.store + this.form.store.get() + + const meta = this.form.getFieldMeta(this.name) ?? { + ...defaultFieldMeta, + ...opts.defaultMeta, + } + + let value = this.form.getFieldValue(this.name) + if ( + !meta.isTouched && + (value as unknown) === undefined && + this.options.defaultValue !== undefined && + !evaluate(value, this.options.defaultValue) + ) { + value = this.options.defaultValue + } + + const relatedFieldMeta = this.getRelatedFieldMetasDerived() + + const isFieldsValidating = relatedFieldMeta.some( + (field) => field.isValidating, + ) + + const isFieldsValid = relatedFieldMeta.every((field) => field.isValid) + + const isTouched = relatedFieldMeta.some((field) => field.isTouched) + const isBlurred = relatedFieldMeta.some((field) => field.isBlurred) + const isDefaultValue = relatedFieldMeta.every( + (field) => field.isDefaultValue, + ) + + const isDirty = relatedFieldMeta.some((field) => field.isDirty) + const isPristine = !isDirty + + const isValidating = !!isFieldsValidating + + // As `errors` is not a primitive, we need to aggressively persist the same referencial value for performance reasons + let errors = prevVal?.errors ?? [] + if (!prevMeta || meta.errorMap !== prevMeta.errorMap) { + errors = Object.values(meta.errorMap).reduce< + Array< + | UnwrapFieldValidateOrFn + | UnwrapFieldValidateOrFn + | UnwrapFieldAsyncValidateOrFn< + TName, + TOnChangeAsync, + TFormOnChangeAsync + > + | UnwrapFieldValidateOrFn + | UnwrapFieldAsyncValidateOrFn< + TName, + TOnBlurAsync, + TFormOnBlurAsync + > + | UnwrapFieldValidateOrFn + | UnwrapFieldAsyncValidateOrFn< + TName, + TOnSubmitAsync, + TFormOnSubmitAsync + > + > + >((prev, curr) => { + if (curr === undefined) return prev + + if (curr && isGlobalFormValidationError(curr)) { + prev.push(curr.form as never) + return prev + } + prev.push(curr as never) + return prev + }, []) + } + + const isGroupValid = errors.length === 0 + const isValid = isFieldsValid && isGroupValid + const submitInvalid = this.options.canSubmitWhenInvalid ?? false + const canSubmit = + (this.formStateStore.state.submissionAttempts === 0 && + !isTouched) /* && + !hasOnMountError */ || + (!isValidating && + !this.formStateStore.state.isSubmitting && + isValid) || + submitInvalid + + const errorMap = meta.errorMap + // TODO: Handle this + /* + if (shouldInvalidateOnMount) { + errors = errors.filter( + (err) => err !== currBaseStore.errorMap.onMount, + ) + errorMap = Object.assign(errorMap, { onMount: undefined }) + } + */ + + if ( + prevVal && + prevMeta && + prevVal.value === value && + prevVal.meta === meta && + prevVal.errorMap === errorMap && + prevVal.errors === errors && + prevVal.isFieldsValidating === isFieldsValidating && + prevVal.isFieldsValid === isFieldsValid && + prevVal.isGroupValid === isGroupValid && + prevVal.isValid === isValid && + prevVal.canSubmit === canSubmit && + prevVal.isTouched === isTouched && + prevVal.isBlurred === isBlurred && + prevVal.isPristine === isPristine && + prevVal.isDefaultValue === isDefaultValue && + prevVal.isDirty === isDirty && + evaluate(prevMeta, meta) + ) { + return prevVal + } + + const state = { + ...this.formStateStore.state, + value, + meta, + errorMap, + errors, + canSubmit, + isFieldsValidating, + isFieldsValid, + isGroupValid, + isValid, + isTouched, + isBlurred, + isPristine, + isDefaultValue, + isDirty, + errorSourceMap: {}, + } as FormGroupStoreState & + FieldLikeState< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync + > + + prevMeta = meta + + return state + }, + ) + + this.handleSubmit = this.handleSubmit.bind(this) + } + + /** + * Updates the field instance with new options. + */ + update = ( + opts: FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, + ) => { + this.options = opts + this.name = opts.name + + // Default Value + if (!this.state.meta.isTouched && this.options.defaultValue !== undefined) { + const formField = this.form.getFieldValue(this.name) + if (!evaluate(formField, opts.defaultValue)) { + this.form.setFieldValue(this.name, opts.defaultValue as never, { + dontUpdateMeta: true, + dontValidate: true, + dontRunListeners: true, + }) + } + } + + if (!this.form.getFieldMeta(this.name)) { + this.form.setFieldMeta(this.name, this.state.meta) + } + } + + /** + * @private + */ + runValidator< + TValue extends TStandardSchemaValidatorValue & { + groupApi: AnyFormGroupApi + }, + TType extends 'validate' | 'validateAsync', + >(props: { + validate: TType extends 'validate' + ? FormGroupValidateOrFn + : FormGroupAsyncValidateOrFn + value: TValue + type: TType + // When `api` is 'field', the return type cannot be `FormValidationError` + }): unknown { + if (isStandardSchemaValidator(props.validate)) { + return standardSchemaValidators[props.type]( + props.value, + props.validate, + ) as never + } + + return (props.validate as FormGroupValidateFn)( + props.value, + ) as never + } + + mount() { + this.update(this.options as never) + return () => {} + } + + /** + * Sets the field value and run the `change` validator. + */ + setValue = (updater: Updater, options?: UpdateMetaOptions) => { + this.form.setFieldValue( + this.name, + updater as never, + mergeOpts(options, { dontRunListeners: true, dontValidate: true }), + ) + + if (!options?.dontRunListeners) { + this.triggerOnChangeListener() + } + + if (!options?.dontValidate) { + this.validate('change') + } + } + + getMeta = () => this.store.state.meta + + /** + * Sets the field metadata. + */ + setMeta = ( + updater: Updater< + FieldLikeMetaBase< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync + > + >, + ) => this.form.setFieldMeta(this.name, updater) + + /** + * Gets the field information object. + */ + getInfo = () => this.form.getFieldInfo(this.name) + + /** + * @private + */ + getRelatedFields = () => { + const fields = Object.values(this.form.fieldInfo) as FieldInfo[] + + const relatedFields: AnyFieldApi[] = [] + for (const field of fields) { + if (!field.instance) continue + // TODO: How to handle FormGroups? + if (!(field.instance instanceof FieldApi)) continue + if (field.instance.name.startsWith(this.name)) { + relatedFields.push(field.instance) + } + } + + return relatedFields + } + /** + * @private + */ + getRelatedFieldMetasDerived = () => { + const fields = Object.entries(this.form.fieldMetaDerived.state) as [ + string, + AnyFieldLikeMeta, + ][] + + const relatedFieldMetas: (AnyFieldLikeMeta & { name: string })[] = [] + for (const [fieldName, fieldMeta] of fields) { + if (fieldName.startsWith(this.name)) { + relatedFieldMetas.push({ ...fieldMeta, name: fieldName }) + } + } + + return relatedFieldMetas + } + + /** + * @private + */ + validateSync = ( + cause: ValidationCause, + errorFromForm: ValidationErrorMap, + opts: { + skipRelatedFieldValidation?: boolean + } = {}, + ) => { + const validates = getSyncValidatorArray(cause, { + ...this.options, + form: this.form, + validationLogic: + this.form.options.validationLogic || defaultValidationLogic, + }) + + const relatedFields = opts.skipRelatedFieldValidation + ? [] + : this.getRelatedFields() + const relatedFieldValidates = relatedFields.reduce( + (acc, field) => { + const fieldValidates = getSyncValidatorArray(cause, { + ...field.options, + form: field.form, + validationLogic: + field.form.options.validationLogic || defaultValidationLogic, + }) + fieldValidates.forEach((validate) => { + ;(validate as any).field = field + }) + return acc.concat(fieldValidates as never) + }, + [] as Array< + SyncValidator & { + field: AnyFieldApi + } + >, + ) + + // Needs type cast as eslint errantly believes this is always falsy + let hasErrored = false as boolean + + batch(() => { + const validateFieldOrGroupFn = ( + fieldOrGroup: AnyFieldApi | AnyFormGroupApi, + validateObj: SyncValidator, + ) => { + const errorMapKey = getErrorMapKey(validateObj.cause) + + const fieldLevelError = validateObj.validate + ? normalizeError( + // TODO: Remove `any` cast + (fieldOrGroup as any).runValidator({ + validate: validateObj.validate, + value: { + value: fieldOrGroup.store.state.value, + validationSource: 'field', + ...(fieldOrGroup instanceof FormGroupApi + ? { + groupApi: fieldOrGroup, + } + : { fieldApi: fieldOrGroup }), + } as never, + type: 'validate', + }), + ) + : undefined + + const formLevelError = errorFromForm[errorMapKey] + + const { newErrorValue, newSource } = + determineFieldLevelErrorSourceAndValue({ + formLevelError, + fieldLevelError, + }) + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (fieldOrGroup.state.meta.errorMap?.[errorMapKey] !== newErrorValue) { + fieldOrGroup.setMeta((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + [errorMapKey]: newErrorValue, + }, + errorSourceMap: { + ...prev.errorSourceMap, + [errorMapKey]: newSource, + }, + })) + } + if (newErrorValue) { + hasErrored = true + } + } + + for (const validateObj of validates) { + validateFieldOrGroupFn(this, validateObj) + } + for (const fieldValidateObj of relatedFieldValidates) { + if (!fieldValidateObj.validate) continue + validateFieldOrGroupFn(fieldValidateObj.field, fieldValidateObj) + } + }) + + /** + * when we have an error for onSubmit in the state, we want + * to clear the error as soon as the user enters a valid value in the field + */ + const submitErrKey = getErrorMapKey('submit') + + if ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + this.state.meta.errorMap?.[submitErrKey] && + cause !== 'submit' && + !hasErrored + ) { + this.setMeta((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + [submitErrKey]: undefined, + }, + errorSourceMap: { + ...prev.errorSourceMap, + [submitErrKey]: undefined, + }, + })) + } + + return { hasErrored } + } + + /** + * @private + */ + validateAsync = async ( + cause: ValidationCause, + formValidationResultPromise: Promise< + FieldErrorMapFromValidator< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync + > + >, + opts: { + skipRelatedFieldValidation?: boolean + } = {}, + ) => { + const validates = getAsyncValidatorArray(cause, { + ...this.options, + form: this.form, + validationLogic: + this.form.options.validationLogic || defaultValidationLogic, + }) + + // Get the field-specific error messages that are coming from the form's validator + const asyncFormValidationResults = await formValidationResultPromise + + const relatedFields = opts.skipRelatedFieldValidation + ? [] + : this.getRelatedFields() + const relatedFieldValidates = relatedFields.reduce( + (acc, field) => { + const fieldValidates = getAsyncValidatorArray(cause, { + ...field.options, + form: field.form, + validationLogic: + field.form.options.validationLogic || defaultValidationLogic, + }) + fieldValidates.forEach((validate) => { + ;(validate as any).field = field + }) + return acc.concat(fieldValidates as never) + }, + [] as Array< + AsyncValidator & { + field: AnyFieldApi + } + >, + ) + + /** + * We have to use a for loop and generate our promises this way, otherwise it won't be sync + * when there are no validators needed to be run + */ + const validatesPromises: Promise[] = [] + const linkedPromises: Promise[] = [] + + // Check if there are actual async validators to run before setting isValidating + // This prevents unnecessary re-renders when there are no async validators + // See: https://github.com/TanStack/form/issues/1130 + const hasAsyncValidators = + validates.some((v) => v.validate) || + relatedFieldValidates.some((v) => v.validate) + + if (hasAsyncValidators) { + if (!this.state.meta.isValidating) { + this.setMeta((prev) => ({ ...prev, isValidating: true })) + } + + for (const linkedField of relatedFields) { + linkedField.setMeta((prev) => ({ ...prev, isValidating: true })) + } + } + + const validateFieldOrGroupAsyncFn = ( + fieldOrGroup: AnyFieldApi | AnyFormGroupApi, + validateObj: AsyncValidator, + promises: Promise[], + ) => { + const errorMapKey = getErrorMapKey(validateObj.cause) + const fieldInfo = fieldOrGroup.getInfo() + const fieldValidatorMeta = fieldInfo.validationMetaMap[errorMapKey] + + fieldValidatorMeta?.lastAbortController.abort() + const controller = new AbortController() + + fieldInfo.validationMetaMap[errorMapKey] = { + lastAbortController: controller, + } + + promises.push( + new Promise(async (resolve) => { + let rawError!: ValidationError | undefined + try { + rawError = await new Promise((rawResolve, rawReject) => { + if (fieldOrGroup.timeoutIds.validations[validateObj.cause]) { + clearTimeout( + fieldOrGroup.timeoutIds.validations[validateObj.cause]!, + ) + } + + fieldOrGroup.timeoutIds.validations[validateObj.cause] = + setTimeout(async () => { + if (controller.signal.aborted) return rawResolve(undefined) + try { + rawResolve( + await this.runValidator({ + validate: validateObj.validate, + value: { + value: fieldOrGroup.store.state.value, + signal: controller.signal, + validationSource: 'field', + ...(fieldOrGroup instanceof FormGroupApi + ? { + groupApi: fieldOrGroup, + } + : { fieldApi: fieldOrGroup }), + } as never, + type: 'validateAsync', + }), + ) + } catch (e) { + rawReject(e) + } + }, validateObj.debounceMs) + }) + } catch (e: unknown) { + rawError = e as ValidationError + } + if (controller.signal.aborted) return resolve(undefined) + + const fieldLevelError = normalizeError(rawError) + const formLevelError = + asyncFormValidationResults[ + fieldOrGroup.name as keyof typeof asyncFormValidationResults + ]?.[errorMapKey] + + const { newErrorValue, newSource } = + determineFieldLevelErrorSourceAndValue({ + formLevelError, + fieldLevelError, + }) + + if (fieldOrGroup.getInfo().instance !== fieldOrGroup) { + return resolve(undefined) + } + + fieldOrGroup.setMeta((prev) => { + return { + ...prev, + errorMap: { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + ...prev?.errorMap, + [errorMapKey]: newErrorValue, + }, + errorSourceMap: { + ...prev.errorSourceMap, + [errorMapKey]: newSource, + }, + } + }) + + resolve(newErrorValue) + }), + ) + } + + // TODO: Dedupe this logic to reduce bundle size + for (const validateObj of validates) { + if (!validateObj.validate) continue + validateFieldOrGroupAsyncFn(this, validateObj, validatesPromises) + } + for (const fieldValitateObj of relatedFieldValidates) { + if (!fieldValitateObj.validate) continue + validateFieldOrGroupAsyncFn( + fieldValitateObj.field, + fieldValitateObj, + linkedPromises, + ) + } + + let results: ValidationError[] = [] + if (validatesPromises.length || linkedPromises.length) { + results = await Promise.all(validatesPromises) + await Promise.all(linkedPromises) + } + + // Only reset isValidating if we set it to true earlier + if (hasAsyncValidators) { + this.setMeta((prev) => ({ ...prev, isValidating: false })) + + for (const linkedField of relatedFields) { + linkedField.setMeta((prev) => ({ ...prev, isValidating: false })) + } + } + + return results.filter(Boolean) + } + + /** + * Validates all fields according to the FIELD level validators. + * This will ignore FORM level validators, use form.validate({ValidationCause}) for a complete validation + */ + validateAllFields = async (cause: ValidationCause) => { + const fieldValidationPromises: Promise[] = [] as any + + batch(() => { + void Object.values(this.getRelatedFields()).forEach((fieldInstance) => { + // Validate the field + fieldValidationPromises.push( + // Remember, `validate` is either a sync operation or a promise + Promise.resolve().then(() => + fieldInstance.validate(cause, { skipFormValidation: true }), + ), + ) + + // If any fields are not touched + if (!fieldInstance.store.state.meta.isTouched) { + // Mark them as touched + fieldInstance.setMeta((prev) => ({ ...prev, isTouched: true })) + } + }) + }) + + const fieldErrorMapMap = await Promise.all(fieldValidationPromises) + return fieldErrorMapMap.flat() + } + + areRelatedFieldsValid = () => { + return Object.values(this.getRelatedFields()).every( + (field) => field.state.meta.isValid, + ) + } + + /** + * Validates the form group and all related children. + */ + validate = ( + cause: ValidationCause, + opts?: { + skipFormValidation?: boolean + skipRelatedFieldValidation?: boolean + }, + ): ValidationError[] | Promise => { + // Attempt to sync validate first + const { fieldsErrorMap } = opts?.skipFormValidation + ? { fieldsErrorMap: {} as never } + : this.form.validateSync(cause, { + dontUpdateFormErrorMap: true, + filterFieldNames: (fieldName) => fieldName.startsWith(this.name), + }) + const { hasErrored } = this.validateSync( + cause, + fieldsErrorMap[this.name] ?? {}, + { skipRelatedFieldValidation: opts?.skipRelatedFieldValidation }, + ) + + if (hasErrored && !this.options.asyncAlways) { + this.getInfo().validationMetaMap[ + getErrorMapKey(cause) + ]?.lastAbortController.abort() + return this.state.meta.errors + } + + // No error? Attempt async validation + const formValidationResultPromise = opts?.skipFormValidation + ? Promise.resolve({}) + : this.form.validateAsync(cause, { + dontUpdateFormErrorMap: true, + filterFieldNames: (fieldName) => fieldName.startsWith(this.name), + }) + return this.validateAsync(cause, formValidationResultPromise, { + skipRelatedFieldValidation: opts?.skipRelatedFieldValidation, + }) + } + + /** + * @private + */ + triggerOnChangeListener = () => { + // // TODO: Solve typings with formListener getting a fieldApi vs a groupApi + // const formDebounceMs = this.form.options.listeners?.onChangeDebounceMs + // if (formDebounceMs && formDebounceMs > 0) { + // if (this.timeoutIds.formListeners.change) { + // clearTimeout(this.timeoutIds.formListeners.change) + // } + // + // this.timeoutIds.formListeners.change = setTimeout(() => { + // this.form.options.listeners?.onChange?.({ + // formApi: this.form, + // groupApi: this, + // }) + // }, formDebounceMs) + // } else { + // this.form.options.listeners?.onChange?.({ + // formApi: this.form, + // groupApi: this, + // }) + // } + + const fieldDebounceMs = this.options.listeners?.onChangeDebounceMs + if (fieldDebounceMs && fieldDebounceMs > 0) { + if (this.timeoutIds.listeners.change) { + clearTimeout(this.timeoutIds.listeners.change) + } + + this.timeoutIds.listeners.change = setTimeout(() => { + this.options.listeners?.onChange?.({ + value: this.state.value, + groupApi: this, + }) + }, fieldDebounceMs) + } else { + this.options.listeners?.onChange?.({ + value: this.state.value, + groupApi: this, + }) + } + } + + /** + * @private + */ + triggerOnSubmitListener() { + this.options.listeners?.onSubmit?.({ + value: this.state.value, + groupApi: this, + }) + } + + // Needs to edgecase in the React adapter specifically to avoid type errors + handleSubmit(): Promise + handleSubmit(submitMeta: TSubmitMeta): Promise + handleSubmit(submitMeta?: TSubmitMeta): Promise { + return this._handleSubmit(submitMeta) + } + + /** + * Handles the form submission, performs validation, and calls the appropriate onSubmit or onSubmitInvalid callbacks. + */ + _handleSubmit = async (submitMeta?: TSubmitMeta): Promise => { + this.formStateStore.setState((old) => ({ + ...old, + // Submission attempts mark the form as not submitted + isSubmitted: false, + // Count submission attempts + submissionAttempts: old.submissionAttempts + 1, + isSubmitSuccessful: false, // Reset isSubmitSuccessful at the start of submission + })) + + batch(() => { + void Object.values(this.getRelatedFields()).forEach((field) => { + // If any fields are not touched + if (!field.state.meta.isTouched) { + // Mark them as touched + field.setMeta((prev) => ({ ...prev, isTouched: true })) + } + }) + }) + + const submitMetaArg = + submitMeta ?? (this.options.onSubmitMeta as TSubmitMeta) + + if (!this.state.meta.isValid) { + this.options.onGroupSubmitInvalid?.({ + value: this.state.value, + groupApi: this, + meta: submitMetaArg, + }) + return + } + + this.formStateStore.setState((d) => ({ ...d, isSubmitting: true })) + + const done = () => { + this.formStateStore.setState((prev) => ({ ...prev, isSubmitting: false })) + } + + await this.validateAllFields('submit') + + // Fields are invalid, do not submit + if (!this.areRelatedFieldsValid()) { + done() + + this.options.onGroupSubmitInvalid?.({ + value: this.state.value, + groupApi: this, + meta: submitMetaArg, + }) + + return + } + + await this.validate('submit', { + // This has already happened in the previous step + skipRelatedFieldValidation: true, + }) + + // Group is invalid, do not submit + if (!this.state.meta.isValid) { + done() + + this.options.onGroupSubmitInvalid?.({ + value: this.state.value, + groupApi: this, + meta: submitMetaArg, + }) + + return + } + + batch(() => { + void Object.values(this.getRelatedFields()).forEach((field) => { + field.options.listeners?.onGroupSubmit?.({ + value: field.state.value, + fieldApi: field, + }) + }) + }) + + this.options.listeners?.onSubmit?.({ + groupApi: this, + value: this.state.value, + }) + + try { + // Run the submit code + await this.options.onGroupSubmit?.({ + value: this.state.value, + groupApi: this, + meta: submitMetaArg, + }) + + batch(() => { + this.formStateStore.setState((prev) => ({ + ...prev, + isSubmitted: true, + isSubmitSuccessful: true, // Set isSubmitSuccessful to true on successful submission + })) + + done() + }) + } catch (err) { + this.formStateStore.setState((prev) => ({ + ...prev, + isSubmitSuccessful: false, // Ensure isSubmitSuccessful is false if an error occurs + })) + + done() + + throw err + } + } +} + +function normalizeError(rawError?: ValidationError) { + if (rawError) { + return rawError + } + + return undefined +} + +function getErrorMapKey(cause: ValidationCause) { + switch (cause) { + case 'submit': + return 'onSubmit' + case 'blur': + return 'onBlur' + case 'mount': + return 'onMount' + case 'server': + return 'onServer' + case 'dynamic': + return 'onDynamic' + case 'change': + default: + return 'onChange' + } +} diff --git a/packages/form-core/src/index.ts b/packages/form-core/src/index.ts index 94c0f3eea..b7fd096ce 100644 --- a/packages/form-core/src/index.ts +++ b/packages/form-core/src/index.ts @@ -1,5 +1,6 @@ export * from './FormApi' export * from './FieldApi' +export * from './FormGroupApi' export * from './utils' export * from './util-types' export * from './types' diff --git a/packages/form-core/src/standardSchemaValidator.ts b/packages/form-core/src/standardSchemaValidator.ts index ae4b93c35..764852856 100644 --- a/packages/form-core/src/standardSchemaValidator.ts +++ b/packages/form-core/src/standardSchemaValidator.ts @@ -86,8 +86,9 @@ export const standardSchemaValidators = { if (!result.issues) return - if (validationSource === 'field') + if (validationSource === 'field') { return result.issues as TStandardSchemaValidatorIssue + } return transformFormIssues(result.issues, value) }, async validateAsync( @@ -101,8 +102,9 @@ export const standardSchemaValidators = { if (!result.issues) return - if (validationSource === 'field') + if (validationSource === 'field') { return result.issues as TStandardSchemaValidatorIssue + } return transformFormIssues(result.issues, value) }, } diff --git a/packages/form-core/src/types.ts b/packages/form-core/src/types.ts index ff5824283..437fa3487 100644 --- a/packages/form-core/src/types.ts +++ b/packages/form-core/src/types.ts @@ -1,6 +1,30 @@ -import type { AnyFieldMeta, AnyFieldMetaBase } from './FieldApi' -import type { DeepKeys, DeepKeysOfType, DeepValue } from './util-types' +import { FieldApi, FieldValidateAsyncFn, FieldValidateFn } from './FieldApi' +import type { + DeepKeys, + DeepKeysOfType, + DeepValue, + UnwrapOneLevelOfArray, +} from './util-types' import type { Updater } from './utils' +import type { + AnyFormApi, + FormApi, + FormAsyncValidateOrFn, + FormValidateAsyncFn, + FormValidateFn, + FormValidateOrFn, + ValidationMeta, +} from './FormApi' +import type { ReadonlyStore } from '@tanstack/store' +import type { + FormGroupAsyncValidateOrFn, + FormGroupValidateAsyncFn, + FormGroupValidateFn, +} from './FormGroupApi' +import type { + StandardSchemaV1, + StandardSchemaV1Issue, +} from './standardSchemaValidator' export type ValidationError = unknown @@ -146,9 +170,8 @@ export interface UpdateMetaOptions { /** * @private - * A list of field manipulation methods that a form-like API must implement. */ -export interface FieldManipulator { +export interface FormLikeAPI { /** * Validates all fields using the correct handlers for a given validation cause. */ @@ -191,14 +214,14 @@ export interface FieldManipulator { */ getFieldMeta: >( field: TField, - ) => AnyFieldMeta | undefined + ) => AnyFieldLikeMeta | undefined /** * Updates the metadata of the specified field. */ setFieldMeta: >( field: TField, - updater: Updater, + updater: Updater, ) => void /** @@ -292,3 +315,832 @@ export interface FieldManipulator { */ resetField: >(field: TField) => void } + +/** + * @private + */ +export type FieldAsyncValidateOrFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = + | FieldValidateAsyncFn + | FormGroupValidateAsyncFn + | StandardSchemaV1 + +type UnwrapFormAsyncValidateOrFnForInner< + TValidateOrFn extends undefined | FormAsyncValidateOrFn, +> = [TValidateOrFn] extends [FormValidateAsyncFn] + ? Awaited> + : [TValidateOrFn] extends [StandardSchemaV1] + ? StandardBrandedSchemaV1 + : undefined + +export type UnwrapFieldAsyncValidateOrFn< + TName extends string, + TValidateOrFn extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TFormValidateOrFn extends undefined | FormAsyncValidateOrFn, +> = + | ([TFormValidateOrFn] extends [StandardSchemaV1] + ? TName extends keyof TStandardOut + ? StandardSchemaV1Issue[] + : undefined + : undefined) + | (UnwrapFormAsyncValidateOrFnForInner extends infer TFormValidateVal + ? TFormValidateVal extends { __standardSchemaV1: true } + ? [DeepValue] extends [never] + ? undefined + : StandardSchemaV1Issue[] + : TFormValidateVal extends { fields: any } + ? TName extends keyof TFormValidateVal['fields'] + ? TFormValidateVal['fields'][TName] + : undefined + : undefined + : never) + | ([TValidateOrFn] extends [FieldValidateAsyncFn] + ? Awaited> + : [TValidateOrFn] extends [StandardSchemaV1] + ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] + StandardSchemaV1Issue[] + : undefined) + | ([TValidateOrFn] extends [FormGroupValidateAsyncFn] + ? Awaited> + : [TValidateOrFn] extends [StandardSchemaV1] + ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] + StandardSchemaV1Issue[] + : undefined) + +/** + * @private + */ +// TODO: Add the `Unwrap` type to the errors +export type FieldErrorMapFromValidator< + TFormData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FieldValidateOrFn, + TOnChange extends undefined | FieldValidateOrFn, + TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnBlur extends undefined | FieldValidateOrFn, + TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnSubmit extends undefined | FieldValidateOrFn, + TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn, +> = Partial< + Record< + DeepKeys, + ValidationErrorMap< + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync + > + > +> + +/** + * @private + */ +export type FieldValidateOrFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = + | FieldValidateFn + | FormGroupValidateFn + | StandardSchemaV1 + +type StandardBrandedSchemaV1 = T & { __standardSchemaV1: true } + +type UnwrapFormValidateOrFnForInner< + TValidateOrFn extends undefined | FormValidateOrFn, +> = [TValidateOrFn] extends [FormValidateFn] + ? ReturnType + : [TValidateOrFn] extends [StandardSchemaV1] + ? StandardBrandedSchemaV1 + : undefined + +export type UnwrapFieldValidateOrFn< + TName extends string, + TValidateOrFn extends undefined | FieldValidateOrFn, + TFormValidateOrFn extends undefined | FormValidateOrFn, +> = + | ([TFormValidateOrFn] extends [StandardSchemaV1] + ? TName extends keyof TStandardOut + ? StandardSchemaV1Issue[] + : undefined + : undefined) + | (UnwrapFormValidateOrFnForInner extends infer TFormValidateVal + ? TFormValidateVal extends { __standardSchemaV1: true } + ? [DeepValue] extends [never] + ? undefined + : StandardSchemaV1Issue[] + : TFormValidateVal extends { fields: any } + ? TName extends keyof TFormValidateVal['fields'] + ? TFormValidateVal['fields'][TName] + : undefined + : undefined + : never) + | ([TValidateOrFn] extends [FieldValidateFn] + ? ReturnType + : [TValidateOrFn] extends [StandardSchemaV1] + ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] + StandardSchemaV1Issue[] + : undefined) + | ([TValidateOrFn] extends [FormGroupValidateFn] + ? ReturnType + : [TValidateOrFn] extends [StandardSchemaV1] + ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] + StandardSchemaV1Issue[] + : undefined) + +/** + * @private + */ +export type FieldLikeMetaBase< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FieldValidateOrFn, + TOnChange extends undefined | FieldValidateOrFn, + TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnBlur extends undefined | FieldValidateOrFn, + TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnSubmit extends undefined | FieldValidateOrFn, + TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnDynamic extends undefined | FieldValidateOrFn, + TOnDynamicAsync extends + | undefined + | FieldAsyncValidateOrFn, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, +> = { + /** + * A flag indicating whether the field has been touched. + */ + isTouched: boolean + /** + * A flag indicating whether the field has been blurred. + */ + isBlurred: boolean + /** + * A flag that is `true` if the field's value has been modified by the user. Opposite of `isPristine`. + */ + isDirty: boolean + /** + * A map of errors related to the field value. + */ + errorMap: ValidationErrorMap< + UnwrapFieldValidateOrFn, + UnwrapFieldValidateOrFn, + UnwrapFieldAsyncValidateOrFn, + UnwrapFieldValidateOrFn, + UnwrapFieldAsyncValidateOrFn, + UnwrapFieldValidateOrFn, + UnwrapFieldAsyncValidateOrFn, + UnwrapFieldValidateOrFn, + UnwrapFieldAsyncValidateOrFn + > + + /** + * @private allows tracking the source of the errors in the error map + */ + errorSourceMap: ValidationErrorMapSource + /** + * A flag indicating whether the field is currently being validated. + */ + isValidating: boolean +} + +/** + * @private + */ +export type AnyFieldLikeMetaBase = FieldLikeMetaBase< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any +> + +/** + * @private + */ +export type FieldLikeMetaDerived< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FieldValidateOrFn, + TOnChange extends undefined | FieldValidateOrFn, + TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnBlur extends undefined | FieldValidateOrFn, + TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnSubmit extends undefined | FieldValidateOrFn, + TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnDynamic extends undefined | FieldValidateOrFn, + TOnDynamicAsync extends + | undefined + | FieldAsyncValidateOrFn, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, +> = { + /** + * An array of errors related to the field value. + */ + errors: Array< + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldAsyncValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldAsyncValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldAsyncValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldAsyncValidateOrFn< + TName, + TOnDynamicAsync, + TFormOnDynamicAsync + > + > + > + /** + * A flag that is `true` if the field's value has not been modified by the user. Opposite of `isDirty`. + */ + isPristine: boolean + /** + * A boolean indicating if the field is valid. Evaluates `true` if there are no field errors. + */ + isValid: boolean + /** + * A flag indicating whether the field's current value is the default value + */ + isDefaultValue: boolean +} + +/** + * @private + * An object type representing the metadata of a field in a form. + */ +export type FieldLikeMeta< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FieldValidateOrFn, + TOnChange extends undefined | FieldValidateOrFn, + TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnBlur extends undefined | FieldValidateOrFn, + TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnSubmit extends undefined | FieldValidateOrFn, + TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnDynamic extends undefined | FieldValidateOrFn, + TOnDynamicAsync extends + | undefined + | FieldAsyncValidateOrFn, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, +> = FieldLikeMetaBase< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync +> & + FieldLikeMetaDerived< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync + > + +/** + * @private + */ +export type AnyFieldLikeMeta = FieldLikeMeta< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any +> + +/** + * @private + * An object type representing the state of a field. + */ +export type FieldLikeState< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FieldValidateOrFn, + TOnChange extends undefined | FieldValidateOrFn, + TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnBlur extends undefined | FieldValidateOrFn, + TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnSubmit extends undefined | FieldValidateOrFn, + TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnDynamic extends undefined | FieldValidateOrFn, + TOnDynamicAsync extends + | undefined + | FieldAsyncValidateOrFn, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, +> = { + /** + * The current value of the field. + */ + value: TData + /** + * The current metadata of the field. + */ + meta: FieldLikeMeta< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync + > +} + +/** + * @private + * An object type representing the options for a field in a form. + */ +export interface FieldLikeOptions< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FieldValidateOrFn, + TOnChange extends undefined | FieldValidateOrFn, + TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnBlur extends undefined | FieldValidateOrFn, + TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnSubmit extends undefined | FieldValidateOrFn, + TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnDynamic extends undefined | FieldValidateOrFn, + TOnDynamicAsync extends + | undefined + | FieldAsyncValidateOrFn, +> { + /** + * The field name. The type will be `DeepKeys` to ensure your name is a deep key of the parent dataset. + */ + name: TName + /** + * An optional default value for the field. + */ + defaultValue?: NoInfer + /** + * The default time to debounce async validation if there is not a more specific debounce time passed. + */ + asyncDebounceMs?: number + /** + * If `true`, always run async validation, even if there are errors emitted during synchronous validation. + */ + asyncAlways?: boolean + /** + * An optional object with default metadata for the field. + */ + defaultMeta?: Partial< + FieldLikeMeta< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + any, + any, + any, + any, + any, + any, + any, + any, + any + > + > + /** + * Disable the `flat(1)` operation on `field.errors`. This is useful if you want to keep the error structure as is. Not suggested for most use-cases. + */ + disableErrorFlat?: boolean +} + +/** + * @private + * An object type representing the required options for the FieldApi class. + */ +export interface FieldLikeApiOptions< + in out TParentData, + in out TName extends DeepKeys, + in out TData extends DeepValue, + in out TOnMount extends + | undefined + | FieldValidateOrFn, + in out TOnChange extends + | undefined + | FieldValidateOrFn, + in out TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn, + in out TOnBlur extends + | undefined + | FieldValidateOrFn, + in out TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn, + in out TOnSubmit extends + | undefined + | FieldValidateOrFn, + in out TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn, + in out TOnDynamic extends + | undefined + | FieldValidateOrFn, + in out TOnDynamicAsync extends + | undefined + | FieldAsyncValidateOrFn, + in out TFormOnMount extends undefined | FormValidateOrFn, + in out TFormOnChange extends undefined | FormValidateOrFn, + in out TFormOnChangeAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnBlur extends undefined | FormValidateOrFn, + in out TFormOnBlurAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnSubmit extends undefined | FormValidateOrFn, + in out TFormOnSubmitAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnDynamic extends undefined | FormValidateOrFn, + in out TFormOnDynamicAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnServer extends undefined | FormAsyncValidateOrFn, + in out TParentSubmitMeta, +> extends FieldLikeOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync +> { + form: FormApi< + TParentData, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > +} + +/** + * @private + */ +export interface FieldLikeAPI< + in out TParentData, + in out TName extends DeepKeys, + in out TData extends DeepValue, + in out TOnMount extends + | undefined + | FieldValidateOrFn, + in out TOnChange extends + | undefined + | FieldValidateOrFn, + in out TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn, + in out TOnBlur extends + | undefined + | FieldValidateOrFn, + in out TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn, + in out TOnSubmit extends + | undefined + | FieldValidateOrFn, + in out TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn, + in out TOnDynamic extends + | undefined + | FieldValidateOrFn, + in out TOnDynamicAsync extends + | undefined + | FieldAsyncValidateOrFn, + in out TFormOnMount extends undefined | FormValidateOrFn, + in out TFormOnChange extends undefined | FormValidateOrFn, + in out TFormOnChangeAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnBlur extends undefined | FormValidateOrFn, + in out TFormOnBlurAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnSubmit extends undefined | FormValidateOrFn, + in out TFormOnSubmitAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnDynamic extends undefined | FormValidateOrFn, + in out TFormOnDynamicAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnServer extends undefined | FormAsyncValidateOrFn, + in out TParentSubmitMeta, + TExtraOptions = {}, +> { + form: AnyFormApi + options: FieldLikeApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > & + TExtraOptions + store: ReadonlyStore< + FieldLikeState< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync + > + > + /** + * The field name. + */ + name: TName + mount: () => () => void + + setValue: (updater: Updater, options?: UpdateMetaOptions) => void + getMeta: () => AnyFieldLikeMeta + setMeta: (updater: Updater) => void + getInfo: () => FieldInfo + validate: ( + cause: ValidationCause, + opts?: { skipFormValidation?: boolean }, + ) => ValidationError[] | Promise + /** + * @private + */ + triggerOnChangeListener: () => void + /** + * @private + */ + triggerOnSubmitListener: () => void +} + +/** + * @private + */ +export interface FieldInfo { + instance: FieldLikeAPI< + TParentData, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > | null + validationMetaMap: Record +} diff --git a/packages/form-core/tests/FormGroupApi.spec.ts b/packages/form-core/tests/FormGroupApi.spec.ts new file mode 100644 index 000000000..789a21e04 --- /dev/null +++ b/packages/form-core/tests/FormGroupApi.spec.ts @@ -0,0 +1,230 @@ +import { describe, expect, it, vi } from 'vitest' +import { FieldApi, FormApi, FormGroupApi } from '../src/index' + +describe('form group api', () => { + it('should allow a submission without submitting the form', async () => { + const onSubmit = vi.fn() + + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + onSubmit, + }) + + const onGroupSubmit = vi.fn() + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit, + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + await step1Group.handleSubmit() + + expect(onGroupSubmit).toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('should handle invalid submissions with form validator', async () => { + const onSubmit = vi.fn() + + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + onSubmit, + validators: { + onSubmit: () => { + return { + fields: { + step1: { + name: { + required: true, + }, + }, + }, + } + }, + }, + }) + + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit, + onGroupSubmitInvalid, + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + await step1Group.handleSubmit() + + expect(onGroupSubmitInvalid).toHaveBeenCalled() + expect(onGroupSubmit).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('should handle invalid submissions with form validator and throw away other unrelated fields errors', async () => { + const onSubmit = vi.fn() + + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + onSubmit, + validators: { + onSubmit: () => { + return { + fields: { + step1: { + name: { + required: true, + }, + }, + step2: { + name: { + required: true, + }, + }, + }, + } + }, + }, + }) + + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit, + onGroupSubmitInvalid, + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + await step1Group.handleSubmit() + + expect(onGroupSubmitInvalid).toHaveBeenCalled() + expect(onGroupSubmit).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('Should handle validations on form groups themselves', async () => { + const onSubmit = vi.fn() + + const form = new FormApi({ + defaultValues: { + step1: { name: '' }, + step2: { name: 'test2' }, + }, + onSubmit, + }) + + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit, + onGroupSubmitInvalid, + validators: { + onSubmit: ({ value }) => { + if (!value.name) { + return 'Name is required' + } + return undefined + }, + }, + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + await step1Group.handleSubmit() + + expect(onGroupSubmitInvalid).toHaveBeenCalled() + expect(onGroupSubmit).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + expect(step1Group.state.meta.errorMap.onSubmit).toBe('Name is required') + }) + it('Should handle submit meta args', async () => { + const onSubmit = vi.fn() + + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + onSubmit, + }) + + const onGroupSubmit = vi.fn() + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit, + onSubmitMeta: {} as { source: string }, + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + await step1Group.handleSubmit({ source: 'button' }) + + expect(onGroupSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + value: { name: 'test' }, + meta: { source: 'button' }, + }), + ) + expect(onSubmit).not.toHaveBeenCalled() + }) + it.todo('Should handle onXListenTo from fields') + it.todo('Should handle onXListenTo from other groups') +})