@4riders/reform 3.0.24
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 +266 -0
- package/dist/index.d.ts +2715 -0
- package/dist/index.es.js +1715 -0
- package/dist/index.es.js.map +1 -0
- package/package.json +70 -0
- package/src/index.ts +90 -0
- package/src/reform/ArrayHelper.ts +164 -0
- package/src/reform/Form.tsx +81 -0
- package/src/reform/FormManager.ts +494 -0
- package/src/reform/Reform.ts +15 -0
- package/src/reform/components/BaseCheckboxField.tsx +72 -0
- package/src/reform/components/BaseDateField.tsx +84 -0
- package/src/reform/components/BaseRadioField.tsx +72 -0
- package/src/reform/components/BaseSelectField.tsx +103 -0
- package/src/reform/components/BaseTextAreaField.tsx +87 -0
- package/src/reform/components/BaseTextField.tsx +135 -0
- package/src/reform/components/InputHTMLProps.tsx +89 -0
- package/src/reform/observers/observer.ts +131 -0
- package/src/reform/observers/observerPath.ts +327 -0
- package/src/reform/observers/useObservers.ts +232 -0
- package/src/reform/useForm.ts +246 -0
- package/src/reform/useFormContext.tsx +37 -0
- package/src/reform/useFormField.ts +75 -0
- package/src/reform/useRender.ts +12 -0
- package/src/yop/MessageProvider.ts +204 -0
- package/src/yop/Metadata.ts +304 -0
- package/src/yop/ObjectsUtil.ts +811 -0
- package/src/yop/TypesUtil.ts +148 -0
- package/src/yop/ValidationContext.ts +207 -0
- package/src/yop/Yop.ts +430 -0
- package/src/yop/constraints/CommonConstraints.ts +124 -0
- package/src/yop/constraints/Constraint.ts +135 -0
- package/src/yop/constraints/MinMaxConstraints.ts +53 -0
- package/src/yop/constraints/OneOfConstraint.ts +40 -0
- package/src/yop/constraints/TestConstraint.ts +176 -0
- package/src/yop/decorators/array.ts +157 -0
- package/src/yop/decorators/boolean.ts +69 -0
- package/src/yop/decorators/date.ts +73 -0
- package/src/yop/decorators/email.ts +66 -0
- package/src/yop/decorators/file.ts +69 -0
- package/src/yop/decorators/id.ts +35 -0
- package/src/yop/decorators/ignored.ts +40 -0
- package/src/yop/decorators/instance.ts +110 -0
- package/src/yop/decorators/number.ts +73 -0
- package/src/yop/decorators/string.ts +90 -0
- package/src/yop/decorators/test.ts +41 -0
- package/src/yop/decorators/time.ts +112 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { FormEvent } from "react"
|
|
2
|
+
import { clone, equal, get, set, SetResult, unset } from "../yop/ObjectsUtil"
|
|
3
|
+
import { FormConfig } from "./useForm"
|
|
4
|
+
import { ValidationForm, ResolvedConstraints, UnsafeResolvedConstraints, ValidationSettings, Yop, ConstraintsAtSettings } from "../yop/Yop"
|
|
5
|
+
import { joinPath, Path } from "../yop/ObjectsUtil"
|
|
6
|
+
import { ValidationStatus } from "../yop/ValidationContext"
|
|
7
|
+
import { ignored } from "../yop/decorators/ignored"
|
|
8
|
+
import { ArrayHelper } from "./ArrayHelper"
|
|
9
|
+
import { Reform } from "./Reform"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Validation settings for reform forms, extending base validation settings.
|
|
13
|
+
* @property method - The validation method to use.
|
|
14
|
+
* @ignore
|
|
15
|
+
*/
|
|
16
|
+
export interface ReformValidationSettings extends ValidationSettings {
|
|
17
|
+
method: "validate" | "validateAt" | "constraintsAt"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Settings for constraintsAt validation, combining reform and Yop constraint settings.
|
|
22
|
+
* @ignore
|
|
23
|
+
*/
|
|
24
|
+
export interface ReformConstraintsAtSettings extends ReformValidationSettings, ConstraintsAtSettings {
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Options object for setValue operations.
|
|
29
|
+
* @property touch - Whether to mark the field as touched.
|
|
30
|
+
* @property validate - Whether to validate after setting the value.
|
|
31
|
+
* @property propagate - Whether to propagate the change to observers.
|
|
32
|
+
* @category Form Management
|
|
33
|
+
*/
|
|
34
|
+
export type SetValueOptionsObject = {
|
|
35
|
+
/** Whether to mark the field as touched. */
|
|
36
|
+
touch?: boolean
|
|
37
|
+
/** Whether to validate after setting the value. */
|
|
38
|
+
validate?: boolean
|
|
39
|
+
/** Whether to propagate the change to observers. */
|
|
40
|
+
propagate?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Options for setValue: either a boolean (validate) or an options object.
|
|
45
|
+
* @category Form Management
|
|
46
|
+
*/
|
|
47
|
+
export type SetValueOptions = boolean | SetValueOptionsObject
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Interface for a form manager, providing value management, validation, and event APIs.
|
|
51
|
+
* @category Form Management
|
|
52
|
+
*/
|
|
53
|
+
export interface FormManager<T> extends ValidationForm {
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Renders the form, causing any changes to be reflected in the UI.
|
|
57
|
+
*/
|
|
58
|
+
render(): void
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Sets the submitting state of the form. Submitting state is automatically set to `true` when the form is submitted, and
|
|
62
|
+
* should be reset to `false` when submission is complete.
|
|
63
|
+
* @param submitting - Whether the form is submitting.
|
|
64
|
+
*/
|
|
65
|
+
setSubmitting(submitting: boolean): void
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The initial values of the form, as provided in the form config. These values are not modified by the form manager, and
|
|
69
|
+
* represent the original state of the form.
|
|
70
|
+
*/
|
|
71
|
+
readonly initialValues: T | null | undefined
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Used when initialValues is provided as a promise. Indicates whether the promise is still pending.
|
|
75
|
+
*/
|
|
76
|
+
readonly initialValuesPending: boolean
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The current values of the form. These values are managed by the form manager and should be considered the source of truth
|
|
80
|
+
* for the form state.
|
|
81
|
+
*/
|
|
82
|
+
readonly values: T
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Sets the value of a form field.
|
|
86
|
+
* @param path - The path to the field.
|
|
87
|
+
* @param value - The value to set.
|
|
88
|
+
* @param options - Options for setting the value. See {@link SetValueOptions}.
|
|
89
|
+
* @returns The result of the set operation, or undefined if the path was invalid. See {@link SetResult}.
|
|
90
|
+
*/
|
|
91
|
+
setValue(path: string | Path, value: unknown, options?: SetValueOptions): SetResult
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Validates the entire form.
|
|
95
|
+
* @param touchedOnly - Whether to validate only touched fields.
|
|
96
|
+
* @param ignore - A function to determine if a path should be ignored during validation.
|
|
97
|
+
*/
|
|
98
|
+
validate(touchedOnly?: boolean, ignore?: (path: Path) => boolean): Map<string, ValidationStatus>
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Validates a specific field located at a given path in the form.
|
|
102
|
+
* @param path - The path to the field.
|
|
103
|
+
* @param touchedOnly - Whether to validate only touched fields.
|
|
104
|
+
* @param skipAsync - Whether to skip asynchronous validation.
|
|
105
|
+
*/
|
|
106
|
+
validateAt(path: string | Path, touchedOnly?: boolean, skipAsync?: boolean): {
|
|
107
|
+
changed: boolean,
|
|
108
|
+
statuses: Map<string, ValidationStatus>
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Updates the asynchronous validation status of a specific field.
|
|
113
|
+
* @param path - The path to the field.
|
|
114
|
+
*/
|
|
115
|
+
updateAsyncStatus(path: string | Path): void
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Scrolls to the first field with a validation error, if any. This is typically called after form submission if there
|
|
119
|
+
* are validation errors, to bring the user's attention to the first error. The implementation will scroll to an HTML with
|
|
120
|
+
* an id set to the path of the field with the error, so form fields should have their id set accordingly for this to work.
|
|
121
|
+
*/
|
|
122
|
+
scrollToFirstError(): void
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Retrieves the constraints for a specific field.
|
|
126
|
+
* @param path - The path to the field.
|
|
127
|
+
* @param unsafeMetadata - Whether to include unsafe field metadata. If `true`, the resolved constraints will be of type
|
|
128
|
+
* {@link UnsafeResolvedConstraints}, which enables modification of the field constraints stored in the class metadata.
|
|
129
|
+
* This can be useful for advanced use cases, but should be used with caution.
|
|
130
|
+
* @return The resolved constraints for the field, or undefined if there are no constraints. See {@link ResolvedConstraints}.
|
|
131
|
+
*/
|
|
132
|
+
constraintsAt<MinMax = unknown>(path: string | Path, unsafeMetadata?: boolean): ResolvedConstraints<MinMax> | undefined
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Handler for form submission. This should be called in the onSubmit handler of the form element. It will prevent the default
|
|
136
|
+
* form submission behavior,
|
|
137
|
+
* @param e - The form submission event.
|
|
138
|
+
*/
|
|
139
|
+
submit(e: FormEvent<HTMLFormElement>): void
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Retrieves an array helper for a specific field. The array helper provides methods for manipulating array fields, such
|
|
143
|
+
* as appending, inserting, and removing elements, with automatic touch, validation, and rendering.
|
|
144
|
+
* @param path - The path to the array field.
|
|
145
|
+
* @return An ArrayHelper instance for the specified path, or undefined if the field is not an array.
|
|
146
|
+
*/
|
|
147
|
+
array<T = any>(path: string): ArrayHelper<T> | undefined
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Adds an event listener for reform events of type {@link ReformSetValueEvent}.
|
|
151
|
+
* @param listener - The event listener to add.
|
|
152
|
+
*/
|
|
153
|
+
addReformEventListener(listener: EventListener): void
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Removes an event listener for reform events of type {@link ReformSetValueEvent}.
|
|
157
|
+
* @param listener - The event listener to remove.
|
|
158
|
+
*/
|
|
159
|
+
removeReformEventListener(listener: EventListener): void
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The event type string for reform set value events.
|
|
164
|
+
* @ignore
|
|
165
|
+
*/
|
|
166
|
+
const ReformSetValueEventType = 'reform:set-value'
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Event fired when a value is set in the form, used for observer propagation.
|
|
170
|
+
* @template T - The type of the value being set.
|
|
171
|
+
* @category Form Management
|
|
172
|
+
*/
|
|
173
|
+
export interface ReformSetValueEvent<T = any> extends CustomEvent<{
|
|
174
|
+
readonly form: FormManager<unknown>,
|
|
175
|
+
readonly path: string,
|
|
176
|
+
readonly previousValue: T,
|
|
177
|
+
readonly value: T,
|
|
178
|
+
readonly options: SetValueOptionsObject
|
|
179
|
+
}> {
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Creates a ReformSetValueEvent for observer propagation.
|
|
184
|
+
* @template T - The type of the value being set.
|
|
185
|
+
* @param form - The form manager instance.
|
|
186
|
+
* @param path - The path to the value being set.
|
|
187
|
+
* @param previousValue - The previous value at the path.
|
|
188
|
+
* @param value - The new value being set.
|
|
189
|
+
* @param options - The set value options.
|
|
190
|
+
* @returns The created ReformSetValueEvent.
|
|
191
|
+
* @ignore
|
|
192
|
+
*/
|
|
193
|
+
function createReformSetValueEvent<T = any>(
|
|
194
|
+
form: FormManager<unknown>,
|
|
195
|
+
path: string,
|
|
196
|
+
previousValue: T,
|
|
197
|
+
value: T,
|
|
198
|
+
options: SetValueOptionsObject
|
|
199
|
+
): ReformSetValueEvent<T> {
|
|
200
|
+
return new CustomEvent(ReformSetValueEventType, { detail: { form, path, previousValue, value, options }})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Implementation of the FormManager interface, providing value management, validation, eventing, and array helpers.
|
|
205
|
+
* @ignore
|
|
206
|
+
*/
|
|
207
|
+
export class InternalFormManager<T extends object | null | undefined> implements FormManager<T> {
|
|
208
|
+
|
|
209
|
+
private _config: FormConfig<T> = { validationSchema: ignored() }
|
|
210
|
+
private yop = new Yop()
|
|
211
|
+
private pathCache = new Map<string, Path>()
|
|
212
|
+
|
|
213
|
+
private _initialValues: unknown = undefined
|
|
214
|
+
private _initialValuesPending = false
|
|
215
|
+
private _values: unknown = undefined
|
|
216
|
+
private _statuses = new Map<string, ValidationStatus>()
|
|
217
|
+
private touched: object | true | null = null
|
|
218
|
+
private _submitting = false
|
|
219
|
+
private _submitted = false
|
|
220
|
+
|
|
221
|
+
private eventTarget = new EventTarget()
|
|
222
|
+
|
|
223
|
+
htmlForm?: HTMLFormElement
|
|
224
|
+
|
|
225
|
+
constructor(readonly render: () => void) {
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
addReformEventListener(listener: EventListener) {
|
|
229
|
+
this.eventTarget.addEventListener(ReformSetValueEventType, listener)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
removeReformEventListener(listener: EventListener) {
|
|
233
|
+
this.eventTarget.removeEventListener(ReformSetValueEventType, listener)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
get initialValuesPending() {
|
|
237
|
+
return this._initialValuesPending
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
get submitted() {
|
|
241
|
+
return this._submitted
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
get submitting() {
|
|
245
|
+
return this._submitting
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
get config() {
|
|
249
|
+
return this._config
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
get store() {
|
|
253
|
+
return this.yop.store
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
set initialValuesPending(pending: boolean) {
|
|
257
|
+
this._initialValuesPending = pending
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
setSubmitting(submitting: boolean): void {
|
|
261
|
+
this._submitting = submitting
|
|
262
|
+
this.render()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
commitInitialValues() {
|
|
266
|
+
this._initialValues = clone(this._config.initialValues)
|
|
267
|
+
if (this._config.initialValuesConverter != null)
|
|
268
|
+
this._initialValues = this._config.initialValuesConverter(this._initialValues as T)
|
|
269
|
+
this._values = clone(this._initialValues)
|
|
270
|
+
this.touched = null
|
|
271
|
+
this._statuses = new Map()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
onRender(config: FormConfig<T>) {
|
|
275
|
+
this._config = config
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
get initialValues(): T | null | undefined {
|
|
279
|
+
return this._initialValues as T | null | undefined
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
get values(): T {
|
|
283
|
+
if (this._values == null && this._config.initialValues != null)
|
|
284
|
+
this.commitInitialValues()
|
|
285
|
+
return this._values as T
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
getValue<V = any>(path: string | Path): V | undefined {
|
|
289
|
+
return get<V>(this.values, path, this.pathCache)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
setValue(path: string | Path, value: unknown, options?: SetValueOptions): SetResult {
|
|
293
|
+
const result = set(this.values, path, value, this.pathCache, { clone: true })
|
|
294
|
+
if (result == null)
|
|
295
|
+
return undefined
|
|
296
|
+
this._values = result.root
|
|
297
|
+
|
|
298
|
+
const { touch, validate, propagate } = { propagate: true, ...(typeof options === "boolean" ? { validate: options } : options) }
|
|
299
|
+
if (touch === false)
|
|
300
|
+
this.untouch(path)
|
|
301
|
+
else if (validate || touch)
|
|
302
|
+
this.touch(path)
|
|
303
|
+
|
|
304
|
+
if (validate) {
|
|
305
|
+
this.validate()
|
|
306
|
+
this.render()
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (this._config.dispatchEvent !== false && propagate === true) {
|
|
310
|
+
setTimeout(() => {
|
|
311
|
+
this.eventTarget.dispatchEvent(createReformSetValueEvent(
|
|
312
|
+
this,
|
|
313
|
+
typeof path === "string" ? path : joinPath(path),
|
|
314
|
+
result.previousValue,
|
|
315
|
+
value,
|
|
316
|
+
{ touch, validate, propagate }
|
|
317
|
+
))
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return result
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
isDirty(path?: string | Path, ignoredPath?: string | Path) {
|
|
325
|
+
if (path == null || path.length === 0)
|
|
326
|
+
return !equal(this.values, this._initialValues, ignoredPath)
|
|
327
|
+
return !equal(get(this.values, path, this.pathCache), get(this._initialValues, path, this.pathCache), ignoredPath)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
isTouched(path: string | Path = []) {
|
|
331
|
+
return get(this.touched, path, this.pathCache) != null
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
touch(path: string | Path = []) {
|
|
335
|
+
this.touched = set(this.touched, path, true, this.pathCache, { condition: currentValue => currentValue === undefined })?.root ?? null
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
untouch(path: string | Path = []) {
|
|
339
|
+
if (path.length === 0)
|
|
340
|
+
this.touched = null
|
|
341
|
+
else
|
|
342
|
+
unset(this.touched, path, this.pathCache)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
getTouchedValue<T = any>(path: string | Path) {
|
|
346
|
+
return get(this.touched, path, this.pathCache) as T
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
setTouchedValue(path: string | Path, value: any) {
|
|
350
|
+
this.touched = set(this.touched, path, value, this.pathCache)?.root ?? null
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
get statuses(): Map<string, ValidationStatus> {
|
|
354
|
+
return this._statuses
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
get errors(): ValidationStatus[] {
|
|
358
|
+
return Array.from(this._statuses.values()).filter(status => status.level === "error")
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
validate(touchedOnly = true, ignore?: (path: Path, form: FormManager<T>) => boolean): Map<string, ValidationStatus> {
|
|
362
|
+
let ignoreFn = ignore
|
|
363
|
+
if (this._config.ignore != null) {
|
|
364
|
+
if (ignore != null)
|
|
365
|
+
ignoreFn = (path, form) => ignore(path, form) || this._config.ignore!(path, form)
|
|
366
|
+
else
|
|
367
|
+
ignoreFn = this._config.ignore
|
|
368
|
+
}
|
|
369
|
+
if (!this._submitted && touchedOnly) {
|
|
370
|
+
if (ignoreFn == null)
|
|
371
|
+
ignoreFn = (path, _form) => !this.isTouched(path)
|
|
372
|
+
else {
|
|
373
|
+
const previousIgnore = ignoreFn
|
|
374
|
+
ignoreFn = (path, form) => !this.isTouched(path) || previousIgnore(path, form)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const schema = this._config.validationSchema ?? ignored()
|
|
379
|
+
const options: ReformValidationSettings = {
|
|
380
|
+
method: "validate",
|
|
381
|
+
form: this,
|
|
382
|
+
groups: this._config.validationGroups,
|
|
383
|
+
ignore: ignoreFn != null ? path => ignoreFn(path, this) : undefined
|
|
384
|
+
}
|
|
385
|
+
if (Array.isArray(this._config.validationPath)) {
|
|
386
|
+
this._statuses = new Map()
|
|
387
|
+
for (const path of this._config.validationPath) {
|
|
388
|
+
options.path = path
|
|
389
|
+
this.yop.rawValidate(this.values, schema, options)?.statuses?.forEach((status, path) => this._statuses.set(path, status))
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
options.path = this._config.validationPath
|
|
394
|
+
this._statuses = this.yop.rawValidate(this.values, schema, options)?.statuses ?? new Map()
|
|
395
|
+
}
|
|
396
|
+
return this._statuses
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
validateAt(path: string | Path, touchedOnly = true, skipAsync = true) {
|
|
400
|
+
if (touchedOnly && !this.submitted && !this.isTouched(path))
|
|
401
|
+
return { changed: false, statuses: new Map<string, ValidationStatus>() }
|
|
402
|
+
|
|
403
|
+
let changed = false
|
|
404
|
+
const prefix = typeof path === "string" ? path : joinPath(path)
|
|
405
|
+
for (const key of this._statuses.keys()) {
|
|
406
|
+
if (key.startsWith(prefix) && (key.length === prefix.length || ['.', '['].includes(key.charAt(prefix.length)))) {
|
|
407
|
+
this._statuses.delete(key)
|
|
408
|
+
changed = true
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const options: ReformValidationSettings = {
|
|
413
|
+
method: "validateAt",
|
|
414
|
+
form: this,
|
|
415
|
+
path,
|
|
416
|
+
skipAsync,
|
|
417
|
+
groups: this._config.validationGroups,
|
|
418
|
+
ignore: this._config.ignore != null ? path => this._config.ignore!(path, this) : undefined
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const statuses = this.yop.rawValidate(this.values, this._config.validationSchema ?? ignored(), options)?.statuses ?? new Map<string, ValidationStatus>()
|
|
422
|
+
statuses.forEach((status, path) => this._statuses.set(path, status))
|
|
423
|
+
return { changed: changed || statuses.size > 0, statuses }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
constraintsAt<MinMax = unknown>(path: string | Path, unsafeMetadata?: boolean): ResolvedConstraints<MinMax> | undefined {
|
|
427
|
+
const settings: ReformConstraintsAtSettings = { method: "constraintsAt", form: this, path, unsafeMetadata }
|
|
428
|
+
return this.yop.constraintsAt(this._config.validationSchema ?? ignored(), this.values, settings)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
updateAsyncStatus(path: string | Path) {
|
|
432
|
+
const status = this.yop.getAsyncStatus(path)
|
|
433
|
+
if (status != null)
|
|
434
|
+
this._statuses.set(status.path, status)
|
|
435
|
+
else {
|
|
436
|
+
path = typeof path === "string" ? path : joinPath(path)
|
|
437
|
+
if (this._statuses.get(path)?.level === "pending")
|
|
438
|
+
this._statuses.delete(path)
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
submit(e: FormEvent<HTMLFormElement>): void {
|
|
443
|
+
e.preventDefault()
|
|
444
|
+
e.stopPropagation()
|
|
445
|
+
|
|
446
|
+
this._submitted = true
|
|
447
|
+
this.setSubmitting(true)
|
|
448
|
+
|
|
449
|
+
setTimeout(async () => {
|
|
450
|
+
let statuses = Array.from(this.validate(false).values())
|
|
451
|
+
const pendings = statuses.filter(status => status.level === "pending")
|
|
452
|
+
|
|
453
|
+
if (pendings.length > 0) {
|
|
454
|
+
this.render()
|
|
455
|
+
const asyncStatuses = (await Promise.all<ValidationStatus | undefined>(pendings.map(status => status.constraint)))
|
|
456
|
+
.filter(status => status != null)
|
|
457
|
+
if (asyncStatuses.length > 0) {
|
|
458
|
+
asyncStatuses.forEach(status => this._statuses.set(status.path, status))
|
|
459
|
+
statuses = Array.from(this._statuses.values())
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const errors = statuses.filter(status => status.level === "error" || (status.level === "unavailable" && status.message))
|
|
464
|
+
const canSubmit = this._config.submitGuard?.(this)
|
|
465
|
+
if (errors.length === 0 && canSubmit !== false)
|
|
466
|
+
(this._config.onSubmit ?? (form => form.setSubmitting(false)))(this)
|
|
467
|
+
else {
|
|
468
|
+
if (Reform.logFormErrors && errors.length > 0)
|
|
469
|
+
console.error("Validation errors", errors)
|
|
470
|
+
if (canSubmit !== false)
|
|
471
|
+
this.scrollToFirstError(errors)
|
|
472
|
+
this.setSubmitting(false)
|
|
473
|
+
}
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
scrollToFirstError(errors?: ValidationStatus[]) {
|
|
478
|
+
errors ??= Array.from(this.statuses.values()).filter(status => status.level === "error" || (status.level === "unavailable" && status.message))
|
|
479
|
+
const element = errors
|
|
480
|
+
.map(status => window.document.getElementById(status.path))
|
|
481
|
+
.filter(elt => elt != null)
|
|
482
|
+
.sort((elt1, elt2) => elt1.compareDocumentPosition(elt2) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1)
|
|
483
|
+
.shift()
|
|
484
|
+
if (element != null) {
|
|
485
|
+
element.scrollIntoView({ behavior: "smooth", block: "center" })
|
|
486
|
+
element.focus({ preventScroll: true })
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
array<T = any>(path: string): ArrayHelper<T> | undefined {
|
|
491
|
+
const helper = new ArrayHelper<T>(this, path)
|
|
492
|
+
return helper.isArray() ? helper : undefined
|
|
493
|
+
}
|
|
494
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static configuration for form error display and logging.
|
|
3
|
+
* @category Form Management
|
|
4
|
+
*/
|
|
5
|
+
export class Reform {
|
|
6
|
+
/**
|
|
7
|
+
* Whether to display form errors in the UI (debug only).
|
|
8
|
+
*/
|
|
9
|
+
public static displayFormErrors = false
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether to log form errors to the console (debug only).
|
|
13
|
+
*/
|
|
14
|
+
public static logFormErrors = true
|
|
15
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import React, { InputHTMLAttributes, useRef } from "react"
|
|
2
|
+
import { InputAttributes, ReformEvents } from "./InputHTMLProps"
|
|
3
|
+
import { useFormField } from "../useFormField"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @ignore
|
|
7
|
+
*/
|
|
8
|
+
export type BaseCheckboxFieldHTMLAttributes = Omit<InputAttributes<'checkbox'>,
|
|
9
|
+
'accept' |
|
|
10
|
+
'alt' |
|
|
11
|
+
'autocomplete' |
|
|
12
|
+
'capture' |
|
|
13
|
+
'dirname' |
|
|
14
|
+
'height' |
|
|
15
|
+
'list' |
|
|
16
|
+
'max' |
|
|
17
|
+
'maxLength' |
|
|
18
|
+
'min' |
|
|
19
|
+
'minLength' |
|
|
20
|
+
'multiple' |
|
|
21
|
+
'placeholder' |
|
|
22
|
+
'readOnly' |
|
|
23
|
+
'size' |
|
|
24
|
+
'src' |
|
|
25
|
+
'step' |
|
|
26
|
+
'type' |
|
|
27
|
+
'width'
|
|
28
|
+
>
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @category Base Inputs Components
|
|
32
|
+
*/
|
|
33
|
+
export type BaseCheckboxFieldProps = BaseCheckboxFieldHTMLAttributes & Omit<ReformEvents<boolean>, 'onBlur'> & {
|
|
34
|
+
render: () => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A base checkbox field component that can be used to create custom checkbox input components connected to the form state.
|
|
39
|
+
* @category Base Inputs Components
|
|
40
|
+
*/
|
|
41
|
+
export function BaseCheckboxField(props: BaseCheckboxFieldProps) {
|
|
42
|
+
const { onChange, render, ...inputProps } = props
|
|
43
|
+
const { value: fieldValue, form } = useFormField<boolean | null, unknown>(props.name)
|
|
44
|
+
|
|
45
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
46
|
+
|
|
47
|
+
const internalOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
48
|
+
const value = event.currentTarget.checked
|
|
49
|
+
if (value !== fieldValue) {
|
|
50
|
+
form.setValue(props.name, value, true)
|
|
51
|
+
onChange?.(value, form)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// If this is the first render or if this input isn't currently edited
|
|
56
|
+
if (inputRef.current == null || inputRef.current !== document.activeElement) {
|
|
57
|
+
const value = fieldValue ?? false
|
|
58
|
+
if (inputRef.current)
|
|
59
|
+
inputRef.current.checked = value
|
|
60
|
+
else
|
|
61
|
+
(inputProps as InputHTMLAttributes<HTMLInputElement>).defaultChecked = value
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<input
|
|
66
|
+
{ ...inputProps }
|
|
67
|
+
type="checkbox"
|
|
68
|
+
ref={ inputRef }
|
|
69
|
+
onChange={ internalOnChange }
|
|
70
|
+
/>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { InputHTMLAttributes, useRef } from "react"
|
|
2
|
+
import { useFormField } from "../useFormField"
|
|
3
|
+
import { BaseTextFieldHTMLAttributes } from "./BaseTextField"
|
|
4
|
+
import { ReformEvents } from "./InputHTMLProps"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @ignore
|
|
8
|
+
*/
|
|
9
|
+
export const localDateToString = (date: Date | null | undefined) => {
|
|
10
|
+
if (date && !isNaN(date.getTime())) {
|
|
11
|
+
const year = date.getFullYear().toString().padStart(4, '0')
|
|
12
|
+
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
|
13
|
+
const day = date.getDate().toString().padStart(2, '0')
|
|
14
|
+
return `${ year }-${ month }-${ day }`
|
|
15
|
+
}
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @ignore
|
|
21
|
+
*/
|
|
22
|
+
export const stringToLocalDate = (value: unknown) => {
|
|
23
|
+
if (value == null || typeof value !== "string")
|
|
24
|
+
return null
|
|
25
|
+
const timeIndex = value.indexOf("T")
|
|
26
|
+
if (timeIndex >= 0)
|
|
27
|
+
value = value.substring(0, timeIndex)
|
|
28
|
+
const date = new Date(value + "T00:00:00")
|
|
29
|
+
return isNaN(date.getTime()) ? null : date
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @category Base Inputs Components
|
|
34
|
+
*/
|
|
35
|
+
type BaseDateFieldProps = BaseTextFieldHTMLAttributes & ReformEvents<Date> & {
|
|
36
|
+
render: () => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A base date field component that can be used to create custom date input components connected to the form state.
|
|
41
|
+
* @category Base Inputs Components
|
|
42
|
+
*/
|
|
43
|
+
export function BaseDateField(props: BaseDateFieldProps) {
|
|
44
|
+
|
|
45
|
+
const { onChange, onBlur, render, ...inputProps } = props
|
|
46
|
+
const { value: fieldValue, form } = useFormField<Date | null, number>(props.name)
|
|
47
|
+
|
|
48
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
49
|
+
|
|
50
|
+
const getInputValue = (event: React.SyntheticEvent<HTMLInputElement>) => {
|
|
51
|
+
const value = event.currentTarget.value
|
|
52
|
+
return stringToLocalDate(value)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const internalOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
56
|
+
if (!event.currentTarget.validity.badInput) {
|
|
57
|
+
const value = getInputValue(event)
|
|
58
|
+
if (value !== fieldValue) {
|
|
59
|
+
form.setValue(props.name, value)
|
|
60
|
+
if (form.validateAt(props.name).changed)
|
|
61
|
+
render()
|
|
62
|
+
onChange?.(value, form)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const internalOnBlur = (event: React.FocusEvent<HTMLInputElement>) => {
|
|
68
|
+
const { valid, valueMissing, badInput } = event.currentTarget.validity
|
|
69
|
+
const value = valid ? getInputValue(event) : valueMissing && !badInput ? null : fieldValue ?? null
|
|
70
|
+
form.setValue(props.name, value, true)
|
|
71
|
+
onBlur?.(value, form)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// If this is the first render or if this input isn't currently edited
|
|
75
|
+
if (inputRef.current == null || inputRef.current !== document.activeElement) {
|
|
76
|
+
const value = localDateToString(fieldValue) ?? ""
|
|
77
|
+
if (inputRef.current)
|
|
78
|
+
inputRef.current.value = value
|
|
79
|
+
else
|
|
80
|
+
(inputProps as InputHTMLAttributes<HTMLInputElement>).defaultValue = value
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return <input { ...inputProps } ref={ inputRef } onChange={ internalOnChange } onBlur={ internalOnBlur } />
|
|
84
|
+
}
|