@pyreon/form 0.11.3 → 0.11.4
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/form",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.4",
|
|
4
4
|
"description": "Signal-based form management for Pyreon",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -46,13 +46,13 @@
|
|
|
46
46
|
"lint": "biome check ."
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@pyreon/core": "^0.11.
|
|
50
|
-
"@pyreon/reactivity": "^0.11.
|
|
49
|
+
"@pyreon/core": "^0.11.4",
|
|
50
|
+
"@pyreon/reactivity": "^0.11.4"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@happy-dom/global-registrator": "^20.8.3",
|
|
54
|
-
"@pyreon/core": "^0.11.
|
|
55
|
-
"@pyreon/reactivity": "^0.11.
|
|
56
|
-
"@pyreon/runtime-dom": "^0.11.
|
|
54
|
+
"@pyreon/core": "^0.11.4",
|
|
55
|
+
"@pyreon/reactivity": "^0.11.4",
|
|
56
|
+
"@pyreon/runtime-dom": "^0.11.4"
|
|
57
57
|
}
|
|
58
58
|
}
|
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import { effect } from "@pyreon/reactivity"
|
|
2
|
+
import { mount } from "@pyreon/runtime-dom"
|
|
3
|
+
import type { FormState } from "../index"
|
|
4
|
+
import {
|
|
5
|
+
FormProvider,
|
|
6
|
+
useFieldArray,
|
|
7
|
+
useForm,
|
|
8
|
+
useFormContext,
|
|
9
|
+
useFormState,
|
|
10
|
+
useWatch,
|
|
11
|
+
} from "../index"
|
|
12
|
+
|
|
13
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function Capture<T>({ fn }: { fn: () => T }) {
|
|
16
|
+
fn()
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function mountWith<T>(fn: () => T): { result: T; unmount: () => void } {
|
|
21
|
+
let result: T | undefined
|
|
22
|
+
const el = document.createElement("div")
|
|
23
|
+
document.body.appendChild(el)
|
|
24
|
+
const unmount = mount(
|
|
25
|
+
<Capture
|
|
26
|
+
fn={() => {
|
|
27
|
+
result = fn()
|
|
28
|
+
}}
|
|
29
|
+
/>,
|
|
30
|
+
el,
|
|
31
|
+
)
|
|
32
|
+
return {
|
|
33
|
+
result: result!,
|
|
34
|
+
unmount: () => {
|
|
35
|
+
unmount()
|
|
36
|
+
el.remove()
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── useFieldArray — additional operations ───────────────────────────────────
|
|
42
|
+
|
|
43
|
+
describe("useFieldArray — additional operations", () => {
|
|
44
|
+
it("append multiple items sequentially", () => {
|
|
45
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray<string>([]))
|
|
46
|
+
|
|
47
|
+
arr.append("a")
|
|
48
|
+
arr.append("b")
|
|
49
|
+
arr.append("c")
|
|
50
|
+
expect(arr.values()).toEqual(["a", "b", "c"])
|
|
51
|
+
expect(arr.length()).toBe(3)
|
|
52
|
+
unmount()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("remove first item shifts remaining", () => {
|
|
56
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray(["x", "y", "z"]))
|
|
57
|
+
|
|
58
|
+
arr.remove(0)
|
|
59
|
+
expect(arr.values()).toEqual(["y", "z"])
|
|
60
|
+
expect(arr.length()).toBe(2)
|
|
61
|
+
unmount()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("remove last item", () => {
|
|
65
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray(["a", "b", "c"]))
|
|
66
|
+
|
|
67
|
+
arr.remove(2)
|
|
68
|
+
expect(arr.values()).toEqual(["a", "b"])
|
|
69
|
+
unmount()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("move item forward (lower to higher index)", () => {
|
|
73
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray(["a", "b", "c", "d"]))
|
|
74
|
+
|
|
75
|
+
arr.move(1, 3) // move "b" from index 1 to index 3
|
|
76
|
+
expect(arr.values()).toEqual(["a", "c", "d", "b"])
|
|
77
|
+
unmount()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("move item backward (higher to lower index)", () => {
|
|
81
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray(["a", "b", "c", "d"]))
|
|
82
|
+
|
|
83
|
+
arr.move(3, 0) // move "d" from index 3 to index 0
|
|
84
|
+
expect(arr.values()).toEqual(["d", "a", "b", "c"])
|
|
85
|
+
unmount()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("swap preserves all other items", () => {
|
|
89
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray(["a", "b", "c", "d", "e"]))
|
|
90
|
+
|
|
91
|
+
arr.swap(1, 3) // swap "b" and "d"
|
|
92
|
+
expect(arr.values()).toEqual(["a", "d", "c", "b", "e"])
|
|
93
|
+
unmount()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("prepend then remove first restores original", () => {
|
|
97
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray(["x", "y"]))
|
|
98
|
+
|
|
99
|
+
arr.prepend("new")
|
|
100
|
+
expect(arr.values()).toEqual(["new", "x", "y"])
|
|
101
|
+
arr.remove(0)
|
|
102
|
+
expect(arr.values()).toEqual(["x", "y"])
|
|
103
|
+
unmount()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("insert at beginning is equivalent to prepend", () => {
|
|
107
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray(["b", "c"]))
|
|
108
|
+
|
|
109
|
+
arr.insert(0, "a")
|
|
110
|
+
expect(arr.values()).toEqual(["a", "b", "c"])
|
|
111
|
+
unmount()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("insert at end is equivalent to append", () => {
|
|
115
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray(["a", "b"]))
|
|
116
|
+
|
|
117
|
+
arr.insert(2, "c")
|
|
118
|
+
expect(arr.values()).toEqual(["a", "b", "c"])
|
|
119
|
+
unmount()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("replace with empty array clears all items", () => {
|
|
123
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray(["a", "b", "c"]))
|
|
124
|
+
|
|
125
|
+
arr.replace([])
|
|
126
|
+
expect(arr.values()).toEqual([])
|
|
127
|
+
expect(arr.length()).toBe(0)
|
|
128
|
+
unmount()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("replace generates new keys for all items", () => {
|
|
132
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray(["a", "b"]))
|
|
133
|
+
|
|
134
|
+
const keysBefore = arr.items().map((i: any) => i.key)
|
|
135
|
+
arr.replace(["x", "y"])
|
|
136
|
+
const keysAfter = arr.items().map((i: any) => i.key)
|
|
137
|
+
|
|
138
|
+
// All keys should be different since replace creates new items
|
|
139
|
+
expect(keysAfter[0]).not.toBe(keysBefore[0])
|
|
140
|
+
expect(keysAfter[1]).not.toBe(keysBefore[1])
|
|
141
|
+
unmount()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("items signal is reactive — effect fires on append", () => {
|
|
145
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray<string>([]))
|
|
146
|
+
|
|
147
|
+
const lengths: number[] = []
|
|
148
|
+
const cleanup = effect(() => {
|
|
149
|
+
lengths.push(arr.items().length)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
expect(lengths).toEqual([0])
|
|
153
|
+
|
|
154
|
+
arr.append("a")
|
|
155
|
+
expect(lengths).toEqual([0, 1])
|
|
156
|
+
|
|
157
|
+
arr.append("b")
|
|
158
|
+
expect(lengths).toEqual([0, 1, 2])
|
|
159
|
+
|
|
160
|
+
cleanup.dispose()
|
|
161
|
+
unmount()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it("update preserves key identity", () => {
|
|
165
|
+
const { result: arr, unmount } = mountWith(() => useFieldArray(["a", "b", "c"]))
|
|
166
|
+
|
|
167
|
+
const keyBefore = arr.items()[1]!.key
|
|
168
|
+
arr.update(1, "updated")
|
|
169
|
+
const keyAfter = arr.items()[1]!.key
|
|
170
|
+
|
|
171
|
+
// Same item, just value changed — key should be identical
|
|
172
|
+
expect(keyAfter).toBe(keyBefore)
|
|
173
|
+
expect(arr.items()[1]!.value()).toBe("updated")
|
|
174
|
+
unmount()
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// ─── useWatch — reactivity ───────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
describe("useWatch — reactivity", () => {
|
|
181
|
+
it("single field watch is reactive to value changes", () => {
|
|
182
|
+
const { result, unmount } = mountWith(() => {
|
|
183
|
+
const form = useForm({
|
|
184
|
+
initialValues: { count: 0 },
|
|
185
|
+
onSubmit: () => {
|
|
186
|
+
/* noop */
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
const count = useWatch(form, "count")
|
|
190
|
+
return { form, count }
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
const values: number[] = []
|
|
194
|
+
const cleanup = effect(() => {
|
|
195
|
+
values.push(result.count() as number)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
expect(values).toEqual([0])
|
|
199
|
+
|
|
200
|
+
result.form.fields.count.setValue(1)
|
|
201
|
+
expect(values).toEqual([0, 1])
|
|
202
|
+
|
|
203
|
+
result.form.fields.count.setValue(42)
|
|
204
|
+
expect(values).toEqual([0, 1, 42])
|
|
205
|
+
|
|
206
|
+
cleanup.dispose()
|
|
207
|
+
unmount()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it("watch all fields is reactive to any field change", () => {
|
|
211
|
+
const { result, unmount } = mountWith(() => {
|
|
212
|
+
const form = useForm({
|
|
213
|
+
initialValues: { first: "A", second: "B" },
|
|
214
|
+
onSubmit: () => {
|
|
215
|
+
/* noop */
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
const all = useWatch(form)
|
|
219
|
+
return { form, all }
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
expect(result.all()).toEqual({ first: "A", second: "B" })
|
|
223
|
+
|
|
224
|
+
result.form.fields.first.setValue("X")
|
|
225
|
+
expect(result.all()).toEqual({ first: "X", second: "B" })
|
|
226
|
+
|
|
227
|
+
result.form.fields.second.setValue("Y")
|
|
228
|
+
expect(result.all()).toEqual({ first: "X", second: "Y" })
|
|
229
|
+
unmount()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it("multiple field watch returns signal array with correct types", () => {
|
|
233
|
+
const { result, unmount } = mountWith(() => {
|
|
234
|
+
const form = useForm({
|
|
235
|
+
initialValues: { name: "Alice", age: 30 },
|
|
236
|
+
onSubmit: () => {
|
|
237
|
+
/* noop */
|
|
238
|
+
},
|
|
239
|
+
})
|
|
240
|
+
const [name, age] = useWatch(form, ["name", "age"])
|
|
241
|
+
return { form, name, age }
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
expect(result.name!()).toBe("Alice")
|
|
245
|
+
expect(result.age!()).toBe(30)
|
|
246
|
+
|
|
247
|
+
result.form.fields.name.setValue("Bob")
|
|
248
|
+
expect(result.name!()).toBe("Bob")
|
|
249
|
+
expect(result.age!()).toBe(30) // unchanged
|
|
250
|
+
unmount()
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// ─── useFormState — additional scenarios ─────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
describe("useFormState — additional scenarios", () => {
|
|
257
|
+
it("tracks dirty fields correctly after multiple changes", () => {
|
|
258
|
+
const { result, unmount } = mountWith(() => {
|
|
259
|
+
const form = useForm({
|
|
260
|
+
initialValues: { email: "", name: "", age: 0 },
|
|
261
|
+
onSubmit: () => {
|
|
262
|
+
/* noop */
|
|
263
|
+
},
|
|
264
|
+
})
|
|
265
|
+
const state = useFormState(form)
|
|
266
|
+
return { form, state }
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
result.form.fields.email.setValue("test@test.com")
|
|
270
|
+
result.form.fields.name.setValue("Alice")
|
|
271
|
+
|
|
272
|
+
const s = result.state()
|
|
273
|
+
expect(s.dirtyFields).toEqual({ email: true, name: true })
|
|
274
|
+
expect(s.isDirty).toBe(true)
|
|
275
|
+
|
|
276
|
+
// Revert email
|
|
277
|
+
result.form.fields.email.setValue("")
|
|
278
|
+
const s2 = result.state()
|
|
279
|
+
expect(s2.dirtyFields).toEqual({ name: true })
|
|
280
|
+
expect(s2.isDirty).toBe(true)
|
|
281
|
+
|
|
282
|
+
// Revert name too
|
|
283
|
+
result.form.fields.name.setValue("")
|
|
284
|
+
const s3 = result.state()
|
|
285
|
+
expect(s3.dirtyFields).toEqual({})
|
|
286
|
+
expect(s3.isDirty).toBe(false)
|
|
287
|
+
unmount()
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it("tracks touched fields after blur events", () => {
|
|
291
|
+
const { result, unmount } = mountWith(() => {
|
|
292
|
+
const form = useForm({
|
|
293
|
+
initialValues: { email: "", password: "" },
|
|
294
|
+
onSubmit: () => {
|
|
295
|
+
/* noop */
|
|
296
|
+
},
|
|
297
|
+
})
|
|
298
|
+
const state = useFormState(form)
|
|
299
|
+
return { form, state }
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
expect(result.state().touchedFields).toEqual({})
|
|
303
|
+
|
|
304
|
+
result.form.fields.email.setTouched()
|
|
305
|
+
expect(result.state().touchedFields).toEqual({ email: true })
|
|
306
|
+
|
|
307
|
+
result.form.fields.password.setTouched()
|
|
308
|
+
expect(result.state().touchedFields).toEqual({ email: true, password: true })
|
|
309
|
+
unmount()
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it("submitCount and submitError are tracked", async () => {
|
|
313
|
+
const { result, unmount } = mountWith(() => {
|
|
314
|
+
const form = useForm({
|
|
315
|
+
initialValues: { name: "valid" },
|
|
316
|
+
onSubmit: async () => {
|
|
317
|
+
throw new Error("Submit failed")
|
|
318
|
+
},
|
|
319
|
+
})
|
|
320
|
+
const state = useFormState(form)
|
|
321
|
+
return { form, state }
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
expect(result.state().submitCount).toBe(0)
|
|
325
|
+
expect(result.state().submitError).toBeUndefined()
|
|
326
|
+
|
|
327
|
+
await result.form.handleSubmit().catch(() => {
|
|
328
|
+
/* expected */
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
expect(result.state().submitCount).toBe(1)
|
|
332
|
+
expect(result.state().submitError).toBeInstanceOf(Error)
|
|
333
|
+
unmount()
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it("isValidating is tracked during async validation", async () => {
|
|
337
|
+
const { result, unmount } = mountWith(() => {
|
|
338
|
+
const form = useForm({
|
|
339
|
+
initialValues: { name: "" },
|
|
340
|
+
validators: {
|
|
341
|
+
name: async (v) => {
|
|
342
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
343
|
+
return !v ? "Required" : undefined
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
onSubmit: () => {
|
|
347
|
+
/* noop */
|
|
348
|
+
},
|
|
349
|
+
})
|
|
350
|
+
const state = useFormState(form)
|
|
351
|
+
return { form, state }
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
expect(result.state().isValidating).toBe(false)
|
|
355
|
+
|
|
356
|
+
const validatePromise = result.form.validate()
|
|
357
|
+
expect(result.state().isValidating).toBe(true)
|
|
358
|
+
|
|
359
|
+
await validatePromise
|
|
360
|
+
expect(result.state().isValidating).toBe(false)
|
|
361
|
+
unmount()
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
// ─── Validation integration — schema-based ───────────────────────────────────
|
|
366
|
+
|
|
367
|
+
describe("validation integration — schema-based", () => {
|
|
368
|
+
it("schema errors are set only on fields without field-level errors", async () => {
|
|
369
|
+
const { result: form, unmount } = mountWith(() =>
|
|
370
|
+
useForm({
|
|
371
|
+
initialValues: { email: "", password: "" },
|
|
372
|
+
validators: {
|
|
373
|
+
email: (v) => (!v ? "Email required" : undefined),
|
|
374
|
+
},
|
|
375
|
+
schema: (_values) => ({
|
|
376
|
+
email: "Schema email error", // Should be overridden by field-level
|
|
377
|
+
password: "Schema password error",
|
|
378
|
+
}),
|
|
379
|
+
onSubmit: () => {
|
|
380
|
+
/* noop */
|
|
381
|
+
},
|
|
382
|
+
}),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
await form.validate()
|
|
386
|
+
|
|
387
|
+
// email has a field-level error ("Email required") — schema error ignored
|
|
388
|
+
expect(form.fields.email.error()).toBe("Email required")
|
|
389
|
+
// password has no field-level validator — schema error applied
|
|
390
|
+
expect(form.fields.password.error()).toBe("Schema password error")
|
|
391
|
+
unmount()
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it("async schema validation works", async () => {
|
|
395
|
+
const { result: form, unmount } = mountWith(() =>
|
|
396
|
+
useForm({
|
|
397
|
+
initialValues: { code: "" },
|
|
398
|
+
schema: async (values) => {
|
|
399
|
+
await new Promise((r) => setTimeout(r, 5))
|
|
400
|
+
if (!values.code) return { code: "Code is required" }
|
|
401
|
+
return {}
|
|
402
|
+
},
|
|
403
|
+
onSubmit: () => {
|
|
404
|
+
/* noop */
|
|
405
|
+
},
|
|
406
|
+
}),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
const valid = await form.validate()
|
|
410
|
+
expect(valid).toBe(false)
|
|
411
|
+
expect(form.fields.code.error()).toBe("Code is required")
|
|
412
|
+
unmount()
|
|
413
|
+
})
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
// ─── form.reset() clears all field state ─────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
describe("form.reset() comprehensive", () => {
|
|
419
|
+
it("clears all field state including errors, touched, dirty, submitCount, submitError", async () => {
|
|
420
|
+
const { result: form, unmount } = mountWith(() =>
|
|
421
|
+
useForm({
|
|
422
|
+
initialValues: { email: "", password: "" },
|
|
423
|
+
validators: {
|
|
424
|
+
email: (v) => (!v ? "Required" : undefined),
|
|
425
|
+
password: (v) => (!v ? "Required" : undefined),
|
|
426
|
+
},
|
|
427
|
+
onSubmit: () => {
|
|
428
|
+
/* noop */
|
|
429
|
+
},
|
|
430
|
+
}),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
// Dirty up the form
|
|
434
|
+
form.fields.email.setValue("changed")
|
|
435
|
+
form.fields.password.setValue("changed")
|
|
436
|
+
form.fields.email.setTouched()
|
|
437
|
+
form.fields.password.setTouched()
|
|
438
|
+
await form.handleSubmit() // triggers validation
|
|
439
|
+
|
|
440
|
+
// Verify state is dirty
|
|
441
|
+
expect(form.fields.email.dirty()).toBe(true)
|
|
442
|
+
expect(form.fields.password.dirty()).toBe(true)
|
|
443
|
+
expect(form.fields.email.touched()).toBe(true)
|
|
444
|
+
expect(form.fields.password.touched()).toBe(true)
|
|
445
|
+
expect(form.submitCount()).toBe(1)
|
|
446
|
+
|
|
447
|
+
// Reset everything
|
|
448
|
+
form.reset()
|
|
449
|
+
|
|
450
|
+
// All state should be cleared
|
|
451
|
+
expect(form.fields.email.value()).toBe("")
|
|
452
|
+
expect(form.fields.password.value()).toBe("")
|
|
453
|
+
expect(form.fields.email.error()).toBeUndefined()
|
|
454
|
+
expect(form.fields.password.error()).toBeUndefined()
|
|
455
|
+
expect(form.fields.email.touched()).toBe(false)
|
|
456
|
+
expect(form.fields.password.touched()).toBe(false)
|
|
457
|
+
expect(form.fields.email.dirty()).toBe(false)
|
|
458
|
+
expect(form.fields.password.dirty()).toBe(false)
|
|
459
|
+
expect(form.submitCount()).toBe(0)
|
|
460
|
+
expect(form.submitError()).toBeUndefined()
|
|
461
|
+
expect(form.isDirty()).toBe(false)
|
|
462
|
+
expect(form.isValid()).toBe(true)
|
|
463
|
+
unmount()
|
|
464
|
+
})
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
// ─── Debounced validation — additional scenarios ─────────────────────────────
|
|
468
|
+
|
|
469
|
+
describe("debounced validation — additional", () => {
|
|
470
|
+
it("debounced validation on blur only fires after delay", async () => {
|
|
471
|
+
const calls: string[] = []
|
|
472
|
+
const { result: form, unmount } = mountWith(() =>
|
|
473
|
+
useForm({
|
|
474
|
+
initialValues: { query: "" },
|
|
475
|
+
validators: {
|
|
476
|
+
query: (v) => {
|
|
477
|
+
calls.push(v as string)
|
|
478
|
+
return undefined
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
validateOn: "blur",
|
|
482
|
+
debounceMs: 40,
|
|
483
|
+
onSubmit: () => {
|
|
484
|
+
/* noop */
|
|
485
|
+
},
|
|
486
|
+
}),
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
form.fields.query.setValue("a")
|
|
490
|
+
form.fields.query.setTouched()
|
|
491
|
+
form.fields.query.setValue("ab")
|
|
492
|
+
form.fields.query.setTouched()
|
|
493
|
+
|
|
494
|
+
// Nothing fired yet
|
|
495
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
496
|
+
expect(calls).toHaveLength(0)
|
|
497
|
+
|
|
498
|
+
// After debounce, only the last blur validation fires
|
|
499
|
+
await new Promise((r) => setTimeout(r, 60))
|
|
500
|
+
expect(calls).toHaveLength(1)
|
|
501
|
+
unmount()
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it("field-level reset clears debounce timer for that field", async () => {
|
|
505
|
+
let callCount = 0
|
|
506
|
+
const { result: form, unmount } = mountWith(() =>
|
|
507
|
+
useForm({
|
|
508
|
+
initialValues: { name: "" },
|
|
509
|
+
validators: {
|
|
510
|
+
name: () => {
|
|
511
|
+
callCount++
|
|
512
|
+
return undefined
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
validateOn: "blur",
|
|
516
|
+
debounceMs: 50,
|
|
517
|
+
onSubmit: () => {
|
|
518
|
+
/* noop */
|
|
519
|
+
},
|
|
520
|
+
}),
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
form.fields.name.setTouched()
|
|
524
|
+
// Reset the individual field before debounce fires
|
|
525
|
+
form.fields.name.reset()
|
|
526
|
+
|
|
527
|
+
await new Promise((r) => setTimeout(r, 80))
|
|
528
|
+
// Timer was cleared by field reset
|
|
529
|
+
expect(callCount).toBe(0)
|
|
530
|
+
unmount()
|
|
531
|
+
})
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
// ─── FormProvider with direct VNode children ─────────────────────────────────
|
|
535
|
+
|
|
536
|
+
describe("FormProvider — VNode children branch", () => {
|
|
537
|
+
it("renders when children is a direct VNode (not a function)", () => {
|
|
538
|
+
let contextForm: FormState<{ name: string }> | undefined
|
|
539
|
+
const el = document.createElement("div")
|
|
540
|
+
document.body.appendChild(el)
|
|
541
|
+
|
|
542
|
+
function Child() {
|
|
543
|
+
contextForm = useFormContext() as FormState<{ name: string }>
|
|
544
|
+
return null
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function TestComponent() {
|
|
548
|
+
const form = useForm({
|
|
549
|
+
initialValues: { name: "Direct" },
|
|
550
|
+
onSubmit: () => {
|
|
551
|
+
/* noop */
|
|
552
|
+
},
|
|
553
|
+
})
|
|
554
|
+
return (
|
|
555
|
+
<FormProvider form={form as any}>
|
|
556
|
+
<Child />
|
|
557
|
+
</FormProvider>
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const unmount = mount(<TestComponent />, el)
|
|
562
|
+
expect(contextForm).toBeDefined()
|
|
563
|
+
expect(contextForm!.fields.name.value()).toBe("Direct")
|
|
564
|
+
unmount()
|
|
565
|
+
el.remove()
|
|
566
|
+
})
|
|
567
|
+
})
|