Skip to content

Commit 67686db

Browse files
author
JB AUBREE
committed
feat: agressive and onBlur modes
1 parent b5b3add commit 67686db

File tree

6 files changed

+105
-44
lines changed

6 files changed

+105
-44
lines changed

src/errors.ts

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,20 @@
1+
import type { MaybeRefOrGetter } from 'vue'
12
import type { FieldErrors, Form, GetErrorsFn, InputSchema } from './types'
3+
import { toValue } from 'vue'
24
import { validators } from './validators'
35

46
export async function getErrors<S extends InputSchema<F>, F extends Form>(
57
schema: S,
6-
form: F,
7-
): Promise<FieldErrors<F>>
8-
export async function getErrors<S, F extends Form>(
9-
schema: S,
10-
form: F,
11-
transformFn: GetErrorsFn<S, F>,
12-
): Promise<FieldErrors<F>>
13-
export async function getErrors<S, F extends Form>(
14-
schema: S,
15-
form: F,
16-
transformFn?: GetErrorsFn<S, F>,
8+
form: MaybeRefOrGetter<F>,
9+
transformFn: GetErrorsFn<S, F> | null,
1710
): Promise<FieldErrors<F>> {
11+
const formValue = toValue(form)
12+
const schemaValue = toValue(schema)
1813
if (transformFn)
19-
return await transformFn(schema, form)
14+
return await transformFn(schemaValue, formValue)
2015
for (const validator of Object.values(validators)) {
21-
if (validator.check(schema)) {
22-
return await validator.getErrors(schema, form)
16+
if (validator.check(schemaValue)) {
17+
return await validator.getErrors(schemaValue, formValue)
2318
}
2419
}
2520
return {}

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ interface SuperstructSchema<F> extends AnyObject {
1717
}
1818

1919
export type Validator = 'Joi' | 'SuperStruct' | 'Valibot' | 'Yup' | 'Zod'
20+
export type ValidationMode = 'eager' | 'lazy' | 'agressive' | 'onBlur'
2021
export type Awaitable<T> = T | PromiseLike<T>
2122
export type FieldErrors<F> = Partial<Record<keyof F, string>>
2223
export type Form = Record<string, unknown>

src/useFormValidation.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,28 @@
1-
import type { FieldErrors, Form, GetErrorsFn, InputSchema, ReturnType } from './types'
1+
import type { FieldErrors, Form, GetErrorsFn, InputSchema, ReturnType, ValidationMode } from './types'
22
import { computed, type MaybeRefOrGetter, ref, shallowRef, toValue, watch } from 'vue'
33
import { getErrors } from './errors'
44
import { polyfillGroupBy } from './polyfill'
5+
import { getInput } from './utils'
56

67
export function useFormValidation<S, F extends Form>(
78
schema: S,
89
form: MaybeRefOrGetter<F>,
9-
options: { mode?: 'eager' | 'lazy', transformFn: GetErrorsFn<S, F> },
10+
options: { mode?: ValidationMode, transformFn: GetErrorsFn<S, F> },
1011
): ReturnType<F>
1112
export function useFormValidation<S extends InputSchema<F>, F extends Form>(
1213
schema: S,
1314
form: MaybeRefOrGetter<F>,
14-
options?: { mode?: 'eager' | 'lazy' },
15+
options?: { mode?: ValidationMode },
1516
): ReturnType<F>
1617
export function useFormValidation<S extends InputSchema<F>, F extends Form>(
1718
schema: S,
1819
form: MaybeRefOrGetter<F>,
19-
options?: { mode?: 'eager' | 'lazy', transformFn?: GetErrorsFn<S, F> },
20+
options?: { mode?: ValidationMode, transformFn?: GetErrorsFn<S, F> },
2021
): ReturnType<F> {
2122
polyfillGroupBy()
22-
const opts = { mode: 'lazy', transformFn: null, ...options }
23+
const opts = { mode: 'lazy' as ValidationMode, transformFn: null, ...options }
2324

2425
const errors = shallowRef<FieldErrors<F>>({})
25-
2626
const isLoading = ref(false)
2727

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

4442
if (hasError.value)
4543
// eslint-disable-next-line ts/no-use-before-define
@@ -49,7 +47,7 @@ export function useFormValidation<S extends InputSchema<F>, F extends Form>(
4947
}
5048

5149
const focusInput = ({ inputName }: { inputName: keyof F }): void => {
52-
(document.querySelector(`input[name="${inputName.toString()}"]`) as HTMLInputElement | null)?.focus()
50+
getInput(inputName.toString())?.focus()
5351
}
5452
const focusFirstErroredInput = (): void => {
5553
for (const key in toValue(form)) {
@@ -61,13 +59,30 @@ export function useFormValidation<S extends InputSchema<F>, F extends Form>(
6159
}
6260

6361
let unwatch: null | (() => void)
64-
const watchFormChanges = (): void | (() => void) => {
62+
const watchFormChanges = (immediate = false): void | ((immediate?: boolean) => void) => {
6563
if (!unwatch)
66-
unwatch = watch(() => toValue(form), validate, { deep: true })
64+
unwatch = watch(() => toValue(form), validate, { deep: true, immediate })
65+
}
66+
67+
const handleBlur = async (field: keyof F): Promise<void> => {
68+
if (opts.mode === 'onBlur') {
69+
isLoading.value = true
70+
const e = await getErrors(schema, form, opts.transformFn)
71+
errors.value[field] = e[field]
72+
if (hasError.value)
73+
watchFormChanges()
74+
isLoading.value = false
75+
}
6776
}
6877

69-
if (opts.mode === 'eager')
70-
watchFormChanges()
78+
if (opts.mode === 'onBlur') {
79+
Object.keys(toValue(form)).forEach((inputName) => {
80+
getInput(inputName)?.addEventListener('blur', () => handleBlur(inputName as keyof F))
81+
})
82+
}
83+
if ((['eager', 'agressive'] as ValidationMode[]).includes(opts.mode)) {
84+
watchFormChanges(opts.mode === 'agressive')
85+
}
7186

7287
return {
7388
validate,

src/utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
export function isNonNullObject(obj: unknown): obj is Record<string, unknown> {
22
return typeof obj === 'object' && obj !== null
33
}
4+
5+
export function getInput(inputName: string): HTMLInputElement | null {
6+
return document.querySelector(`input[name="${inputName}"]`)
7+
}

test/useFormValidation.test.ts

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Ref } from 'vue'
22
import { beforeEach, describe, expect, it, vi } from 'vitest'
3-
import { ref, toValue } from 'vue'
3+
import { ref } from 'vue'
44
import * as z from 'zod'
55
import * as errorModule from '../src/errors'
66
import { getErrors } from '../src/errors'
@@ -31,6 +31,10 @@ describe('useFormValidation', () => {
3131
field2: '',
3232
})
3333
vi.clearAllMocks()
34+
document.body.innerHTML = `
35+
<input name="field1" />
36+
<input name="field2" />
37+
`
3438
})
3539

3640
it('should initialize with no errors', () => {
@@ -45,7 +49,7 @@ describe('useFormValidation', () => {
4549
vi.mocked(getErrors).mockResolvedValue(mockErrors)
4650
const { validate, errors, isValid, errorCount } = useFormValidation(schema, form)
4751
await validate()
48-
expect(getErrors).toHaveBeenCalledWith(schema, toValue(form))
52+
expect(getErrors).toHaveBeenCalledWith(schema, form, null)
4953
expect(errors.value).toEqual(mockErrors)
5054
expect(isValid.value).toBe(false)
5155
expect(errorCount.value).toBe(1)
@@ -62,11 +66,6 @@ describe('useFormValidation', () => {
6266
})
6367

6468
it('should focus the first errored input', async () => {
65-
document.body.innerHTML = `
66-
<input name="field1" />
67-
<input name="field2" />
68-
`
69-
7069
const mockErrors = { field1: 'Required' }
7170
vi.mocked(getErrors).mockResolvedValue(mockErrors)
7271
const { validate, focusFirstErroredInput } = useFormValidation(schema, form)
@@ -79,11 +78,6 @@ describe('useFormValidation', () => {
7978
})
8079

8180
it('should focus the input when focusInput is called with inputName', () => {
82-
document.body.innerHTML = `
83-
<input name="field1" />
84-
<input name="field2" />
85-
`
86-
8781
const { focusInput } = useFormValidation(schema, form)
8882
const input: HTMLInputElement | null = document.querySelector('input[name="field1"]')
8983
expect(input).toBeDefined()
@@ -101,7 +95,7 @@ describe('useFormValidation', () => {
10195
expect(errors.value).toEqual({ field1: 'Required' })
10296
expect(isValid.value).toBe(false)
10397
expect(errorCount.value).toBe(1)
104-
expect(getErrors).toHaveBeenCalledWith(schema, toValue(form))
98+
expect(getErrors).toHaveBeenCalledWith(schema, form, null)
10599
})
106100

107101
it('should update errors in real-time when form changes in eager mode', async () => {
@@ -130,7 +124,7 @@ describe('useFormValidation', () => {
130124
},
131125
})
132126
await validate()
133-
expect(getErrorsSpy).toHaveBeenCalledWith(schema, toValue(form), expect.any(Function))
127+
expect(getErrorsSpy).toHaveBeenCalledWith(schema, form, expect.any(Function))
134128
expect(errors.value).toEqual({ field1: 'Transformed error' })
135129
getErrorsSpy.mockRestore()
136130
})
@@ -145,4 +139,28 @@ describe('useFormValidation', () => {
145139
// @ts-expect-error field is invalid on purpose
146140
expect(getErrorMessage('nonExistentField')).toBeUndefined()
147141
})
142+
143+
it('should add blur event listeners to inputs in onBlur mode', () => {
144+
const input1 = document.querySelector<HTMLInputElement>('input[name="field1"]')
145+
const input2 = document.querySelector<HTMLInputElement>('input[name="field2"]')
146+
expect(input1).toBeDefined()
147+
expect(input2).toBeDefined()
148+
const blurSpy1 = vi.spyOn(input1 as HTMLInputElement, 'addEventListener')
149+
const blurSpy2 = vi.spyOn(input2 as HTMLInputElement, 'addEventListener')
150+
useFormValidation(schema, form, { mode: 'onBlur' })
151+
expect(blurSpy1).toHaveBeenCalledWith('blur', expect.any(Function))
152+
expect(blurSpy2).toHaveBeenCalledWith('blur', expect.any(Function))
153+
})
154+
155+
it('should update errors only for the blurred field in onBlur mode', async () => {
156+
const mockErrors = { field1: 'field1 is required' }
157+
vi.spyOn(errorModule, 'getErrors').mockResolvedValue(mockErrors)
158+
const { errors } = useFormValidation(schema, form, { mode: 'onBlur' })
159+
const input1 = document.querySelector<HTMLInputElement>('input[name="field1"]')
160+
expect(input1).toBeDefined()
161+
input1?.dispatchEvent(new Event('blur'))
162+
await flushPromises()
163+
expect(errors.value).toEqual({ field1: 'field1 is required' })
164+
expect(errors.value.field2).toBeUndefined()
165+
})
148166
})

test/utils.test.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { describe, expect, it } from 'vitest'
2-
import { isNonNullObject } from '../src/utils'
1+
import { afterEach, describe, expect, it } from 'vitest'
2+
import { getInput, isNonNullObject } from '../src/utils'
33

44
describe('isNonNullObject', () => {
55
it('should return true for non-null objects', () => {
@@ -28,3 +28,31 @@ describe('isNonNullObject', () => {
2828
expect(isNonNullObject(new TestClass())).toBe(true)
2929
})
3030
})
31+
32+
describe('getInput', () => {
33+
afterEach(() => {
34+
document.body.innerHTML = ''
35+
})
36+
37+
it('should return the input element with the specified name', () => {
38+
const inputName = 'testInput'
39+
const inputElement = document.createElement('input')
40+
inputElement.setAttribute('name', inputName)
41+
document.body.appendChild(inputElement)
42+
const result = getInput(inputName)
43+
expect(result).toBe(inputElement)
44+
})
45+
46+
it('should return null if there is no input element with the specified name', () => {
47+
const result = getInput('nonExistentInput')
48+
expect(result).toBeNull()
49+
})
50+
51+
it('should return null if the input element has a different name', () => {
52+
const inputElement = document.createElement('input')
53+
inputElement.setAttribute('name', 'differentName')
54+
document.body.appendChild(inputElement)
55+
const result = getInput('testInput')
56+
expect(result).toBeNull()
57+
})
58+
})

0 commit comments

Comments
 (0)