Skip to content
This repository was archived by the owner on Apr 9, 2025. It is now read-only.

Feature/54 loading states validating submitting #75

Merged
merged 8 commits into from
Jun 22, 2024
Merged
20 changes: 12 additions & 8 deletions docs/api/use-form-handler/form-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ Provides you with the reactive state of the form, including validation, dirty an

## Return

| attribute | type | description |
| --------- | ------------------------- | -------------------------------------------------------------- |
| dirty | `Record<string, boolean>` | Object containing all the inputs that have been modified |
| errors | `Record<string, string>` | Object containing all the current field errors of the form |
| touched | `Record<string, boolean>` | 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<string, boolean>` | Object containing all the fields that have been modified |
| errors | `Record<string, string>` | Object containing all the current field errors of the form |
| touched | `Record<string, boolean>` | Object containing all the fields the users has interacted with |
| validating | `Record<string, boolean>` | 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

Expand Down Expand Up @@ -58,8 +60,10 @@ export interface FormState<T> {
isDirty: boolean
isTouched: boolean
isValid: boolean
isValidating: boolean
dirty: Record<keyof T, boolean>
touched: Record<keyof T, boolean>
errors: Record<keyof T, string | undefined>
validating: Record<keyof T, boolean>
}
```
4 changes: 4 additions & 0 deletions docs/api/use-form-handler/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<keyof T, boolean>>
>
Expand All @@ -191,6 +192,9 @@ export declare const useFormHandler: <
readonly errors: import('@vue/reactivity').DeepReadonly<
import('@vue/reactivity').UnwrapRef<Record<keyof T, string | undefined>>
>
readonly validating: import('@vue/reactivity').DeepReadonly<
import('@vue/reactivity').UnwrapRef<Record<keyof T, boolean>>
>
}
handleSubmit: (
successFn: HandleSubmitSuccessFn,
Expand Down
3 changes: 2 additions & 1 deletion docs/api/use-form-handler/register.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>` | 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. |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -281,6 +281,7 @@ export type Register = (
onChange?: (() => Promise<void>) | undefined
isDirty?: boolean | undefined
isTouched?: boolean | undefined
isValidating?: boolean | undefined
disabled?: boolean | undefined
name: keyof T
modelValue: T[keyof T]
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
3 changes: 3 additions & 0 deletions src/types/baseControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
}
Expand Down
6 changes: 5 additions & 1 deletion src/types/formHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,20 @@ export interface FormState<T> {
isDirty: boolean
isTouched: boolean
isValid: boolean
isValidating: boolean
dirty: Record<keyof T, boolean>
touched: Record<keyof T, boolean>
errors: Record<keyof T, string | undefined>
validating: Record<keyof T, boolean>
}

/** Optional function to be called after a form failed to submit */
export type HandleSubmitErrorFn<T> = (errors: FormState<T>['errors']) => void

/** Expected function to be called after a form submitted successfully */
export type HandleSubmitSuccessFn<T> = (values: Record<keyof T, any>) => void
export type HandleSubmitSuccessFn<T> = (
values: Record<keyof T, any>
) => PossiblePromise<void>

export type Build<T extends Record<string, any> = Record<string, any>> = <
TBuild extends Partial<T>,
Expand Down
27 changes: 27 additions & 0 deletions src/useFormHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ export const initialState = () => ({
touched: {},
dirty: {},
errors: {},
validating: {},
isDirty: false,
isTouched: false,
isValid: true,
isValidating: false,
})

type Refs<T> = Record<keyof T, WrappedReference>
Expand Down Expand Up @@ -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 <T>({
name,
values,
Expand All @@ -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 <T extends Record<string, any>>(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -319,6 +345,7 @@ export const useFormHandler = <
...(withDetails && {
isDirty: !!formState.dirty[name],
isTouched: !!formState.touched[name],
isValidating: !!formState.validating[name],
}),
...(native !== false && {
onChange: async () => {
Expand Down
55 changes: 54 additions & 1 deletion test/useFormHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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')
Expand Down
Loading