@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/src/form.ts
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import { createOwner, state, type Signal } from '@vobs/reactivity'
|
|
2
|
+
import type { VobsNode } from '@vobs/vobs'
|
|
3
|
+
import { Field } from './field'
|
|
4
|
+
|
|
5
|
+
export type FormFieldName<T extends object> = Extract<keyof T, string>
|
|
6
|
+
export type ValidationTrigger = 'input' | 'blur' | 'submit' | 'manual'
|
|
7
|
+
export type ValidationResult = string | null | undefined | void
|
|
8
|
+
export type ValidationOutput = string | null | Promise<string | null>
|
|
9
|
+
|
|
10
|
+
export type Validator<TValue, TValues extends object> = (
|
|
11
|
+
value: TValue,
|
|
12
|
+
values: Readonly<TValues>,
|
|
13
|
+
signal?: AbortSignal
|
|
14
|
+
) => ValidationResult | PromiseLike<ValidationResult>
|
|
15
|
+
|
|
16
|
+
export type ValidatorMap<T extends object> = Partial<{
|
|
17
|
+
[K in FormFieldName<T>]: Validator<T[K], T> | readonly Validator<T[K], T>[]
|
|
18
|
+
}>
|
|
19
|
+
|
|
20
|
+
export type FormErrorName<T extends object> = FormFieldName<T> | '__form'
|
|
21
|
+
export type FormErrors<T extends object> = Partial<Record<FormErrorName<T>, string>>
|
|
22
|
+
|
|
23
|
+
export interface SchemaAdapter<T extends object> {
|
|
24
|
+
validate(values: Readonly<T>): FormErrors<T> | PromiseLike<FormErrors<T>>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type SubmitHandler<T extends object, TResult = unknown> = (
|
|
28
|
+
values: Readonly<T>
|
|
29
|
+
) => TResult | PromiseLike<TResult>
|
|
30
|
+
|
|
31
|
+
export interface FormOptions<T extends object> {
|
|
32
|
+
validators?: ValidatorMap<T>
|
|
33
|
+
schema?: SchemaAdapter<T>
|
|
34
|
+
validateOn?: ValidationTrigger
|
|
35
|
+
validateDebounce?: number | Partial<Record<FormFieldName<T>, number>>
|
|
36
|
+
onSubmit?: SubmitHandler<T>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DynamicFieldOptions<T extends object, TValue = unknown> {
|
|
40
|
+
validators?: Validator<TValue, T> | readonly Validator<TValue, T>[]
|
|
41
|
+
validateDebounce?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FormField<T> {
|
|
45
|
+
readonly name: string
|
|
46
|
+
readonly value: Signal<T>
|
|
47
|
+
readonly touched: Signal<boolean>
|
|
48
|
+
readonly dirty: Signal<boolean>
|
|
49
|
+
readonly error: Signal<string | null>
|
|
50
|
+
readonly validating: Signal<boolean>
|
|
51
|
+
set(value: T): void
|
|
52
|
+
markTouched(): ValidationOutput
|
|
53
|
+
validate(): ValidationOutput
|
|
54
|
+
reset(value?: T): void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SubmitSuccess<T extends object, TResult = unknown> {
|
|
58
|
+
readonly valid: true
|
|
59
|
+
readonly values: Readonly<T>
|
|
60
|
+
readonly result: TResult | undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SubmitFailure<T extends object> {
|
|
64
|
+
readonly valid: false
|
|
65
|
+
readonly errors: FormErrors<T>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type SubmitResult<T extends object, TResult = unknown> =
|
|
69
|
+
| SubmitSuccess<T, TResult>
|
|
70
|
+
| SubmitFailure<T>
|
|
71
|
+
|
|
72
|
+
export interface Form<T extends object> {
|
|
73
|
+
readonly values: Readonly<T>
|
|
74
|
+
readonly dirty: Signal<boolean>
|
|
75
|
+
readonly touched: Signal<ReadonlySet<FormFieldName<T>>>
|
|
76
|
+
readonly errors: Signal<Readonly<FormErrors<T>>>
|
|
77
|
+
readonly hasErrors: Signal<boolean>
|
|
78
|
+
readonly submitting: Signal<boolean>
|
|
79
|
+
readonly validating: Signal<boolean>
|
|
80
|
+
readonly validatingFields: Signal<ReadonlySet<FormFieldName<T>>>
|
|
81
|
+
readonly fieldNames: Signal<ReadonlySet<string>>
|
|
82
|
+
readonly Field: FormFieldComponent<T>
|
|
83
|
+
field<K extends FormFieldName<T>>(name: K): FormField<T[K]>
|
|
84
|
+
addField<TValue>(name: string, initialValue: TValue, options?: DynamicFieldOptions<T, TValue>): FormField<TValue>
|
|
85
|
+
removeField(name: string): boolean
|
|
86
|
+
validateField<K extends FormFieldName<T>>(name: K): ValidationOutput
|
|
87
|
+
validateAll(): FormErrors<T> | Promise<FormErrors<T>>
|
|
88
|
+
setServerErrors(errors: Partial<Record<FormErrorName<T>, string | null | undefined>>): void
|
|
89
|
+
clearErrors(name?: FormErrorName<T>): void
|
|
90
|
+
getErrorFields(): FormFieldName<T>[]
|
|
91
|
+
submit<TResult = unknown>(handler?: SubmitHandler<T, TResult>): Promise<SubmitResult<T, TResult>>
|
|
92
|
+
reset(values?: Partial<T>): void
|
|
93
|
+
dispose(): void
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface FormFieldComponent<T extends object> {
|
|
97
|
+
(props: FormFieldProps<T>): VobsNode
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface FormFieldProps<T extends object> {
|
|
101
|
+
form: Form<T>
|
|
102
|
+
name: string
|
|
103
|
+
label?: string
|
|
104
|
+
children?: (field: FormField<unknown>) => VobsNode
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface InternalField<TValue, TValues extends object> {
|
|
108
|
+
readonly name: string
|
|
109
|
+
readonly value: Signal<TValue>
|
|
110
|
+
readonly touched: Signal<boolean>
|
|
111
|
+
readonly dirty: Signal<boolean>
|
|
112
|
+
readonly error: Signal<string | null>
|
|
113
|
+
readonly validating: Signal<boolean>
|
|
114
|
+
initialValue: TValue
|
|
115
|
+
validationRun: number
|
|
116
|
+
validators?: readonly Validator<unknown, TValues>[]
|
|
117
|
+
validateDebounce?: number
|
|
118
|
+
controller?: AbortController
|
|
119
|
+
debounceTimer?: ReturnType<typeof setTimeout>
|
|
120
|
+
debounceCancel?: () => void
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const FORM_DISPOSED = 'Vobs forms: 表单已销毁'
|
|
124
|
+
|
|
125
|
+
export function useForm<T extends object>(initialValues: T, options: FormOptions<T> = {}): Form<T> {
|
|
126
|
+
return createForm(initialValues, options)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function createForm<T extends object>(initialValues: T, options: FormOptions<T> = {}): Form<T> {
|
|
130
|
+
const owner = createOwner()
|
|
131
|
+
const names = Object.keys(initialValues)
|
|
132
|
+
const fields = new Map<string, InternalField<unknown, T>>()
|
|
133
|
+
const touchedNames = new Set<FormFieldName<T>>()
|
|
134
|
+
const validatingNames = new Set<FormFieldName<T>>()
|
|
135
|
+
const dirty = owner.run(() => state(false))
|
|
136
|
+
const touched = owner.run(() => state<ReadonlySet<FormFieldName<T>>>(new Set()))
|
|
137
|
+
const errors = owner.run(() => state<Readonly<FormErrors<T>>>({} as FormErrors<T>))
|
|
138
|
+
const hasErrors = owner.run(() => state(false))
|
|
139
|
+
const submitting = owner.run(() => state(false))
|
|
140
|
+
const validating = owner.run(() => state(false))
|
|
141
|
+
const validatingFields = owner.run(() => state<ReadonlySet<FormFieldName<T>>>(new Set()))
|
|
142
|
+
const fieldNames = owner.run(() => state<ReadonlySet<string>>(new Set(names)))
|
|
143
|
+
let schemaRun = 0
|
|
144
|
+
let schemaPending = 0
|
|
145
|
+
let formError: string | null = null
|
|
146
|
+
let disposed = false
|
|
147
|
+
let submitPromise: Promise<SubmitResult<T, unknown>> | null = null
|
|
148
|
+
|
|
149
|
+
owner.run(() => {
|
|
150
|
+
for (const name of names) {
|
|
151
|
+
const initialValue = initialValues[name as FormFieldName<T>]
|
|
152
|
+
fields.set(name, {
|
|
153
|
+
name,
|
|
154
|
+
value: state(initialValue),
|
|
155
|
+
touched: state(false),
|
|
156
|
+
dirty: state(false),
|
|
157
|
+
error: state<string | null>(null),
|
|
158
|
+
validating: state(false),
|
|
159
|
+
initialValue,
|
|
160
|
+
validationRun: 0
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const values = createValuesProxy<T>(fields)
|
|
166
|
+
|
|
167
|
+
function assertActive(): void {
|
|
168
|
+
if (disposed) throw new Error(FORM_DISPOSED)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getInternalField(name: string): InternalField<unknown, T> {
|
|
172
|
+
const field = fields.get(name)
|
|
173
|
+
if (!field) throw new Error(`Vobs forms: 未定义字段 "${name}"`)
|
|
174
|
+
return field
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function updateAggregateErrors(): void {
|
|
178
|
+
const next: FormErrors<T> = {}
|
|
179
|
+
if (formError) next.__form = formError
|
|
180
|
+
for (const name of names) {
|
|
181
|
+
const error = fields.get(name)!.error.value
|
|
182
|
+
if (error) next[name as FormErrorName<T>] = error
|
|
183
|
+
}
|
|
184
|
+
errors.value = next
|
|
185
|
+
hasErrors.value = Object.keys(next).length > 0
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function setFieldError(name: string, message: string | null): void {
|
|
189
|
+
const field = getInternalField(name)
|
|
190
|
+
field.error.value = message
|
|
191
|
+
updateAggregateErrors()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function setFormError(message: string | null): void {
|
|
195
|
+
formError = message
|
|
196
|
+
updateAggregateErrors()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function updateDirty(): void {
|
|
200
|
+
dirty.value = names.some(name => fields.get(name)!.dirty.value)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function updateValidating(): void {
|
|
204
|
+
validating.value = validatingNames.size > 0 || schemaPending > 0
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function setFieldValidating(name: string, active: boolean): void {
|
|
208
|
+
const field = getInternalField(name)
|
|
209
|
+
field.validating.value = active
|
|
210
|
+
const typedName = name as FormFieldName<T>
|
|
211
|
+
if (active) validatingNames.add(typedName)
|
|
212
|
+
else validatingNames.delete(typedName)
|
|
213
|
+
validatingFields.value = new Set(validatingNames)
|
|
214
|
+
updateValidating()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function normalizeMessage(value: unknown): string | null {
|
|
218
|
+
if (value === undefined || value === null || value === '' || value === false) return null
|
|
219
|
+
if (value instanceof Error) return value.message || '校验失败'
|
|
220
|
+
return String(value)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function validatorList(name: string): readonly Validator<unknown, T>[] {
|
|
224
|
+
const field = fields.get(name)
|
|
225
|
+
if (field?.validators) return field.validators
|
|
226
|
+
const configured = options.validators?.[name as FormFieldName<T>] as
|
|
227
|
+
| Validator<unknown, T>
|
|
228
|
+
| readonly Validator<unknown, T>[]
|
|
229
|
+
| undefined
|
|
230
|
+
if (!configured) return []
|
|
231
|
+
return (Array.isArray(configured) ? configured : [configured]) as readonly Validator<unknown, T>[]
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function runValidators(name: string, value: unknown, snapshot: Readonly<T>, signal: AbortSignal): ValidationOutput {
|
|
235
|
+
const validators = validatorList(name)
|
|
236
|
+
let index = 0
|
|
237
|
+
|
|
238
|
+
const next = (): ValidationOutput => {
|
|
239
|
+
while (index < validators.length) {
|
|
240
|
+
const validator = validators[index++]
|
|
241
|
+
let result: ValidationResult | PromiseLike<ValidationResult>
|
|
242
|
+
try {
|
|
243
|
+
result = validator(value, snapshot, signal)
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (signal.aborted) return null
|
|
246
|
+
return normalizeMessage(error) ?? '校验失败'
|
|
247
|
+
}
|
|
248
|
+
if (isPromiseLike(result)) {
|
|
249
|
+
return Promise.resolve(result).then(
|
|
250
|
+
message => normalizeMessage(message) ?? next(),
|
|
251
|
+
error => signal.aborted ? null : normalizeMessage(error) ?? '校验失败'
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
const message = normalizeMessage(result)
|
|
255
|
+
if (message) return message
|
|
256
|
+
}
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return next()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function runSchema(snapshot: Readonly<T>): FormErrors<T> | Promise<FormErrors<T>> {
|
|
264
|
+
if (!options.schema) return {}
|
|
265
|
+
try {
|
|
266
|
+
const result = options.schema.validate(snapshot)
|
|
267
|
+
if (isPromiseLike(result)) {
|
|
268
|
+
return Promise.resolve(result).then(normalizeErrors, error => ({
|
|
269
|
+
__form: normalizeMessage(error) ?? '校验失败'
|
|
270
|
+
} as FormErrors<T>))
|
|
271
|
+
}
|
|
272
|
+
return normalizeErrors(result)
|
|
273
|
+
} catch (error) {
|
|
274
|
+
return { __form: normalizeMessage(error) ?? '校验失败' } as FormErrors<T>
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function normalizeErrors(value: unknown): FormErrors<T> {
|
|
279
|
+
if (!value || typeof value !== 'object') return {}
|
|
280
|
+
const result: FormErrors<T> = {}
|
|
281
|
+
for (const [name, message] of Object.entries(value)) {
|
|
282
|
+
const normalized = normalizeMessage(message)
|
|
283
|
+
if (normalized) result[name as FormFieldName<T>] = normalized
|
|
284
|
+
}
|
|
285
|
+
return result
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function applyValidation(name: string, run: number, result: unknown): string | null {
|
|
289
|
+
const field = fields.get(name)
|
|
290
|
+
const message = normalizeMessage(result)
|
|
291
|
+
if (!disposed && field && field.validationRun === run) setFieldError(name, message)
|
|
292
|
+
return message
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function debounceFor(name: string, field: InternalField<unknown, T>): number {
|
|
296
|
+
if (field.validateDebounce !== undefined) return field.validateDebounce
|
|
297
|
+
if (typeof options.validateDebounce === 'number') return options.validateDebounce
|
|
298
|
+
return options.validateDebounce?.[name as FormFieldName<T>] ?? 0
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function cancelFieldValidation(field: InternalField<unknown, T>): void {
|
|
302
|
+
field.validationRun++
|
|
303
|
+
field.controller?.abort()
|
|
304
|
+
field.controller = undefined
|
|
305
|
+
if (field.debounceCancel) field.debounceCancel()
|
|
306
|
+
if (field.debounceTimer !== undefined) clearTimeout(field.debounceTimer)
|
|
307
|
+
field.debounceTimer = undefined
|
|
308
|
+
field.debounceCancel = undefined
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function beginFieldValidation(field: InternalField<unknown, T>): AbortController {
|
|
312
|
+
field.controller?.abort()
|
|
313
|
+
if (field.debounceCancel) field.debounceCancel()
|
|
314
|
+
if (field.debounceTimer !== undefined) clearTimeout(field.debounceTimer)
|
|
315
|
+
field.debounceCancel = undefined
|
|
316
|
+
const controller = new AbortController()
|
|
317
|
+
field.controller = controller
|
|
318
|
+
return controller
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function validateFieldInternal(name: string, includeSchema: boolean): ValidationOutput {
|
|
322
|
+
assertActive()
|
|
323
|
+
const field = getInternalField(name)
|
|
324
|
+
const run = ++field.validationRun
|
|
325
|
+
const controller = beginFieldValidation(field)
|
|
326
|
+
const snapshot = snapshotValues<T>(fields)
|
|
327
|
+
const execute = (): ValidationOutput => runValidators(name, field.value.value, snapshot, controller.signal)
|
|
328
|
+
const delay = debounceFor(name, field)
|
|
329
|
+
const fieldResult = delay > 0
|
|
330
|
+
? new Promise<ValidationResult>(resolve => {
|
|
331
|
+
field.debounceCancel = () => resolve(null)
|
|
332
|
+
field.debounceTimer = setTimeout(() => {
|
|
333
|
+
field.debounceTimer = undefined
|
|
334
|
+
field.debounceCancel = undefined
|
|
335
|
+
resolve(execute())
|
|
336
|
+
}, delay)
|
|
337
|
+
})
|
|
338
|
+
: execute()
|
|
339
|
+
|
|
340
|
+
const finish = (message: unknown): ValidationOutput => {
|
|
341
|
+
if (!includeSchema || !options.schema) return applyValidation(name, run, message)
|
|
342
|
+
const schemaResult = runSchema(snapshot)
|
|
343
|
+
if (isPromiseLike(schemaResult)) {
|
|
344
|
+
schemaPending++
|
|
345
|
+
updateValidating()
|
|
346
|
+
return Promise.resolve(schemaResult).then(schemaErrors => {
|
|
347
|
+
const schemaMessage = schemaErrors[name as FormFieldName<T>]
|
|
348
|
+
setFormError(schemaErrors.__form ?? null)
|
|
349
|
+
return applyValidation(name, run, schemaMessage ?? message)
|
|
350
|
+
}).finally(() => {
|
|
351
|
+
schemaPending--
|
|
352
|
+
updateValidating()
|
|
353
|
+
}) as Promise<string | null>
|
|
354
|
+
}
|
|
355
|
+
setFormError(schemaResult.__form ?? null)
|
|
356
|
+
return applyValidation(name, run, schemaResult[name as FormFieldName<T>] ?? message)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (isPromiseLike(fieldResult)) {
|
|
360
|
+
setFieldValidating(name, true)
|
|
361
|
+
return Promise.resolve(fieldResult).then(finish).finally(() => {
|
|
362
|
+
if (!disposed && field.validationRun === run) {
|
|
363
|
+
field.controller = undefined
|
|
364
|
+
field.debounceCancel = undefined
|
|
365
|
+
setFieldValidating(name, false)
|
|
366
|
+
}
|
|
367
|
+
}) as Promise<string | null>
|
|
368
|
+
}
|
|
369
|
+
field.controller = undefined
|
|
370
|
+
field.debounceCancel = undefined
|
|
371
|
+
if (!disposed && field.validationRun === run) setFieldValidating(name, false)
|
|
372
|
+
return finish(fieldResult)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function validateField(name: FormFieldName<T>): ValidationOutput {
|
|
376
|
+
return validateFieldInternal(name, true)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function applyAllErrors(fieldErrors: FormErrors<T>, schemaErrors: FormErrors<T>): FormErrors<T> {
|
|
380
|
+
const result: FormErrors<T> = {}
|
|
381
|
+
setFormError(schemaErrors.__form ?? null)
|
|
382
|
+
if (schemaErrors.__form) result.__form = schemaErrors.__form
|
|
383
|
+
for (const name of names) {
|
|
384
|
+
const errorName = name as FormErrorName<T>
|
|
385
|
+
const message = schemaErrors[errorName] ?? fieldErrors[errorName] ?? null
|
|
386
|
+
setFieldError(name, message)
|
|
387
|
+
if (message) result[errorName] = message
|
|
388
|
+
}
|
|
389
|
+
return result
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function validateAll(): FormErrors<T> | Promise<FormErrors<T>> {
|
|
393
|
+
assertActive()
|
|
394
|
+
for (const name of names) {
|
|
395
|
+
const field = fields.get(name)!
|
|
396
|
+
field.touched.value = true
|
|
397
|
+
touchedNames.add(name as FormFieldName<T>)
|
|
398
|
+
}
|
|
399
|
+
touched.value = new Set(touchedNames)
|
|
400
|
+
|
|
401
|
+
const currentSchemaRun = ++schemaRun
|
|
402
|
+
const fieldResults = names.map(name => validateFieldInternal(name, false))
|
|
403
|
+
const schemaResult = options.schema ? runSchema(snapshotValues<T>(fields)) : {}
|
|
404
|
+
const combine = (fieldMessages: readonly (string | null)[], schemaErrors: FormErrors<T>): FormErrors<T> => {
|
|
405
|
+
if (currentSchemaRun !== schemaRun) return {}
|
|
406
|
+
const fieldErrors: FormErrors<T> = {}
|
|
407
|
+
names.forEach((name, index) => {
|
|
408
|
+
if (fieldMessages[index]) fieldErrors[name as FormErrorName<T>] = fieldMessages[index]!
|
|
409
|
+
})
|
|
410
|
+
return applyAllErrors(fieldErrors, schemaErrors)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (fieldResults.some(isPromiseLike) || isPromiseLike(schemaResult)) {
|
|
414
|
+
if (isPromiseLike(schemaResult)) {
|
|
415
|
+
schemaPending++
|
|
416
|
+
updateValidating()
|
|
417
|
+
}
|
|
418
|
+
return Promise.all(fieldResults.map(result => Promise.resolve(result))).then(fieldMessages => {
|
|
419
|
+
if (isPromiseLike(schemaResult)) {
|
|
420
|
+
return Promise.resolve(schemaResult).then(schemaErrors => combine(fieldMessages, schemaErrors as FormErrors<T>))
|
|
421
|
+
}
|
|
422
|
+
return combine(fieldMessages, schemaResult)
|
|
423
|
+
}).finally(() => {
|
|
424
|
+
if (isPromiseLike(schemaResult)) {
|
|
425
|
+
schemaPending--
|
|
426
|
+
updateValidating()
|
|
427
|
+
}
|
|
428
|
+
}).then(result => result as FormErrors<T>)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return combine(fieldResults as string[], schemaResult as FormErrors<T>)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function fieldApi<K extends FormFieldName<T>>(name: K): FormField<T[K]> {
|
|
435
|
+
const field = getInternalField(name)
|
|
436
|
+
return {
|
|
437
|
+
name,
|
|
438
|
+
value: field.value as Signal<T[K]>,
|
|
439
|
+
touched: field.touched,
|
|
440
|
+
dirty: field.dirty,
|
|
441
|
+
error: field.error,
|
|
442
|
+
validating: field.validating,
|
|
443
|
+
set(value: T[K]): void {
|
|
444
|
+
assertActive()
|
|
445
|
+
const nextValue = value
|
|
446
|
+
field.value.value = nextValue
|
|
447
|
+
field.dirty.value = !Object.is(nextValue, field.initialValue)
|
|
448
|
+
field.error.value = null
|
|
449
|
+
updateAggregateErrors()
|
|
450
|
+
updateDirty()
|
|
451
|
+
if (options.validateOn === 'input') void settle(validateField(name))
|
|
452
|
+
},
|
|
453
|
+
markTouched(): ValidationOutput {
|
|
454
|
+
assertActive()
|
|
455
|
+
field.touched.value = true
|
|
456
|
+
touchedNames.add(name)
|
|
457
|
+
touched.value = new Set(touchedNames)
|
|
458
|
+
return options.validateOn === 'blur' ? validateField(name) : null
|
|
459
|
+
},
|
|
460
|
+
validate(): ValidationOutput {
|
|
461
|
+
return validateField(name)
|
|
462
|
+
},
|
|
463
|
+
reset(value?: T[K]): void {
|
|
464
|
+
assertActive()
|
|
465
|
+
cancelFieldValidation(field)
|
|
466
|
+
setFieldValidating(name, false)
|
|
467
|
+
const nextValue = arguments.length === 0 ? field.initialValue : value as T[K]
|
|
468
|
+
field.initialValue = nextValue
|
|
469
|
+
field.value.value = nextValue
|
|
470
|
+
field.dirty.value = false
|
|
471
|
+
field.touched.value = false
|
|
472
|
+
touchedNames.delete(name)
|
|
473
|
+
field.error.value = null
|
|
474
|
+
touched.value = new Set(touchedNames)
|
|
475
|
+
updateAggregateErrors()
|
|
476
|
+
updateDirty()
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function addField<TValue>(name: string, initialValue: TValue, fieldOptions: DynamicFieldOptions<T, TValue> = {}): FormField<TValue> {
|
|
482
|
+
assertActive()
|
|
483
|
+
if (!name) throw new Error('Vobs forms: 字段名不能为空')
|
|
484
|
+
if (fields.has(name)) throw new Error(`Vobs forms: 字段 "${name}" 已存在`)
|
|
485
|
+
const validators = fieldOptions.validators
|
|
486
|
+
? (Array.isArray(fieldOptions.validators) ? fieldOptions.validators : [fieldOptions.validators]) as readonly Validator<unknown, T>[]
|
|
487
|
+
: undefined
|
|
488
|
+
fields.set(name, {
|
|
489
|
+
name,
|
|
490
|
+
value: state(initialValue),
|
|
491
|
+
touched: state(false),
|
|
492
|
+
dirty: state(false),
|
|
493
|
+
error: state<string | null>(null),
|
|
494
|
+
validating: state(false),
|
|
495
|
+
initialValue,
|
|
496
|
+
validationRun: 0,
|
|
497
|
+
validators,
|
|
498
|
+
validateDebounce: fieldOptions.validateDebounce
|
|
499
|
+
})
|
|
500
|
+
names.push(name)
|
|
501
|
+
fieldNames.value = new Set(names)
|
|
502
|
+
return fieldApi(name as FormFieldName<T>) as unknown as FormField<TValue>
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function removeField(name: string): boolean {
|
|
506
|
+
assertActive()
|
|
507
|
+
const field = fields.get(name)
|
|
508
|
+
if (!field) return false
|
|
509
|
+
cancelFieldValidation(field)
|
|
510
|
+
setFieldValidating(name, false)
|
|
511
|
+
fields.delete(name)
|
|
512
|
+
const index = names.indexOf(name)
|
|
513
|
+
if (index >= 0) names.splice(index, 1)
|
|
514
|
+
touchedNames.delete(name as FormFieldName<T>)
|
|
515
|
+
fieldNames.value = new Set(names)
|
|
516
|
+
touched.value = new Set(touchedNames)
|
|
517
|
+
updateAggregateErrors()
|
|
518
|
+
updateDirty()
|
|
519
|
+
return true
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function setServerErrors(serverErrors: Partial<Record<FormErrorName<T>, string | null | undefined>>): void {
|
|
523
|
+
assertActive()
|
|
524
|
+
for (const [name, message] of Object.entries(serverErrors)) {
|
|
525
|
+
if (name === '__form') {
|
|
526
|
+
setFormError(normalizeMessage(message))
|
|
527
|
+
continue
|
|
528
|
+
}
|
|
529
|
+
if (!fields.has(name)) continue
|
|
530
|
+
setFieldError(name, normalizeMessage(message))
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function clearErrors(name?: FormErrorName<T>): void {
|
|
535
|
+
assertActive()
|
|
536
|
+
if (name === '__form') {
|
|
537
|
+
setFormError(null)
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
const targets = (name ? [name] : names) as string[]
|
|
541
|
+
for (const target of targets) {
|
|
542
|
+
const field = getInternalField(target)
|
|
543
|
+
cancelFieldValidation(field)
|
|
544
|
+
setFieldValidating(target, false)
|
|
545
|
+
field.error.value = null
|
|
546
|
+
}
|
|
547
|
+
if (!name) setFormError(null)
|
|
548
|
+
updateAggregateErrors()
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function getErrorFields(): FormFieldName<T>[] {
|
|
552
|
+
return names.filter(name => Boolean(fields.get(name)!.error.value)) as FormFieldName<T>[]
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function reset(nextValues: Partial<T> = {}): void {
|
|
556
|
+
assertActive()
|
|
557
|
+
schemaRun++
|
|
558
|
+
setFormError(null)
|
|
559
|
+
for (const name of names) {
|
|
560
|
+
const field = getInternalField(name)
|
|
561
|
+
cancelFieldValidation(field)
|
|
562
|
+
setFieldValidating(name, false)
|
|
563
|
+
const nextValue = Object.prototype.hasOwnProperty.call(nextValues, name)
|
|
564
|
+
? nextValues[name as FormFieldName<T>]
|
|
565
|
+
: field.initialValue
|
|
566
|
+
field.initialValue = nextValue as unknown
|
|
567
|
+
field.value.value = nextValue
|
|
568
|
+
field.dirty.value = false
|
|
569
|
+
field.touched.value = false
|
|
570
|
+
field.error.value = null
|
|
571
|
+
}
|
|
572
|
+
touchedNames.clear()
|
|
573
|
+
touched.value = new Set()
|
|
574
|
+
updateAggregateErrors()
|
|
575
|
+
updateDirty()
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function submit<TResult = unknown>(handler?: SubmitHandler<T, TResult>): Promise<SubmitResult<T, TResult>> {
|
|
579
|
+
assertActive()
|
|
580
|
+
if (submitPromise) return submitPromise as Promise<SubmitResult<T, TResult>>
|
|
581
|
+
const submitHandler = handler ?? options.onSubmit as SubmitHandler<T, TResult> | undefined
|
|
582
|
+
const run = async (): Promise<SubmitResult<T, TResult>> => {
|
|
583
|
+
submitting.value = true
|
|
584
|
+
try {
|
|
585
|
+
const validationErrors = await validateAll()
|
|
586
|
+
if (Object.keys(validationErrors).length > 0) {
|
|
587
|
+
return { valid: false, errors: validationErrors }
|
|
588
|
+
}
|
|
589
|
+
const snapshot = snapshotValues<T>(fields)
|
|
590
|
+
const result = submitHandler ? await submitHandler(snapshot) : undefined
|
|
591
|
+
return { valid: true, values: snapshot, result }
|
|
592
|
+
} finally {
|
|
593
|
+
submitting.value = false
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const pending = run().finally(() => { submitPromise = null })
|
|
597
|
+
submitPromise = pending as Promise<SubmitResult<T, unknown>>
|
|
598
|
+
return pending
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
owner.onDispose(() => {
|
|
602
|
+
for (const field of fields.values()) cancelFieldValidation(field)
|
|
603
|
+
disposed = true
|
|
604
|
+
fields.clear()
|
|
605
|
+
touchedNames.clear()
|
|
606
|
+
validatingNames.clear()
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
const api = {
|
|
610
|
+
values,
|
|
611
|
+
dirty,
|
|
612
|
+
touched,
|
|
613
|
+
errors,
|
|
614
|
+
hasErrors,
|
|
615
|
+
submitting,
|
|
616
|
+
validating,
|
|
617
|
+
validatingFields,
|
|
618
|
+
fieldNames,
|
|
619
|
+
Field: (props: Omit<FormFieldProps<T>, 'form'>) => Field({ ...props, form: api }),
|
|
620
|
+
field: fieldApi,
|
|
621
|
+
addField,
|
|
622
|
+
removeField,
|
|
623
|
+
validateField,
|
|
624
|
+
validateAll,
|
|
625
|
+
setServerErrors,
|
|
626
|
+
clearErrors,
|
|
627
|
+
getErrorFields,
|
|
628
|
+
submit,
|
|
629
|
+
reset,
|
|
630
|
+
dispose(): void {
|
|
631
|
+
if (!disposed) owner.dispose()
|
|
632
|
+
}
|
|
633
|
+
} as Form<T>
|
|
634
|
+
|
|
635
|
+
return api
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function snapshotValues<T extends object>(fields: Map<string, InternalField<unknown, T>>): T {
|
|
639
|
+
const snapshot: Record<string, unknown> = {}
|
|
640
|
+
for (const [name, field] of fields) snapshot[name] = field.value.value
|
|
641
|
+
return snapshot as T
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function createValuesProxy<T extends object>(fields: Map<string, InternalField<unknown, T>>): Readonly<T> {
|
|
645
|
+
return new Proxy({} as T, {
|
|
646
|
+
get(_target, property: string | symbol): unknown {
|
|
647
|
+
if (typeof property !== 'string') return undefined
|
|
648
|
+
return fields.get(property)?.value.value
|
|
649
|
+
},
|
|
650
|
+
set(): boolean {
|
|
651
|
+
throw new Error('Vobs forms: values 不能直接赋值,请使用 field(name).set(value)')
|
|
652
|
+
},
|
|
653
|
+
has(_target, property: string | symbol): boolean {
|
|
654
|
+
return typeof property === 'string' && fields.has(property)
|
|
655
|
+
},
|
|
656
|
+
ownKeys(): string[] {
|
|
657
|
+
return [...fields.keys()]
|
|
658
|
+
},
|
|
659
|
+
getOwnPropertyDescriptor(_target, property: string | symbol): PropertyDescriptor | undefined {
|
|
660
|
+
if (typeof property !== 'string' || !fields.has(property)) return undefined
|
|
661
|
+
return { enumerable: true, configurable: true }
|
|
662
|
+
}
|
|
663
|
+
})
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function isPromiseLike<T>(value: unknown): value is PromiseLike<T> {
|
|
667
|
+
return Boolean(value) && (typeof value === 'object' || typeof value === 'function')
|
|
668
|
+
&& typeof (value as PromiseLike<T>).then === 'function'
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function settle(value: ValidationOutput): Promise<void> {
|
|
672
|
+
return isPromiseLike(value) ? Promise.resolve(value).then(() => undefined) : Promise.resolve()
|
|
673
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createForm,
|
|
3
|
+
useForm
|
|
4
|
+
} from './form'
|
|
5
|
+
export { Field } from './field'
|
|
6
|
+
export type {
|
|
7
|
+
DynamicFieldOptions,
|
|
8
|
+
Form,
|
|
9
|
+
FormErrorName,
|
|
10
|
+
FormErrors,
|
|
11
|
+
FormField,
|
|
12
|
+
FormFieldComponent,
|
|
13
|
+
FormFieldProps,
|
|
14
|
+
FormFieldName,
|
|
15
|
+
FormOptions,
|
|
16
|
+
SchemaAdapter,
|
|
17
|
+
SubmitFailure,
|
|
18
|
+
SubmitHandler,
|
|
19
|
+
SubmitResult,
|
|
20
|
+
SubmitSuccess,
|
|
21
|
+
ValidationOutput,
|
|
22
|
+
ValidationResult,
|
|
23
|
+
ValidationTrigger,
|
|
24
|
+
Validator,
|
|
25
|
+
ValidatorMap
|
|
26
|
+
} from './form'
|
|
27
|
+
export { rules } from './rules'
|
|
28
|
+
export { FORMS_KEY, formsPlugin } from './plugin'
|
|
29
|
+
export type { FormsClient, FormsPluginOptions } from './plugin'
|