@kontsedal/olas-core 0.0.1-rc.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 +21 -0
- package/README.md +64 -0
- package/dist/index.cjs +363 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +178 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +178 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +339 -0
- package/dist/index.mjs.map +1 -0
- package/dist/root-BImHnGj1.mjs +3270 -0
- package/dist/root-BImHnGj1.mjs.map +1 -0
- package/dist/root-Bazp5_Ik.cjs +3347 -0
- package/dist/root-Bazp5_Ik.cjs.map +1 -0
- package/dist/testing.cjs +81 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +56 -0
- package/dist/testing.d.cts.map +1 -0
- package/dist/testing.d.mts +56 -0
- package/dist/testing.d.mts.map +1 -0
- package/dist/testing.mjs +78 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/types-CAMgqCMz.d.mts +816 -0
- package/dist/types-CAMgqCMz.d.mts.map +1 -0
- package/dist/types-emq_lZd7.d.cts +816 -0
- package/dist/types-emq_lZd7.d.cts.map +1 -0
- package/package.json +47 -0
- package/src/__dev__.d.ts +8 -0
- package/src/controller/define.ts +50 -0
- package/src/controller/index.ts +12 -0
- package/src/controller/instance.ts +499 -0
- package/src/controller/root.ts +160 -0
- package/src/controller/types.ts +195 -0
- package/src/devtools.ts +0 -0
- package/src/emitter.ts +79 -0
- package/src/errors.ts +49 -0
- package/src/forms/field.ts +303 -0
- package/src/forms/form-types.ts +130 -0
- package/src/forms/form.ts +640 -0
- package/src/forms/index.ts +2 -0
- package/src/forms/types.ts +1 -0
- package/src/forms/validators.ts +70 -0
- package/src/index.ts +89 -0
- package/src/query/client.ts +934 -0
- package/src/query/define.ts +154 -0
- package/src/query/entry.ts +322 -0
- package/src/query/focus-online.ts +73 -0
- package/src/query/index.ts +3 -0
- package/src/query/infinite.ts +462 -0
- package/src/query/keys.ts +33 -0
- package/src/query/local.ts +113 -0
- package/src/query/mutation.ts +384 -0
- package/src/query/plugin.ts +135 -0
- package/src/query/types.ts +168 -0
- package/src/query/use.ts +321 -0
- package/src/scope.ts +42 -0
- package/src/selection.ts +146 -0
- package/src/signals/index.ts +3 -0
- package/src/signals/readonly.ts +22 -0
- package/src/signals/runtime.ts +115 -0
- package/src/signals/types.ts +31 -0
- package/src/testing.ts +142 -0
- package/src/timing/debounced.ts +32 -0
- package/src/timing/index.ts +2 -0
- package/src/timing/throttled.ts +46 -0
- package/src/utils.ts +13 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
import type { Field } from '../controller/types'
|
|
2
|
+
import { batch, computed, effect, type Signal, signal, untracked } from '../signals'
|
|
3
|
+
import type { ReadSignal } from '../signals/types'
|
|
4
|
+
import { bindFieldDevtoolsOwner, createField } from './field'
|
|
5
|
+
import type {
|
|
6
|
+
DeepPartial,
|
|
7
|
+
FieldArray,
|
|
8
|
+
FieldArrayItemErrors,
|
|
9
|
+
FieldArrayOptions,
|
|
10
|
+
FieldArrayValidator,
|
|
11
|
+
FieldArrayValue,
|
|
12
|
+
Form,
|
|
13
|
+
FormErrors,
|
|
14
|
+
FormOptions,
|
|
15
|
+
FormSchema,
|
|
16
|
+
FormValidator,
|
|
17
|
+
FormValue,
|
|
18
|
+
ItemInitial,
|
|
19
|
+
} from './form-types'
|
|
20
|
+
|
|
21
|
+
const FORM_BRAND = Symbol.for('olas.form')
|
|
22
|
+
const FIELD_ARRAY_BRAND = Symbol.for('olas.fieldArray')
|
|
23
|
+
|
|
24
|
+
const isForm = (x: unknown): x is Form<FormSchema> =>
|
|
25
|
+
typeof x === 'object' && x !== null && (x as Record<symbol, unknown>)[FORM_BRAND] === true
|
|
26
|
+
|
|
27
|
+
const isFieldArray = (x: unknown): x is FieldArray<Field<unknown> | Form<FormSchema>> =>
|
|
28
|
+
typeof x === 'object' && x !== null && (x as Record<symbol, unknown>)[FIELD_ARRAY_BRAND] === true
|
|
29
|
+
|
|
30
|
+
const isField = (x: unknown): x is Field<unknown> =>
|
|
31
|
+
typeof x === 'object' && x !== null && !isForm(x) && !isFieldArray(x)
|
|
32
|
+
|
|
33
|
+
class FormImpl<S extends FormSchema> implements Form<S> {
|
|
34
|
+
readonly [FORM_BRAND] = true
|
|
35
|
+
|
|
36
|
+
readonly fields: S
|
|
37
|
+
readonly value: ReadSignal<FormValue<S>>
|
|
38
|
+
readonly errors: ReadSignal<FormErrors<S>>
|
|
39
|
+
readonly isValid: ReadSignal<boolean>
|
|
40
|
+
readonly isDirty: ReadSignal<boolean>
|
|
41
|
+
readonly touched: ReadSignal<boolean>
|
|
42
|
+
readonly isValidating: ReadSignal<boolean>
|
|
43
|
+
readonly flatErrors: ReadSignal<Array<{ path: string; errors: string[] }>>
|
|
44
|
+
|
|
45
|
+
private readonly topLevelErrors$: Signal<string[]> = signal([])
|
|
46
|
+
readonly topLevelErrors: ReadSignal<string[]> = this.topLevelErrors$
|
|
47
|
+
private readonly topLevelValidating$: Signal<boolean> = signal(false)
|
|
48
|
+
|
|
49
|
+
private readonly validators: ReadonlyArray<FormValidator<S>>
|
|
50
|
+
private readonly options: FormOptions<S> | undefined
|
|
51
|
+
private validatorDispose: (() => void) | null = null
|
|
52
|
+
private currentValidatorRun = 0
|
|
53
|
+
private currentValidatorAbort: AbortController | null = null
|
|
54
|
+
private disposed = false
|
|
55
|
+
|
|
56
|
+
constructor(schema: S, options?: FormOptions<S>) {
|
|
57
|
+
this.fields = schema
|
|
58
|
+
this.options = options
|
|
59
|
+
this.validators = options?.validators ?? []
|
|
60
|
+
|
|
61
|
+
// Apply initial values (one-shot or initial snapshot from a function).
|
|
62
|
+
// `asInitial: true` flag tells leaf fields to set their value AND re-anchor
|
|
63
|
+
// their `reset()` target without marking themselves dirty.
|
|
64
|
+
if (options?.initial !== undefined) {
|
|
65
|
+
const ini = typeof options.initial === 'function' ? options.initial() : options.initial
|
|
66
|
+
if (ini !== undefined) this.applyPartial(ini as DeepPartial<FormValue<S>>, true)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.value = computed(() => this.computeValue())
|
|
70
|
+
this.errors = computed(() => this.computeErrors())
|
|
71
|
+
this.isDirty = computed(() => this.computeBool('isDirty'))
|
|
72
|
+
this.touched = computed(() => this.computeBool('touched'))
|
|
73
|
+
this.isValidating = computed(() => {
|
|
74
|
+
if (this.topLevelValidating$.value) return true
|
|
75
|
+
for (const child of Object.values(this.fields)) {
|
|
76
|
+
if ((child as { isValidating: ReadSignal<boolean> }).isValidating.value) return true
|
|
77
|
+
}
|
|
78
|
+
return false
|
|
79
|
+
})
|
|
80
|
+
this.isValid = computed(() => {
|
|
81
|
+
if (this.topLevelErrors$.value.length > 0) return false
|
|
82
|
+
if (this.isValidating.value) return false
|
|
83
|
+
for (const child of Object.values(this.fields)) {
|
|
84
|
+
if (!(child as { isValid: ReadSignal<boolean> }).isValid.value) return false
|
|
85
|
+
}
|
|
86
|
+
return true
|
|
87
|
+
})
|
|
88
|
+
this.flatErrors = computed(() => this.computeFlatErrors())
|
|
89
|
+
|
|
90
|
+
if (this.validators.length > 0) {
|
|
91
|
+
this.validatorDispose = effect(() => this.runTopLevelValidators())
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private computeValue(): FormValue<S> {
|
|
96
|
+
const out: Record<string, unknown> = {}
|
|
97
|
+
for (const [k, child] of Object.entries(this.fields)) {
|
|
98
|
+
if (isForm(child) || isFieldArray(child)) {
|
|
99
|
+
out[k] = (child as { value: ReadSignal<unknown> }).value.value
|
|
100
|
+
} else {
|
|
101
|
+
// Field<T> is itself a ReadSignal<T>; .value returns T (tracked).
|
|
102
|
+
out[k] = (child as Field<unknown>).value
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out as FormValue<S>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private computeErrors(): FormErrors<S> {
|
|
109
|
+
const out: Record<string, unknown> = {}
|
|
110
|
+
for (const [k, child] of Object.entries(this.fields)) {
|
|
111
|
+
if (isForm(child)) {
|
|
112
|
+
out[k] = child.errors.value
|
|
113
|
+
} else if (isFieldArray(child)) {
|
|
114
|
+
out[k] = child.errors.value
|
|
115
|
+
} else {
|
|
116
|
+
const errs = (child as Field<unknown>).errors.value
|
|
117
|
+
out[k] = errs.length > 0 ? errs : undefined
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out as FormErrors<S>
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private computeBool(key: 'isDirty' | 'touched'): boolean {
|
|
124
|
+
for (const child of Object.values(this.fields)) {
|
|
125
|
+
const sig = (child as unknown as Record<string, ReadSignal<boolean>>)[key]
|
|
126
|
+
if (sig?.value) return true
|
|
127
|
+
}
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private computeFlatErrors(): Array<{ path: string; errors: string[] }> {
|
|
132
|
+
const out: Array<{ path: string; errors: string[] }> = []
|
|
133
|
+
const tle = this.topLevelErrors$.value
|
|
134
|
+
if (tle.length > 0) out.push({ path: '', errors: tle })
|
|
135
|
+
walkErrors(this.fields, '', out)
|
|
136
|
+
return out
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
set(partial: DeepPartial<FormValue<S>>): void {
|
|
140
|
+
if (this.disposed) return
|
|
141
|
+
batch(() => this.applyPartial(partial, false))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private applyPartial(partial: DeepPartial<FormValue<S>>, asInitial: boolean): void {
|
|
145
|
+
for (const [k, val] of Object.entries(partial)) {
|
|
146
|
+
const child = (this.fields as Record<string, unknown>)[k]
|
|
147
|
+
if (!child) continue
|
|
148
|
+
if (isForm(child)) {
|
|
149
|
+
// Nested form: recurse via its own `set` (user) or rebuild via reset
|
|
150
|
+
// through the same `applyPartial`-with-`asInitial` flag (initial).
|
|
151
|
+
if (asInitial) {
|
|
152
|
+
;(child as Form<FormSchema>).resetWithInitial(val as DeepPartial<FormValue<FormSchema>>)
|
|
153
|
+
} else {
|
|
154
|
+
child.set(val as DeepPartial<FormValue<FormSchema>>)
|
|
155
|
+
}
|
|
156
|
+
} else if (isFieldArray(child)) {
|
|
157
|
+
const arr = child
|
|
158
|
+
// Replace items: clear, then add each
|
|
159
|
+
arr.clear()
|
|
160
|
+
for (const itemVal of val as unknown[]) {
|
|
161
|
+
arr.add(itemVal as ItemInitial<Field<unknown>>)
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
const f = child as Field<unknown>
|
|
165
|
+
if (asInitial) f.setAsInitial(val)
|
|
166
|
+
else f.set(val)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Internal: re-seat this form's leaves from `partial` as their new initial. */
|
|
172
|
+
resetWithInitial(partial: DeepPartial<FormValue<S>>): void {
|
|
173
|
+
if (this.disposed) return
|
|
174
|
+
batch(() => this.applyPartial(partial, true))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
reset(): void {
|
|
178
|
+
if (this.disposed) return
|
|
179
|
+
batch(() => {
|
|
180
|
+
for (const child of Object.values(this.fields)) {
|
|
181
|
+
if (isForm(child) || isFieldArray(child)) {
|
|
182
|
+
;(child as { reset: () => void }).reset()
|
|
183
|
+
} else {
|
|
184
|
+
;(child as Field<unknown>).reset()
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
this.topLevelErrors$.set([])
|
|
188
|
+
})
|
|
189
|
+
// Re-apply initial if provided — as initial (no dirty bump).
|
|
190
|
+
if (this.options?.initial !== undefined) {
|
|
191
|
+
const ini =
|
|
192
|
+
typeof this.options.initial === 'function' ? this.options.initial() : this.options.initial
|
|
193
|
+
if (ini !== undefined) this.applyPartial(ini as DeepPartial<FormValue<S>>, true)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
markAllTouched(): void {
|
|
198
|
+
if (this.disposed) return
|
|
199
|
+
for (const child of Object.values(this.fields)) {
|
|
200
|
+
if (isForm(child)) child.markAllTouched()
|
|
201
|
+
else if (isFieldArray(child)) child.markAllTouched()
|
|
202
|
+
else (child as Field<unknown>).markTouched()
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async validate(): Promise<boolean> {
|
|
207
|
+
if (this.disposed) return this.isValid.peek()
|
|
208
|
+
const tasks: Promise<unknown>[] = []
|
|
209
|
+
for (const child of Object.values(this.fields)) {
|
|
210
|
+
if (isForm(child) || isFieldArray(child)) {
|
|
211
|
+
tasks.push((child as { validate: () => Promise<boolean> }).validate())
|
|
212
|
+
} else {
|
|
213
|
+
tasks.push((child as Field<unknown>).revalidate())
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
await Promise.all(tasks)
|
|
217
|
+
// Wait for top-level validators to finish.
|
|
218
|
+
if (this.topLevelValidating$.peek()) {
|
|
219
|
+
await new Promise<void>((resolve) => {
|
|
220
|
+
const unsub = this.topLevelValidating$.subscribe((v) => {
|
|
221
|
+
if (!v) {
|
|
222
|
+
unsub()
|
|
223
|
+
resolve()
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
return this.isValid.peek()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
dispose(): void {
|
|
232
|
+
if (this.disposed) return
|
|
233
|
+
this.disposed = true
|
|
234
|
+
this.validatorDispose?.()
|
|
235
|
+
this.currentValidatorAbort?.abort()
|
|
236
|
+
for (const child of Object.values(this.fields)) {
|
|
237
|
+
;(child as { dispose?: () => void }).dispose?.()
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private runTopLevelValidators(): void {
|
|
242
|
+
if (this.disposed) return
|
|
243
|
+
const value = this.value.value
|
|
244
|
+
this.currentValidatorAbort?.abort()
|
|
245
|
+
const abort = new AbortController()
|
|
246
|
+
this.currentValidatorAbort = abort
|
|
247
|
+
const myId = ++this.currentValidatorRun
|
|
248
|
+
|
|
249
|
+
const syncErrors: string[] = []
|
|
250
|
+
const asyncPromises: Promise<string | null>[] = []
|
|
251
|
+
for (const v of this.validators) {
|
|
252
|
+
const r = v(value, abort.signal)
|
|
253
|
+
if (r instanceof Promise) asyncPromises.push(r)
|
|
254
|
+
else if (r != null) syncErrors.push(r)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (syncErrors.length > 0) {
|
|
258
|
+
batch(() => {
|
|
259
|
+
this.topLevelErrors$.set(syncErrors)
|
|
260
|
+
this.topLevelValidating$.set(false)
|
|
261
|
+
})
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (asyncPromises.length === 0) {
|
|
266
|
+
batch(() => {
|
|
267
|
+
this.topLevelErrors$.set([])
|
|
268
|
+
this.topLevelValidating$.set(false)
|
|
269
|
+
})
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
batch(() => {
|
|
274
|
+
this.topLevelErrors$.set([])
|
|
275
|
+
this.topLevelValidating$.set(true)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
Promise.allSettled(asyncPromises).then((results) => {
|
|
279
|
+
if (myId !== this.currentValidatorRun || this.disposed) return
|
|
280
|
+
const errs: string[] = []
|
|
281
|
+
for (const r of results) {
|
|
282
|
+
if (r.status === 'fulfilled' && r.value != null) errs.push(r.value)
|
|
283
|
+
}
|
|
284
|
+
batch(() => {
|
|
285
|
+
this.topLevelErrors$.set(errs)
|
|
286
|
+
this.topLevelValidating$.set(false)
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function walkErrors(
|
|
293
|
+
fields: FormSchema,
|
|
294
|
+
prefix: string,
|
|
295
|
+
out: Array<{ path: string; errors: string[] }>,
|
|
296
|
+
): void {
|
|
297
|
+
for (const [k, child] of Object.entries(fields)) {
|
|
298
|
+
const path = prefix ? `${prefix}.${k}` : k
|
|
299
|
+
if (isForm(child)) {
|
|
300
|
+
const tle = child.topLevelErrors.value
|
|
301
|
+
if (tle.length > 0) out.push({ path, errors: tle })
|
|
302
|
+
walkErrors(child.fields, path, out)
|
|
303
|
+
} else if (isFieldArray(child)) {
|
|
304
|
+
const tle = child.topLevelErrors.value
|
|
305
|
+
if (tle.length > 0) out.push({ path, errors: tle })
|
|
306
|
+
const items = child.items.value
|
|
307
|
+
items.forEach((item, idx) => {
|
|
308
|
+
const itemPath = `${path}[${idx}]`
|
|
309
|
+
if (isForm(item)) {
|
|
310
|
+
const itle = item.topLevelErrors.value
|
|
311
|
+
if (itle.length > 0) out.push({ path: itemPath, errors: itle })
|
|
312
|
+
walkErrors(item.fields, itemPath, out)
|
|
313
|
+
} else {
|
|
314
|
+
const errs = (item as Field<unknown>).errors.value
|
|
315
|
+
if (errs.length > 0) out.push({ path: itemPath, errors: errs })
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
} else {
|
|
319
|
+
const errs = (child as Field<unknown>).errors.value
|
|
320
|
+
if (errs.length > 0) out.push({ path, errors: errs })
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I> {
|
|
326
|
+
readonly [FIELD_ARRAY_BRAND] = true
|
|
327
|
+
|
|
328
|
+
readonly items: ReadSignal<ReadonlyArray<I>>
|
|
329
|
+
readonly value: ReadSignal<FieldArrayValue<I>>
|
|
330
|
+
readonly errors: ReadSignal<Array<FieldArrayItemErrors<I> | undefined>>
|
|
331
|
+
readonly size: ReadSignal<number>
|
|
332
|
+
readonly isValid: ReadSignal<boolean>
|
|
333
|
+
readonly isDirty: ReadSignal<boolean>
|
|
334
|
+
readonly touched: ReadSignal<boolean>
|
|
335
|
+
readonly isValidating: ReadSignal<boolean>
|
|
336
|
+
|
|
337
|
+
private readonly items$: Signal<I[]>
|
|
338
|
+
private readonly topLevelErrors$: Signal<string[]> = signal([])
|
|
339
|
+
readonly topLevelErrors: ReadSignal<string[]> = this.topLevelErrors$
|
|
340
|
+
private readonly topLevelValidating$: Signal<boolean> = signal(false)
|
|
341
|
+
|
|
342
|
+
private readonly itemFactory: (initial?: ItemInitial<I>) => I
|
|
343
|
+
private readonly initialItems: Array<ItemInitial<I>> = []
|
|
344
|
+
private readonly validators: ReadonlyArray<FieldArrayValidator<I>>
|
|
345
|
+
private currentValidatorRun = 0
|
|
346
|
+
private currentValidatorAbort: AbortController | null = null
|
|
347
|
+
private validatorDispose: (() => void) | null = null
|
|
348
|
+
private disposed = false
|
|
349
|
+
|
|
350
|
+
constructor(itemFactory: (initial?: ItemInitial<I>) => I, options?: FieldArrayOptions<I>) {
|
|
351
|
+
this.itemFactory = itemFactory
|
|
352
|
+
this.validators = options?.validators ?? []
|
|
353
|
+
this.items$ = signal<I[]>([])
|
|
354
|
+
if (options?.initial) {
|
|
355
|
+
this.initialItems = options.initial
|
|
356
|
+
for (const ini of options.initial) {
|
|
357
|
+
this.items$.peek().push(itemFactory(ini))
|
|
358
|
+
}
|
|
359
|
+
// re-set to trigger subscribers
|
|
360
|
+
this.items$.set([...this.items$.peek()])
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
this.items = this.items$
|
|
364
|
+
this.size = computed(() => this.items$.value.length)
|
|
365
|
+
this.value = computed(
|
|
366
|
+
() =>
|
|
367
|
+
this.items$.value.map((item) => {
|
|
368
|
+
if (isForm(item)) return item.value.value
|
|
369
|
+
// Field is a ReadSignal — `.value` is the actual value.
|
|
370
|
+
return (item as Field<unknown>).value
|
|
371
|
+
}) as FieldArrayValue<I>,
|
|
372
|
+
)
|
|
373
|
+
this.errors = computed(() =>
|
|
374
|
+
this.items$.value.map((item) => {
|
|
375
|
+
if (isForm(item)) return item.errors.value as FieldArrayItemErrors<I>
|
|
376
|
+
const errs = (item as Field<unknown>).errors.value
|
|
377
|
+
return (errs.length > 0 ? errs : undefined) as FieldArrayItemErrors<I> | undefined
|
|
378
|
+
}),
|
|
379
|
+
)
|
|
380
|
+
this.isDirty = computed(() => {
|
|
381
|
+
for (const item of this.items$.value) {
|
|
382
|
+
if ((item as { isDirty: ReadSignal<boolean> }).isDirty.value) return true
|
|
383
|
+
}
|
|
384
|
+
return false
|
|
385
|
+
})
|
|
386
|
+
this.touched = computed(() => {
|
|
387
|
+
for (const item of this.items$.value) {
|
|
388
|
+
if ((item as { touched: ReadSignal<boolean> }).touched.value) return true
|
|
389
|
+
}
|
|
390
|
+
return false
|
|
391
|
+
})
|
|
392
|
+
this.isValidating = computed(() => {
|
|
393
|
+
if (this.topLevelValidating$.value) return true
|
|
394
|
+
for (const item of this.items$.value) {
|
|
395
|
+
if ((item as { isValidating: ReadSignal<boolean> }).isValidating.value) return true
|
|
396
|
+
}
|
|
397
|
+
return false
|
|
398
|
+
})
|
|
399
|
+
this.isValid = computed(() => {
|
|
400
|
+
if (this.topLevelErrors$.value.length > 0) return false
|
|
401
|
+
if (this.isValidating.value) return false
|
|
402
|
+
for (const item of this.items$.value) {
|
|
403
|
+
if (!(item as { isValid: ReadSignal<boolean> }).isValid.value) return false
|
|
404
|
+
}
|
|
405
|
+
return true
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
if (this.validators.length > 0) {
|
|
409
|
+
this.validatorDispose = effect(() => this.runTopLevelValidators())
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
at(index: number): I | undefined {
|
|
414
|
+
return this.items$.peek()[index]
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
add(initial?: ItemInitial<I>): void {
|
|
418
|
+
if (this.disposed) return
|
|
419
|
+
const item = this.itemFactory(initial)
|
|
420
|
+
this.items$.set([...this.items$.peek(), item])
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
insert(index: number, initial?: ItemInitial<I>): void {
|
|
424
|
+
if (this.disposed) return
|
|
425
|
+
const item = this.itemFactory(initial)
|
|
426
|
+
const next = [...this.items$.peek()]
|
|
427
|
+
next.splice(index, 0, item)
|
|
428
|
+
this.items$.set(next)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
remove(index: number): void {
|
|
432
|
+
if (this.disposed) return
|
|
433
|
+
const next = [...this.items$.peek()]
|
|
434
|
+
const [removed] = next.splice(index, 1)
|
|
435
|
+
if (removed) {
|
|
436
|
+
;(removed as { dispose?: () => void }).dispose?.()
|
|
437
|
+
}
|
|
438
|
+
this.items$.set(next)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
move(from: number, to: number): void {
|
|
442
|
+
if (this.disposed) return
|
|
443
|
+
const next = [...this.items$.peek()]
|
|
444
|
+
const [item] = next.splice(from, 1)
|
|
445
|
+
if (item) next.splice(to, 0, item)
|
|
446
|
+
this.items$.set(next)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
clear(): void {
|
|
450
|
+
if (this.disposed) return
|
|
451
|
+
for (const item of this.items$.peek()) {
|
|
452
|
+
;(item as { dispose?: () => void }).dispose?.()
|
|
453
|
+
}
|
|
454
|
+
this.items$.set([])
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
reset(): void {
|
|
458
|
+
if (this.disposed) return
|
|
459
|
+
batch(() => {
|
|
460
|
+
this.clear()
|
|
461
|
+
for (const ini of this.initialItems) {
|
|
462
|
+
this.add(ini)
|
|
463
|
+
}
|
|
464
|
+
this.topLevelErrors$.set([])
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
markAllTouched(): void {
|
|
469
|
+
for (const item of this.items$.peek()) {
|
|
470
|
+
if (isForm(item)) item.markAllTouched()
|
|
471
|
+
else (item as Field<unknown>).markTouched()
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async validate(): Promise<boolean> {
|
|
476
|
+
if (this.disposed) return this.isValid.peek()
|
|
477
|
+
const tasks: Promise<unknown>[] = []
|
|
478
|
+
for (const item of this.items$.peek()) {
|
|
479
|
+
if (isForm(item)) tasks.push(item.validate())
|
|
480
|
+
else tasks.push((item as Field<unknown>).revalidate())
|
|
481
|
+
}
|
|
482
|
+
await Promise.all(tasks)
|
|
483
|
+
if (this.topLevelValidating$.peek()) {
|
|
484
|
+
await new Promise<void>((resolve) => {
|
|
485
|
+
const unsub = this.topLevelValidating$.subscribe((v) => {
|
|
486
|
+
if (!v) {
|
|
487
|
+
unsub()
|
|
488
|
+
resolve()
|
|
489
|
+
}
|
|
490
|
+
})
|
|
491
|
+
})
|
|
492
|
+
}
|
|
493
|
+
return this.isValid.peek()
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
dispose(): void {
|
|
497
|
+
if (this.disposed) return
|
|
498
|
+
this.disposed = true
|
|
499
|
+
this.validatorDispose?.()
|
|
500
|
+
this.currentValidatorAbort?.abort()
|
|
501
|
+
for (const item of this.items$.peek()) {
|
|
502
|
+
;(item as { dispose?: () => void }).dispose?.()
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private runTopLevelValidators(): void {
|
|
507
|
+
if (this.disposed) return
|
|
508
|
+
const value = this.value.value
|
|
509
|
+
this.currentValidatorAbort?.abort()
|
|
510
|
+
const abort = new AbortController()
|
|
511
|
+
this.currentValidatorAbort = abort
|
|
512
|
+
const myId = ++this.currentValidatorRun
|
|
513
|
+
|
|
514
|
+
const syncErrors: string[] = []
|
|
515
|
+
const asyncPromises: Promise<string | null>[] = []
|
|
516
|
+
for (const v of this.validators) {
|
|
517
|
+
const r = v(value, abort.signal)
|
|
518
|
+
if (r instanceof Promise) asyncPromises.push(r)
|
|
519
|
+
else if (r != null) syncErrors.push(r)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (syncErrors.length > 0) {
|
|
523
|
+
batch(() => {
|
|
524
|
+
this.topLevelErrors$.set(syncErrors)
|
|
525
|
+
this.topLevelValidating$.set(false)
|
|
526
|
+
})
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (asyncPromises.length === 0) {
|
|
531
|
+
batch(() => {
|
|
532
|
+
this.topLevelErrors$.set([])
|
|
533
|
+
this.topLevelValidating$.set(false)
|
|
534
|
+
})
|
|
535
|
+
return
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
batch(() => {
|
|
539
|
+
this.topLevelErrors$.set([])
|
|
540
|
+
this.topLevelValidating$.set(true)
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
Promise.allSettled(asyncPromises).then((results) => {
|
|
544
|
+
if (myId !== this.currentValidatorRun || this.disposed) return
|
|
545
|
+
const errs: string[] = []
|
|
546
|
+
for (const r of results) {
|
|
547
|
+
if (r.status === 'fulfilled' && r.value != null) errs.push(r.value)
|
|
548
|
+
}
|
|
549
|
+
batch(() => {
|
|
550
|
+
this.topLevelErrors$.set(errs)
|
|
551
|
+
this.topLevelValidating$.set(false)
|
|
552
|
+
})
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function createForm<S extends FormSchema>(schema: S, options?: FormOptions<S>): Form<S> {
|
|
558
|
+
return new FormImpl(schema, options)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export function createFieldArray<I extends Field<any> | Form<any>>(
|
|
562
|
+
itemFactory: (initial?: ItemInitial<I>) => I,
|
|
563
|
+
options?: FieldArrayOptions<I>,
|
|
564
|
+
): FieldArray<I> {
|
|
565
|
+
return new FieldArrayImpl<I>(itemFactory, options)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Recursively wire every leaf `Field` in a form / field-array tree to a
|
|
570
|
+
* devtools emitter. Returns a single disposer that tears down every standalone
|
|
571
|
+
* `effect()` registered along the way (used for FieldArray watching), so the
|
|
572
|
+
* caller — `ctx.form` / `ctx.fieldArray` in the controller — can register one
|
|
573
|
+
* cleanup entry and have the whole subtree's reactive work die with the
|
|
574
|
+
* controller. Spec §20.9.
|
|
575
|
+
*/
|
|
576
|
+
export function bindTreeToDevtools(
|
|
577
|
+
node: Field<unknown> | Form<FormSchema> | FieldArray<Field<unknown> | Form<FormSchema>>,
|
|
578
|
+
prefix: string,
|
|
579
|
+
controllerPath: readonly string[],
|
|
580
|
+
emitter: import('../devtools').DevtoolsEmitter,
|
|
581
|
+
): () => void {
|
|
582
|
+
const disposers: Array<() => void> = []
|
|
583
|
+
bindTreeToDevtoolsInto(node, prefix, controllerPath, emitter, disposers)
|
|
584
|
+
return () => {
|
|
585
|
+
for (const d of disposers) {
|
|
586
|
+
try {
|
|
587
|
+
d()
|
|
588
|
+
} catch {
|
|
589
|
+
// Disposer failures must not break sibling cleanup.
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
disposers.length = 0
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function bindTreeToDevtoolsInto(
|
|
597
|
+
node: Field<unknown> | Form<FormSchema> | FieldArray<Field<unknown> | Form<FormSchema>>,
|
|
598
|
+
prefix: string,
|
|
599
|
+
controllerPath: readonly string[],
|
|
600
|
+
emitter: import('../devtools').DevtoolsEmitter,
|
|
601
|
+
disposers: Array<() => void>,
|
|
602
|
+
): void {
|
|
603
|
+
if (isForm(node)) {
|
|
604
|
+
for (const [key, child] of Object.entries(node.fields)) {
|
|
605
|
+
bindTreeToDevtoolsInto(
|
|
606
|
+
child,
|
|
607
|
+
prefix === '' ? key : `${prefix}.${key}`,
|
|
608
|
+
controllerPath,
|
|
609
|
+
emitter,
|
|
610
|
+
disposers,
|
|
611
|
+
)
|
|
612
|
+
}
|
|
613
|
+
return
|
|
614
|
+
}
|
|
615
|
+
if (isFieldArray(node)) {
|
|
616
|
+
// Re-bind on every items change so dynamically-added entries get tracked.
|
|
617
|
+
// `effect()` returns its own disposer; capture it so the controller can
|
|
618
|
+
// tear it down on dispose (otherwise dynamic forms leak reactive work).
|
|
619
|
+
const arr = node as FieldArray<Field<unknown> | Form<FormSchema>>
|
|
620
|
+
const stop = effect(() => {
|
|
621
|
+
const items = arr.items.value
|
|
622
|
+
items.forEach((item, idx) => {
|
|
623
|
+
bindTreeToDevtoolsInto(item, `${prefix}[${idx}]`, controllerPath, emitter, disposers)
|
|
624
|
+
})
|
|
625
|
+
})
|
|
626
|
+
disposers.push(stop)
|
|
627
|
+
return
|
|
628
|
+
}
|
|
629
|
+
// Leaf Field.
|
|
630
|
+
bindFieldDevtoolsOwner(node as Field<unknown>, {
|
|
631
|
+
controllerPath,
|
|
632
|
+
fieldName: prefix,
|
|
633
|
+
emitter,
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Quiet unused-import linter without exporting these symbols publicly.
|
|
638
|
+
void createField
|
|
639
|
+
void untracked
|
|
640
|
+
void isField
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type Validator<T> = (value: T, signal: AbortSignal) => string | null | Promise<string | null>
|