@vobs/forms 0.1.0 → 1.0.0
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 +1 -1
- package/README.md +52 -0
- package/package.json +13 -33
- package/src/field.ts +57 -0
- package/src/form.test.ts +322 -0
- package/src/form.ts +673 -0
- package/src/index.ts +29 -0
- package/src/plugin.ts +22 -0
- package/src/rules.ts +62 -0
- package/dist/index.d.ts +0 -296
- package/dist/index.js +0 -467
package/LICENSE
CHANGED
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @vobs/forms
|
|
2
|
+
|
|
3
|
+
Signal-based form state for vobs: per-field `dirty`/`touched`/`error` tracking, async validation with debounce and `AbortSignal` cancellation, schema adapters, and deduplicated submits.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @vobs/forms
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createDOMRenderer, createVobs, setRenderer } from '@vobs/vobs'
|
|
15
|
+
import { Field, rules, useForm } from '@vobs/forms'
|
|
16
|
+
|
|
17
|
+
setRenderer(createDOMRenderer())
|
|
18
|
+
|
|
19
|
+
const form = useForm({ email: '' }, {
|
|
20
|
+
validators: { email: [rules.required, rules.email] },
|
|
21
|
+
validateOn: 'blur'
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const app = createVobs({
|
|
25
|
+
render: () => Field({ form, name: 'email', label: 'Email' })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
app.mount(document.getElementById('app')!)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`Field` renders a label, an `<input>` bound to the field value (or a custom control via the `children(field)` render prop), and the error message once the field is touched. Server-side errors set through `form.setServerErrors()` are cleared automatically when that field changes.
|
|
32
|
+
|
|
33
|
+
## API
|
|
34
|
+
|
|
35
|
+
| Signature | Description |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `useForm<T>(initialValues: T, options?: FormOptions<T>): Form<T>` | Creates a form. Options: `validators` (`ValidatorMap`, sync or async, supports cross-field access to `values`), `validateOn` (`'input' \| 'blur' \| 'submit' \| 'manual'`), `validateDebounce` in milliseconds, `schema` (`SchemaAdapter`), `onSubmit`. |
|
|
38
|
+
| `form.field(name)` | `FormField` with signal-based `value`, `error`, `dirty`, `touched`, plus `set()`, `markTouched()`, `validate()`, and `reset(nextValue?)` (the passed value becomes the new dirty baseline). |
|
|
39
|
+
| `form.validateField(name)` / `form.validateAll()` | Run validators and return the error map; async validation only ever applies the latest call. |
|
|
40
|
+
| `form.submit()` | Validates everything first and ignores concurrent calls (they share one promise). Resolves with `{ valid: true, values, result }` on success or `{ valid: false, errors }` when validation fails. |
|
|
41
|
+
| `form.setServerErrors(errors)` | Backfills errors that clear on the next change of the field. |
|
|
42
|
+
| `form.addField(name, value, options?)` / `form.removeField(name)` | Dynamic field collections; `fieldNames`, values, and the error aggregate stay in sync. |
|
|
43
|
+
| `form.reset(values?)` | Restores values and cancels stale validation state for all fields. |
|
|
44
|
+
| `Field(props: FormFieldProps)` | Label + input (or `children(field)`) + error message, wired to `form.field(name)`. |
|
|
45
|
+
| `rules` | Built-in validators: `required`, `email`, `minLength(n)`, `maxLength(n)`, `min(n)`, `max(n)`, `pattern(regex, message)`. |
|
|
46
|
+
| `formsPlugin(options?)` / `FORMS_KEY` | App plugin providing a `FormsClient` so consumers can `inject(FORMS_KEY).createForm(...)`. |
|
|
47
|
+
|
|
48
|
+
Async validators receive an `AbortSignal` that is aborted whenever a newer validation starts, and with `validateDebounce` the call is delayed until the input settles.
|
|
49
|
+
|
|
50
|
+
## Types
|
|
51
|
+
|
|
52
|
+
DynamicFieldOptions, Form, FormErrorName, FormErrors, FormField, FormFieldComponent, FormFieldProps, FormFieldName, FormOptions, SchemaAdapter, SubmitFailure, SubmitHandler, SubmitResult, SubmitSuccess, ValidationOutput, ValidationResult, ValidationTrigger, Validator, ValidatorMap, FormsClient, FormsPluginOptions
|
package/package.json
CHANGED
|
@@ -1,41 +1,21 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "@vobs/forms",
|
|
3
|
-
"version": "0.1.0",
|
|
4
|
-
"description": "Schema-driven form state primitives for vobs.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"publishConfig": {
|
|
7
|
-
"access": "public"
|
|
8
|
-
},
|
|
9
2
|
"license": "MIT",
|
|
10
|
-
"author": "vobsjs",
|
|
11
|
-
"repository": {
|
|
12
|
-
"type": "git",
|
|
13
|
-
"url": "git+https://github.com/vobsjs/vobs.git",
|
|
14
|
-
"directory": "packages/features/forms"
|
|
15
|
-
},
|
|
16
|
-
"bugs": {
|
|
17
|
-
"url": "https://github.com/vobsjs/vobs/issues"
|
|
18
|
-
},
|
|
19
|
-
"homepage": "https://github.com/vobsjs/vobs#readme",
|
|
20
|
-
"dependencies": {
|
|
21
|
-
"@vobs/reactivity": "0.1.0",
|
|
22
|
-
"@vobs/runtime-core": "0.1.0"
|
|
23
|
-
},
|
|
24
3
|
"files": [
|
|
25
|
-
"
|
|
4
|
+
"src",
|
|
5
|
+
"README.md",
|
|
6
|
+
"LICENSE"
|
|
26
7
|
],
|
|
8
|
+
"name": "@vobs/forms",
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "src/index.ts",
|
|
12
|
+
"types": "src/index.ts",
|
|
27
13
|
"exports": {
|
|
28
|
-
".":
|
|
29
|
-
|
|
30
|
-
"import": "./dist/index.js"
|
|
31
|
-
},
|
|
32
|
-
"./package.json": "./package.json"
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./rules": "./src/rules.ts"
|
|
33
16
|
},
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"sideEffects": false,
|
|
38
|
-
"engines": {
|
|
39
|
-
"node": ">=20.19.0"
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@vobs/reactivity": "1.0.0",
|
|
19
|
+
"@vobs/vobs": "1.0.0"
|
|
40
20
|
}
|
|
41
21
|
}
|
package/src/field.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { effect } from '@vobs/reactivity'
|
|
2
|
+
import {
|
|
3
|
+
addEventListener,
|
|
4
|
+
createElement,
|
|
5
|
+
createFragment,
|
|
6
|
+
createText,
|
|
7
|
+
insertBefore,
|
|
8
|
+
insertDynamic,
|
|
9
|
+
setAttribute,
|
|
10
|
+
setProperty,
|
|
11
|
+
type VobsNode
|
|
12
|
+
} from '@vobs/vobs'
|
|
13
|
+
import type { Form, FormField, FormFieldProps } from './form'
|
|
14
|
+
|
|
15
|
+
export function Field<T extends object>(props: FormFieldProps<T>): VobsNode {
|
|
16
|
+
const field = props.form.field(props.name as never) as FormField<unknown>
|
|
17
|
+
|
|
18
|
+
return createFragment((parent, anchor) => {
|
|
19
|
+
const wrapper = createElement('div')
|
|
20
|
+
setAttribute(wrapper, 'data-vobs-field', props.name)
|
|
21
|
+
insertBefore(parent, wrapper, anchor)
|
|
22
|
+
|
|
23
|
+
if (props.label) {
|
|
24
|
+
const label = createElement('label')
|
|
25
|
+
insertBefore(label, createText(props.label), null)
|
|
26
|
+
insertBefore(wrapper, label, null)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (props.children) {
|
|
30
|
+
const child = props.children(field)
|
|
31
|
+
if (child) insertBefore(wrapper, child, null)
|
|
32
|
+
} else {
|
|
33
|
+
const input = createElement('input')
|
|
34
|
+
effect(() => setProperty(input, 'value', field.value.value))
|
|
35
|
+
addEventListener(input, 'input', event => {
|
|
36
|
+
field.set((event.target as HTMLInputElement).value)
|
|
37
|
+
})
|
|
38
|
+
addEventListener(input, 'blur', () => { void field.markTouched() })
|
|
39
|
+
insertBefore(wrapper, input, null)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
insertDynamic(wrapper, null, () => {
|
|
43
|
+
if (!field.touched.value || !field.error.value) return null
|
|
44
|
+
const error = createElement('span')
|
|
45
|
+
setAttribute(error, 'data-vobs-field-error', '')
|
|
46
|
+
insertBefore(error, createText(field.error.value), null)
|
|
47
|
+
return error
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function formField<T extends object>(
|
|
53
|
+
form: Form<T>,
|
|
54
|
+
props: Omit<FormFieldProps<T>, 'form'>
|
|
55
|
+
): VobsNode {
|
|
56
|
+
return Field({ ...props, form })
|
|
57
|
+
}
|
package/src/form.test.ts
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { effect } from '@vobs/reactivity'
|
|
3
|
+
import { createDOMRenderer, createText, createVobs, setRenderer } from '@vobs/vobs'
|
|
4
|
+
import {
|
|
5
|
+
FORMS_KEY,
|
|
6
|
+
type FormsClient,
|
|
7
|
+
type FormOptions,
|
|
8
|
+
formsPlugin,
|
|
9
|
+
Field,
|
|
10
|
+
rules,
|
|
11
|
+
useForm
|
|
12
|
+
} from './index'
|
|
13
|
+
|
|
14
|
+
async function nextMicrotask(): Promise<void> {
|
|
15
|
+
await Promise.resolve()
|
|
16
|
+
await Promise.resolve()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('@vobs/forms', () => {
|
|
20
|
+
it('字段独立更新,并维护 dirty、touched 和 values', async () => {
|
|
21
|
+
const form = useForm({ name: '', email: '' })
|
|
22
|
+
const name = form.field('name')
|
|
23
|
+
const email = form.field('email')
|
|
24
|
+
let nameRuns = 0
|
|
25
|
+
let emailRuns = 0
|
|
26
|
+
|
|
27
|
+
effect(() => {
|
|
28
|
+
void name.value.value
|
|
29
|
+
nameRuns++
|
|
30
|
+
})
|
|
31
|
+
effect(() => {
|
|
32
|
+
void email.value.value
|
|
33
|
+
emailRuns++
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
name.set('Alice')
|
|
37
|
+
await nextMicrotask()
|
|
38
|
+
|
|
39
|
+
expect(nameRuns).toBe(2)
|
|
40
|
+
expect(emailRuns).toBe(1)
|
|
41
|
+
expect(name.dirty.value).toBe(true)
|
|
42
|
+
expect(form.dirty.value).toBe(true)
|
|
43
|
+
expect(form.values.name).toBe('Alice')
|
|
44
|
+
expect(Object.keys(form.values)).toEqual(['name', 'email'])
|
|
45
|
+
|
|
46
|
+
name.markTouched()
|
|
47
|
+
expect(name.touched.value).toBe(true)
|
|
48
|
+
expect(form.touched.value).toEqual(new Set(['name']))
|
|
49
|
+
expect(email.dirty.value).toBe(false)
|
|
50
|
+
form.dispose()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('同步校验支持多规则、跨字段校验和全量错误', () => {
|
|
54
|
+
const form = useForm({ name: '', password: 'a', confirm: 'b' }, {
|
|
55
|
+
validators: {
|
|
56
|
+
name: [rules.required, rules.minLength(2)],
|
|
57
|
+
confirm: (value, values) => value === values.password ? null : '两次密码不一致'
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
expect(form.validateField('name')).toBe('必填')
|
|
62
|
+
expect(form.field('name').error.value).toBe('必填')
|
|
63
|
+
expect(form.validateField('confirm')).toBe('两次密码不一致')
|
|
64
|
+
expect(form.validateAll()).toEqual({ name: '必填', confirm: '两次密码不一致' })
|
|
65
|
+
expect(form.hasErrors.value).toBe(true)
|
|
66
|
+
expect(form.getErrorFields()).toEqual(['name', 'confirm'])
|
|
67
|
+
form.dispose()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('异步校验只提交最新一次结果', async () => {
|
|
71
|
+
const resolvers: Array<(message: string | null) => void> = []
|
|
72
|
+
const form = useForm({ email: '' }, {
|
|
73
|
+
validators: {
|
|
74
|
+
email: value => new Promise<string | null>(resolve => {
|
|
75
|
+
resolvers.push(() => resolve(value === 'old' ? '旧错误' : null))
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
const email = form.field('email')
|
|
80
|
+
|
|
81
|
+
email.set('old')
|
|
82
|
+
const first = email.validate()
|
|
83
|
+
email.set('new')
|
|
84
|
+
const second = email.validate()
|
|
85
|
+
expect(form.validating.value).toBe(true)
|
|
86
|
+
expect(resolvers).toHaveLength(2)
|
|
87
|
+
resolvers[1](null)
|
|
88
|
+
await second
|
|
89
|
+
resolvers[0]('旧错误')
|
|
90
|
+
await first
|
|
91
|
+
|
|
92
|
+
expect(email.error.value).toBeNull()
|
|
93
|
+
expect(form.validating.value).toBe(false)
|
|
94
|
+
expect(form.validatingFields.value.size).toBe(0)
|
|
95
|
+
form.dispose()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('按照 blur 触发校验,并支持服务端错误回填后自动清除', () => {
|
|
99
|
+
const form = useForm({ email: '' }, {
|
|
100
|
+
validateOn: 'blur',
|
|
101
|
+
validators: { email: rules.email }
|
|
102
|
+
})
|
|
103
|
+
const email = form.field('email')
|
|
104
|
+
|
|
105
|
+
email.set('invalid')
|
|
106
|
+
expect(email.error.value).toBeNull()
|
|
107
|
+
email.markTouched()
|
|
108
|
+
expect(email.error.value).toBe('邮箱格式错误')
|
|
109
|
+
|
|
110
|
+
form.setServerErrors({ email: '邮箱已被注册' })
|
|
111
|
+
expect(email.error.value).toBe('邮箱已被注册')
|
|
112
|
+
email.set('valid@example.com')
|
|
113
|
+
expect(email.error.value).toBeNull()
|
|
114
|
+
form.dispose()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('提交前全量校验,成功提交,并忽略并发提交', async () => {
|
|
118
|
+
const submit = vi.fn(async (values: Readonly<{ name: string }>) => values.name)
|
|
119
|
+
const form = useForm({ name: '' }, {
|
|
120
|
+
validators: { name: rules.required },
|
|
121
|
+
onSubmit: submit
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const invalid = await form.submit()
|
|
125
|
+
expect(invalid).toEqual({ valid: false, errors: { name: '必填' } })
|
|
126
|
+
expect(submit).not.toHaveBeenCalled()
|
|
127
|
+
|
|
128
|
+
form.field('name').set('Alice')
|
|
129
|
+
const first = form.submit()
|
|
130
|
+
const second = form.submit()
|
|
131
|
+
expect(first).toBe(second)
|
|
132
|
+
await expect(first).resolves.toEqual({ valid: true, values: { name: 'Alice' }, result: 'Alice' })
|
|
133
|
+
expect(submit).toHaveBeenCalledTimes(1)
|
|
134
|
+
expect(form.submitting.value).toBe(false)
|
|
135
|
+
form.dispose()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('schema 适配器可以返回异步字段错误', async () => {
|
|
139
|
+
const form = useForm({ name: 'x' }, {
|
|
140
|
+
schema: {
|
|
141
|
+
validate: async values => values.name.length < 2 ? { name: '至少 2 个字符' } : {}
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
await expect(form.validateField('name')).resolves.toBe('至少 2 个字符')
|
|
146
|
+
expect(form.errors.value).toEqual({ name: '至少 2 个字符' })
|
|
147
|
+
form.dispose()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('schema 抛出的表单级错误会参与错误聚合和提交判定', () => {
|
|
151
|
+
const form = useForm({ name: 'Alice' }, {
|
|
152
|
+
schema: {
|
|
153
|
+
validate: () => { throw new Error('整表校验失败') }
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
expect(form.validateAll()).toEqual({ __form: '整表校验失败' })
|
|
158
|
+
expect(form.errors.value).toEqual({ __form: '整表校验失败' })
|
|
159
|
+
expect(form.hasErrors.value).toBe(true)
|
|
160
|
+
expect(form.getErrorFields()).toEqual([])
|
|
161
|
+
form.clearErrors('__form')
|
|
162
|
+
expect(form.hasErrors.value).toBe(false)
|
|
163
|
+
form.dispose()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('重置字段和表单会取消过期校验状态', () => {
|
|
167
|
+
const form = useForm({ name: '' })
|
|
168
|
+
const name = form.field('name')
|
|
169
|
+
name.set('Alice')
|
|
170
|
+
name.markTouched()
|
|
171
|
+
form.setServerErrors({ name: '服务端错误' })
|
|
172
|
+
form.reset()
|
|
173
|
+
|
|
174
|
+
expect(form.values.name).toBe('')
|
|
175
|
+
expect(name.dirty.value).toBe(false)
|
|
176
|
+
expect(name.touched.value).toBe(false)
|
|
177
|
+
expect(name.error.value).toBeNull()
|
|
178
|
+
expect(form.dirty.value).toBe(false)
|
|
179
|
+
expect(form.touched.value.size).toBe(0)
|
|
180
|
+
form.dispose()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('字段和表单 reset 会把传入值设为新的 dirty 基线', () => {
|
|
184
|
+
const form = useForm({ name: '' })
|
|
185
|
+
const name = form.field('name')
|
|
186
|
+
|
|
187
|
+
name.set('Alice')
|
|
188
|
+
name.reset('Bob')
|
|
189
|
+
expect(form.values.name).toBe('Bob')
|
|
190
|
+
expect(name.dirty.value).toBe(false)
|
|
191
|
+
name.set('Alice')
|
|
192
|
+
expect(name.dirty.value).toBe(true)
|
|
193
|
+
|
|
194
|
+
form.reset({ name: 'Carol' })
|
|
195
|
+
expect(form.values.name).toBe('Carol')
|
|
196
|
+
expect(name.dirty.value).toBe(false)
|
|
197
|
+
name.set('Bob')
|
|
198
|
+
expect(form.dirty.value).toBe(true)
|
|
199
|
+
form.dispose()
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('formsPlugin 向应用插件提供 FormsClient', () => {
|
|
203
|
+
const client: FormsClient = {
|
|
204
|
+
createForm<T extends object>(initialValues: T, options?: FormOptions<T>) {
|
|
205
|
+
return useForm(initialValues, options)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
let injected: unknown
|
|
209
|
+
const app = createVobs({
|
|
210
|
+
render: () => createText('forms'),
|
|
211
|
+
plugins: [
|
|
212
|
+
formsPlugin({ client }),
|
|
213
|
+
{
|
|
214
|
+
name: 'forms-consumer',
|
|
215
|
+
install(context) {
|
|
216
|
+
injected = context.inject(FORMS_KEY)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
]
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
expect(injected).toBe(client)
|
|
223
|
+
app.destroy()
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('支持动态字段增删,并同步字段集合、值和错误聚合', () => {
|
|
227
|
+
const form = useForm({ name: '' })
|
|
228
|
+
const fields = form.addField('phone', '', { validators: rules.required })
|
|
229
|
+
|
|
230
|
+
expect(form.fieldNames.value).toEqual(new Set(['name', 'phone']))
|
|
231
|
+
expect((form.values as Readonly<Record<string, unknown>>).phone).toBe('')
|
|
232
|
+
expect(fields.validate()).toBe('必填')
|
|
233
|
+
expect(form.errors.value).toEqual({ phone: '必填' })
|
|
234
|
+
|
|
235
|
+
expect(form.removeField('phone')).toBe(true)
|
|
236
|
+
expect(form.removeField('phone')).toBe(false)
|
|
237
|
+
expect(form.fieldNames.value).toEqual(new Set(['name']))
|
|
238
|
+
expect((form.values as Readonly<Record<string, unknown>>).phone).toBeUndefined()
|
|
239
|
+
expect(form.errors.value).toEqual({})
|
|
240
|
+
form.dispose()
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('异步校验支持防抖,并在新校验开始时取消旧 AbortSignal', async () => {
|
|
244
|
+
vi.useFakeTimers()
|
|
245
|
+
try {
|
|
246
|
+
const signals: AbortSignal[] = []
|
|
247
|
+
const validator = vi.fn((_value: unknown, _values: Readonly<{ email: string }>, signal?: AbortSignal) => {
|
|
248
|
+
signals.push(signal!)
|
|
249
|
+
return Promise.resolve(null)
|
|
250
|
+
})
|
|
251
|
+
const form = useForm({ email: '' }, {
|
|
252
|
+
validateDebounce: 100,
|
|
253
|
+
validateOn: 'input',
|
|
254
|
+
validators: { email: validator }
|
|
255
|
+
})
|
|
256
|
+
const email = form.field('email')
|
|
257
|
+
|
|
258
|
+
const first = email.validate()
|
|
259
|
+
email.set('new')
|
|
260
|
+
const second = email.validate()
|
|
261
|
+
await vi.advanceTimersByTimeAsync(99)
|
|
262
|
+
expect(validator).not.toHaveBeenCalled()
|
|
263
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
264
|
+
await second
|
|
265
|
+
await first
|
|
266
|
+
|
|
267
|
+
expect(validator).toHaveBeenCalledTimes(1)
|
|
268
|
+
expect(signals[0].aborted).toBe(false)
|
|
269
|
+
form.dispose()
|
|
270
|
+
} finally {
|
|
271
|
+
vi.useRealTimers()
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('新一轮异步校验会 abort 旧校验,并只保留最新结果', async () => {
|
|
276
|
+
const signals: AbortSignal[] = []
|
|
277
|
+
const resolvers: Array<(message: string | null) => void> = []
|
|
278
|
+
const form = useForm({ email: '' }, {
|
|
279
|
+
validators: {
|
|
280
|
+
email: (_value, _values, signal) => new Promise<string | null>(resolve => {
|
|
281
|
+
signals.push(signal!)
|
|
282
|
+
resolvers.push(resolve)
|
|
283
|
+
signal?.addEventListener('abort', () => resolve(null), { once: true })
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
const first = form.field('email').validate()
|
|
289
|
+
const second = form.field('email').validate()
|
|
290
|
+
expect(signals).toHaveLength(2)
|
|
291
|
+
expect(signals[0].aborted).toBe(true)
|
|
292
|
+
resolvers[1](null)
|
|
293
|
+
await expect(second).resolves.toBeNull()
|
|
294
|
+
await expect(first).resolves.toBeNull()
|
|
295
|
+
expect(form.validating.value).toBe(false)
|
|
296
|
+
form.dispose()
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('官方 Field 提供默认输入、label、错误显示和双向值绑定', async () => {
|
|
300
|
+
setRenderer(createDOMRenderer())
|
|
301
|
+
const form = useForm({ name: '' }, { validateOn: 'blur', validators: { name: rules.required } })
|
|
302
|
+
const container = document.createElement('div')
|
|
303
|
+
const app = createVobs({
|
|
304
|
+
render: () => Field({ form, name: 'name', label: '姓名' })
|
|
305
|
+
})
|
|
306
|
+
app.mount(container)
|
|
307
|
+
|
|
308
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
309
|
+
expect(container.querySelector('label')?.textContent).toBe('姓名')
|
|
310
|
+
input.value = 'Alice'
|
|
311
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
312
|
+
expect(form.values.name).toBe('Alice')
|
|
313
|
+
|
|
314
|
+
input.value = ''
|
|
315
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
316
|
+
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
|
317
|
+
await nextMicrotask()
|
|
318
|
+
expect(container.querySelector('[data-vobs-field-error]')?.textContent).toBe('必填')
|
|
319
|
+
app.destroy()
|
|
320
|
+
form.dispose()
|
|
321
|
+
})
|
|
322
|
+
})
|