Skip to content

Commit

Permalink
feat: agressive and onBlur modes
Browse files Browse the repository at this point in the history
  • Loading branch information
JB AUBREE committed Nov 5, 2024
1 parent b5b3add commit 67686db
Show file tree
Hide file tree
Showing 6 changed files with 105 additions and 44 deletions.
23 changes: 9 additions & 14 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -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<S extends InputSchema<F>, F extends Form>(
schema: S,
form: F,
): Promise<FieldErrors<F>>
export async function getErrors<S, F extends Form>(
schema: S,
form: F,
transformFn: GetErrorsFn<S, F>,
): Promise<FieldErrors<F>>
export async function getErrors<S, F extends Form>(
schema: S,
form: F,
transformFn?: GetErrorsFn<S, F>,
form: MaybeRefOrGetter<F>,
transformFn: GetErrorsFn<S, F> | null,
): Promise<FieldErrors<F>> {
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 {}
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface SuperstructSchema<F> extends AnyObject {
}

export type Validator = 'Joi' | 'SuperStruct' | 'Valibot' | 'Yup' | 'Zod'
export type ValidationMode = 'eager' | 'lazy' | 'agressive' | 'onBlur'
export type Awaitable<T> = T | PromiseLike<T>
export type FieldErrors<F> = Partial<Record<keyof F, string>>
export type Form = Record<string, unknown>
Expand Down
43 changes: 29 additions & 14 deletions src/useFormValidation.ts
Original file line number Diff line number Diff line change
@@ -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<S, F extends Form>(
schema: S,
form: MaybeRefOrGetter<F>,
options: { mode?: 'eager' | 'lazy', transformFn: GetErrorsFn<S, F> },
options: { mode?: ValidationMode, transformFn: GetErrorsFn<S, F> },
): ReturnType<F>
export function useFormValidation<S extends InputSchema<F>, F extends Form>(
schema: S,
form: MaybeRefOrGetter<F>,
options?: { mode?: 'eager' | 'lazy' },
options?: { mode?: ValidationMode },
): ReturnType<F>
export function useFormValidation<S extends InputSchema<F>, F extends Form>(
schema: S,
form: MaybeRefOrGetter<F>,
options?: { mode?: 'eager' | 'lazy', transformFn?: GetErrorsFn<S, F> },
options?: { mode?: ValidationMode, transformFn?: GetErrorsFn<S, F> },
): ReturnType<F> {
polyfillGroupBy()
const opts = { mode: 'lazy', transformFn: null, ...options }
const opts = { mode: 'lazy' as ValidationMode, transformFn: null, ...options }

const errors = shallowRef<FieldErrors<F>>({})

const isLoading = ref(false)

const errorCount = computed(() => Object.keys(errors.value).length)
Expand All @@ -37,9 +37,7 @@ export function useFormValidation<S extends InputSchema<F>, F extends Form>(
const validate = async (): Promise<FieldErrors<F>> => {
isLoading.value = true
clearErrors()
errors.value = opts.transformFn
? await getErrors<S, F>(toValue(schema), toValue(form), opts.transformFn)
: await getErrors<S, F>(toValue(schema), toValue(form))
errors.value = await getErrors(schema, form, opts.transformFn)

if (hasError.value)
// eslint-disable-next-line ts/no-use-before-define
Expand All @@ -49,7 +47,7 @@ export function useFormValidation<S extends InputSchema<F>, 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)) {
Expand All @@ -61,13 +59,30 @@ export function useFormValidation<S extends InputSchema<F>, 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<void> => {
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,
Expand Down
4 changes: 4 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export function isNonNullObject(obj: unknown): obj is Record<string, unknown> {
return typeof obj === 'object' && obj !== null
}

export function getInput(inputName: string): HTMLInputElement | null {
return document.querySelector(`input[name="${inputName}"]`)
}
46 changes: 32 additions & 14 deletions test/useFormValidation.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -31,6 +31,10 @@ describe('useFormValidation', () => {
field2: '',
})
vi.clearAllMocks()
document.body.innerHTML = `
<input name="field1" />
<input name="field2" />
`
})

it('should initialize with no errors', () => {
Expand All @@ -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)
Expand All @@ -62,11 +66,6 @@ describe('useFormValidation', () => {
})

it('should focus the first errored input', async () => {
document.body.innerHTML = `
<input name="field1" />
<input name="field2" />
`

const mockErrors = { field1: 'Required' }
vi.mocked(getErrors).mockResolvedValue(mockErrors)
const { validate, focusFirstErroredInput } = useFormValidation(schema, form)
Expand All @@ -79,11 +78,6 @@ describe('useFormValidation', () => {
})

it('should focus the input when focusInput is called with inputName', () => {
document.body.innerHTML = `
<input name="field1" />
<input name="field2" />
`

const { focusInput } = useFormValidation(schema, form)
const input: HTMLInputElement | null = document.querySelector('input[name="field1"]')
expect(input).toBeDefined()
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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()
})
Expand All @@ -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<HTMLInputElement>('input[name="field1"]')
const input2 = document.querySelector<HTMLInputElement>('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<HTMLInputElement>('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()
})
})
32 changes: 30 additions & 2 deletions test/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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()
})
})

0 comments on commit 67686db

Please sign in to comment.