@kontsedal/olas-core 0.0.5 → 0.1.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/dist/index.cjs +0 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +109 -27
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +109 -27
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +0 -0
- package/dist/index.mjs.map +1 -1
- package/dist/{root-DqWolle_.mjs → root-Byq-QYTp.mjs} +1478 -347
- package/dist/root-Byq-QYTp.mjs.map +1 -0
- package/dist/{root-lBp7qziQ.cjs → root-CPSL5kpR.cjs} +1483 -346
- package/dist/root-CPSL5kpR.cjs.map +1 -0
- package/dist/testing.cjs +5 -1
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +3 -2
- package/dist/testing.d.cts.map +1 -1
- package/dist/testing.d.mts +3 -2
- package/dist/testing.d.mts.map +1 -1
- package/dist/testing.mjs +5 -2
- package/dist/testing.mjs.map +1 -1
- package/dist/{types-BH1o6nYa.d.mts → types-Brp-4WaZ.d.mts} +244 -23
- package/dist/types-Brp-4WaZ.d.mts.map +1 -0
- package/dist/{types-C4Vtkxbn.d.cts → types-DzDIypPo.d.cts} +244 -23
- package/dist/types-DzDIypPo.d.cts.map +1 -0
- package/package.json +4 -1
- package/src/controller/instance.ts +360 -94
- package/src/controller/root.ts +58 -30
- package/src/controller/types.ts +66 -4
- package/src/emitter.ts +8 -2
- package/src/errors.ts +39 -3
- package/src/forms/field.ts +219 -25
- package/src/forms/form-types.ts +16 -3
- package/src/forms/form.ts +360 -38
- package/src/forms/index.ts +3 -1
- package/src/forms/types.ts +27 -1
- package/src/forms/validators.ts +45 -11
- package/src/index.ts +13 -7
- package/src/query/client.ts +237 -58
- package/src/query/define.ts +0 -0
- package/src/query/entry.ts +259 -15
- package/src/query/focus-online.ts +53 -11
- package/src/query/infinite.ts +351 -47
- package/src/query/keys.ts +33 -2
- package/src/query/local.ts +3 -0
- package/src/query/mutation.ts +27 -6
- package/src/query/plugin.ts +43 -4
- package/src/query/types.ts +76 -6
- package/src/query/use.ts +53 -10
- package/src/selection.ts +49 -12
- package/src/signals/readonly.ts +3 -0
- package/src/signals/runtime.ts +27 -0
- package/src/signals/types.ts +10 -0
- package/src/testing.ts +9 -0
- package/src/timing/debounced.ts +106 -23
- package/src/timing/index.ts +1 -1
- package/src/timing/throttled.ts +85 -28
- package/src/utils.ts +15 -2
- package/dist/root-DqWolle_.mjs.map +0 -1
- package/dist/root-lBp7qziQ.cjs.map +0 -1
- package/dist/types-BH1o6nYa.d.mts.map +0 -1
- package/dist/types-C4Vtkxbn.d.cts.map +0 -1
package/src/forms/form.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
FormValue,
|
|
23
23
|
ItemInitial,
|
|
24
24
|
} from './form-types'
|
|
25
|
+
import type { FormIssue, ValidatorResult } from './types'
|
|
25
26
|
|
|
26
27
|
const FORM_BRAND = Symbol.for('olas.form')
|
|
27
28
|
const FIELD_ARRAY_BRAND = Symbol.for('olas.fieldArray')
|
|
@@ -35,6 +36,87 @@ const isFieldArray = (x: unknown): x is FieldArray<Field<unknown> | Form<FormSch
|
|
|
35
36
|
const isField = (x: unknown): x is Field<unknown> =>
|
|
36
37
|
typeof x === 'object' && x !== null && !isForm(x) && !isFieldArray(x)
|
|
37
38
|
|
|
39
|
+
/** Any node that can receive parent-form-validator-routed errors (T5.2). */
|
|
40
|
+
type FormErrorTarget = { setFormErrors?: (msgs: ReadonlyArray<string>) => void }
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Walk a form tree from `root` following `FormIssue.path` segments. Forms walk
|
|
44
|
+
* keys; field-arrays walk numeric indices. Returns the resolved node, or
|
|
45
|
+
* `undefined` if the path runs off the tree (bad index, or a leaf reached with
|
|
46
|
+
* an unconsumed path segment). Reads are untracked (`fields` is a static object;
|
|
47
|
+
* `at()` peeks) so calling this inside a validator effect adds no dependencies.
|
|
48
|
+
*/
|
|
49
|
+
function resolveNode(root: unknown, segments: ReadonlyArray<string | number>): unknown {
|
|
50
|
+
let cursor: unknown = root
|
|
51
|
+
for (const seg of segments) {
|
|
52
|
+
if (cursor === undefined || cursor === null) return undefined
|
|
53
|
+
if (isForm(cursor)) {
|
|
54
|
+
cursor = (cursor.fields as Record<string, unknown>)[String(seg)]
|
|
55
|
+
} else if (isFieldArray(cursor)) {
|
|
56
|
+
const idx = Number(seg)
|
|
57
|
+
if (!Number.isInteger(idx) || idx < 0) return undefined
|
|
58
|
+
cursor = (cursor as { at(i: number): unknown }).at(idx)
|
|
59
|
+
} else {
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return cursor
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Normalize one validator result into the shared `FormIssue[]` shape. A plain
|
|
68
|
+
* string is a message on the node itself (empty path); `null` contributes
|
|
69
|
+
* nothing; a `FormIssue[]` is appended verbatim.
|
|
70
|
+
*/
|
|
71
|
+
function appendIssues(out: FormIssue[], result: ValidatorResult): void {
|
|
72
|
+
if (result == null) return
|
|
73
|
+
if (typeof result === 'string') {
|
|
74
|
+
out.push({ path: [], message: result })
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
for (const issue of result) out.push(issue)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Route a fully-collected issue set for one form-level validation run:
|
|
82
|
+
* - empty-path (and unresolvable) issues → `topLevelErrors$` on the owning node
|
|
83
|
+
* - path issues → the resolved descendant's `setFormErrors`
|
|
84
|
+
*
|
|
85
|
+
* Targets that received an error last run but not this one are cleared, so a
|
|
86
|
+
* fixed cross-field rule removes its message from the field it landed on.
|
|
87
|
+
* Returns the new target set for the caller to retain. MUST run inside a batch.
|
|
88
|
+
*/
|
|
89
|
+
function routeFormIssues(
|
|
90
|
+
root: unknown,
|
|
91
|
+
issues: FormIssue[],
|
|
92
|
+
topLevelErrors$: Signal<string[]>,
|
|
93
|
+
lastTargets: Set<FormErrorTarget>,
|
|
94
|
+
): Set<FormErrorTarget> {
|
|
95
|
+
const topLevel: string[] = []
|
|
96
|
+
const byTarget = new Map<FormErrorTarget, string[]>()
|
|
97
|
+
for (const issue of issues) {
|
|
98
|
+
if (issue.path.length === 0) {
|
|
99
|
+
topLevel.push(issue.message)
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
const target = resolveNode(root, issue.path) as FormErrorTarget | undefined
|
|
103
|
+
if (target === undefined || typeof target.setFormErrors !== 'function') {
|
|
104
|
+
// Unresolvable path — surface at the top level rather than dropping it.
|
|
105
|
+
topLevel.push(issue.message)
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
const list = byTarget.get(target)
|
|
109
|
+
if (list) list.push(issue.message)
|
|
110
|
+
else byTarget.set(target, [issue.message])
|
|
111
|
+
}
|
|
112
|
+
topLevelErrors$.set(topLevel)
|
|
113
|
+
for (const t of lastTargets) {
|
|
114
|
+
if (!byTarget.has(t)) t.setFormErrors?.([])
|
|
115
|
+
}
|
|
116
|
+
for (const [t, msgs] of byTarget) t.setFormErrors?.(msgs)
|
|
117
|
+
return new Set(byTarget.keys())
|
|
118
|
+
}
|
|
119
|
+
|
|
38
120
|
class FormImpl<S extends FormSchema> implements Form<S> {
|
|
39
121
|
readonly [FORM_BRAND] = true
|
|
40
122
|
|
|
@@ -46,10 +128,34 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
46
128
|
readonly touched: ReadSignal<boolean>
|
|
47
129
|
readonly isValidating: ReadSignal<boolean>
|
|
48
130
|
readonly flatErrors: ReadSignal<Array<{ path: string; errors: string[] }>>
|
|
131
|
+
/**
|
|
132
|
+
* Dotted paths of every leaf whose `isDirty` is `true`. Recomputes when
|
|
133
|
+
* any child's dirty state flips; ordered by depth-first traversal so two
|
|
134
|
+
* snapshots of a stable tree compare with `===`-friendly references on
|
|
135
|
+
* unchanged subsets. Useful for partial-update PATCH payloads and
|
|
136
|
+
* "highlight the changed inputs" UIs.
|
|
137
|
+
*/
|
|
138
|
+
readonly dirtyFields: ReadSignal<string[]>
|
|
49
139
|
|
|
50
140
|
private readonly topLevelErrors$: Signal<string[]> = signal([])
|
|
51
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Errors routed to THIS form by an ancestor form-level validator (a
|
|
143
|
+
* `FormIssue` whose path resolves to this node). Merged into `topLevelErrors`
|
|
144
|
+
* beside this form's own validator output, so `topLevelErrors` means "errors
|
|
145
|
+
* attached to this node itself, whatever their source". Owned by the ancestor
|
|
146
|
+
* router (T5.2) — see `setFormErrors`.
|
|
147
|
+
*/
|
|
148
|
+
private readonly parentFormErrors$: Signal<string[]> = signal([])
|
|
149
|
+
readonly topLevelErrors: ReadSignal<string[]> = computed(() => {
|
|
150
|
+
const own = this.topLevelErrors$.value
|
|
151
|
+
const parent = this.parentFormErrors$.value
|
|
152
|
+
if (parent.length === 0) return own
|
|
153
|
+
if (own.length === 0) return parent
|
|
154
|
+
return [...own, ...parent]
|
|
155
|
+
})
|
|
52
156
|
private readonly topLevelValidating$: Signal<boolean> = signal(false)
|
|
157
|
+
/** Targets written by the last form-level run — cleared next run if absent. */
|
|
158
|
+
private lastFormErrorTargets: Set<FormErrorTarget> = new Set()
|
|
53
159
|
|
|
54
160
|
// Submission lifecycle.
|
|
55
161
|
private readonly isSubmitting$: Signal<boolean> = signal(false)
|
|
@@ -129,7 +235,9 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
129
235
|
return false
|
|
130
236
|
})
|
|
131
237
|
this.isValid = computed(() => {
|
|
132
|
-
|
|
238
|
+
// Merged view: this form's own top-level validators AND any errors an
|
|
239
|
+
// ancestor form-level validator routed onto this node (T5.2).
|
|
240
|
+
if (this.topLevelErrors.value.length > 0) return false
|
|
133
241
|
if (this.isValidating.value) return false
|
|
134
242
|
for (const child of Object.values(this.fields)) {
|
|
135
243
|
if (!(child as { isValid: ReadSignal<boolean> }).isValid.value) return false
|
|
@@ -137,6 +245,11 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
137
245
|
return true
|
|
138
246
|
})
|
|
139
247
|
this.flatErrors = computed(() => this.computeFlatErrors())
|
|
248
|
+
this.dirtyFields = computed(() => {
|
|
249
|
+
const out: string[] = []
|
|
250
|
+
collectDirtyFields(this.fields, '', out)
|
|
251
|
+
return out
|
|
252
|
+
})
|
|
140
253
|
|
|
141
254
|
if (this.validators.length > 0) {
|
|
142
255
|
this.validatorDispose = effect(() => this.runTopLevelValidators())
|
|
@@ -181,7 +294,7 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
181
294
|
|
|
182
295
|
private computeFlatErrors(): Array<{ path: string; errors: string[] }> {
|
|
183
296
|
const out: Array<{ path: string; errors: string[] }> = []
|
|
184
|
-
const tle = this.topLevelErrors
|
|
297
|
+
const tle = this.topLevelErrors.value
|
|
185
298
|
if (tle.length > 0) out.push({ path: '', errors: tle })
|
|
186
299
|
walkErrors(this.fields, '', out)
|
|
187
300
|
return out
|
|
@@ -272,13 +385,24 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
272
385
|
}
|
|
273
386
|
}
|
|
274
387
|
this.topLevelErrors$.set([])
|
|
388
|
+
this.parentFormErrors$.set([])
|
|
389
|
+
// Submission lifecycle is conceptually part of "form state"; resetting
|
|
390
|
+
// a form means the user is starting over. Without these clears, a UI
|
|
391
|
+
// bound to `submitCount`/`submitError` would show stale state after
|
|
392
|
+
// `reset()`. `isSubmitting` is deliberately NOT cleared — only the
|
|
393
|
+
// owning submit() flow can flip it back.
|
|
394
|
+
this.submitCount$.set(0)
|
|
395
|
+
this.submitError$.set(undefined)
|
|
396
|
+
// Re-apply initial (as initial, no dirty bump) INSIDE the batch — a
|
|
397
|
+
// separate pass would fire a second notification and briefly expose the
|
|
398
|
+
// "reset to construction seed, then re-seat to current initial" tearing
|
|
399
|
+
// (visible with a reactive `initial: () => …` whose deps changed) (T5.3).
|
|
400
|
+
if (this.options?.initial !== undefined) {
|
|
401
|
+
const ini =
|
|
402
|
+
typeof this.options.initial === 'function' ? this.options.initial() : this.options.initial
|
|
403
|
+
if (ini !== undefined) this.applyPartial(ini as DeepPartial<FormValue<S>>, true)
|
|
404
|
+
}
|
|
275
405
|
})
|
|
276
|
-
// Re-apply initial if provided — as initial (no dirty bump).
|
|
277
|
-
if (this.options?.initial !== undefined) {
|
|
278
|
-
const ini =
|
|
279
|
-
typeof this.options.initial === 'function' ? this.options.initial() : this.options.initial
|
|
280
|
-
if (ini !== undefined) this.applyPartial(ini as DeepPartial<FormValue<S>>, true)
|
|
281
|
-
}
|
|
282
406
|
}
|
|
283
407
|
|
|
284
408
|
markAllTouched(): void {
|
|
@@ -336,14 +460,14 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
336
460
|
* unless `onError: 'rethrow'`. A `resetOnSuccess: true` option calls
|
|
337
461
|
* `reset()` after the handler resolves successfully.
|
|
338
462
|
*/
|
|
339
|
-
async submit(
|
|
340
|
-
handler: (value: FormValue<S>) =>
|
|
463
|
+
async submit<R = unknown>(
|
|
464
|
+
handler: (value: FormValue<S>) => R | Promise<R>,
|
|
341
465
|
options?: {
|
|
342
466
|
validateBeforeSubmit?: boolean
|
|
343
467
|
resetOnSuccess?: boolean
|
|
344
468
|
onError?: 'rethrow' | 'capture'
|
|
345
469
|
},
|
|
346
|
-
): Promise<{ ok: boolean; data?:
|
|
470
|
+
): Promise<{ ok: boolean; data?: Awaited<R>; error?: unknown }> {
|
|
347
471
|
if (this.disposed) return { ok: false, error: new Error('form is disposed') }
|
|
348
472
|
|
|
349
473
|
// Double-submit guard — refusing to start a second submission while one
|
|
@@ -371,7 +495,7 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
371
495
|
return { ok: false }
|
|
372
496
|
}
|
|
373
497
|
}
|
|
374
|
-
const result = await handler(this.value.peek())
|
|
498
|
+
const result = (await handler(this.value.peek())) as Awaited<R>
|
|
375
499
|
if (options?.resetOnSuccess) this.reset()
|
|
376
500
|
this.isSubmitting$.set(false)
|
|
377
501
|
return { ok: true, data: result }
|
|
@@ -405,9 +529,45 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
405
529
|
})
|
|
406
530
|
}
|
|
407
531
|
|
|
532
|
+
/**
|
|
533
|
+
* Internal — receive errors routed here by an ancestor form-level validator
|
|
534
|
+
* (a `FormIssue` whose path resolved to this nested form). Guards against
|
|
535
|
+
* spurious writes so a whole-tree clear pass doesn't wake subscribers. See
|
|
536
|
+
* `routeFormIssues`.
|
|
537
|
+
*/
|
|
538
|
+
setFormErrors(errors: ReadonlyArray<string>): void {
|
|
539
|
+
if (this.disposed) return
|
|
540
|
+
if (this.parentFormErrors$.peek().length === 0 && errors.length === 0) return
|
|
541
|
+
this.parentFormErrors$.set(errors.length === 0 ? [] : [...errors])
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Reset a named subtree to its initial. `path` uses the same dotted /
|
|
546
|
+
* bracket notation as `setErrors` / `flatErrors`. Useful when
|
|
547
|
+
* `Form.set({foo: undefined})` would be ambiguous ("clear" vs "leave
|
|
548
|
+
* alone" — the latter is what `set` does today).
|
|
549
|
+
*
|
|
550
|
+
* Walks via `resolvePath`; the resolved target must expose `reset()`
|
|
551
|
+
* (Field, Form, or FieldArray all do). Unknown paths are silently
|
|
552
|
+
* ignored — `flatErrors`-shaped paths from upstream code shouldn't
|
|
553
|
+
* crash this. Pass an empty string to reset the whole form (same as
|
|
554
|
+
* `form.reset()`).
|
|
555
|
+
*/
|
|
556
|
+
clearSubtree(path: string): void {
|
|
557
|
+
if (this.disposed) return
|
|
558
|
+
if (path === '') {
|
|
559
|
+
this.reset()
|
|
560
|
+
return
|
|
561
|
+
}
|
|
562
|
+
const target = this.resolvePath(path) as { reset?: () => void } | undefined
|
|
563
|
+
if (target === undefined) return
|
|
564
|
+
if (typeof target.reset === 'function') target.reset()
|
|
565
|
+
}
|
|
566
|
+
|
|
408
567
|
private resolvePath(path: string): unknown {
|
|
409
568
|
if (path === '') return undefined
|
|
410
|
-
const segments = path
|
|
569
|
+
const segments = splitPath(path)
|
|
570
|
+
if (segments === null) return undefined
|
|
411
571
|
let cursor: unknown = this
|
|
412
572
|
for (const seg of segments) {
|
|
413
573
|
if (cursor === undefined || cursor === null) return undefined
|
|
@@ -450,26 +610,40 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
450
610
|
this.currentValidatorAbort = abort
|
|
451
611
|
const myId = ++this.currentValidatorRun
|
|
452
612
|
|
|
453
|
-
const
|
|
454
|
-
const asyncPromises: Promise<
|
|
613
|
+
const syncIssues: FormIssue[] = []
|
|
614
|
+
const asyncPromises: Promise<ValidatorResult>[] = []
|
|
455
615
|
for (const v of this.validators) {
|
|
456
616
|
try {
|
|
457
617
|
const r = v(value, abort.signal)
|
|
458
618
|
if (r instanceof Promise) asyncPromises.push(r)
|
|
459
|
-
else
|
|
619
|
+
else appendIssues(syncIssues, r)
|
|
460
620
|
} catch (err) {
|
|
461
621
|
try {
|
|
462
622
|
this.onValidatorError?.(err)
|
|
463
623
|
} catch {
|
|
464
624
|
// The reporter must not propagate.
|
|
465
625
|
}
|
|
466
|
-
|
|
626
|
+
// Prod shows a generic message (don't leak internal error text into
|
|
627
|
+
// form errors); the real error still reaches `onValidatorError` (T5.3).
|
|
628
|
+
syncIssues.push({
|
|
629
|
+
path: [],
|
|
630
|
+
message: __DEV__
|
|
631
|
+
? err instanceof Error
|
|
632
|
+
? err.message
|
|
633
|
+
: String(err)
|
|
634
|
+
: 'Validation failed',
|
|
635
|
+
})
|
|
467
636
|
}
|
|
468
637
|
}
|
|
469
638
|
|
|
470
|
-
if (
|
|
639
|
+
if (syncIssues.length > 0) {
|
|
471
640
|
batch(() => {
|
|
472
|
-
this.
|
|
641
|
+
this.lastFormErrorTargets = routeFormIssues(
|
|
642
|
+
this,
|
|
643
|
+
syncIssues,
|
|
644
|
+
this.topLevelErrors$,
|
|
645
|
+
this.lastFormErrorTargets,
|
|
646
|
+
)
|
|
473
647
|
this.topLevelValidating$.set(false)
|
|
474
648
|
})
|
|
475
649
|
return
|
|
@@ -477,31 +651,105 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
477
651
|
|
|
478
652
|
if (asyncPromises.length === 0) {
|
|
479
653
|
batch(() => {
|
|
480
|
-
this.
|
|
654
|
+
this.lastFormErrorTargets = routeFormIssues(
|
|
655
|
+
this,
|
|
656
|
+
[],
|
|
657
|
+
this.topLevelErrors$,
|
|
658
|
+
this.lastFormErrorTargets,
|
|
659
|
+
)
|
|
481
660
|
this.topLevelValidating$.set(false)
|
|
482
661
|
})
|
|
483
662
|
return
|
|
484
663
|
}
|
|
485
664
|
|
|
486
665
|
batch(() => {
|
|
487
|
-
this.
|
|
666
|
+
this.lastFormErrorTargets = routeFormIssues(
|
|
667
|
+
this,
|
|
668
|
+
[],
|
|
669
|
+
this.topLevelErrors$,
|
|
670
|
+
this.lastFormErrorTargets,
|
|
671
|
+
)
|
|
488
672
|
this.topLevelValidating$.set(true)
|
|
489
673
|
})
|
|
490
674
|
|
|
491
675
|
Promise.allSettled(asyncPromises).then((results) => {
|
|
492
676
|
if (myId !== this.currentValidatorRun || this.disposed) return
|
|
493
|
-
const
|
|
677
|
+
const issues: FormIssue[] = []
|
|
494
678
|
for (const r of results) {
|
|
495
|
-
if (r.status === 'fulfilled'
|
|
679
|
+
if (r.status === 'fulfilled') appendIssues(issues, r.value)
|
|
496
680
|
}
|
|
497
681
|
batch(() => {
|
|
498
|
-
this.
|
|
682
|
+
this.lastFormErrorTargets = routeFormIssues(
|
|
683
|
+
this,
|
|
684
|
+
issues,
|
|
685
|
+
this.topLevelErrors$,
|
|
686
|
+
this.lastFormErrorTargets,
|
|
687
|
+
)
|
|
499
688
|
this.topLevelValidating$.set(false)
|
|
500
689
|
})
|
|
501
690
|
})
|
|
502
691
|
}
|
|
503
692
|
}
|
|
504
693
|
|
|
694
|
+
/**
|
|
695
|
+
* Split a path string into segments, accepting both dot syntax (`users.0.name`)
|
|
696
|
+
* and the bracket syntax `walkErrors` emits for array items (`users[0].name`).
|
|
697
|
+
* Returns `null` if the path is malformed (unclosed bracket, empty bracket,
|
|
698
|
+
* non-numeric bracket index).
|
|
699
|
+
*/
|
|
700
|
+
function splitPath(path: string): string[] | null {
|
|
701
|
+
const out: string[] = []
|
|
702
|
+
let current = ''
|
|
703
|
+
for (let i = 0; i < path.length; i++) {
|
|
704
|
+
const ch = path[i]
|
|
705
|
+
if (ch === '.') {
|
|
706
|
+
if (current !== '') {
|
|
707
|
+
out.push(current)
|
|
708
|
+
current = ''
|
|
709
|
+
}
|
|
710
|
+
continue
|
|
711
|
+
}
|
|
712
|
+
if (ch === '[') {
|
|
713
|
+
if (current !== '') {
|
|
714
|
+
out.push(current)
|
|
715
|
+
current = ''
|
|
716
|
+
}
|
|
717
|
+
const close = path.indexOf(']', i + 1)
|
|
718
|
+
if (close === -1) return null
|
|
719
|
+
const idx = path.slice(i + 1, close)
|
|
720
|
+
if (idx === '' || !/^\d+$/.test(idx)) return null
|
|
721
|
+
out.push(idx)
|
|
722
|
+
i = close
|
|
723
|
+
continue
|
|
724
|
+
}
|
|
725
|
+
if (ch === ']') return null
|
|
726
|
+
current += ch
|
|
727
|
+
}
|
|
728
|
+
if (current !== '') out.push(current)
|
|
729
|
+
return out
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function collectDirtyFields(fields: FormSchema, prefix: string, out: string[]): void {
|
|
733
|
+
for (const [k, child] of Object.entries(fields)) {
|
|
734
|
+
const path = prefix ? `${prefix}.${k}` : k
|
|
735
|
+
if (isForm(child)) {
|
|
736
|
+
collectDirtyFields(child.fields, path, out)
|
|
737
|
+
} else if (isFieldArray(child)) {
|
|
738
|
+
const items = child.items.value
|
|
739
|
+
items.forEach((item, idx) => {
|
|
740
|
+
const itemPath = `${path}[${idx}]`
|
|
741
|
+
if (isForm(item)) {
|
|
742
|
+
collectDirtyFields(item.fields, itemPath, out)
|
|
743
|
+
} else if ((item as Field<unknown>).isDirty.value) {
|
|
744
|
+
out.push(itemPath)
|
|
745
|
+
}
|
|
746
|
+
})
|
|
747
|
+
} else if ((child as Field<unknown>).isDirty.value) {
|
|
748
|
+
out.push(path)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
505
753
|
function walkErrors(
|
|
506
754
|
fields: FormSchema,
|
|
507
755
|
prefix: string,
|
|
@@ -548,9 +796,28 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
|
|
|
548
796
|
readonly isValidating: ReadSignal<boolean>
|
|
549
797
|
|
|
550
798
|
private readonly items$: Signal<I[]>
|
|
799
|
+
/**
|
|
800
|
+
* Structural dirtiness — flipped by `add`/`insert`/`remove`/`move`/`clear`.
|
|
801
|
+
* Item-level `isDirty` alone misses these, so a reactive `initial` + the
|
|
802
|
+
* default `resetOnInitialChange: 'when-clean'` would re-seat the array on a
|
|
803
|
+
* background refetch and delete rows the user just added (T5.1). Reset by
|
|
804
|
+
* `reset()` and by an initial-driven re-anchor (`replaceInitialItems`).
|
|
805
|
+
*/
|
|
806
|
+
private readonly structurallyDirty$: Signal<boolean> = signal(false)
|
|
551
807
|
private readonly topLevelErrors$: Signal<string[]> = signal([])
|
|
552
|
-
|
|
808
|
+
/** Errors routed to this array by an ancestor form-level validator (T5.2) —
|
|
809
|
+
* merged into `topLevelErrors` beside the array's own validator output. */
|
|
810
|
+
private readonly parentFormErrors$: Signal<string[]> = signal([])
|
|
811
|
+
readonly topLevelErrors: ReadSignal<string[]> = computed(() => {
|
|
812
|
+
const own = this.topLevelErrors$.value
|
|
813
|
+
const parent = this.parentFormErrors$.value
|
|
814
|
+
if (parent.length === 0) return own
|
|
815
|
+
if (own.length === 0) return parent
|
|
816
|
+
return [...own, ...parent]
|
|
817
|
+
})
|
|
553
818
|
private readonly topLevelValidating$: Signal<boolean> = signal(false)
|
|
819
|
+
/** Targets written by the last array-level run — cleared next run if absent. */
|
|
820
|
+
private lastFormErrorTargets: Set<FormErrorTarget> = new Set()
|
|
554
821
|
|
|
555
822
|
private readonly itemFactory: (initial?: ItemInitial<I>) => I
|
|
556
823
|
private initialItems: Array<ItemInitial<I>> = []
|
|
@@ -602,6 +869,7 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
|
|
|
602
869
|
}),
|
|
603
870
|
)
|
|
604
871
|
this.isDirty = computed(() => {
|
|
872
|
+
if (this.structurallyDirty$.value) return true // add/remove/move/clear
|
|
605
873
|
for (const item of this.items$.value) {
|
|
606
874
|
if ((item as { isDirty: ReadSignal<boolean> }).isDirty.value) return true
|
|
607
875
|
}
|
|
@@ -621,7 +889,9 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
|
|
|
621
889
|
return false
|
|
622
890
|
})
|
|
623
891
|
this.isValid = computed(() => {
|
|
624
|
-
|
|
892
|
+
// Merged view: the array's own top-level validators AND any errors an
|
|
893
|
+
// ancestor form-level validator routed onto this node (T5.2).
|
|
894
|
+
if (this.topLevelErrors.value.length > 0) return false
|
|
625
895
|
if (this.isValidating.value) return false
|
|
626
896
|
for (const item of this.items$.value) {
|
|
627
897
|
if (!(item as { isValid: ReadSignal<boolean> }).isValid.value) return false
|
|
@@ -638,10 +908,21 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
|
|
|
638
908
|
return this.items$.peek()[index]
|
|
639
909
|
}
|
|
640
910
|
|
|
911
|
+
/**
|
|
912
|
+
* Internal — receive errors routed here by an ancestor form-level validator
|
|
913
|
+
* (a `FormIssue` whose path resolved to this array). See `routeFormIssues`.
|
|
914
|
+
*/
|
|
915
|
+
setFormErrors(errors: ReadonlyArray<string>): void {
|
|
916
|
+
if (this.disposed) return
|
|
917
|
+
if (this.parentFormErrors$.peek().length === 0 && errors.length === 0) return
|
|
918
|
+
this.parentFormErrors$.set(errors.length === 0 ? [] : [...errors])
|
|
919
|
+
}
|
|
920
|
+
|
|
641
921
|
add(initial?: ItemInitial<I>): void {
|
|
642
922
|
if (this.disposed) return
|
|
643
923
|
const item = this.itemFactory(initial)
|
|
644
924
|
this.items$.set([...this.items$.peek(), item])
|
|
925
|
+
this.structurallyDirty$.set(true)
|
|
645
926
|
}
|
|
646
927
|
|
|
647
928
|
insert(index: number, initial?: ItemInitial<I>): void {
|
|
@@ -650,6 +931,7 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
|
|
|
650
931
|
const next = [...this.items$.peek()]
|
|
651
932
|
next.splice(index, 0, item)
|
|
652
933
|
this.items$.set(next)
|
|
934
|
+
this.structurallyDirty$.set(true)
|
|
653
935
|
}
|
|
654
936
|
|
|
655
937
|
remove(index: number): void {
|
|
@@ -660,6 +942,7 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
|
|
|
660
942
|
;(removed as { dispose?: () => void }).dispose?.()
|
|
661
943
|
}
|
|
662
944
|
this.items$.set(next)
|
|
945
|
+
this.structurallyDirty$.set(true)
|
|
663
946
|
}
|
|
664
947
|
|
|
665
948
|
move(from: number, to: number): void {
|
|
@@ -668,6 +951,7 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
|
|
|
668
951
|
const [item] = next.splice(from, 1)
|
|
669
952
|
if (item) next.splice(to, 0, item)
|
|
670
953
|
this.items$.set(next)
|
|
954
|
+
this.structurallyDirty$.set(true)
|
|
671
955
|
}
|
|
672
956
|
|
|
673
957
|
clear(): void {
|
|
@@ -676,6 +960,7 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
|
|
|
676
960
|
;(item as { dispose?: () => void }).dispose?.()
|
|
677
961
|
}
|
|
678
962
|
this.items$.set([])
|
|
963
|
+
this.structurallyDirty$.set(true)
|
|
679
964
|
}
|
|
680
965
|
|
|
681
966
|
/**
|
|
@@ -686,6 +971,10 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
|
|
|
686
971
|
*/
|
|
687
972
|
replaceInitialItems(items: ReadonlyArray<ItemInitial<I>>): void {
|
|
688
973
|
this.initialItems = [...items]
|
|
974
|
+
// The array was just re-seated from `initial` (reactive-initial re-apply or
|
|
975
|
+
// `resetWithInitial`) — this is the new clean baseline, so the clear()/add()
|
|
976
|
+
// that drove it must not leave the array structurally dirty (T5.1).
|
|
977
|
+
this.structurallyDirty$.set(false)
|
|
689
978
|
}
|
|
690
979
|
|
|
691
980
|
reset(): void {
|
|
@@ -696,6 +985,10 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
|
|
|
696
985
|
this.add(ini)
|
|
697
986
|
}
|
|
698
987
|
this.topLevelErrors$.set([])
|
|
988
|
+
this.parentFormErrors$.set([])
|
|
989
|
+
// clear()/add() above flipped structural dirt; reset() lands on the
|
|
990
|
+
// clean initial baseline (T5.1).
|
|
991
|
+
this.structurallyDirty$.set(false)
|
|
699
992
|
})
|
|
700
993
|
}
|
|
701
994
|
|
|
@@ -749,26 +1042,40 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
|
|
|
749
1042
|
this.currentValidatorAbort = abort
|
|
750
1043
|
const myId = ++this.currentValidatorRun
|
|
751
1044
|
|
|
752
|
-
const
|
|
753
|
-
const asyncPromises: Promise<
|
|
1045
|
+
const syncIssues: FormIssue[] = []
|
|
1046
|
+
const asyncPromises: Promise<ValidatorResult>[] = []
|
|
754
1047
|
for (const v of this.validators) {
|
|
755
1048
|
try {
|
|
756
1049
|
const r = v(value, abort.signal)
|
|
757
1050
|
if (r instanceof Promise) asyncPromises.push(r)
|
|
758
|
-
else
|
|
1051
|
+
else appendIssues(syncIssues, r)
|
|
759
1052
|
} catch (err) {
|
|
760
1053
|
try {
|
|
761
1054
|
this.onValidatorError?.(err)
|
|
762
1055
|
} catch {
|
|
763
1056
|
// The reporter must not propagate.
|
|
764
1057
|
}
|
|
765
|
-
|
|
1058
|
+
// Prod shows a generic message (don't leak internal error text into
|
|
1059
|
+
// form errors); the real error still reaches `onValidatorError` (T5.3).
|
|
1060
|
+
syncIssues.push({
|
|
1061
|
+
path: [],
|
|
1062
|
+
message: __DEV__
|
|
1063
|
+
? err instanceof Error
|
|
1064
|
+
? err.message
|
|
1065
|
+
: String(err)
|
|
1066
|
+
: 'Validation failed',
|
|
1067
|
+
})
|
|
766
1068
|
}
|
|
767
1069
|
}
|
|
768
1070
|
|
|
769
|
-
if (
|
|
1071
|
+
if (syncIssues.length > 0) {
|
|
770
1072
|
batch(() => {
|
|
771
|
-
this.
|
|
1073
|
+
this.lastFormErrorTargets = routeFormIssues(
|
|
1074
|
+
this,
|
|
1075
|
+
syncIssues,
|
|
1076
|
+
this.topLevelErrors$,
|
|
1077
|
+
this.lastFormErrorTargets,
|
|
1078
|
+
)
|
|
772
1079
|
this.topLevelValidating$.set(false)
|
|
773
1080
|
})
|
|
774
1081
|
return
|
|
@@ -776,25 +1083,40 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
|
|
|
776
1083
|
|
|
777
1084
|
if (asyncPromises.length === 0) {
|
|
778
1085
|
batch(() => {
|
|
779
|
-
this.
|
|
1086
|
+
this.lastFormErrorTargets = routeFormIssues(
|
|
1087
|
+
this,
|
|
1088
|
+
[],
|
|
1089
|
+
this.topLevelErrors$,
|
|
1090
|
+
this.lastFormErrorTargets,
|
|
1091
|
+
)
|
|
780
1092
|
this.topLevelValidating$.set(false)
|
|
781
1093
|
})
|
|
782
1094
|
return
|
|
783
1095
|
}
|
|
784
1096
|
|
|
785
1097
|
batch(() => {
|
|
786
|
-
this.
|
|
1098
|
+
this.lastFormErrorTargets = routeFormIssues(
|
|
1099
|
+
this,
|
|
1100
|
+
[],
|
|
1101
|
+
this.topLevelErrors$,
|
|
1102
|
+
this.lastFormErrorTargets,
|
|
1103
|
+
)
|
|
787
1104
|
this.topLevelValidating$.set(true)
|
|
788
1105
|
})
|
|
789
1106
|
|
|
790
1107
|
Promise.allSettled(asyncPromises).then((results) => {
|
|
791
1108
|
if (myId !== this.currentValidatorRun || this.disposed) return
|
|
792
|
-
const
|
|
1109
|
+
const issues: FormIssue[] = []
|
|
793
1110
|
for (const r of results) {
|
|
794
|
-
if (r.status === 'fulfilled'
|
|
1111
|
+
if (r.status === 'fulfilled') appendIssues(issues, r.value)
|
|
795
1112
|
}
|
|
796
1113
|
batch(() => {
|
|
797
|
-
this.
|
|
1114
|
+
this.lastFormErrorTargets = routeFormIssues(
|
|
1115
|
+
this,
|
|
1116
|
+
issues,
|
|
1117
|
+
this.topLevelErrors$,
|
|
1118
|
+
this.lastFormErrorTargets,
|
|
1119
|
+
)
|
|
798
1120
|
this.topLevelValidating$.set(false)
|
|
799
1121
|
})
|
|
800
1122
|
})
|
package/src/forms/index.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
export type { FieldTransform, ValidateOn } from './field'
|
|
1
2
|
export type { StandardSchemaV1 } from './standard-schema'
|
|
2
3
|
export { isStandardSchema } from './standard-schema'
|
|
3
|
-
export type { Validator } from './types'
|
|
4
|
+
export type { FormIssue, Validator, ValidatorResult } from './types'
|
|
4
5
|
export {
|
|
5
6
|
email,
|
|
6
7
|
max,
|
|
7
8
|
maxLength,
|
|
8
9
|
min,
|
|
9
10
|
minLength,
|
|
11
|
+
mustBeTrue,
|
|
10
12
|
pattern,
|
|
11
13
|
required,
|
|
12
14
|
validator,
|
package/src/forms/types.ts
CHANGED
|
@@ -1 +1,27 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* A single validation issue, optionally targeting a descendant of a form tree.
|
|
3
|
+
*
|
|
4
|
+
* - An **empty** `path` means the node the validator is attached to itself —
|
|
5
|
+
* for a leaf `Field` that's the field; for a `Form` / `FieldArray` it's the
|
|
6
|
+
* node's `topLevelErrors`.
|
|
7
|
+
* - A **non-empty** `path` routes the `message` to the matching descendant
|
|
8
|
+
* (`Form` walks keys, `FieldArray` walks numeric indices). Unresolvable
|
|
9
|
+
* paths fall back to the owning node's `topLevelErrors` rather than vanishing.
|
|
10
|
+
*
|
|
11
|
+
* Segments are object keys (`string`) or array indices (`number`). Returned by
|
|
12
|
+
* form-level validators (cross-field rules) and by the Standard-Schema
|
|
13
|
+
* `validator(...)` adapter, which maps each `issue.path` here. See SPEC §8.3.
|
|
14
|
+
*/
|
|
15
|
+
export type FormIssue = { path: (string | number)[]; message: string }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What a {@link Validator} may return synchronously. A plain `string` is a
|
|
19
|
+
* message on the node itself (equivalent to a `FormIssue` with an empty path);
|
|
20
|
+
* `null` means "no error"; a `FormIssue[]` targets specific descendants.
|
|
21
|
+
*/
|
|
22
|
+
export type ValidatorResult = string | null | FormIssue[]
|
|
23
|
+
|
|
24
|
+
export type Validator<T> = (
|
|
25
|
+
value: T,
|
|
26
|
+
signal: AbortSignal,
|
|
27
|
+
) => ValidatorResult | Promise<ValidatorResult>
|