diff --git a/src/errors.ts b/src/errors.ts index 90f3d55..65905a0 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,25 +1,20 @@ +import type { MaybeRefOrGetter } from 'vue' import type { FieldErrors, Form, GetErrorsFn, InputSchema } from './types' +import { toValue } from 'vue' import { validators } from './validators' export async function getErrors, F extends Form>( schema: S, - form: F, -): Promise> -export async function getErrors( - schema: S, - form: F, - transformFn: GetErrorsFn, -): Promise> -export async function getErrors( - schema: S, - form: F, - transformFn?: GetErrorsFn, + form: MaybeRefOrGetter, + transformFn: GetErrorsFn | null, ): Promise> { + const formValue = toValue(form) + const schemaValue = toValue(schema) if (transformFn) - return await transformFn(schema, form) + return await transformFn(schemaValue, formValue) for (const validator of Object.values(validators)) { - if (validator.check(schema)) { - return await validator.getErrors(schema, form) + if (validator.check(schemaValue)) { + return await validator.getErrors(schemaValue, formValue) } } return {} diff --git a/src/types.ts b/src/types.ts index ff1a84c..e67a277 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,7 @@ interface SuperstructSchema extends AnyObject { } export type Validator = 'Joi' | 'SuperStruct' | 'Valibot' | 'Yup' | 'Zod' +export type ValidationMode = 'eager' | 'lazy' | 'agressive' | 'onBlur' export type Awaitable = T | PromiseLike export type FieldErrors = Partial> export type Form = Record diff --git a/src/useFormValidation.ts b/src/useFormValidation.ts index d40fa97..907a6b9 100644 --- a/src/useFormValidation.ts +++ b/src/useFormValidation.ts @@ -1,28 +1,28 @@ -import type { FieldErrors, Form, GetErrorsFn, InputSchema, ReturnType } from './types' +import type { FieldErrors, Form, GetErrorsFn, InputSchema, ReturnType, ValidationMode } from './types' import { computed, type MaybeRefOrGetter, ref, shallowRef, toValue, watch } from 'vue' import { getErrors } from './errors' import { polyfillGroupBy } from './polyfill' +import { getInput } from './utils' export function useFormValidation( schema: S, form: MaybeRefOrGetter, - options: { mode?: 'eager' | 'lazy', transformFn: GetErrorsFn }, + options: { mode?: ValidationMode, transformFn: GetErrorsFn }, ): ReturnType export function useFormValidation, F extends Form>( schema: S, form: MaybeRefOrGetter, - options?: { mode?: 'eager' | 'lazy' }, + options?: { mode?: ValidationMode }, ): ReturnType export function useFormValidation, F extends Form>( schema: S, form: MaybeRefOrGetter, - options?: { mode?: 'eager' | 'lazy', transformFn?: GetErrorsFn }, + options?: { mode?: ValidationMode, transformFn?: GetErrorsFn }, ): ReturnType { polyfillGroupBy() - const opts = { mode: 'lazy', transformFn: null, ...options } + const opts = { mode: 'lazy' as ValidationMode, transformFn: null, ...options } const errors = shallowRef>({}) - const isLoading = ref(false) const errorCount = computed(() => Object.keys(errors.value).length) @@ -37,9 +37,7 @@ export function useFormValidation, F extends Form>( const validate = async (): Promise> => { isLoading.value = true clearErrors() - errors.value = opts.transformFn - ? await getErrors(toValue(schema), toValue(form), opts.transformFn) - : await getErrors(toValue(schema), toValue(form)) + errors.value = await getErrors(schema, form, opts.transformFn) if (hasError.value) // eslint-disable-next-line ts/no-use-before-define @@ -49,7 +47,7 @@ export function useFormValidation, F extends Form>( } const focusInput = ({ inputName }: { inputName: keyof F }): void => { - (document.querySelector(`input[name="${inputName.toString()}"]`) as HTMLInputElement | null)?.focus() + getInput(inputName.toString())?.focus() } const focusFirstErroredInput = (): void => { for (const key in toValue(form)) { @@ -61,13 +59,30 @@ export function useFormValidation, F extends Form>( } let unwatch: null | (() => void) - const watchFormChanges = (): void | (() => void) => { + const watchFormChanges = (immediate = false): void | ((immediate?: boolean) => void) => { if (!unwatch) - unwatch = watch(() => toValue(form), validate, { deep: true }) + unwatch = watch(() => toValue(form), validate, { deep: true, immediate }) + } + + const handleBlur = async (field: keyof F): Promise => { + if (opts.mode === 'onBlur') { + isLoading.value = true + const e = await getErrors(schema, form, opts.transformFn) + errors.value[field] = e[field] + if (hasError.value) + watchFormChanges() + isLoading.value = false + } } - if (opts.mode === 'eager') - watchFormChanges() + if (opts.mode === 'onBlur') { + Object.keys(toValue(form)).forEach((inputName) => { + getInput(inputName)?.addEventListener('blur', () => handleBlur(inputName as keyof F)) + }) + } + if ((['eager', 'agressive'] as ValidationMode[]).includes(opts.mode)) { + watchFormChanges(opts.mode === 'agressive') + } return { validate, diff --git a/src/utils.ts b/src/utils.ts index 357237a..a9e85dd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,7 @@ export function isNonNullObject(obj: unknown): obj is Record { return typeof obj === 'object' && obj !== null } + +export function getInput(inputName: string): HTMLInputElement | null { + return document.querySelector(`input[name="${inputName}"]`) +} diff --git a/test/useFormValidation.test.ts b/test/useFormValidation.test.ts index 3a2e49b..3d02bf6 100644 --- a/test/useFormValidation.test.ts +++ b/test/useFormValidation.test.ts @@ -1,6 +1,6 @@ import type { Ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ref, toValue } from 'vue' +import { ref } from 'vue' import * as z from 'zod' import * as errorModule from '../src/errors' import { getErrors } from '../src/errors' @@ -31,6 +31,10 @@ describe('useFormValidation', () => { field2: '', }) vi.clearAllMocks() + document.body.innerHTML = ` + + + ` }) it('should initialize with no errors', () => { @@ -45,7 +49,7 @@ describe('useFormValidation', () => { vi.mocked(getErrors).mockResolvedValue(mockErrors) const { validate, errors, isValid, errorCount } = useFormValidation(schema, form) await validate() - expect(getErrors).toHaveBeenCalledWith(schema, toValue(form)) + expect(getErrors).toHaveBeenCalledWith(schema, form, null) expect(errors.value).toEqual(mockErrors) expect(isValid.value).toBe(false) expect(errorCount.value).toBe(1) @@ -62,11 +66,6 @@ describe('useFormValidation', () => { }) it('should focus the first errored input', async () => { - document.body.innerHTML = ` - - - ` - const mockErrors = { field1: 'Required' } vi.mocked(getErrors).mockResolvedValue(mockErrors) const { validate, focusFirstErroredInput } = useFormValidation(schema, form) @@ -79,11 +78,6 @@ describe('useFormValidation', () => { }) it('should focus the input when focusInput is called with inputName', () => { - document.body.innerHTML = ` - - - ` - const { focusInput } = useFormValidation(schema, form) const input: HTMLInputElement | null = document.querySelector('input[name="field1"]') expect(input).toBeDefined() @@ -101,7 +95,7 @@ describe('useFormValidation', () => { expect(errors.value).toEqual({ field1: 'Required' }) expect(isValid.value).toBe(false) expect(errorCount.value).toBe(1) - expect(getErrors).toHaveBeenCalledWith(schema, toValue(form)) + expect(getErrors).toHaveBeenCalledWith(schema, form, null) }) it('should update errors in real-time when form changes in eager mode', async () => { @@ -130,7 +124,7 @@ describe('useFormValidation', () => { }, }) await validate() - expect(getErrorsSpy).toHaveBeenCalledWith(schema, toValue(form), expect.any(Function)) + expect(getErrorsSpy).toHaveBeenCalledWith(schema, form, expect.any(Function)) expect(errors.value).toEqual({ field1: 'Transformed error' }) getErrorsSpy.mockRestore() }) @@ -145,4 +139,28 @@ describe('useFormValidation', () => { // @ts-expect-error field is invalid on purpose expect(getErrorMessage('nonExistentField')).toBeUndefined() }) + + it('should add blur event listeners to inputs in onBlur mode', () => { + const input1 = document.querySelector('input[name="field1"]') + const input2 = document.querySelector('input[name="field2"]') + expect(input1).toBeDefined() + expect(input2).toBeDefined() + const blurSpy1 = vi.spyOn(input1 as HTMLInputElement, 'addEventListener') + const blurSpy2 = vi.spyOn(input2 as HTMLInputElement, 'addEventListener') + useFormValidation(schema, form, { mode: 'onBlur' }) + expect(blurSpy1).toHaveBeenCalledWith('blur', expect.any(Function)) + expect(blurSpy2).toHaveBeenCalledWith('blur', expect.any(Function)) + }) + + it('should update errors only for the blurred field in onBlur mode', async () => { + const mockErrors = { field1: 'field1 is required' } + vi.spyOn(errorModule, 'getErrors').mockResolvedValue(mockErrors) + const { errors } = useFormValidation(schema, form, { mode: 'onBlur' }) + const input1 = document.querySelector('input[name="field1"]') + expect(input1).toBeDefined() + input1?.dispatchEvent(new Event('blur')) + await flushPromises() + expect(errors.value).toEqual({ field1: 'field1 is required' }) + expect(errors.value.field2).toBeUndefined() + }) }) diff --git a/test/utils.test.ts b/test/utils.test.ts index 8e22108..6ebece5 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest' -import { isNonNullObject } from '../src/utils' +import { afterEach, describe, expect, it } from 'vitest' +import { getInput, isNonNullObject } from '../src/utils' describe('isNonNullObject', () => { it('should return true for non-null objects', () => { @@ -28,3 +28,31 @@ describe('isNonNullObject', () => { expect(isNonNullObject(new TestClass())).toBe(true) }) }) + +describe('getInput', () => { + afterEach(() => { + document.body.innerHTML = '' + }) + + it('should return the input element with the specified name', () => { + const inputName = 'testInput' + const inputElement = document.createElement('input') + inputElement.setAttribute('name', inputName) + document.body.appendChild(inputElement) + const result = getInput(inputName) + expect(result).toBe(inputElement) + }) + + it('should return null if there is no input element with the specified name', () => { + const result = getInput('nonExistentInput') + expect(result).toBeNull() + }) + + it('should return null if the input element has a different name', () => { + const inputElement = document.createElement('input') + inputElement.setAttribute('name', 'differentName') + document.body.appendChild(inputElement) + const result = getInput('testInput') + expect(result).toBeNull() + }) +})