@pyreon/form 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +259 -0
- package/lib/analysis/devtools.js.html +5406 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/devtools.js +74 -0
- package/lib/devtools.js.map +1 -0
- package/lib/index.js +451 -0
- package/lib/index.js.map +1 -0
- package/lib/types/devtools.d.ts +67 -0
- package/lib/types/devtools.d.ts.map +1 -0
- package/lib/types/devtools2.d.ts +31 -0
- package/lib/types/devtools2.d.ts.map +1 -0
- package/lib/types/index.d.ts +457 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +291 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +57 -0
- package/src/context.ts +67 -0
- package/src/devtools.ts +100 -0
- package/src/index.ts +26 -0
- package/src/tests/devtools.test.ts +206 -0
- package/src/tests/form.test.ts +1860 -0
- package/src/types.ts +116 -0
- package/src/use-field-array.ts +117 -0
- package/src/use-field.ts +69 -0
- package/src/use-form-state.ts +74 -0
- package/src/use-form.ts +436 -0
- package/src/use-watch.ts +69 -0
|
@@ -0,0 +1,1860 @@
|
|
|
1
|
+
import { h } from '@pyreon/core'
|
|
2
|
+
import { mount } from '@pyreon/runtime-dom'
|
|
3
|
+
import {
|
|
4
|
+
useForm,
|
|
5
|
+
useFieldArray,
|
|
6
|
+
useField,
|
|
7
|
+
useWatch,
|
|
8
|
+
useFormState,
|
|
9
|
+
FormProvider,
|
|
10
|
+
useFormContext,
|
|
11
|
+
} from '../index'
|
|
12
|
+
import type { FormState } from '../index'
|
|
13
|
+
|
|
14
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
function mountWith<T>(fn: () => T): { result: T; unmount: () => void } {
|
|
17
|
+
let result: T | undefined
|
|
18
|
+
const el = document.createElement('div')
|
|
19
|
+
document.body.appendChild(el)
|
|
20
|
+
const unmount = mount(
|
|
21
|
+
h(() => {
|
|
22
|
+
result = fn()
|
|
23
|
+
return null
|
|
24
|
+
}, null),
|
|
25
|
+
el,
|
|
26
|
+
)
|
|
27
|
+
return {
|
|
28
|
+
result: result!,
|
|
29
|
+
unmount: () => {
|
|
30
|
+
unmount()
|
|
31
|
+
el.remove()
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type LoginForm = {
|
|
37
|
+
email: string
|
|
38
|
+
password: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── useForm ─────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
describe('useForm', () => {
|
|
44
|
+
it('initializes with correct values', () => {
|
|
45
|
+
const { result: form, unmount } = mountWith(() =>
|
|
46
|
+
useForm({
|
|
47
|
+
initialValues: { email: 'test@test.com', password: '' },
|
|
48
|
+
onSubmit: () => {
|
|
49
|
+
/* noop */
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
expect(form.fields.email.value()).toBe('test@test.com')
|
|
55
|
+
expect(form.fields.password.value()).toBe('')
|
|
56
|
+
expect(form.isValid()).toBe(true)
|
|
57
|
+
expect(form.isDirty()).toBe(false)
|
|
58
|
+
expect(form.isSubmitting()).toBe(false)
|
|
59
|
+
expect(form.submitCount()).toBe(0)
|
|
60
|
+
unmount()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('setValue updates field value and marks dirty', () => {
|
|
64
|
+
const { result: form, unmount } = mountWith(() =>
|
|
65
|
+
useForm<LoginForm>({
|
|
66
|
+
initialValues: { email: '', password: '' },
|
|
67
|
+
onSubmit: () => {
|
|
68
|
+
/* noop */
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
form.fields.email.setValue('hello@world.com')
|
|
74
|
+
expect(form.fields.email.value()).toBe('hello@world.com')
|
|
75
|
+
expect(form.fields.email.dirty()).toBe(true)
|
|
76
|
+
expect(form.isDirty()).toBe(true)
|
|
77
|
+
unmount()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('setTouched marks field as touched', () => {
|
|
81
|
+
const { result: form, unmount } = mountWith(() =>
|
|
82
|
+
useForm<LoginForm>({
|
|
83
|
+
initialValues: { email: '', password: '' },
|
|
84
|
+
onSubmit: () => {
|
|
85
|
+
/* noop */
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
expect(form.fields.email.touched()).toBe(false)
|
|
91
|
+
form.fields.email.setTouched()
|
|
92
|
+
expect(form.fields.email.touched()).toBe(true)
|
|
93
|
+
unmount()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('field-level validation on blur', async () => {
|
|
97
|
+
const { result: form, unmount } = mountWith(() =>
|
|
98
|
+
useForm<LoginForm>({
|
|
99
|
+
initialValues: { email: '', password: '' },
|
|
100
|
+
validators: {
|
|
101
|
+
email: (v) => (!v ? 'Required' : undefined),
|
|
102
|
+
},
|
|
103
|
+
onSubmit: () => {
|
|
104
|
+
/* noop */
|
|
105
|
+
},
|
|
106
|
+
validateOn: 'blur',
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
// No error initially
|
|
111
|
+
expect(form.fields.email.error()).toBeUndefined()
|
|
112
|
+
|
|
113
|
+
// Trigger blur
|
|
114
|
+
form.fields.email.setTouched()
|
|
115
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
116
|
+
|
|
117
|
+
expect(form.fields.email.error()).toBe('Required')
|
|
118
|
+
expect(form.isValid()).toBe(false)
|
|
119
|
+
|
|
120
|
+
// Fix the value
|
|
121
|
+
form.fields.email.setValue('test@test.com')
|
|
122
|
+
form.fields.email.setTouched()
|
|
123
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
124
|
+
|
|
125
|
+
expect(form.fields.email.error()).toBeUndefined()
|
|
126
|
+
expect(form.isValid()).toBe(true)
|
|
127
|
+
unmount()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('field-level validation on change', async () => {
|
|
131
|
+
const { result: form, unmount } = mountWith(() =>
|
|
132
|
+
useForm<LoginForm>({
|
|
133
|
+
initialValues: { email: '', password: '' },
|
|
134
|
+
validators: {
|
|
135
|
+
email: (v) => (!v ? 'Required' : undefined),
|
|
136
|
+
},
|
|
137
|
+
onSubmit: () => {
|
|
138
|
+
/* noop */
|
|
139
|
+
},
|
|
140
|
+
validateOn: 'change',
|
|
141
|
+
}),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
// Error should be set immediately via effect
|
|
145
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
146
|
+
expect(form.fields.email.error()).toBe('Required')
|
|
147
|
+
|
|
148
|
+
form.fields.email.setValue('hello')
|
|
149
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
150
|
+
expect(form.fields.email.error()).toBeUndefined()
|
|
151
|
+
unmount()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('handleSubmit validates and calls onSubmit when valid', async () => {
|
|
155
|
+
let submitted: LoginForm | undefined
|
|
156
|
+
const { result: form, unmount } = mountWith(() =>
|
|
157
|
+
useForm<LoginForm>({
|
|
158
|
+
initialValues: { email: 'a@b.com', password: '12345678' },
|
|
159
|
+
validators: {
|
|
160
|
+
email: (v) => (!v ? 'Required' : undefined),
|
|
161
|
+
password: (v) => (v.length < 8 ? 'Too short' : undefined),
|
|
162
|
+
},
|
|
163
|
+
onSubmit: (values) => {
|
|
164
|
+
submitted = values
|
|
165
|
+
},
|
|
166
|
+
}),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
await form.handleSubmit()
|
|
170
|
+
expect(submitted).toEqual({ email: 'a@b.com', password: '12345678' })
|
|
171
|
+
expect(form.submitCount()).toBe(1)
|
|
172
|
+
unmount()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('handleSubmit does not call onSubmit when invalid', async () => {
|
|
176
|
+
let called = false
|
|
177
|
+
const { result: form, unmount } = mountWith(() =>
|
|
178
|
+
useForm<LoginForm>({
|
|
179
|
+
initialValues: { email: '', password: '' },
|
|
180
|
+
validators: {
|
|
181
|
+
email: (v) => (!v ? 'Required' : undefined),
|
|
182
|
+
},
|
|
183
|
+
onSubmit: () => {
|
|
184
|
+
called = true
|
|
185
|
+
},
|
|
186
|
+
}),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
await form.handleSubmit()
|
|
190
|
+
expect(called).toBe(false)
|
|
191
|
+
expect(form.submitCount()).toBe(1)
|
|
192
|
+
expect(form.fields.email.error()).toBe('Required')
|
|
193
|
+
expect(form.fields.email.touched()).toBe(true)
|
|
194
|
+
unmount()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('handleSubmit sets isSubmitting during async onSubmit', async () => {
|
|
198
|
+
const states: boolean[] = []
|
|
199
|
+
const { result: form, unmount } = mountWith(() =>
|
|
200
|
+
useForm<LoginForm>({
|
|
201
|
+
initialValues: { email: 'a@b.com', password: '12345678' },
|
|
202
|
+
onSubmit: async () => {
|
|
203
|
+
states.push(form.isSubmitting())
|
|
204
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
205
|
+
},
|
|
206
|
+
}),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
const submitPromise = form.handleSubmit()
|
|
210
|
+
// Should not be submitting yet at this exact moment (microtask)
|
|
211
|
+
await submitPromise
|
|
212
|
+
expect(states[0]).toBe(true)
|
|
213
|
+
expect(form.isSubmitting()).toBe(false)
|
|
214
|
+
unmount()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('schema validation runs after field validators', async () => {
|
|
218
|
+
let submitted = false
|
|
219
|
+
const { result: form, unmount } = mountWith(() =>
|
|
220
|
+
useForm({
|
|
221
|
+
initialValues: { password: '12345678', confirmPassword: '12345679' },
|
|
222
|
+
schema: (values) => {
|
|
223
|
+
const errors: Partial<
|
|
224
|
+
Record<'password' | 'confirmPassword', string>
|
|
225
|
+
> = {}
|
|
226
|
+
if (values.password !== values.confirmPassword) {
|
|
227
|
+
errors.confirmPassword = 'Passwords must match'
|
|
228
|
+
}
|
|
229
|
+
return errors
|
|
230
|
+
},
|
|
231
|
+
onSubmit: () => {
|
|
232
|
+
submitted = true
|
|
233
|
+
},
|
|
234
|
+
}),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
await form.handleSubmit()
|
|
238
|
+
expect(submitted).toBe(false)
|
|
239
|
+
expect(form.fields.confirmPassword.error()).toBe('Passwords must match')
|
|
240
|
+
|
|
241
|
+
// Fix the value
|
|
242
|
+
form.fields.confirmPassword.setValue('12345678')
|
|
243
|
+
await form.handleSubmit()
|
|
244
|
+
expect(submitted).toBe(true)
|
|
245
|
+
unmount()
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('values() returns all current values', () => {
|
|
249
|
+
const { result: form, unmount } = mountWith(() =>
|
|
250
|
+
useForm<LoginForm>({
|
|
251
|
+
initialValues: { email: 'a@b.com', password: 'secret' },
|
|
252
|
+
onSubmit: () => {
|
|
253
|
+
/* noop */
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
expect(form.values()).toEqual({ email: 'a@b.com', password: 'secret' })
|
|
259
|
+
form.fields.email.setValue('new@email.com')
|
|
260
|
+
expect(form.values()).toEqual({
|
|
261
|
+
email: 'new@email.com',
|
|
262
|
+
password: 'secret',
|
|
263
|
+
})
|
|
264
|
+
unmount()
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('errors() returns all current errors', async () => {
|
|
268
|
+
const { result: form, unmount } = mountWith(() =>
|
|
269
|
+
useForm<LoginForm>({
|
|
270
|
+
initialValues: { email: '', password: '' },
|
|
271
|
+
validators: {
|
|
272
|
+
email: (v) => (!v ? 'Required' : undefined),
|
|
273
|
+
password: (v) => (!v ? 'Required' : undefined),
|
|
274
|
+
},
|
|
275
|
+
onSubmit: () => {
|
|
276
|
+
/* noop */
|
|
277
|
+
},
|
|
278
|
+
}),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
await form.validate()
|
|
282
|
+
expect(form.errors()).toEqual({
|
|
283
|
+
email: 'Required',
|
|
284
|
+
password: 'Required',
|
|
285
|
+
})
|
|
286
|
+
unmount()
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('reset() restores initial values and clears state', async () => {
|
|
290
|
+
const { result: form, unmount } = mountWith(() =>
|
|
291
|
+
useForm<LoginForm>({
|
|
292
|
+
initialValues: { email: '', password: '' },
|
|
293
|
+
validators: {
|
|
294
|
+
email: (v) => (!v ? 'Required' : undefined),
|
|
295
|
+
},
|
|
296
|
+
onSubmit: () => {
|
|
297
|
+
/* noop */
|
|
298
|
+
},
|
|
299
|
+
}),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
form.fields.email.setValue('changed')
|
|
303
|
+
form.fields.email.setTouched()
|
|
304
|
+
await form.handleSubmit()
|
|
305
|
+
|
|
306
|
+
form.reset()
|
|
307
|
+
expect(form.fields.email.value()).toBe('')
|
|
308
|
+
expect(form.fields.email.error()).toBeUndefined()
|
|
309
|
+
expect(form.fields.email.touched()).toBe(false)
|
|
310
|
+
expect(form.fields.email.dirty()).toBe(false)
|
|
311
|
+
expect(form.submitCount()).toBe(0)
|
|
312
|
+
unmount()
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('validate() returns true when all valid', async () => {
|
|
316
|
+
const { result: form, unmount } = mountWith(() =>
|
|
317
|
+
useForm<LoginForm>({
|
|
318
|
+
initialValues: { email: 'test@test.com', password: '12345678' },
|
|
319
|
+
validators: {
|
|
320
|
+
email: (v) => (!v ? 'Required' : undefined),
|
|
321
|
+
},
|
|
322
|
+
onSubmit: () => {
|
|
323
|
+
/* noop */
|
|
324
|
+
},
|
|
325
|
+
}),
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
const valid = await form.validate()
|
|
329
|
+
expect(valid).toBe(true)
|
|
330
|
+
unmount()
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('async validators work', async () => {
|
|
334
|
+
const { result: form, unmount } = mountWith(() =>
|
|
335
|
+
useForm({
|
|
336
|
+
initialValues: { username: 'taken' },
|
|
337
|
+
validators: {
|
|
338
|
+
username: async (v) => {
|
|
339
|
+
await new Promise((r) => setTimeout(r, 5))
|
|
340
|
+
return v === 'taken' ? 'Already taken' : undefined
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
onSubmit: () => {
|
|
344
|
+
/* noop */
|
|
345
|
+
},
|
|
346
|
+
}),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
const valid = await form.validate()
|
|
350
|
+
expect(valid).toBe(false)
|
|
351
|
+
expect(form.fields.username.error()).toBe('Already taken')
|
|
352
|
+
unmount()
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('setting value back to initial clears dirty', () => {
|
|
356
|
+
const { result: form, unmount } = mountWith(() =>
|
|
357
|
+
useForm<LoginForm>({
|
|
358
|
+
initialValues: { email: 'original', password: '' },
|
|
359
|
+
onSubmit: () => {
|
|
360
|
+
/* noop */
|
|
361
|
+
},
|
|
362
|
+
}),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
form.fields.email.setValue('changed')
|
|
366
|
+
expect(form.fields.email.dirty()).toBe(true)
|
|
367
|
+
|
|
368
|
+
form.fields.email.setValue('original')
|
|
369
|
+
expect(form.fields.email.dirty()).toBe(false)
|
|
370
|
+
expect(form.isDirty()).toBe(false)
|
|
371
|
+
unmount()
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('cross-field validation — validators receive all form values', async () => {
|
|
375
|
+
const { result: form, unmount } = mountWith(() =>
|
|
376
|
+
useForm({
|
|
377
|
+
initialValues: { password: 'abc123', confirmPassword: 'different' },
|
|
378
|
+
validators: {
|
|
379
|
+
confirmPassword: (value, allValues) =>
|
|
380
|
+
value !== allValues.password ? 'Passwords must match' : undefined,
|
|
381
|
+
},
|
|
382
|
+
onSubmit: () => {
|
|
383
|
+
/* noop */
|
|
384
|
+
},
|
|
385
|
+
}),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
const valid = await form.validate()
|
|
389
|
+
expect(valid).toBe(false)
|
|
390
|
+
expect(form.fields.confirmPassword.error()).toBe('Passwords must match')
|
|
391
|
+
|
|
392
|
+
form.fields.confirmPassword.setValue('abc123')
|
|
393
|
+
const valid2 = await form.validate()
|
|
394
|
+
expect(valid2).toBe(true)
|
|
395
|
+
expect(form.fields.confirmPassword.error()).toBeUndefined()
|
|
396
|
+
unmount()
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('register() returns value signal and event handlers', () => {
|
|
400
|
+
const { result: form, unmount } = mountWith(() =>
|
|
401
|
+
useForm<LoginForm>({
|
|
402
|
+
initialValues: { email: '', password: '' },
|
|
403
|
+
onSubmit: () => {
|
|
404
|
+
/* noop */
|
|
405
|
+
},
|
|
406
|
+
}),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
const props = form.register('email')
|
|
410
|
+
expect(props.value).toBe(form.fields.email.value)
|
|
411
|
+
expect(typeof props.onInput).toBe('function')
|
|
412
|
+
expect(typeof props.onBlur).toBe('function')
|
|
413
|
+
unmount()
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('register() onInput updates field value', () => {
|
|
417
|
+
const { result: form, unmount } = mountWith(() =>
|
|
418
|
+
useForm<LoginForm>({
|
|
419
|
+
initialValues: { email: '', password: '' },
|
|
420
|
+
onSubmit: () => {
|
|
421
|
+
/* noop */
|
|
422
|
+
},
|
|
423
|
+
}),
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
const props = form.register('email')
|
|
427
|
+
const fakeEvent = { target: { value: 'test@test.com' } } as unknown as Event
|
|
428
|
+
props.onInput(fakeEvent)
|
|
429
|
+
|
|
430
|
+
expect(form.fields.email.value()).toBe('test@test.com')
|
|
431
|
+
expect(form.fields.email.dirty()).toBe(true)
|
|
432
|
+
unmount()
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('register() onBlur marks field as touched and validates', async () => {
|
|
436
|
+
const { result: form, unmount } = mountWith(() =>
|
|
437
|
+
useForm<LoginForm>({
|
|
438
|
+
initialValues: { email: '', password: '' },
|
|
439
|
+
validators: {
|
|
440
|
+
email: (v) => (!v ? 'Required' : undefined),
|
|
441
|
+
},
|
|
442
|
+
onSubmit: () => {
|
|
443
|
+
/* noop */
|
|
444
|
+
},
|
|
445
|
+
validateOn: 'blur',
|
|
446
|
+
}),
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
const props = form.register('email')
|
|
450
|
+
props.onBlur()
|
|
451
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
452
|
+
|
|
453
|
+
expect(form.fields.email.touched()).toBe(true)
|
|
454
|
+
expect(form.fields.email.error()).toBe('Required')
|
|
455
|
+
unmount()
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
it('setFieldError sets a single field error', () => {
|
|
459
|
+
const { result: form, unmount } = mountWith(() =>
|
|
460
|
+
useForm<LoginForm>({
|
|
461
|
+
initialValues: { email: '', password: '' },
|
|
462
|
+
onSubmit: () => {
|
|
463
|
+
/* noop */
|
|
464
|
+
},
|
|
465
|
+
}),
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
form.setFieldError('email', 'Server error: email taken')
|
|
469
|
+
expect(form.fields.email.error()).toBe('Server error: email taken')
|
|
470
|
+
expect(form.isValid()).toBe(false)
|
|
471
|
+
|
|
472
|
+
form.setFieldError('email', undefined)
|
|
473
|
+
expect(form.fields.email.error()).toBeUndefined()
|
|
474
|
+
expect(form.isValid()).toBe(true)
|
|
475
|
+
unmount()
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it('setErrors sets multiple field errors at once', () => {
|
|
479
|
+
const { result: form, unmount } = mountWith(() =>
|
|
480
|
+
useForm<LoginForm>({
|
|
481
|
+
initialValues: { email: '', password: '' },
|
|
482
|
+
onSubmit: () => {
|
|
483
|
+
/* noop */
|
|
484
|
+
},
|
|
485
|
+
}),
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
form.setErrors({
|
|
489
|
+
email: 'Invalid email',
|
|
490
|
+
password: 'Too weak',
|
|
491
|
+
})
|
|
492
|
+
expect(form.fields.email.error()).toBe('Invalid email')
|
|
493
|
+
expect(form.fields.password.error()).toBe('Too weak')
|
|
494
|
+
expect(form.isValid()).toBe(false)
|
|
495
|
+
unmount()
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('debounceMs delays validation', async () => {
|
|
499
|
+
let callCount = 0
|
|
500
|
+
const { result: form, unmount } = mountWith(() =>
|
|
501
|
+
useForm({
|
|
502
|
+
initialValues: { name: '' },
|
|
503
|
+
validators: {
|
|
504
|
+
name: (v) => {
|
|
505
|
+
callCount++
|
|
506
|
+
return !v ? 'Required' : undefined
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
onSubmit: () => {
|
|
510
|
+
/* noop */
|
|
511
|
+
},
|
|
512
|
+
validateOn: 'blur',
|
|
513
|
+
debounceMs: 50,
|
|
514
|
+
}),
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
// Trigger multiple rapid blurs
|
|
518
|
+
form.fields.name.setTouched()
|
|
519
|
+
form.fields.name.setTouched()
|
|
520
|
+
form.fields.name.setTouched()
|
|
521
|
+
|
|
522
|
+
// Should not have validated yet
|
|
523
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
524
|
+
expect(callCount).toBe(0)
|
|
525
|
+
|
|
526
|
+
// After debounce period, should have validated once
|
|
527
|
+
await new Promise((r) => setTimeout(r, 60))
|
|
528
|
+
expect(callCount).toBe(1)
|
|
529
|
+
expect(form.fields.name.error()).toBe('Required')
|
|
530
|
+
unmount()
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
it('validate() bypasses debounce for immediate validation', async () => {
|
|
534
|
+
let callCount = 0
|
|
535
|
+
const { result: form, unmount } = mountWith(() =>
|
|
536
|
+
useForm({
|
|
537
|
+
initialValues: { name: '' },
|
|
538
|
+
validators: {
|
|
539
|
+
name: (v) => {
|
|
540
|
+
callCount++
|
|
541
|
+
return !v ? 'Required' : undefined
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
onSubmit: () => {
|
|
545
|
+
/* noop */
|
|
546
|
+
},
|
|
547
|
+
debounceMs: 500,
|
|
548
|
+
}),
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
// Direct validate() should run immediately regardless of debounce
|
|
552
|
+
const valid = await form.validate()
|
|
553
|
+
expect(valid).toBe(false)
|
|
554
|
+
expect(callCount).toBe(1)
|
|
555
|
+
unmount()
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it('setFieldValue sets a field value from the form level', () => {
|
|
559
|
+
const { result: form, unmount } = mountWith(() =>
|
|
560
|
+
useForm<LoginForm>({
|
|
561
|
+
initialValues: { email: '', password: '' },
|
|
562
|
+
onSubmit: () => {
|
|
563
|
+
/* noop */
|
|
564
|
+
},
|
|
565
|
+
}),
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
form.setFieldValue('email', 'new@email.com')
|
|
569
|
+
expect(form.fields.email.value()).toBe('new@email.com')
|
|
570
|
+
expect(form.fields.email.dirty()).toBe(true)
|
|
571
|
+
unmount()
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
it('clearErrors clears all field errors', async () => {
|
|
575
|
+
const { result: form, unmount } = mountWith(() =>
|
|
576
|
+
useForm<LoginForm>({
|
|
577
|
+
initialValues: { email: '', password: '' },
|
|
578
|
+
validators: {
|
|
579
|
+
email: (v) => (!v ? 'Required' : undefined),
|
|
580
|
+
password: (v) => (!v ? 'Required' : undefined),
|
|
581
|
+
},
|
|
582
|
+
onSubmit: () => {
|
|
583
|
+
/* noop */
|
|
584
|
+
},
|
|
585
|
+
}),
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
await form.validate()
|
|
589
|
+
expect(form.isValid()).toBe(false)
|
|
590
|
+
|
|
591
|
+
form.clearErrors()
|
|
592
|
+
expect(form.fields.email.error()).toBeUndefined()
|
|
593
|
+
expect(form.fields.password.error()).toBeUndefined()
|
|
594
|
+
expect(form.isValid()).toBe(true)
|
|
595
|
+
unmount()
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
it('resetField resets a single field without affecting others', () => {
|
|
599
|
+
const { result: form, unmount } = mountWith(() =>
|
|
600
|
+
useForm<LoginForm>({
|
|
601
|
+
initialValues: { email: '', password: '' },
|
|
602
|
+
onSubmit: () => {
|
|
603
|
+
/* noop */
|
|
604
|
+
},
|
|
605
|
+
}),
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
form.fields.email.setValue('changed')
|
|
609
|
+
form.fields.password.setValue('changed')
|
|
610
|
+
form.fields.email.setTouched()
|
|
611
|
+
|
|
612
|
+
form.resetField('email')
|
|
613
|
+
expect(form.fields.email.value()).toBe('')
|
|
614
|
+
expect(form.fields.email.dirty()).toBe(false)
|
|
615
|
+
expect(form.fields.email.touched()).toBe(false)
|
|
616
|
+
// Password should be unaffected
|
|
617
|
+
expect(form.fields.password.value()).toBe('changed')
|
|
618
|
+
expect(form.fields.password.dirty()).toBe(true)
|
|
619
|
+
unmount()
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
it('isValidating tracks async validation state', async () => {
|
|
623
|
+
const { result: form, unmount } = mountWith(() =>
|
|
624
|
+
useForm({
|
|
625
|
+
initialValues: { name: '' },
|
|
626
|
+
validators: {
|
|
627
|
+
name: async (v) => {
|
|
628
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
629
|
+
return !v ? 'Required' : undefined
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
onSubmit: () => {
|
|
633
|
+
/* noop */
|
|
634
|
+
},
|
|
635
|
+
}),
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
expect(form.isValidating()).toBe(false)
|
|
639
|
+
const validatePromise = form.validate()
|
|
640
|
+
expect(form.isValidating()).toBe(true)
|
|
641
|
+
await validatePromise
|
|
642
|
+
expect(form.isValidating()).toBe(false)
|
|
643
|
+
unmount()
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
it('handleSubmit calls preventDefault on event', async () => {
|
|
647
|
+
const { result: form, unmount } = mountWith(() =>
|
|
648
|
+
useForm<LoginForm>({
|
|
649
|
+
initialValues: { email: 'a@b.com', password: '12345678' },
|
|
650
|
+
onSubmit: () => {
|
|
651
|
+
/* noop */
|
|
652
|
+
},
|
|
653
|
+
}),
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
let preventDefaultCalled = false
|
|
657
|
+
const fakeEvent = {
|
|
658
|
+
preventDefault: () => {
|
|
659
|
+
preventDefaultCalled = true
|
|
660
|
+
},
|
|
661
|
+
} as unknown as Event
|
|
662
|
+
|
|
663
|
+
await form.handleSubmit(fakeEvent)
|
|
664
|
+
expect(preventDefaultCalled).toBe(true)
|
|
665
|
+
unmount()
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
it('register() with checkbox type uses checked property', () => {
|
|
669
|
+
const { result: form, unmount } = mountWith(() =>
|
|
670
|
+
useForm({
|
|
671
|
+
initialValues: { remember: false },
|
|
672
|
+
onSubmit: () => {
|
|
673
|
+
/* noop */
|
|
674
|
+
},
|
|
675
|
+
}),
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
const props = form.register('remember', { type: 'checkbox' })
|
|
679
|
+
expect(props.checked).toBeDefined()
|
|
680
|
+
expect(props.checked!()).toBe(false)
|
|
681
|
+
|
|
682
|
+
// Simulate checkbox change
|
|
683
|
+
const fakeEvent = {
|
|
684
|
+
target: { checked: true, value: 'on' },
|
|
685
|
+
} as unknown as Event
|
|
686
|
+
props.onInput(fakeEvent)
|
|
687
|
+
|
|
688
|
+
expect(form.fields.remember.value()).toBe(true)
|
|
689
|
+
expect(props.checked!()).toBe(true)
|
|
690
|
+
unmount()
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
it('register() with number type uses valueAsNumber when valid', () => {
|
|
694
|
+
const { result: form, unmount } = mountWith(() =>
|
|
695
|
+
useForm({
|
|
696
|
+
initialValues: { age: 0 },
|
|
697
|
+
onSubmit: () => {
|
|
698
|
+
/* noop */
|
|
699
|
+
},
|
|
700
|
+
}),
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
const props = form.register('age', { type: 'number' })
|
|
704
|
+
|
|
705
|
+
// Simulate input with a valid number
|
|
706
|
+
const validEvent = {
|
|
707
|
+
target: { value: '25', valueAsNumber: 25 },
|
|
708
|
+
} as unknown as Event
|
|
709
|
+
props.onInput(validEvent)
|
|
710
|
+
expect(form.fields.age.value()).toBe(25)
|
|
711
|
+
|
|
712
|
+
// Simulate input with NaN (e.g. empty string) — falls back to target.value
|
|
713
|
+
const nanEvent = {
|
|
714
|
+
target: { value: '', valueAsNumber: NaN },
|
|
715
|
+
} as unknown as Event
|
|
716
|
+
props.onInput(nanEvent)
|
|
717
|
+
expect(form.fields.age.value()).toBe('')
|
|
718
|
+
unmount()
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
it('register() returns same props for repeated calls (memoized)', () => {
|
|
722
|
+
const { result: form, unmount } = mountWith(() =>
|
|
723
|
+
useForm<LoginForm>({
|
|
724
|
+
initialValues: { email: '', password: '' },
|
|
725
|
+
onSubmit: () => {
|
|
726
|
+
/* noop */
|
|
727
|
+
},
|
|
728
|
+
}),
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
const first = form.register('email')
|
|
732
|
+
const second = form.register('email')
|
|
733
|
+
expect(first).toBe(second)
|
|
734
|
+
unmount()
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
it('submitError captures onSubmit errors', async () => {
|
|
738
|
+
const { result: form, unmount } = mountWith(() =>
|
|
739
|
+
useForm<LoginForm>({
|
|
740
|
+
initialValues: { email: 'a@b.com', password: '12345678' },
|
|
741
|
+
onSubmit: async () => {
|
|
742
|
+
throw new Error('Server error')
|
|
743
|
+
},
|
|
744
|
+
}),
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
expect(form.submitError()).toBeUndefined()
|
|
748
|
+
await form.handleSubmit().catch(() => {
|
|
749
|
+
/* expected */
|
|
750
|
+
})
|
|
751
|
+
expect(form.submitError()).toBeInstanceOf(Error)
|
|
752
|
+
expect((form.submitError() as Error).message).toBe('Server error')
|
|
753
|
+
|
|
754
|
+
// Reset clears submitError
|
|
755
|
+
form.reset()
|
|
756
|
+
expect(form.submitError()).toBeUndefined()
|
|
757
|
+
unmount()
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
it('dirty detection works for object field values', () => {
|
|
761
|
+
const { result: form, unmount } = mountWith(() =>
|
|
762
|
+
useForm({
|
|
763
|
+
initialValues: { address: { city: 'NYC', zip: '10001' } },
|
|
764
|
+
onSubmit: () => {
|
|
765
|
+
/* noop */
|
|
766
|
+
},
|
|
767
|
+
}),
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
// Same structure = not dirty
|
|
771
|
+
form.fields.address.setValue({ city: 'NYC', zip: '10001' })
|
|
772
|
+
expect(form.fields.address.dirty()).toBe(false)
|
|
773
|
+
|
|
774
|
+
// Different structure = dirty
|
|
775
|
+
form.fields.address.setValue({ city: 'LA', zip: '90001' })
|
|
776
|
+
expect(form.fields.address.dirty()).toBe(true)
|
|
777
|
+
unmount()
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
it('dirty detection works for array field values', () => {
|
|
781
|
+
const { result: form, unmount } = mountWith(() =>
|
|
782
|
+
useForm({
|
|
783
|
+
initialValues: { tags: ['a', 'b'] },
|
|
784
|
+
onSubmit: () => {
|
|
785
|
+
/* noop */
|
|
786
|
+
},
|
|
787
|
+
}),
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
// Same array = not dirty
|
|
791
|
+
form.fields.tags.setValue(['a', 'b'])
|
|
792
|
+
expect(form.fields.tags.dirty()).toBe(false)
|
|
793
|
+
|
|
794
|
+
// Different array = dirty
|
|
795
|
+
form.fields.tags.setValue(['a', 'b', 'c'])
|
|
796
|
+
expect(form.fields.tags.dirty()).toBe(true)
|
|
797
|
+
unmount()
|
|
798
|
+
})
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
// ─── useFieldArray ───────────────────────────────────────────────────────────
|
|
802
|
+
|
|
803
|
+
describe('useFieldArray', () => {
|
|
804
|
+
it('initializes with provided values', () => {
|
|
805
|
+
const { result: arr, unmount } = mountWith(() =>
|
|
806
|
+
useFieldArray(['a', 'b', 'c']),
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
expect(arr.length()).toBe(3)
|
|
810
|
+
expect(arr.values()).toEqual(['a', 'b', 'c'])
|
|
811
|
+
unmount()
|
|
812
|
+
})
|
|
813
|
+
|
|
814
|
+
it('initializes empty by default', () => {
|
|
815
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray<string>())
|
|
816
|
+
|
|
817
|
+
expect(arr.length()).toBe(0)
|
|
818
|
+
expect(arr.values()).toEqual([])
|
|
819
|
+
unmount()
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
it('append adds to end', () => {
|
|
823
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray(['a']))
|
|
824
|
+
|
|
825
|
+
arr.append('b')
|
|
826
|
+
expect(arr.values()).toEqual(['a', 'b'])
|
|
827
|
+
expect(arr.length()).toBe(2)
|
|
828
|
+
unmount()
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
it('prepend adds to start', () => {
|
|
832
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray(['b']))
|
|
833
|
+
|
|
834
|
+
arr.prepend('a')
|
|
835
|
+
expect(arr.values()).toEqual(['a', 'b'])
|
|
836
|
+
unmount()
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
it('insert at index', () => {
|
|
840
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray(['a', 'c']))
|
|
841
|
+
|
|
842
|
+
arr.insert(1, 'b')
|
|
843
|
+
expect(arr.values()).toEqual(['a', 'b', 'c'])
|
|
844
|
+
unmount()
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
it('remove by index', () => {
|
|
848
|
+
const { result: arr, unmount } = mountWith(() =>
|
|
849
|
+
useFieldArray(['a', 'b', 'c']),
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
arr.remove(1)
|
|
853
|
+
expect(arr.values()).toEqual(['a', 'c'])
|
|
854
|
+
unmount()
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
it('move reorders items', () => {
|
|
858
|
+
const { result: arr, unmount } = mountWith(() =>
|
|
859
|
+
useFieldArray(['a', 'b', 'c']),
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
arr.move(0, 2)
|
|
863
|
+
expect(arr.values()).toEqual(['b', 'c', 'a'])
|
|
864
|
+
unmount()
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
it('swap exchanges items', () => {
|
|
868
|
+
const { result: arr, unmount } = mountWith(() =>
|
|
869
|
+
useFieldArray(['a', 'b', 'c']),
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
arr.swap(0, 2)
|
|
873
|
+
expect(arr.values()).toEqual(['c', 'b', 'a'])
|
|
874
|
+
unmount()
|
|
875
|
+
})
|
|
876
|
+
|
|
877
|
+
it('replace replaces all items', () => {
|
|
878
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray(['a', 'b']))
|
|
879
|
+
|
|
880
|
+
arr.replace(['x', 'y', 'z'])
|
|
881
|
+
expect(arr.values()).toEqual(['x', 'y', 'z'])
|
|
882
|
+
expect(arr.length()).toBe(3)
|
|
883
|
+
unmount()
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
it('items have stable keys', () => {
|
|
887
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray(['a', 'b']))
|
|
888
|
+
|
|
889
|
+
const keysBefore = arr.items().map((i: any) => i.key)
|
|
890
|
+
arr.append('c')
|
|
891
|
+
const keysAfter = arr.items().map((i: any) => i.key)
|
|
892
|
+
|
|
893
|
+
// First two keys should be preserved
|
|
894
|
+
expect(keysAfter[0]).toBe(keysBefore[0])
|
|
895
|
+
expect(keysAfter[1]).toBe(keysBefore[1])
|
|
896
|
+
// New item gets a new key
|
|
897
|
+
expect(keysAfter[2]).not.toBe(keysBefore[0])
|
|
898
|
+
expect(keysAfter[2]).not.toBe(keysBefore[1])
|
|
899
|
+
unmount()
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
it('individual item values are reactive signals', () => {
|
|
903
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray(['a', 'b']))
|
|
904
|
+
|
|
905
|
+
const item = arr.items()[0]!
|
|
906
|
+
expect(item.value()).toBe('a')
|
|
907
|
+
item.value.set('updated')
|
|
908
|
+
expect(item.value()).toBe('updated')
|
|
909
|
+
unmount()
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
it('update modifies value at index', () => {
|
|
913
|
+
const { result: arr, unmount } = mountWith(() =>
|
|
914
|
+
useFieldArray(['a', 'b', 'c']),
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
arr.update(1, 'updated')
|
|
918
|
+
expect(arr.values()).toEqual(['a', 'updated', 'c'])
|
|
919
|
+
|
|
920
|
+
// Key should be preserved
|
|
921
|
+
const item = arr.items()[1]!
|
|
922
|
+
expect(item.value()).toBe('updated')
|
|
923
|
+
unmount()
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
it('update with invalid (out-of-bounds) index is a no-op', () => {
|
|
927
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray(['a', 'b']))
|
|
928
|
+
|
|
929
|
+
arr.update(99, 'nope')
|
|
930
|
+
expect(arr.values()).toEqual(['a', 'b'])
|
|
931
|
+
|
|
932
|
+
arr.update(-1, 'nope')
|
|
933
|
+
expect(arr.values()).toEqual(['a', 'b'])
|
|
934
|
+
unmount()
|
|
935
|
+
})
|
|
936
|
+
|
|
937
|
+
it('move with invalid from index does not insert undefined', () => {
|
|
938
|
+
const { result: arr, unmount } = mountWith(() =>
|
|
939
|
+
useFieldArray(['a', 'b', 'c']),
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
// splice(99,1) returns [] so item is undefined → splice(to,0) is a no-op
|
|
943
|
+
arr.move(99, 0)
|
|
944
|
+
// The array should still have 3 items (the splice removed nothing, guard prevented insert)
|
|
945
|
+
// Actually splice removes nothing and returns [], item is undefined so nothing inserted
|
|
946
|
+
expect(arr.values()).toEqual(['a', 'b', 'c'])
|
|
947
|
+
unmount()
|
|
948
|
+
})
|
|
949
|
+
|
|
950
|
+
it('swap with one invalid index is a no-op', () => {
|
|
951
|
+
const { result: arr, unmount } = mountWith(() =>
|
|
952
|
+
useFieldArray(['a', 'b', 'c']),
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
// indexA out of bounds
|
|
956
|
+
arr.swap(99, 0)
|
|
957
|
+
expect(arr.values()).toEqual(['a', 'b', 'c'])
|
|
958
|
+
|
|
959
|
+
// indexB out of bounds
|
|
960
|
+
arr.swap(0, 99)
|
|
961
|
+
expect(arr.values()).toEqual(['a', 'b', 'c'])
|
|
962
|
+
|
|
963
|
+
// Both out of bounds
|
|
964
|
+
arr.swap(99, 100)
|
|
965
|
+
expect(arr.values()).toEqual(['a', 'b', 'c'])
|
|
966
|
+
unmount()
|
|
967
|
+
})
|
|
968
|
+
})
|
|
969
|
+
|
|
970
|
+
// ─── structuredEqual (via dirty tracking) ────────────────────────────────────
|
|
971
|
+
|
|
972
|
+
describe('structuredEqual coverage via dirty tracking', () => {
|
|
973
|
+
it('arrays with different lengths are detected as dirty', () => {
|
|
974
|
+
const { result: form, unmount } = mountWith(() =>
|
|
975
|
+
useForm({
|
|
976
|
+
initialValues: { items: ['a', 'b'] as string[] },
|
|
977
|
+
onSubmit: () => {
|
|
978
|
+
/* noop */
|
|
979
|
+
},
|
|
980
|
+
}),
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
// Longer array
|
|
984
|
+
form.fields.items.setValue(['a', 'b', 'c'])
|
|
985
|
+
expect(form.fields.items.dirty()).toBe(true)
|
|
986
|
+
|
|
987
|
+
// Shorter array
|
|
988
|
+
form.fields.items.setValue(['a'])
|
|
989
|
+
expect(form.fields.items.dirty()).toBe(true)
|
|
990
|
+
|
|
991
|
+
// Same length same elements — not dirty
|
|
992
|
+
form.fields.items.setValue(['a', 'b'])
|
|
993
|
+
expect(form.fields.items.dirty()).toBe(false)
|
|
994
|
+
unmount()
|
|
995
|
+
})
|
|
996
|
+
|
|
997
|
+
it('arrays with same length but different elements are detected as dirty', () => {
|
|
998
|
+
const { result: form, unmount } = mountWith(() =>
|
|
999
|
+
useForm({
|
|
1000
|
+
initialValues: { items: ['a', 'b', 'c'] as string[] },
|
|
1001
|
+
onSubmit: () => {
|
|
1002
|
+
/* noop */
|
|
1003
|
+
},
|
|
1004
|
+
}),
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
form.fields.items.setValue(['a', 'b', 'x'])
|
|
1008
|
+
expect(form.fields.items.dirty()).toBe(true)
|
|
1009
|
+
|
|
1010
|
+
form.fields.items.setValue(['x', 'b', 'c'])
|
|
1011
|
+
expect(form.fields.items.dirty()).toBe(true)
|
|
1012
|
+
|
|
1013
|
+
// Restoring original clears dirty
|
|
1014
|
+
form.fields.items.setValue(['a', 'b', 'c'])
|
|
1015
|
+
expect(form.fields.items.dirty()).toBe(false)
|
|
1016
|
+
unmount()
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
it('objects with different number of keys are detected as dirty', () => {
|
|
1020
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1021
|
+
useForm({
|
|
1022
|
+
initialValues: { meta: { x: 1, y: 2 } as Record<string, number> },
|
|
1023
|
+
onSubmit: () => {
|
|
1024
|
+
/* noop */
|
|
1025
|
+
},
|
|
1026
|
+
}),
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
// More keys
|
|
1030
|
+
form.fields.meta.setValue({ x: 1, y: 2, z: 3 })
|
|
1031
|
+
expect(form.fields.meta.dirty()).toBe(true)
|
|
1032
|
+
|
|
1033
|
+
// Fewer keys
|
|
1034
|
+
form.fields.meta.setValue({ x: 1 })
|
|
1035
|
+
expect(form.fields.meta.dirty()).toBe(true)
|
|
1036
|
+
|
|
1037
|
+
// Same keys same values — not dirty
|
|
1038
|
+
form.fields.meta.setValue({ x: 1, y: 2 })
|
|
1039
|
+
expect(form.fields.meta.dirty()).toBe(false)
|
|
1040
|
+
unmount()
|
|
1041
|
+
})
|
|
1042
|
+
|
|
1043
|
+
it('objects with same key count but different values are detected as dirty', () => {
|
|
1044
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1045
|
+
useForm({
|
|
1046
|
+
initialValues: { meta: { x: 1, y: 2 } as Record<string, number> },
|
|
1047
|
+
onSubmit: () => {
|
|
1048
|
+
/* noop */
|
|
1049
|
+
},
|
|
1050
|
+
}),
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
form.fields.meta.setValue({ x: 1, y: 999 })
|
|
1054
|
+
expect(form.fields.meta.dirty()).toBe(true)
|
|
1055
|
+
unmount()
|
|
1056
|
+
})
|
|
1057
|
+
|
|
1058
|
+
it('null vs object is detected as dirty', () => {
|
|
1059
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1060
|
+
useForm({
|
|
1061
|
+
initialValues: { data: { a: 1 } as Record<string, number> | null },
|
|
1062
|
+
onSubmit: () => {
|
|
1063
|
+
/* noop */
|
|
1064
|
+
},
|
|
1065
|
+
}),
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
form.fields.data.setValue(null)
|
|
1069
|
+
expect(form.fields.data.dirty()).toBe(true)
|
|
1070
|
+
unmount()
|
|
1071
|
+
})
|
|
1072
|
+
})
|
|
1073
|
+
|
|
1074
|
+
// ─── validateOn: 'submit' ───────────────────────────────────────────────────
|
|
1075
|
+
|
|
1076
|
+
describe('validateOn: submit', () => {
|
|
1077
|
+
it('does not validate on blur', async () => {
|
|
1078
|
+
let validatorCalls = 0
|
|
1079
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1080
|
+
useForm({
|
|
1081
|
+
initialValues: { name: '' },
|
|
1082
|
+
validators: {
|
|
1083
|
+
name: (v) => {
|
|
1084
|
+
validatorCalls++
|
|
1085
|
+
return !v ? 'Required' : undefined
|
|
1086
|
+
},
|
|
1087
|
+
},
|
|
1088
|
+
onSubmit: () => {
|
|
1089
|
+
/* noop */
|
|
1090
|
+
},
|
|
1091
|
+
validateOn: 'submit',
|
|
1092
|
+
}),
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
// Blur should NOT trigger validation
|
|
1096
|
+
form.fields.name.setTouched()
|
|
1097
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
1098
|
+
expect(validatorCalls).toBe(0)
|
|
1099
|
+
expect(form.fields.name.error()).toBeUndefined()
|
|
1100
|
+
|
|
1101
|
+
// setValue should NOT trigger validation
|
|
1102
|
+
form.fields.name.setValue('hello')
|
|
1103
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
1104
|
+
expect(validatorCalls).toBe(0)
|
|
1105
|
+
expect(form.fields.name.error()).toBeUndefined()
|
|
1106
|
+
unmount()
|
|
1107
|
+
})
|
|
1108
|
+
|
|
1109
|
+
it('validates only when handleSubmit is called', async () => {
|
|
1110
|
+
let submitted = false
|
|
1111
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1112
|
+
useForm({
|
|
1113
|
+
initialValues: { name: '' },
|
|
1114
|
+
validators: {
|
|
1115
|
+
name: (v) => (!v ? 'Required' : undefined),
|
|
1116
|
+
},
|
|
1117
|
+
onSubmit: () => {
|
|
1118
|
+
submitted = true
|
|
1119
|
+
},
|
|
1120
|
+
validateOn: 'submit',
|
|
1121
|
+
}),
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
// No validation until submit
|
|
1125
|
+
form.fields.name.setTouched()
|
|
1126
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
1127
|
+
expect(form.fields.name.error()).toBeUndefined()
|
|
1128
|
+
|
|
1129
|
+
// Submit triggers validation
|
|
1130
|
+
await form.handleSubmit()
|
|
1131
|
+
expect(submitted).toBe(false)
|
|
1132
|
+
expect(form.fields.name.error()).toBe('Required')
|
|
1133
|
+
|
|
1134
|
+
// Fix and resubmit
|
|
1135
|
+
form.fields.name.setValue('hello')
|
|
1136
|
+
await form.handleSubmit()
|
|
1137
|
+
expect(submitted).toBe(true)
|
|
1138
|
+
unmount()
|
|
1139
|
+
})
|
|
1140
|
+
})
|
|
1141
|
+
|
|
1142
|
+
// ─── debounceMs for field validation ─────────────────────────────────────────
|
|
1143
|
+
|
|
1144
|
+
describe('debounceMs field validation', () => {
|
|
1145
|
+
it('debounced validation on change mode', async () => {
|
|
1146
|
+
let callCount = 0
|
|
1147
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1148
|
+
useForm({
|
|
1149
|
+
initialValues: { name: '' },
|
|
1150
|
+
validators: {
|
|
1151
|
+
name: (v) => {
|
|
1152
|
+
callCount++
|
|
1153
|
+
return !v ? 'Required' : undefined
|
|
1154
|
+
},
|
|
1155
|
+
},
|
|
1156
|
+
onSubmit: () => {
|
|
1157
|
+
/* noop */
|
|
1158
|
+
},
|
|
1159
|
+
validateOn: 'change',
|
|
1160
|
+
debounceMs: 50,
|
|
1161
|
+
}),
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
// Change should trigger debounced validation
|
|
1165
|
+
form.fields.name.setValue('a')
|
|
1166
|
+
form.fields.name.setValue('ab')
|
|
1167
|
+
form.fields.name.setValue('abc')
|
|
1168
|
+
|
|
1169
|
+
// Not yet validated
|
|
1170
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
1171
|
+
expect(callCount).toBe(0)
|
|
1172
|
+
|
|
1173
|
+
// After debounce, should validate
|
|
1174
|
+
await new Promise((r) => setTimeout(r, 80))
|
|
1175
|
+
expect(callCount).toBeGreaterThanOrEqual(1)
|
|
1176
|
+
expect(form.fields.name.error()).toBeUndefined()
|
|
1177
|
+
unmount()
|
|
1178
|
+
})
|
|
1179
|
+
|
|
1180
|
+
it('debounced validation resolves after timer fires', async () => {
|
|
1181
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1182
|
+
useForm({
|
|
1183
|
+
initialValues: { name: '' },
|
|
1184
|
+
validators: {
|
|
1185
|
+
name: (v) => (!v ? 'Required' : undefined),
|
|
1186
|
+
},
|
|
1187
|
+
onSubmit: () => {
|
|
1188
|
+
/* noop */
|
|
1189
|
+
},
|
|
1190
|
+
validateOn: 'blur',
|
|
1191
|
+
debounceMs: 30,
|
|
1192
|
+
}),
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
form.fields.name.setTouched()
|
|
1196
|
+
|
|
1197
|
+
// Before debounce fires
|
|
1198
|
+
await new Promise((r) => setTimeout(r, 5))
|
|
1199
|
+
expect(form.fields.name.error()).toBeUndefined()
|
|
1200
|
+
|
|
1201
|
+
// After debounce fires
|
|
1202
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
1203
|
+
expect(form.fields.name.error()).toBe('Required')
|
|
1204
|
+
unmount()
|
|
1205
|
+
})
|
|
1206
|
+
|
|
1207
|
+
it('reset clears pending debounce timers', async () => {
|
|
1208
|
+
let callCount = 0
|
|
1209
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1210
|
+
useForm({
|
|
1211
|
+
initialValues: { name: '' },
|
|
1212
|
+
validators: {
|
|
1213
|
+
name: (v) => {
|
|
1214
|
+
callCount++
|
|
1215
|
+
return !v ? 'Required' : undefined
|
|
1216
|
+
},
|
|
1217
|
+
},
|
|
1218
|
+
onSubmit: () => {
|
|
1219
|
+
/* noop */
|
|
1220
|
+
},
|
|
1221
|
+
validateOn: 'blur',
|
|
1222
|
+
debounceMs: 50,
|
|
1223
|
+
}),
|
|
1224
|
+
)
|
|
1225
|
+
|
|
1226
|
+
form.fields.name.setTouched()
|
|
1227
|
+
// Reset before debounce fires
|
|
1228
|
+
form.reset()
|
|
1229
|
+
|
|
1230
|
+
await new Promise((r) => setTimeout(r, 80))
|
|
1231
|
+
// Validator should not have been called since timer was cleared
|
|
1232
|
+
expect(callCount).toBe(0)
|
|
1233
|
+
expect(form.fields.name.error()).toBeUndefined()
|
|
1234
|
+
unmount()
|
|
1235
|
+
})
|
|
1236
|
+
})
|
|
1237
|
+
|
|
1238
|
+
// ─── Edge case: nonexistent field names ──────────────────────────────────────
|
|
1239
|
+
|
|
1240
|
+
describe('useForm nonexistent field operations', () => {
|
|
1241
|
+
it('setFieldValue with nonexistent field throws', () => {
|
|
1242
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1243
|
+
useForm({
|
|
1244
|
+
initialValues: { name: 'Alice' },
|
|
1245
|
+
onSubmit: () => {
|
|
1246
|
+
/* noop */
|
|
1247
|
+
},
|
|
1248
|
+
}),
|
|
1249
|
+
)
|
|
1250
|
+
|
|
1251
|
+
expect(() => form.setFieldValue('nonexistent' as any, 'value')).toThrow(
|
|
1252
|
+
'[@pyreon/form] Field "nonexistent" does not exist',
|
|
1253
|
+
)
|
|
1254
|
+
expect(form.fields.name.value()).toBe('Alice')
|
|
1255
|
+
unmount()
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
it('setFieldError with nonexistent field throws', () => {
|
|
1259
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1260
|
+
useForm({
|
|
1261
|
+
initialValues: { name: 'Alice' },
|
|
1262
|
+
onSubmit: () => {
|
|
1263
|
+
/* noop */
|
|
1264
|
+
},
|
|
1265
|
+
}),
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
expect(() => form.setFieldError('nonexistent' as any, 'error')).toThrow(
|
|
1269
|
+
'[@pyreon/form] Field "nonexistent" does not exist',
|
|
1270
|
+
)
|
|
1271
|
+
expect(form.isValid()).toBe(true)
|
|
1272
|
+
unmount()
|
|
1273
|
+
})
|
|
1274
|
+
|
|
1275
|
+
it('resetField with nonexistent field is a no-op', () => {
|
|
1276
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1277
|
+
useForm({
|
|
1278
|
+
initialValues: { name: 'Alice' },
|
|
1279
|
+
onSubmit: () => {
|
|
1280
|
+
/* noop */
|
|
1281
|
+
},
|
|
1282
|
+
}),
|
|
1283
|
+
)
|
|
1284
|
+
|
|
1285
|
+
form.resetField('nonexistent' as any)
|
|
1286
|
+
expect(form.fields.name.value()).toBe('Alice')
|
|
1287
|
+
unmount()
|
|
1288
|
+
})
|
|
1289
|
+
})
|
|
1290
|
+
|
|
1291
|
+
// ─── Edge case: structuredEqual mixed types ──────────────────────────────────
|
|
1292
|
+
|
|
1293
|
+
describe('dirty detection with mixed types', () => {
|
|
1294
|
+
it('number vs string is dirty', () => {
|
|
1295
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1296
|
+
useForm({
|
|
1297
|
+
initialValues: { value: 0 as any },
|
|
1298
|
+
onSubmit: () => {
|
|
1299
|
+
/* noop */
|
|
1300
|
+
},
|
|
1301
|
+
}),
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
form.fields.value.setValue('0' as any)
|
|
1305
|
+
expect(form.fields.value.dirty()).toBe(true)
|
|
1306
|
+
unmount()
|
|
1307
|
+
})
|
|
1308
|
+
})
|
|
1309
|
+
|
|
1310
|
+
// ─── validate() branch coverage ──────────────────────────────────────────────
|
|
1311
|
+
|
|
1312
|
+
describe('validate() branch coverage', () => {
|
|
1313
|
+
it('getErrors returns empty when no errors exist', async () => {
|
|
1314
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1315
|
+
useForm({
|
|
1316
|
+
initialValues: { name: 'valid', email: 'a@b.com' },
|
|
1317
|
+
validators: {
|
|
1318
|
+
name: (v) => (!v ? 'Required' : undefined),
|
|
1319
|
+
email: (v) => (!v ? 'Required' : undefined),
|
|
1320
|
+
},
|
|
1321
|
+
onSubmit: () => {
|
|
1322
|
+
/* noop */
|
|
1323
|
+
},
|
|
1324
|
+
}),
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
// Validate with all valid values — errors() should return empty object
|
|
1328
|
+
await form.validate()
|
|
1329
|
+
expect(form.errors()).toEqual({})
|
|
1330
|
+
unmount()
|
|
1331
|
+
})
|
|
1332
|
+
|
|
1333
|
+
it('stale async field-level validation on blur is discarded', async () => {
|
|
1334
|
+
const resolvers: Array<(v: string | undefined) => void> = []
|
|
1335
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1336
|
+
useForm({
|
|
1337
|
+
initialValues: { name: '' },
|
|
1338
|
+
validators: {
|
|
1339
|
+
name: (_v) => {
|
|
1340
|
+
return new Promise<string | undefined>((resolve) => {
|
|
1341
|
+
resolvers.push(resolve)
|
|
1342
|
+
})
|
|
1343
|
+
},
|
|
1344
|
+
},
|
|
1345
|
+
onSubmit: () => {
|
|
1346
|
+
/* noop */
|
|
1347
|
+
},
|
|
1348
|
+
validateOn: 'blur',
|
|
1349
|
+
}),
|
|
1350
|
+
)
|
|
1351
|
+
|
|
1352
|
+
// Trigger first blur validation
|
|
1353
|
+
form.fields.name.setTouched()
|
|
1354
|
+
// Trigger second blur validation (bumps version, makes first stale)
|
|
1355
|
+
form.fields.name.setTouched()
|
|
1356
|
+
|
|
1357
|
+
// Resolve the first (stale) result
|
|
1358
|
+
resolvers[0]!('Stale error from blur')
|
|
1359
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
1360
|
+
// Error should NOT be set since it's stale
|
|
1361
|
+
expect(form.fields.name.error()).toBeUndefined()
|
|
1362
|
+
|
|
1363
|
+
// Resolve the second (current) result
|
|
1364
|
+
resolvers[1]!(undefined)
|
|
1365
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
1366
|
+
expect(form.fields.name.error()).toBeUndefined()
|
|
1367
|
+
unmount()
|
|
1368
|
+
})
|
|
1369
|
+
|
|
1370
|
+
it('stale async validation results are discarded during validate()', async () => {
|
|
1371
|
+
const resolvers: Array<(v: string | undefined) => void> = []
|
|
1372
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1373
|
+
useForm({
|
|
1374
|
+
initialValues: { name: '' },
|
|
1375
|
+
validators: {
|
|
1376
|
+
name: (_v) => {
|
|
1377
|
+
return new Promise<string | undefined>((resolve) => {
|
|
1378
|
+
resolvers.push(resolve)
|
|
1379
|
+
})
|
|
1380
|
+
},
|
|
1381
|
+
},
|
|
1382
|
+
onSubmit: () => {
|
|
1383
|
+
/* noop */
|
|
1384
|
+
},
|
|
1385
|
+
}),
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
// Start first validation
|
|
1389
|
+
const firstValidate = form.validate()
|
|
1390
|
+
// Start second validation before first resolves — bumps version
|
|
1391
|
+
const secondValidate = form.validate()
|
|
1392
|
+
|
|
1393
|
+
// Resolve the first (stale) validation — should be discarded
|
|
1394
|
+
resolvers[0]!('Stale error')
|
|
1395
|
+
// Resolve the second (current) validation
|
|
1396
|
+
resolvers[1]!(undefined)
|
|
1397
|
+
|
|
1398
|
+
await Promise.all([firstValidate, secondValidate])
|
|
1399
|
+
|
|
1400
|
+
// The stale error should NOT have been applied
|
|
1401
|
+
expect(form.fields.name.error()).toBeUndefined()
|
|
1402
|
+
unmount()
|
|
1403
|
+
})
|
|
1404
|
+
|
|
1405
|
+
it('field-level validator throwing during validate() captures error', async () => {
|
|
1406
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1407
|
+
useForm({
|
|
1408
|
+
initialValues: { name: 'Alice' },
|
|
1409
|
+
validators: {
|
|
1410
|
+
name: () => {
|
|
1411
|
+
throw new Error('Validator crashed')
|
|
1412
|
+
},
|
|
1413
|
+
},
|
|
1414
|
+
onSubmit: () => {
|
|
1415
|
+
/* noop */
|
|
1416
|
+
},
|
|
1417
|
+
}),
|
|
1418
|
+
)
|
|
1419
|
+
|
|
1420
|
+
const valid = await form.validate()
|
|
1421
|
+
expect(valid).toBe(false)
|
|
1422
|
+
expect(form.fields.name.error()).toBe('Validator crashed')
|
|
1423
|
+
unmount()
|
|
1424
|
+
})
|
|
1425
|
+
|
|
1426
|
+
it('field-level validator throwing on blur captures error', async () => {
|
|
1427
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1428
|
+
useForm({
|
|
1429
|
+
initialValues: { name: '' },
|
|
1430
|
+
validators: {
|
|
1431
|
+
name: () => {
|
|
1432
|
+
throw new Error('Blur validator crashed')
|
|
1433
|
+
},
|
|
1434
|
+
},
|
|
1435
|
+
validateOn: 'blur',
|
|
1436
|
+
onSubmit: () => {
|
|
1437
|
+
/* noop */
|
|
1438
|
+
},
|
|
1439
|
+
}),
|
|
1440
|
+
)
|
|
1441
|
+
|
|
1442
|
+
form.fields.name.setTouched()
|
|
1443
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
1444
|
+
expect(form.fields.name.error()).toBe('Blur validator crashed')
|
|
1445
|
+
unmount()
|
|
1446
|
+
})
|
|
1447
|
+
|
|
1448
|
+
it('schema validator with keys having undefined value does not block submit', async () => {
|
|
1449
|
+
let submitted = false
|
|
1450
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1451
|
+
useForm({
|
|
1452
|
+
initialValues: { name: 'Alice', email: 'a@b.com' },
|
|
1453
|
+
schema: (_values) => {
|
|
1454
|
+
// Return an object where some keys have undefined values
|
|
1455
|
+
return { name: undefined, email: undefined } as any
|
|
1456
|
+
},
|
|
1457
|
+
onSubmit: () => {
|
|
1458
|
+
submitted = true
|
|
1459
|
+
},
|
|
1460
|
+
}),
|
|
1461
|
+
)
|
|
1462
|
+
|
|
1463
|
+
await form.handleSubmit()
|
|
1464
|
+
// Schema returned keys but all with undefined values — should pass
|
|
1465
|
+
expect(submitted).toBe(true)
|
|
1466
|
+
unmount()
|
|
1467
|
+
})
|
|
1468
|
+
|
|
1469
|
+
it('schema validator throwing sets submitError and returns false', async () => {
|
|
1470
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1471
|
+
useForm({
|
|
1472
|
+
initialValues: { name: 'Alice', email: 'a@b.com' },
|
|
1473
|
+
schema: () => {
|
|
1474
|
+
throw new Error('Schema exploded')
|
|
1475
|
+
},
|
|
1476
|
+
onSubmit: () => {
|
|
1477
|
+
/* noop */
|
|
1478
|
+
},
|
|
1479
|
+
}),
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
const valid = await form.validate()
|
|
1483
|
+
expect(valid).toBe(false)
|
|
1484
|
+
expect(form.submitError()).toBeInstanceOf(Error)
|
|
1485
|
+
expect((form.submitError() as Error).message).toBe('Schema exploded')
|
|
1486
|
+
unmount()
|
|
1487
|
+
})
|
|
1488
|
+
|
|
1489
|
+
it('fields without validators return undefined in validate()', async () => {
|
|
1490
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1491
|
+
useForm({
|
|
1492
|
+
initialValues: { name: 'Alice', noValidator: 'test' },
|
|
1493
|
+
validators: {
|
|
1494
|
+
name: (v) => (!v ? 'Required' : undefined),
|
|
1495
|
+
// noValidator field has no validator
|
|
1496
|
+
},
|
|
1497
|
+
onSubmit: () => {
|
|
1498
|
+
/* noop */
|
|
1499
|
+
},
|
|
1500
|
+
}),
|
|
1501
|
+
)
|
|
1502
|
+
|
|
1503
|
+
const valid = await form.validate()
|
|
1504
|
+
expect(valid).toBe(true)
|
|
1505
|
+
expect(form.fields.noValidator.error()).toBeUndefined()
|
|
1506
|
+
unmount()
|
|
1507
|
+
})
|
|
1508
|
+
})
|
|
1509
|
+
|
|
1510
|
+
// ─── Edge case: debounceMs + validateOn: 'change' ───────────────────────────
|
|
1511
|
+
|
|
1512
|
+
describe('debounceMs with validateOn change', () => {
|
|
1513
|
+
it('debounces validation on change', async () => {
|
|
1514
|
+
let callCount = 0
|
|
1515
|
+
const { result: form, unmount } = mountWith(() =>
|
|
1516
|
+
useForm({
|
|
1517
|
+
initialValues: { name: '' },
|
|
1518
|
+
validators: {
|
|
1519
|
+
name: async (v) => {
|
|
1520
|
+
callCount++
|
|
1521
|
+
return v.length < 3 ? 'Too short' : undefined
|
|
1522
|
+
},
|
|
1523
|
+
},
|
|
1524
|
+
validateOn: 'change',
|
|
1525
|
+
debounceMs: 50,
|
|
1526
|
+
onSubmit: () => {
|
|
1527
|
+
/* noop */
|
|
1528
|
+
},
|
|
1529
|
+
}),
|
|
1530
|
+
)
|
|
1531
|
+
|
|
1532
|
+
form.fields.name.setValue('a')
|
|
1533
|
+
form.fields.name.setValue('ab')
|
|
1534
|
+
form.fields.name.setValue('abc')
|
|
1535
|
+
|
|
1536
|
+
// None should have fired yet
|
|
1537
|
+
expect(callCount).toBe(0)
|
|
1538
|
+
|
|
1539
|
+
await new Promise((r) => setTimeout(r, 80))
|
|
1540
|
+
// Only the last one should have fired after debounce
|
|
1541
|
+
expect(callCount).toBe(1)
|
|
1542
|
+
expect(form.fields.name.error()).toBeUndefined()
|
|
1543
|
+
unmount()
|
|
1544
|
+
})
|
|
1545
|
+
})
|
|
1546
|
+
|
|
1547
|
+
// ─── useField ────────────────────────────────────────────────────────────────
|
|
1548
|
+
|
|
1549
|
+
describe('useField', () => {
|
|
1550
|
+
it('extracts a single field from a form', () => {
|
|
1551
|
+
const { result, unmount } = mountWith(() => {
|
|
1552
|
+
const form = useForm({
|
|
1553
|
+
initialValues: { email: '', password: '' },
|
|
1554
|
+
onSubmit: () => {
|
|
1555
|
+
/* noop */
|
|
1556
|
+
},
|
|
1557
|
+
})
|
|
1558
|
+
const field = useField(form, 'email')
|
|
1559
|
+
return { form, field }
|
|
1560
|
+
})
|
|
1561
|
+
|
|
1562
|
+
expect(result.field.value()).toBe('')
|
|
1563
|
+
result.field.setValue('test@test.com')
|
|
1564
|
+
expect(result.form.fields.email.value()).toBe('test@test.com')
|
|
1565
|
+
expect(result.field.dirty()).toBe(true)
|
|
1566
|
+
unmount()
|
|
1567
|
+
})
|
|
1568
|
+
|
|
1569
|
+
it('hasError and showError computed correctly', async () => {
|
|
1570
|
+
const { result, unmount } = mountWith(() => {
|
|
1571
|
+
const form = useForm({
|
|
1572
|
+
initialValues: { email: '' },
|
|
1573
|
+
validators: { email: (v) => (!v ? 'Required' : undefined) },
|
|
1574
|
+
onSubmit: () => {
|
|
1575
|
+
/* noop */
|
|
1576
|
+
},
|
|
1577
|
+
})
|
|
1578
|
+
const field = useField(form, 'email')
|
|
1579
|
+
return { form, field }
|
|
1580
|
+
})
|
|
1581
|
+
|
|
1582
|
+
expect(result.field.hasError()).toBe(false)
|
|
1583
|
+
expect(result.field.showError()).toBe(false)
|
|
1584
|
+
|
|
1585
|
+
// Trigger validation
|
|
1586
|
+
result.field.setTouched()
|
|
1587
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
1588
|
+
|
|
1589
|
+
expect(result.field.hasError()).toBe(true)
|
|
1590
|
+
// showError = touched AND hasError
|
|
1591
|
+
expect(result.field.showError()).toBe(true)
|
|
1592
|
+
unmount()
|
|
1593
|
+
})
|
|
1594
|
+
|
|
1595
|
+
it('register() delegates to form.register()', () => {
|
|
1596
|
+
const { result, unmount } = mountWith(() => {
|
|
1597
|
+
const form = useForm({
|
|
1598
|
+
initialValues: { email: '' },
|
|
1599
|
+
onSubmit: () => {
|
|
1600
|
+
/* noop */
|
|
1601
|
+
},
|
|
1602
|
+
})
|
|
1603
|
+
const field = useField(form, 'email')
|
|
1604
|
+
return { form, field }
|
|
1605
|
+
})
|
|
1606
|
+
|
|
1607
|
+
const fieldProps = result.field.register()
|
|
1608
|
+
const formProps = result.form.register('email')
|
|
1609
|
+
// Should be the same memoized object
|
|
1610
|
+
expect(fieldProps).toBe(formProps)
|
|
1611
|
+
unmount()
|
|
1612
|
+
})
|
|
1613
|
+
|
|
1614
|
+
it('register() with checkbox type', () => {
|
|
1615
|
+
const { result, unmount } = mountWith(() => {
|
|
1616
|
+
const form = useForm({
|
|
1617
|
+
initialValues: { remember: false },
|
|
1618
|
+
onSubmit: () => {
|
|
1619
|
+
/* noop */
|
|
1620
|
+
},
|
|
1621
|
+
})
|
|
1622
|
+
const field = useField(form, 'remember')
|
|
1623
|
+
return { form, field }
|
|
1624
|
+
})
|
|
1625
|
+
|
|
1626
|
+
const props = result.field.register({ type: 'checkbox' })
|
|
1627
|
+
expect(props.checked).toBeDefined()
|
|
1628
|
+
expect(props.checked!()).toBe(false)
|
|
1629
|
+
unmount()
|
|
1630
|
+
})
|
|
1631
|
+
|
|
1632
|
+
it('reset delegates to field reset', () => {
|
|
1633
|
+
const { result, unmount } = mountWith(() => {
|
|
1634
|
+
const form = useForm({
|
|
1635
|
+
initialValues: { name: 'initial' },
|
|
1636
|
+
onSubmit: () => {
|
|
1637
|
+
/* noop */
|
|
1638
|
+
},
|
|
1639
|
+
})
|
|
1640
|
+
const field = useField(form, 'name')
|
|
1641
|
+
return { form, field }
|
|
1642
|
+
})
|
|
1643
|
+
|
|
1644
|
+
result.field.setValue('changed')
|
|
1645
|
+
result.field.setTouched()
|
|
1646
|
+
expect(result.field.dirty()).toBe(true)
|
|
1647
|
+
expect(result.field.touched()).toBe(true)
|
|
1648
|
+
|
|
1649
|
+
result.field.reset()
|
|
1650
|
+
expect(result.field.value()).toBe('initial')
|
|
1651
|
+
expect(result.field.dirty()).toBe(false)
|
|
1652
|
+
expect(result.field.touched()).toBe(false)
|
|
1653
|
+
unmount()
|
|
1654
|
+
})
|
|
1655
|
+
})
|
|
1656
|
+
|
|
1657
|
+
// ─── useWatch ────────────────────────────────────────────────────────────────
|
|
1658
|
+
|
|
1659
|
+
describe('useWatch', () => {
|
|
1660
|
+
it('watches a single field value', () => {
|
|
1661
|
+
const { result, unmount } = mountWith(() => {
|
|
1662
|
+
const form = useForm({
|
|
1663
|
+
initialValues: { email: 'a@b.com', password: '' },
|
|
1664
|
+
onSubmit: () => {
|
|
1665
|
+
/* noop */
|
|
1666
|
+
},
|
|
1667
|
+
})
|
|
1668
|
+
const email = useWatch(form, 'email')
|
|
1669
|
+
return { form, email }
|
|
1670
|
+
})
|
|
1671
|
+
|
|
1672
|
+
expect(result.email()).toBe('a@b.com')
|
|
1673
|
+
result.form.fields.email.setValue('new@email.com')
|
|
1674
|
+
expect(result.email()).toBe('new@email.com')
|
|
1675
|
+
unmount()
|
|
1676
|
+
})
|
|
1677
|
+
|
|
1678
|
+
it('watches multiple fields', () => {
|
|
1679
|
+
const { result, unmount } = mountWith(() => {
|
|
1680
|
+
const form = useForm({
|
|
1681
|
+
initialValues: { first: 'John', last: 'Doe' },
|
|
1682
|
+
onSubmit: () => {
|
|
1683
|
+
/* noop */
|
|
1684
|
+
},
|
|
1685
|
+
})
|
|
1686
|
+
const [first, last] = useWatch(form, ['first', 'last'])
|
|
1687
|
+
return { form, first, last }
|
|
1688
|
+
})
|
|
1689
|
+
|
|
1690
|
+
expect(result.first!()).toBe('John')
|
|
1691
|
+
expect(result.last!()).toBe('Doe')
|
|
1692
|
+
result.form.fields.first.setValue('Jane')
|
|
1693
|
+
expect(result.first!()).toBe('Jane')
|
|
1694
|
+
unmount()
|
|
1695
|
+
})
|
|
1696
|
+
|
|
1697
|
+
it('watches all fields when no name provided', () => {
|
|
1698
|
+
const { result, unmount } = mountWith(() => {
|
|
1699
|
+
const form = useForm({
|
|
1700
|
+
initialValues: { email: 'a@b.com', name: 'Alice' },
|
|
1701
|
+
onSubmit: () => {
|
|
1702
|
+
/* noop */
|
|
1703
|
+
},
|
|
1704
|
+
})
|
|
1705
|
+
const all = useWatch(form)
|
|
1706
|
+
return { form, all }
|
|
1707
|
+
})
|
|
1708
|
+
|
|
1709
|
+
expect(result.all()).toEqual({ email: 'a@b.com', name: 'Alice' })
|
|
1710
|
+
result.form.fields.email.setValue('new@email.com')
|
|
1711
|
+
expect(result.all()).toEqual({ email: 'new@email.com', name: 'Alice' })
|
|
1712
|
+
unmount()
|
|
1713
|
+
})
|
|
1714
|
+
})
|
|
1715
|
+
|
|
1716
|
+
// ─── useFormState ────────────────────────────────────────────────────────────
|
|
1717
|
+
|
|
1718
|
+
describe('useFormState', () => {
|
|
1719
|
+
it('returns full form state summary', async () => {
|
|
1720
|
+
const { result, unmount } = mountWith(() => {
|
|
1721
|
+
const form = useForm({
|
|
1722
|
+
initialValues: { email: '', password: '' },
|
|
1723
|
+
validators: { email: (v) => (!v ? 'Required' : undefined) },
|
|
1724
|
+
onSubmit: () => {
|
|
1725
|
+
/* noop */
|
|
1726
|
+
},
|
|
1727
|
+
})
|
|
1728
|
+
const state = useFormState(form)
|
|
1729
|
+
return { form, state }
|
|
1730
|
+
})
|
|
1731
|
+
|
|
1732
|
+
const s = result.state()
|
|
1733
|
+
expect(s.isSubmitting).toBe(false)
|
|
1734
|
+
expect(s.isValidating).toBe(false)
|
|
1735
|
+
expect(s.isValid).toBe(true)
|
|
1736
|
+
expect(s.isDirty).toBe(false)
|
|
1737
|
+
expect(s.submitCount).toBe(0)
|
|
1738
|
+
expect(s.submitError).toBeUndefined()
|
|
1739
|
+
expect(s.touchedFields).toEqual({})
|
|
1740
|
+
expect(s.dirtyFields).toEqual({})
|
|
1741
|
+
expect(s.errors).toEqual({})
|
|
1742
|
+
|
|
1743
|
+
// Change form state
|
|
1744
|
+
result.form.fields.email.setValue('test')
|
|
1745
|
+
result.form.fields.email.setTouched()
|
|
1746
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
1747
|
+
|
|
1748
|
+
const s2 = result.state()
|
|
1749
|
+
expect(s2.isDirty).toBe(true)
|
|
1750
|
+
expect(s2.dirtyFields).toEqual({ email: true })
|
|
1751
|
+
expect(s2.touchedFields).toEqual({ email: true })
|
|
1752
|
+
unmount()
|
|
1753
|
+
})
|
|
1754
|
+
|
|
1755
|
+
it('works with selector for fine-grained reactivity', async () => {
|
|
1756
|
+
const { result, unmount } = mountWith(() => {
|
|
1757
|
+
const form = useForm({
|
|
1758
|
+
initialValues: { email: '' },
|
|
1759
|
+
validators: { email: (v) => (!v ? 'Required' : undefined) },
|
|
1760
|
+
onSubmit: () => {
|
|
1761
|
+
/* noop */
|
|
1762
|
+
},
|
|
1763
|
+
})
|
|
1764
|
+
const canSubmit = useFormState(form, (s) => s.isValid && !s.isSubmitting)
|
|
1765
|
+
return { form, canSubmit }
|
|
1766
|
+
})
|
|
1767
|
+
|
|
1768
|
+
expect(result.canSubmit()).toBe(true)
|
|
1769
|
+
|
|
1770
|
+
// Trigger validation to make it invalid
|
|
1771
|
+
await result.form.validate()
|
|
1772
|
+
expect(result.canSubmit()).toBe(false)
|
|
1773
|
+
|
|
1774
|
+
// Fix value
|
|
1775
|
+
result.form.fields.email.setValue('test@test.com')
|
|
1776
|
+
await result.form.validate()
|
|
1777
|
+
expect(result.canSubmit()).toBe(true)
|
|
1778
|
+
unmount()
|
|
1779
|
+
})
|
|
1780
|
+
|
|
1781
|
+
it('tracks errors in summary', async () => {
|
|
1782
|
+
const { result, unmount } = mountWith(() => {
|
|
1783
|
+
const form = useForm({
|
|
1784
|
+
initialValues: { email: '', name: '' },
|
|
1785
|
+
validators: {
|
|
1786
|
+
email: (v) => (!v ? 'Email required' : undefined),
|
|
1787
|
+
name: (v) => (!v ? 'Name required' : undefined),
|
|
1788
|
+
},
|
|
1789
|
+
onSubmit: () => {
|
|
1790
|
+
/* noop */
|
|
1791
|
+
},
|
|
1792
|
+
})
|
|
1793
|
+
const state = useFormState(form)
|
|
1794
|
+
return { form, state }
|
|
1795
|
+
})
|
|
1796
|
+
|
|
1797
|
+
await result.form.validate()
|
|
1798
|
+
const s = result.state()
|
|
1799
|
+
expect(s.errors).toEqual({ email: 'Email required', name: 'Name required' })
|
|
1800
|
+
expect(s.isValid).toBe(false)
|
|
1801
|
+
unmount()
|
|
1802
|
+
})
|
|
1803
|
+
})
|
|
1804
|
+
|
|
1805
|
+
// ─── FormProvider / useFormContext ────────────────────────────────────────────
|
|
1806
|
+
|
|
1807
|
+
describe('FormProvider / useFormContext', () => {
|
|
1808
|
+
it('provides form through context', () => {
|
|
1809
|
+
let contextForm: FormState<{ email: string }> | undefined
|
|
1810
|
+
const el = document.createElement('div')
|
|
1811
|
+
document.body.appendChild(el)
|
|
1812
|
+
|
|
1813
|
+
const unmount = mount(
|
|
1814
|
+
h(() => {
|
|
1815
|
+
const form = useForm({
|
|
1816
|
+
initialValues: { email: 'context@test.com' },
|
|
1817
|
+
onSubmit: () => {
|
|
1818
|
+
/* noop */
|
|
1819
|
+
},
|
|
1820
|
+
})
|
|
1821
|
+
return h(FormProvider as any, { form }, () =>
|
|
1822
|
+
h(() => {
|
|
1823
|
+
contextForm = useFormContext<{ email: string }>()
|
|
1824
|
+
return null
|
|
1825
|
+
}, null),
|
|
1826
|
+
)
|
|
1827
|
+
}, null),
|
|
1828
|
+
el,
|
|
1829
|
+
)
|
|
1830
|
+
|
|
1831
|
+
expect(contextForm).toBeDefined()
|
|
1832
|
+
expect(contextForm!.fields.email.value()).toBe('context@test.com')
|
|
1833
|
+
unmount()
|
|
1834
|
+
el.remove()
|
|
1835
|
+
})
|
|
1836
|
+
|
|
1837
|
+
it('throws when useFormContext is called outside FormProvider', () => {
|
|
1838
|
+
const el = document.createElement('div')
|
|
1839
|
+
document.body.appendChild(el)
|
|
1840
|
+
|
|
1841
|
+
let error: Error | undefined
|
|
1842
|
+
const unmount = mount(
|
|
1843
|
+
h(() => {
|
|
1844
|
+
try {
|
|
1845
|
+
useFormContext()
|
|
1846
|
+
} catch (e) {
|
|
1847
|
+
error = e as Error
|
|
1848
|
+
}
|
|
1849
|
+
return null
|
|
1850
|
+
}, null),
|
|
1851
|
+
el,
|
|
1852
|
+
)
|
|
1853
|
+
|
|
1854
|
+
expect(error).toBeDefined()
|
|
1855
|
+
expect(error!.message).toContain('useFormContext')
|
|
1856
|
+
expect(error!.message).toContain('FormProvider')
|
|
1857
|
+
unmount()
|
|
1858
|
+
el.remove()
|
|
1859
|
+
})
|
|
1860
|
+
})
|