diff --git a/docs/api/use-form-handler/form-state.md b/docs/api/use-form-handler/form-state.md index a0e054f..50f01b4 100644 --- a/docs/api/use-form-handler/form-state.md +++ b/docs/api/use-form-handler/form-state.md @@ -4,14 +4,16 @@ Provides you with the reactive state of the form, including validation, dirty an ## Return -| attribute | type | description | -| --------- | ------------------------- | -------------------------------------------------------------- | -| dirty | `Record` | Object containing all the inputs that have been modified | -| errors | `Record` | Object containing all the current field errors of the form | -| touched | `Record` | Object containing all the inputs the users has interacted with | -| isDirty | `boolean` | True if there is any modified field on the form | -| isTouched | `boolean` | True if there has been any interaction with a form field | -| isValid | `boolean` | True if there are no form errors | +| attribute | type | description | +| ------------ | ------------------------- | -------------------------------------------------------------- | +| dirty | `Record` | Object containing all the fields that have been modified | +| errors | `Record` | Object containing all the current field errors of the form | +| touched | `Record` | Object containing all the fields the users has interacted with | +| validating | `Record` | Object containing all the fields undergoing validation | +| isDirty | `boolean` | True if there is any modified field on the form | +| isTouched | `boolean` | True if there has been any interaction with a form field | +| isValid | `boolean` | True if there are no form errors | +| isValidating | `boolean` | True if there are field validations in progress | ## Rules @@ -58,8 +60,10 @@ export interface FormState { isDirty: boolean isTouched: boolean isValid: boolean + isValidating: boolean dirty: Record touched: Record errors: Record + validating: Record } ``` diff --git a/docs/api/use-form-handler/index.md b/docs/api/use-form-handler/index.md index 3128bb2..f579242 100644 --- a/docs/api/use-form-handler/index.md +++ b/docs/api/use-form-handler/index.md @@ -182,6 +182,7 @@ export declare const useFormHandler: < readonly isDirty: boolean readonly isTouched: boolean readonly isValid: boolean + readonly isValidating: boolean readonly dirty: import('@vue/reactivity').DeepReadonly< import('@vue/reactivity').UnwrapRef> > @@ -191,6 +192,9 @@ export declare const useFormHandler: < readonly errors: import('@vue/reactivity').DeepReadonly< import('@vue/reactivity').UnwrapRef> > + readonly validating: import('@vue/reactivity').DeepReadonly< + import('@vue/reactivity').UnwrapRef> + > } handleSubmit: ( successFn: HandleSubmitSuccessFn, diff --git a/docs/api/use-form-handler/register.md b/docs/api/use-form-handler/register.md index 1d5573f..93a628c 100644 --- a/docs/api/use-form-handler/register.md +++ b/docs/api/use-form-handler/register.md @@ -39,6 +39,7 @@ Coming soon... | disabled | `boolean` | Disabled state binding for the field | | isDirty | `boolean` | Dirty state binding for the field. Only returned if `withDetails` is true | | isTouched | `boolean` | Touched state binding for the field. Only returned if `withDetails` is true | +| isValidating | `boolean` | Validating state binding for the field. Only returned if `withDetails` is true | | onChange | `(el: any) => Promise` | Value update handler for native inputs | | required | `boolean \| string` | Native required validation. Only returned if `useNativeValidations` is set to true and `required` is set. | | min | `number \| Object` | Native min validation. Only returned if `useNativeValidations` is set to true and `min` is set. | @@ -243,7 +244,6 @@ Custom validations are kept very simple, can be synchronous or asynchronous. We ## Type Declarations ```ts - interface ValidationWithMessage { value: number | string | RegExp message: string @@ -281,6 +281,7 @@ export type Register = ( onChange?: (() => Promise) | undefined isDirty?: boolean | undefined isTouched?: boolean | undefined + isValidating?: boolean | undefined disabled?: boolean | undefined name: keyof T modelValue: T[keyof T] diff --git a/src/constants.ts b/src/constants.ts index 919c791..f9d70aa 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -6,6 +6,7 @@ export const BaseInputProps = { name: { type: String, required: true }, isDirty: { type: Boolean, default: () => false }, isTouched: { type: Boolean, default: () => false }, + isValidating: { type: Boolean, default: () => false }, disabled: { type: Boolean, default: () => false }, error: { type: String, default: () => '' }, onBlur: { type: Function, required: true }, diff --git a/src/types/baseControl.ts b/src/types/baseControl.ts index 8ae01f7..63f26ee 100644 --- a/src/types/baseControl.ts +++ b/src/types/baseControl.ts @@ -29,6 +29,9 @@ export interface BaseControlProps< /** Current touched state of the control */ isTouched?: boolean + /** Current validating state of the control */ + isValidating?: boolean + /** Handler binding for native inputs */ onChange?: (el: any) => Promise } diff --git a/src/types/formHandler.ts b/src/types/formHandler.ts index 2ea0f50..efb1a9c 100644 --- a/src/types/formHandler.ts +++ b/src/types/formHandler.ts @@ -11,16 +11,20 @@ export interface FormState { isDirty: boolean isTouched: boolean isValid: boolean + isValidating: boolean dirty: Record touched: Record errors: Record + validating: Record } /** Optional function to be called after a form failed to submit */ export type HandleSubmitErrorFn = (errors: FormState['errors']) => void /** Expected function to be called after a form submitted successfully */ -export type HandleSubmitSuccessFn = (values: Record) => void +export type HandleSubmitSuccessFn = ( + values: Record +) => PossiblePromise export type Build = Record> = < TBuild extends Partial, diff --git a/src/useFormHandler.ts b/src/useFormHandler.ts index 8f2b74b..4131384 100644 --- a/src/useFormHandler.ts +++ b/src/useFormHandler.ts @@ -34,9 +34,11 @@ export const initialState = () => ({ touched: {}, dirty: {}, errors: {}, + validating: {}, isDirty: false, isTouched: false, isValid: true, + isValidating: false, }) type Refs = Record @@ -106,6 +108,10 @@ export const useFormHandler = < formState.isTouched = Object.values(formState.touched).some(Boolean) } + const _updateValidatingState = () => { + formState.isValidating = Object.values(formState.validating).some(Boolean) + } + const _validateField = async ({ name, values, @@ -118,14 +124,21 @@ export const useFormHandler = < if (_refs[name]._disabled) { return } + const timeout = setTimeout( + () => setValidating(name as keyof TForm, true), + 20 + ) for (const validation of Object.values(_refs[name]._validations)) { const result = await (validation as any)(values[name]) + if (result !== true) { formState.errors[name] = result break } delete formState.errors[name] } + clearTimeout(timeout) + setValidating(name as keyof TForm, false) } const _validateForm = async >( @@ -184,6 +197,19 @@ export const useFormHandler = < } } + const setValidating = (name: keyof TForm, validating: boolean) => { + console.log(validating) + if (formState.validating[name] !== validating) { + if (validating) { + formState.validating[name] = true + _updateValidatingState() + return + } + delete formState.validating[name] + _updateValidatingState() + } + } + const resetField = (name: keyof TForm) => { values[name] = _getInitial(name) setTouched(name, false) @@ -319,6 +345,7 @@ export const useFormHandler = < ...(withDetails && { isDirty: !!formState.dirty[name], isTouched: !!formState.touched[name], + isValidating: !!formState.validating[name], }), ...(native !== false && { onChange: async () => { diff --git a/test/useFormHandler.test.ts b/test/useFormHandler.test.ts index 0519349..59038a3 100644 --- a/test/useFormHandler.test.ts +++ b/test/useFormHandler.test.ts @@ -3,6 +3,8 @@ import { initialState, useFormHandler } from '@/useFormHandler' import { expect, it, describe } from 'vitest' import { retry } from './testing-utils' +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + describe('useFormHandler()', () => { describe('register()', () => { it('should register a field', () => { @@ -12,6 +14,7 @@ describe('useFormHandler()', () => { expect(field.onBlur).toBeDefined() expect(field.isDirty).toBeUndefined() expect(field.isTouched).toBeUndefined() + expect(field.isValidating).toBeUndefined() expect(field.onClear).toBeDefined() expect(field.onChange).toBeDefined() expect(field.modelValue).toBe(null) @@ -37,11 +40,12 @@ describe('useFormHandler()', () => { expect(field.modelValue).toBeDefined() expect(field['onUpdate:modelValue']).toBeDefined() }) - it('should apply dirty and touched states when withDetails is specified', () => { + it('should apply dirty, touched and validation states when withDetails is specified', () => { const { register } = useFormHandler() const field = register('field', { withDetails: true }) expect(field.isDirty).toBeDefined() expect(field.isTouched).toBeDefined() + expect(field.isValidating).toBeDefined() }) it('should apply default value', () => { const { values, register } = useFormHandler() @@ -142,6 +146,55 @@ describe('useFormHandler()', () => { expect(values.field).toBe(null) expect(formState.isDirty).toBeTruthy() }) + it('should set validating state when async validation is in progress', async () => { + const { register, formState, triggerValidation } = useFormHandler() + register('field', { + validate: { + asyncValidation: async (value: string) => { + await sleep(200) + return value === 'test' || 'error' + }, + }, + }) + triggerValidation('field') + await sleep(20) + expect(formState.isValidating).toBeTruthy() + expect(formState.validating).toStrictEqual({ field: true }) + await sleep(200) + expect(formState.isValidating).toBeFalsy() + expect(formState.validating).toStrictEqual({}) + }) + it('should correctly validate when async validation fails', async () => { + const { register, formState, triggerValidation } = useFormHandler() + register('field', { + validate: { + asyncValidation: async (value: string) => { + await sleep(200) + return value === 'test' || 'error' + }, + }, + }) + triggerValidation('field') + await sleep(200) + expect(formState.errors.field).toBe('error') + expect(formState.isValid).toBeFalsy() + expect(formState.validating).toStrictEqual({}) + expect(formState.isValidating).toBeFalsy() + }) + it('should not set validating state for sync validation', async () => { + const { register, formState, triggerValidation } = useFormHandler() + register('field', { + validate: { + error: (value: string) => value === 'test' || 'error', + }, + }) + triggerValidation('field') + await sleep(10) + expect(formState.errors.field).toBe('error') + expect(formState.isValid).toBeFalsy() + expect(formState.isValidating).toBeFalsy() + expect(formState.validating).toStrictEqual({}) + }) it('should set an error programmatically', async () => { const { formState, setError } = useFormHandler() setError('field', 'some error')