mobx-formly 0.0.3 → 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/create-form.d.ts +9 -3
- package/dist/create-form.js +5 -7
- package/dist/form.d.ts +239 -41
- package/dist/form.js +249 -318
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/path-store.d.ts +38 -0
- package/dist/path-store.js +132 -0
- package/dist/types.d.ts +88 -9
- package/dist/utils.d.ts +10 -0
- package/dist/utils.js +156 -9
- package/dist/validation.d.ts +52 -0
- package/dist/validation.js +232 -0
- package/package.json +10 -2
package/dist/form.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { action, computed, makeObservable, observable,
|
|
1
|
+
import { action, computed, makeObservable, observable, runInAction } from 'mobx';
|
|
2
2
|
import { createRef } from 'yummies/mobx';
|
|
3
3
|
import { clone, deleteAtPath, extractValue, getAtPath, isEqual, setAtPath } from './utils.js';
|
|
4
|
-
|
|
4
|
+
import { collectChangedPaths, collectDirtyPaths, collectErrorPaths, findErrorAtPath } from './utils.js';
|
|
5
|
+
import { PathStore } from './path-store.js';
|
|
6
|
+
import { FormValidator } from './validation.js';
|
|
7
|
+
const isPromiseLike = (value) => !!value && typeof value.then === 'function';
|
|
8
|
+
export class BaseForm {
|
|
5
9
|
/**
|
|
6
10
|
* Current form values.
|
|
7
11
|
*
|
|
@@ -9,21 +13,17 @@ export class Form {
|
|
|
9
13
|
*/
|
|
10
14
|
values;
|
|
11
15
|
/**
|
|
12
|
-
*
|
|
16
|
+
* Cached default values used by reset, resetField, and dirty comparison.
|
|
17
|
+
* Updated by reset unless `keepDefaultValues` is passed.
|
|
13
18
|
*
|
|
14
|
-
* [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#
|
|
19
|
+
* [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#defaultvalues)
|
|
15
20
|
*/
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
errorProxyCache = new Map();
|
|
20
|
-
fieldStatesByPath = observable.map();
|
|
21
|
-
fieldStatePathCounts = observable.map();
|
|
22
|
-
fieldStateChildren = new Map();
|
|
23
|
-
fieldStateProxyCache = new Map();
|
|
21
|
+
defaultValues;
|
|
22
|
+
errorStore = new PathStore();
|
|
23
|
+
fieldStateStore = new PathStore();
|
|
24
24
|
/** Validation errors nested by field path. */
|
|
25
25
|
get errors() {
|
|
26
|
-
return this.
|
|
26
|
+
return this.errorStore.proxy();
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
29
|
* Field paths whose values differ from their defaults.
|
|
@@ -44,13 +44,12 @@ export class Form {
|
|
|
44
44
|
*/
|
|
45
45
|
validatingFields = {};
|
|
46
46
|
/**
|
|
47
|
-
* Observable state for each registered field.
|
|
47
|
+
* Observable state for each registered field, nested by field path.
|
|
48
48
|
*
|
|
49
49
|
* [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#fieldstate)
|
|
50
50
|
*/
|
|
51
|
-
/** Observable state for each registered field, nested by field path. */
|
|
52
51
|
get fieldState() {
|
|
53
|
-
return this.
|
|
52
|
+
return this.fieldStateStore.proxy();
|
|
54
53
|
}
|
|
55
54
|
/**
|
|
56
55
|
* Whether a submission is currently running.
|
|
@@ -82,29 +81,38 @@ export class Form {
|
|
|
82
81
|
* [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#refs)
|
|
83
82
|
*/
|
|
84
83
|
refs = new Map();
|
|
85
|
-
defaultValues;
|
|
86
84
|
options;
|
|
87
85
|
fieldOptions = new Map();
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
changedPaths = new Set();
|
|
91
|
-
isMutating = false;
|
|
92
|
-
observerTreeChanged = false;
|
|
86
|
+
touchedValidationFields = new Set();
|
|
87
|
+
validator;
|
|
93
88
|
activeSubmissions = 0;
|
|
94
89
|
resetVersion = 0;
|
|
90
|
+
isValidOverride = observable.box(undefined);
|
|
95
91
|
validationVersion = 0;
|
|
96
92
|
fieldValidationVersions = new Map();
|
|
93
|
+
/** Whether at least one validation pass has completed since construction or reset. */
|
|
94
|
+
hasValidationRun = false;
|
|
97
95
|
constructor(options = {}) {
|
|
98
96
|
this.options = {
|
|
99
97
|
...options,
|
|
100
98
|
mode: options.mode ?? 'onSubmit',
|
|
101
99
|
reValidateMode: options.reValidateMode ?? 'onChange',
|
|
102
100
|
disabled: options.disabled ?? false,
|
|
101
|
+
shouldFocusError: options.shouldFocusError ?? true,
|
|
103
102
|
};
|
|
104
103
|
this.defaultValues = clone((options.defaultValues ?? {}));
|
|
105
104
|
this.values = clone((options.values ?? options.defaultValues ?? {}));
|
|
105
|
+
this.validator = new FormValidator({
|
|
106
|
+
options: this.options,
|
|
107
|
+
fieldOptions: this.fieldOptions,
|
|
108
|
+
refs: this.refs,
|
|
109
|
+
valueAt: (path) => getAtPath(this.values, path),
|
|
110
|
+
snapshot: () => this.snapshot,
|
|
111
|
+
applyError: (path, error) => this.applyError(path, error),
|
|
112
|
+
});
|
|
106
113
|
makeObservable(this, {
|
|
107
114
|
values: observable.deep,
|
|
115
|
+
defaultValues: observable.deep,
|
|
108
116
|
dirtyFields: observable.shallow,
|
|
109
117
|
touchedFields: observable.shallow,
|
|
110
118
|
validatingFields: observable.shallow,
|
|
@@ -114,6 +122,8 @@ export class Form {
|
|
|
114
122
|
submitCount: observable,
|
|
115
123
|
disabled: computed,
|
|
116
124
|
isDirty: computed,
|
|
125
|
+
isTouched: computed,
|
|
126
|
+
isValidating: computed,
|
|
117
127
|
isValid: computed,
|
|
118
128
|
snapshot: computed,
|
|
119
129
|
register: action,
|
|
@@ -142,11 +152,31 @@ export class Form {
|
|
|
142
152
|
*/
|
|
143
153
|
get isDirty() { return Object.keys(this.dirtyFields).length > 0; }
|
|
144
154
|
/**
|
|
145
|
-
* Whether
|
|
155
|
+
* Whether any field has been touched.
|
|
156
|
+
*
|
|
157
|
+
* [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#istouched)
|
|
158
|
+
*/
|
|
159
|
+
get isTouched() { return Object.keys(this.touchedFields).length > 0; }
|
|
160
|
+
/** Whether any field validation is currently running. */
|
|
161
|
+
get isValidating() { return Object.keys(this.validatingFields).length > 0; }
|
|
162
|
+
/**
|
|
163
|
+
* Whether the form has no errors. With a schema or resolver the first read
|
|
164
|
+
* schedules a full validation pass, so validity reflects the schema instead
|
|
165
|
+
* of defaulting to true until something validates.
|
|
146
166
|
*
|
|
147
167
|
* [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#isvalid)
|
|
148
168
|
*/
|
|
149
|
-
get isValid() {
|
|
169
|
+
get isValid() {
|
|
170
|
+
const override = this.isValidOverride.get();
|
|
171
|
+
if (override !== undefined)
|
|
172
|
+
return override;
|
|
173
|
+
if (!this.hasValidationRun && (this.options.schema || this.options.resolver)) {
|
|
174
|
+
this.hasValidationRun = true;
|
|
175
|
+
// Deferred so this computed getter stays free of observable side effects.
|
|
176
|
+
queueMicrotask(() => { void this.runValidation(); });
|
|
177
|
+
}
|
|
178
|
+
return this.errorStore.size === 0;
|
|
179
|
+
}
|
|
150
180
|
/**
|
|
151
181
|
* Registers a field and returns its ref and event handlers.
|
|
152
182
|
*
|
|
@@ -155,11 +185,7 @@ export class Form {
|
|
|
155
185
|
register(name, options = {}) {
|
|
156
186
|
const path = name;
|
|
157
187
|
this.fieldOptions.set(path, options);
|
|
158
|
-
|
|
159
|
-
if (!ref) {
|
|
160
|
-
ref = createRef();
|
|
161
|
-
this.refs.set(path, ref);
|
|
162
|
-
}
|
|
188
|
+
const ref = this.ref(name);
|
|
163
189
|
this.ensureFieldState(path);
|
|
164
190
|
return {
|
|
165
191
|
name: path,
|
|
@@ -169,19 +195,44 @@ export class Form {
|
|
|
169
195
|
return;
|
|
170
196
|
const value = this.transformValue(extractValue(eventOrValue), options);
|
|
171
197
|
const shouldValidate = this.shouldValidateOnChange(path);
|
|
198
|
+
const deps = this.dependentFields(options.deps);
|
|
172
199
|
this.setValue(path, value, { shouldDirty: true, shouldValidate: false });
|
|
173
|
-
if (shouldValidate)
|
|
200
|
+
if (shouldValidate && deps.length) {
|
|
201
|
+
await Promise.all([this.trigger(path), this.trigger(deps)]);
|
|
202
|
+
}
|
|
203
|
+
else if (shouldValidate) {
|
|
174
204
|
await this.trigger(path);
|
|
205
|
+
}
|
|
206
|
+
else if (deps.length) {
|
|
207
|
+
await this.trigger(deps);
|
|
208
|
+
}
|
|
175
209
|
},
|
|
176
210
|
onBlur: async () => {
|
|
177
211
|
if (this.disabled)
|
|
178
212
|
return;
|
|
179
213
|
this.markTouched(path);
|
|
180
|
-
if (this.options.mode === '
|
|
214
|
+
if (this.options.mode === 'onTouched')
|
|
215
|
+
this.touchedValidationFields.add(path);
|
|
216
|
+
if (this.options.mode === 'onBlur' || this.options.mode === 'onTouched' || this.options.mode === 'all' || (this.hasError(path) && this.options.reValidateMode === 'onBlur')) {
|
|
217
|
+
this.validator.flushDelayed(path);
|
|
181
218
|
await this.trigger(path);
|
|
219
|
+
}
|
|
182
220
|
},
|
|
183
221
|
};
|
|
184
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Returns the stable MobX-aware ref for a field path, creating it on demand.
|
|
225
|
+
* The ref can be used by a view adapter before the field is registered.
|
|
226
|
+
*/
|
|
227
|
+
ref(name) {
|
|
228
|
+
const path = name;
|
|
229
|
+
let ref = this.refs.get(path);
|
|
230
|
+
if (!ref) {
|
|
231
|
+
ref = createRef();
|
|
232
|
+
this.refs.set(path, ref);
|
|
233
|
+
}
|
|
234
|
+
return ref;
|
|
235
|
+
}
|
|
185
236
|
/**
|
|
186
237
|
* Removes a field, its value, and its associated state.
|
|
187
238
|
*
|
|
@@ -190,12 +241,14 @@ export class Form {
|
|
|
190
241
|
unregister(name) {
|
|
191
242
|
const path = name;
|
|
192
243
|
this.fieldValidationVersions.set(path, (this.fieldValidationVersions.get(path) ?? 0) + 1);
|
|
244
|
+
this.validator.cancelDelayed(path);
|
|
245
|
+
this.touchedValidationFields.delete(path);
|
|
193
246
|
deleteAtPath(this.values, path);
|
|
194
247
|
this.applyError(path, undefined);
|
|
195
248
|
delete this.dirtyFields[path];
|
|
196
249
|
delete this.touchedFields[path];
|
|
197
250
|
delete this.validatingFields[path];
|
|
198
|
-
this.
|
|
251
|
+
this.fieldStateStore.delete(path);
|
|
199
252
|
this.fieldOptions.delete(path);
|
|
200
253
|
this.refs.delete(path);
|
|
201
254
|
}
|
|
@@ -209,98 +262,49 @@ export class Form {
|
|
|
209
262
|
setAtPath(this.values, path, value);
|
|
210
263
|
this.applyValueChange(path, config);
|
|
211
264
|
}
|
|
212
|
-
/**
|
|
213
|
-
* Groups direct value changes and processes their changed paths together.
|
|
214
|
-
*
|
|
215
|
-
* [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#mutatemutator-config)
|
|
216
|
-
*/
|
|
217
265
|
mutate(mutator, config = {}) {
|
|
218
|
-
this.
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
this.isMutating = false;
|
|
226
|
-
}
|
|
227
|
-
this.scheduleObserverCleanup();
|
|
228
|
-
const paths = [...this.changedPaths];
|
|
229
|
-
for (const path of paths)
|
|
230
|
-
this.applyValueChange(path, { ...config, shouldValidate: false });
|
|
231
|
-
if (paths.length && (config.shouldValidate ?? true)) {
|
|
232
|
-
void this.trigger(paths);
|
|
233
|
-
}
|
|
234
|
-
if (this.observerTreeChanged) {
|
|
235
|
-
this.disposeValueObservers();
|
|
236
|
-
this.observerTreeChanged = false;
|
|
237
|
-
this.ensureValueObservers();
|
|
266
|
+
const before = clone(this.values);
|
|
267
|
+
const result = mutator();
|
|
268
|
+
if (isPromiseLike(result)) {
|
|
269
|
+
return result.then(() => runInAction(() => this.finishMutation(before, config)), (error) => {
|
|
270
|
+
runInAction(() => this.finishMutation(before, config));
|
|
271
|
+
throw error;
|
|
272
|
+
});
|
|
238
273
|
}
|
|
274
|
+
this.finishMutation(before, config);
|
|
275
|
+
}
|
|
276
|
+
finishMutation(before, config) {
|
|
277
|
+
if (isEqual(before, this.values))
|
|
278
|
+
return;
|
|
279
|
+
const changedPaths = collectChangedPaths(before, this.values);
|
|
280
|
+
const dirtyPaths = collectDirtyPaths(this.values, this.defaultValues);
|
|
281
|
+
if (config.shouldDirty ?? true)
|
|
282
|
+
this.syncDirtyFields(dirtyPaths);
|
|
283
|
+
if (config.shouldTouch ?? true)
|
|
284
|
+
for (const path of changedPaths)
|
|
285
|
+
this.markTouched(path);
|
|
286
|
+
if (config.shouldValidate ?? true)
|
|
287
|
+
void this.trigger();
|
|
239
288
|
}
|
|
240
289
|
applyValueChange(path, config) {
|
|
241
290
|
if (config.shouldDirty ?? true)
|
|
242
291
|
this.updateDirty(path);
|
|
243
|
-
if (config.shouldTouch)
|
|
292
|
+
if (config.shouldTouch ?? true)
|
|
244
293
|
this.markTouched(path);
|
|
245
294
|
if (config.shouldValidate)
|
|
246
295
|
void this.trigger(path);
|
|
247
296
|
}
|
|
248
|
-
ensureValueObservers() {
|
|
249
|
-
if (!this.valueObservers || this.observerTreeChanged) {
|
|
250
|
-
this.disposeValueObservers();
|
|
251
|
-
this.valueObservers = [];
|
|
252
|
-
this.observeValueTree(this.values, '', this.valueObservers);
|
|
253
|
-
this.observerTreeChanged = false;
|
|
254
|
-
}
|
|
255
|
-
this.scheduleObserverCleanup();
|
|
256
|
-
}
|
|
257
|
-
scheduleObserverCleanup() {
|
|
258
|
-
if (this.observerTimer)
|
|
259
|
-
clearTimeout(this.observerTimer);
|
|
260
|
-
this.observerTimer = setTimeout(() => this.disposeValueObservers(), 10 * 60 * 1000);
|
|
261
|
-
const timer = this.observerTimer;
|
|
262
|
-
timer.unref?.();
|
|
263
|
-
}
|
|
264
|
-
disposeValueObservers() {
|
|
265
|
-
for (const dispose of this.valueObservers ?? [])
|
|
266
|
-
dispose();
|
|
267
|
-
this.valueObservers = undefined;
|
|
268
|
-
this.observerTimer = undefined;
|
|
269
|
-
}
|
|
270
|
-
observeValueTree(value, basePath, disposers) {
|
|
271
|
-
if (!value || typeof value !== 'object')
|
|
272
|
-
return;
|
|
273
|
-
if (Array.isArray(value)) {
|
|
274
|
-
disposers.push(observe(value, (change) => {
|
|
275
|
-
if (change.type === 'splice')
|
|
276
|
-
this.observerTreeChanged = true;
|
|
277
|
-
if (this.isMutating && basePath)
|
|
278
|
-
this.changedPaths.add(basePath);
|
|
279
|
-
}));
|
|
280
|
-
}
|
|
281
|
-
else {
|
|
282
|
-
disposers.push(observe(value, (change) => {
|
|
283
|
-
if (change.type === 'update' && (typeof change.newValue === 'object' || typeof change.oldValue === 'object')) {
|
|
284
|
-
this.observerTreeChanged = true;
|
|
285
|
-
}
|
|
286
|
-
if (!this.isMutating)
|
|
287
|
-
return;
|
|
288
|
-
const path = basePath ? `${basePath}.${String(change.name)}` : String(change.name);
|
|
289
|
-
if (path)
|
|
290
|
-
this.changedPaths.add(path);
|
|
291
|
-
}));
|
|
292
|
-
}
|
|
293
|
-
for (const [key, child] of Object.entries(value)) {
|
|
294
|
-
this.observeValueTree(child, basePath ? `${basePath}.${key}` : key, disposers);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
297
|
/**
|
|
298
|
-
* Sets an error for a field.
|
|
298
|
+
* Sets an error for a field and can focus it.
|
|
299
299
|
*
|
|
300
300
|
* [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#seterrorname-error)
|
|
301
301
|
*/
|
|
302
|
-
setError(name, error) {
|
|
303
|
-
|
|
302
|
+
setError(name, error, config = {}) {
|
|
303
|
+
const path = name;
|
|
304
|
+
this.validator.cancelDelayed(path);
|
|
305
|
+
this.applyError(path, error);
|
|
306
|
+
if (config.shouldFocus)
|
|
307
|
+
this.refs.get(path)?.current?.focus();
|
|
304
308
|
}
|
|
305
309
|
/**
|
|
306
310
|
* Clears one, several, or all field errors.
|
|
@@ -309,20 +313,34 @@ export class Form {
|
|
|
309
313
|
*/
|
|
310
314
|
clearErrors(name) {
|
|
311
315
|
if (!name) {
|
|
312
|
-
this.
|
|
313
|
-
|
|
316
|
+
this.validator.cancelAllDelayed();
|
|
317
|
+
this.isValidOverride.set(undefined);
|
|
318
|
+
this.errorStore.clear();
|
|
319
|
+
for (const [, state] of this.fieldStateStore.entries())
|
|
314
320
|
this.applyFieldState(state, undefined);
|
|
315
321
|
return;
|
|
316
322
|
}
|
|
317
|
-
for (const path of Array.isArray(name) ? name : [name])
|
|
318
|
-
this.
|
|
323
|
+
for (const path of Array.isArray(name) ? name : [name]) {
|
|
324
|
+
this.clearErrorPath(path);
|
|
325
|
+
}
|
|
319
326
|
}
|
|
320
327
|
/**
|
|
321
328
|
* Validates one field, several fields, or the complete form.
|
|
322
329
|
*
|
|
323
330
|
* [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#triggername)
|
|
324
331
|
*/
|
|
325
|
-
async trigger(name) {
|
|
332
|
+
async trigger(name, config = {}) {
|
|
333
|
+
const paths = name ? (Array.isArray(name) ? name : [name]).map(String) : [...this.fieldOptions.keys()];
|
|
334
|
+
if (config.shouldTouch)
|
|
335
|
+
for (const path of paths)
|
|
336
|
+
this.markTouched(path);
|
|
337
|
+
const valid = (await this.runValidation(name)).valid;
|
|
338
|
+
if (!valid && config.shouldFocus)
|
|
339
|
+
this.focusFirstError(paths);
|
|
340
|
+
return valid;
|
|
341
|
+
}
|
|
342
|
+
async runValidation(name) {
|
|
343
|
+
this.hasValidationRun = true;
|
|
326
344
|
const paths = name ? (Array.isArray(name) ? name : [name]).map(String) : undefined;
|
|
327
345
|
const run = ++this.validationVersion;
|
|
328
346
|
const fieldVersions = new Map();
|
|
@@ -334,31 +352,24 @@ export class Form {
|
|
|
334
352
|
this.validatingFields[path] = true;
|
|
335
353
|
this.ensureFieldState(path).isValidating = true;
|
|
336
354
|
}
|
|
355
|
+
let valid = true;
|
|
337
356
|
try {
|
|
338
|
-
const
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
for (const path of validationPaths) {
|
|
342
|
-
const schemaError = this.getError(schemaErrors, path);
|
|
343
|
-
if (schemaError && this.isValidationCurrent(path, run, fieldVersions)) {
|
|
344
|
-
this.applyError(path, schemaError);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
});
|
|
357
|
+
const schemaResult = await this.validator.resolveSchemaErrors(paths);
|
|
358
|
+
const schemaErrors = schemaResult.errors;
|
|
359
|
+
const validationPaths = paths ?? [...new Set([...this.fieldOptions.keys(), ...this.errorStore.paths(), ...collectErrorPaths(schemaErrors)])];
|
|
348
360
|
for (const path of validationPaths) {
|
|
349
|
-
const ruleError = await this.validateRules(path);
|
|
361
|
+
const ruleError = await this.validator.validateRules(path);
|
|
350
362
|
runInAction(() => {
|
|
351
363
|
if (this.isValidationCurrent(path, run, fieldVersions)) {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
this.applyError(path, undefined);
|
|
364
|
+
const schemaError = findErrorAtPath(schemaErrors, path);
|
|
365
|
+
const error = this.validator.mergeErrors(schemaError, ruleError);
|
|
366
|
+
if (error)
|
|
367
|
+
valid = false;
|
|
368
|
+
this.validator.applyValidationError(path, error);
|
|
358
369
|
}
|
|
359
370
|
});
|
|
360
371
|
}
|
|
361
|
-
return
|
|
372
|
+
return { valid, values: valid ? schemaResult.values : undefined };
|
|
362
373
|
}
|
|
363
374
|
finally {
|
|
364
375
|
runInAction(() => {
|
|
@@ -386,15 +397,17 @@ export class Form {
|
|
|
386
397
|
this.submitCount += 1;
|
|
387
398
|
const submissionResetVersion = this.resetVersion;
|
|
388
399
|
try {
|
|
389
|
-
const valid = await this.
|
|
400
|
+
const { valid, values } = await this.runValidation();
|
|
390
401
|
if (valid) {
|
|
391
|
-
await onValid(this.snapshot, this);
|
|
402
|
+
await onValid(values ?? this.snapshot, this);
|
|
392
403
|
runInAction(() => {
|
|
393
404
|
if (this.resetVersion === submissionResetVersion)
|
|
394
405
|
this.isSubmitSuccessful = true;
|
|
395
406
|
});
|
|
396
407
|
}
|
|
397
408
|
else {
|
|
409
|
+
if (this.options.shouldFocusError)
|
|
410
|
+
this.focusFirstError();
|
|
398
411
|
await onInvalid?.(this.errors, this);
|
|
399
412
|
runInAction(() => {
|
|
400
413
|
if (this.resetVersion === submissionResetVersion)
|
|
@@ -416,35 +429,52 @@ export class Form {
|
|
|
416
429
|
* [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#resetvalues-options)
|
|
417
430
|
*/
|
|
418
431
|
reset(values, options = {}) {
|
|
419
|
-
this.
|
|
432
|
+
this.validator.cancelAllDelayed();
|
|
433
|
+
this.touchedValidationFields.clear();
|
|
420
434
|
this.resetVersion += 1;
|
|
421
435
|
this.validationVersion += 1;
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
state
|
|
436
|
+
if (!options.keepIsValidating) {
|
|
437
|
+
for (const path of Object.keys(this.validatingFields)) {
|
|
438
|
+
delete this.validatingFields[path];
|
|
439
|
+
const state = this.fieldStateStore.get(path);
|
|
440
|
+
if (state)
|
|
441
|
+
state.isValidating = false;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (!options.keepValues) {
|
|
445
|
+
const next = clone((values ?? this.defaultValues));
|
|
446
|
+
if (options.keepDirtyValues) {
|
|
447
|
+
for (const path of Object.keys(this.dirtyFields)) {
|
|
448
|
+
const value = getAtPath(this.values, path);
|
|
449
|
+
if (value !== undefined)
|
|
450
|
+
setAtPath(next, path, clone(value));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
this.values = next;
|
|
454
|
+
// New values need a fresh validity check on the next isValid read.
|
|
455
|
+
this.hasValidationRun = false;
|
|
427
456
|
}
|
|
428
|
-
const next = clone((values ?? this.defaultValues));
|
|
429
|
-
this.values = next;
|
|
430
457
|
if (!options.keepDefaultValues && values)
|
|
431
458
|
Object.assign(this.defaultValues, clone(values));
|
|
432
|
-
if (!options.keepDirty)
|
|
459
|
+
if (!options.keepDirty && !options.keepDirtyValues)
|
|
433
460
|
this.dirtyFields = {};
|
|
434
461
|
if (!options.keepTouched)
|
|
435
462
|
this.touchedFields = {};
|
|
463
|
+
// Raw validity without the lazy warm-up: reset must never schedule validation.
|
|
464
|
+
const wasValid = this.isValidOverride.get() ?? this.errorStore.size === 0;
|
|
436
465
|
if (!options.keepErrors) {
|
|
437
|
-
this.
|
|
438
|
-
for (const [, state] of this.
|
|
466
|
+
this.errorStore.clear();
|
|
467
|
+
for (const [, state] of this.fieldStateStore.entries())
|
|
439
468
|
this.applyFieldState(state, undefined);
|
|
440
469
|
}
|
|
441
|
-
|
|
470
|
+
this.isValidOverride.set(options.keepIsValid ? wasValid : undefined);
|
|
471
|
+
if (!options.keepIsSubmitted)
|
|
442
472
|
this.isSubmitted = false;
|
|
473
|
+
if (!options.keepIsSubmitSuccessful)
|
|
443
474
|
this.isSubmitSuccessful = false;
|
|
444
|
-
}
|
|
445
475
|
if (!options.keepSubmitCount)
|
|
446
476
|
this.submitCount = 0;
|
|
447
|
-
for (const [path, state] of this.
|
|
477
|
+
for (const [path, state] of this.fieldStateStore.entries()) {
|
|
448
478
|
state.isDirty = !!this.dirtyFields[path];
|
|
449
479
|
state.isTouched = !!this.touchedFields[path];
|
|
450
480
|
}
|
|
@@ -454,18 +484,26 @@ export class Form {
|
|
|
454
484
|
*
|
|
455
485
|
* [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#resetfieldname)
|
|
456
486
|
*/
|
|
457
|
-
resetField(name) {
|
|
487
|
+
resetField(name, options = {}) {
|
|
458
488
|
const path = name;
|
|
489
|
+
this.validator.cancelDelayed(path);
|
|
490
|
+
this.touchedValidationFields.delete(path);
|
|
459
491
|
this.fieldValidationVersions.set(path, (this.fieldValidationVersions.get(path) ?? 0) + 1);
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
492
|
+
if ('defaultValue' in options)
|
|
493
|
+
setAtPath(this.defaultValues, path, clone(options.defaultValue));
|
|
494
|
+
setAtPath(this.values, path, clone('defaultValue' in options ? options.defaultValue : getAtPath(this.defaultValues, path)));
|
|
495
|
+
if (!options.keepError)
|
|
496
|
+
this.applyError(path, undefined);
|
|
497
|
+
if (!options.keepDirty)
|
|
498
|
+
delete this.dirtyFields[path];
|
|
499
|
+
if (!options.keepTouched)
|
|
500
|
+
delete this.touchedFields[path];
|
|
464
501
|
delete this.validatingFields[path];
|
|
465
502
|
const state = this.ensureFieldState(path);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
state.
|
|
503
|
+
if (!options.keepError)
|
|
504
|
+
this.applyFieldState(state, undefined);
|
|
505
|
+
state.isDirty = options.keepDirty ? !!this.dirtyFields[path] : false;
|
|
506
|
+
state.isTouched = options.keepTouched ? !!this.touchedFields[path] : false;
|
|
469
507
|
state.isValidating = false;
|
|
470
508
|
}
|
|
471
509
|
/**
|
|
@@ -480,6 +518,46 @@ export class Form {
|
|
|
480
518
|
* [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#snapshot)
|
|
481
519
|
*/
|
|
482
520
|
get snapshot() { return clone(this.values); }
|
|
521
|
+
/** Reconciles dirty paths against the complete current value tree. */
|
|
522
|
+
syncDirtyFields(paths) {
|
|
523
|
+
const dirty = new Set(paths);
|
|
524
|
+
this.dirtyFields = Object.fromEntries(paths.map((path) => [path, true]));
|
|
525
|
+
for (const [path, state] of this.fieldStateStore.entries())
|
|
526
|
+
state.isDirty = dirty.has(path);
|
|
527
|
+
for (const path of paths)
|
|
528
|
+
this.ensureFieldState(path).isDirty = true;
|
|
529
|
+
}
|
|
530
|
+
/** Clears an exact error path and every nested error below it. */
|
|
531
|
+
clearErrorPath(path) {
|
|
532
|
+
const paths = this.errorStore.paths().filter((stored) => stored === path || stored.startsWith(`${path}.`));
|
|
533
|
+
if (!paths.length) {
|
|
534
|
+
this.validator.cancelDelayed(path);
|
|
535
|
+
this.applyError(path, undefined);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
for (const storedPath of paths) {
|
|
539
|
+
this.validator.cancelDelayed(storedPath);
|
|
540
|
+
this.applyError(storedPath, undefined);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/** Focuses the first errored registered field, skipping form-level errors. */
|
|
544
|
+
focusFirstError(paths) {
|
|
545
|
+
const candidates = paths ?? this.errorStore.paths();
|
|
546
|
+
for (const path of candidates) {
|
|
547
|
+
if (!this.hasError(path))
|
|
548
|
+
continue;
|
|
549
|
+
const ref = this.refs.get(path)?.current;
|
|
550
|
+
if (ref?.focus) {
|
|
551
|
+
ref.focus();
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
dependentFields(deps) {
|
|
557
|
+
if (!deps)
|
|
558
|
+
return [];
|
|
559
|
+
return (Array.isArray(deps) ? deps : [deps]).map(String);
|
|
560
|
+
}
|
|
483
561
|
markTouched(path) {
|
|
484
562
|
this.touchedFields[path] = true;
|
|
485
563
|
this.ensureFieldState(path).isTouched = true;
|
|
@@ -491,7 +569,12 @@ export class Form {
|
|
|
491
569
|
this.dirtyFields[path] = true;
|
|
492
570
|
this.ensureFieldState(path).isDirty = !!this.dirtyFields[path];
|
|
493
571
|
}
|
|
494
|
-
shouldValidateOnChange(path) {
|
|
572
|
+
shouldValidateOnChange(path) {
|
|
573
|
+
return this.options.mode === 'onChange'
|
|
574
|
+
|| this.options.mode === 'all'
|
|
575
|
+
|| (this.options.mode === 'onTouched' && this.touchedValidationFields.has(path))
|
|
576
|
+
|| (this.hasError(path) && this.options.reValidateMode === 'onChange');
|
|
577
|
+
}
|
|
495
578
|
isValidationCurrent(path, run, fieldVersions) {
|
|
496
579
|
if (this.validationVersion !== run)
|
|
497
580
|
return false;
|
|
@@ -508,174 +591,22 @@ export class Form {
|
|
|
508
591
|
return new Date(String(value));
|
|
509
592
|
return value;
|
|
510
593
|
}
|
|
511
|
-
async validateSchema() {
|
|
512
|
-
if (!this.options.schema)
|
|
513
|
-
return {};
|
|
514
|
-
const schema = this.options.schema;
|
|
515
|
-
if ('safeParseAsync' in schema) {
|
|
516
|
-
const result = await schema.safeParseAsync(this.snapshot);
|
|
517
|
-
if (result.success)
|
|
518
|
-
return {};
|
|
519
|
-
return this.normalizeSchemaErrors(result.error);
|
|
520
|
-
}
|
|
521
|
-
const result = await schema['~run']({ value: this.snapshot, typed: false }, {});
|
|
522
|
-
if (!result.issues?.length)
|
|
523
|
-
return {};
|
|
524
|
-
return this.normalizeSchemaErrors({ issues: result.issues });
|
|
525
|
-
}
|
|
526
|
-
normalizeSchemaErrors(error) {
|
|
527
|
-
return error.issues.reduce((errors, issue) => {
|
|
528
|
-
const path = (issue.path ?? []).map((part) => String(typeof part === 'object' ? part.key : part)).join('.') || 'root';
|
|
529
|
-
if (!this.getError(errors, path))
|
|
530
|
-
setAtPath(errors, path, { type: issue.code ?? issue.type ?? 'validation', message: issue.message });
|
|
531
|
-
return errors;
|
|
532
|
-
}, {});
|
|
533
|
-
}
|
|
534
|
-
async validateRules(path) {
|
|
535
|
-
const rules = this.fieldOptions.get(path);
|
|
536
|
-
if (!rules)
|
|
537
|
-
return undefined;
|
|
538
|
-
const value = getAtPath(this.values, path);
|
|
539
|
-
const fail = (type, rule) => typeof rule === 'string' ? { type, message: rule } : { type };
|
|
540
|
-
if (rules.required && (value === undefined || value === null || value === ''))
|
|
541
|
-
return fail('required', rules.required);
|
|
542
|
-
if (rules.minLength && String(value ?? '').length < rules.minLength.value)
|
|
543
|
-
return { type: 'minLength', message: rules.minLength.message };
|
|
544
|
-
if (rules.maxLength && String(value ?? '').length > rules.maxLength.value)
|
|
545
|
-
return { type: 'maxLength', message: rules.maxLength.message };
|
|
546
|
-
if (rules.min && Number(value) < rules.min.value)
|
|
547
|
-
return { type: 'min', message: rules.min.message };
|
|
548
|
-
if (rules.max && Number(value) > rules.max.value)
|
|
549
|
-
return { type: 'max', message: rules.max.message };
|
|
550
|
-
if (rules.pattern && !rules.pattern.value.test(String(value ?? '')))
|
|
551
|
-
return { type: 'pattern', message: rules.pattern.message };
|
|
552
|
-
if (rules.validate) {
|
|
553
|
-
let result;
|
|
554
|
-
try {
|
|
555
|
-
result = await rules.validate(value, this.snapshot);
|
|
556
|
-
}
|
|
557
|
-
catch {
|
|
558
|
-
return { type: 'validate', message: 'Validation failed' };
|
|
559
|
-
}
|
|
560
|
-
if (result !== true)
|
|
561
|
-
return { type: 'validate', message: typeof result === 'string' ? result : undefined };
|
|
562
|
-
}
|
|
563
|
-
return undefined;
|
|
564
|
-
}
|
|
565
594
|
ensureFieldState(path) {
|
|
566
|
-
|
|
567
|
-
if (existing)
|
|
568
|
-
return existing;
|
|
569
|
-
const state = { invalid: false, isDirty: false, isTouched: false, isValidating: false };
|
|
570
|
-
this.setPathStore(this.fieldStatesByPath, this.fieldStatePathCounts, this.fieldStateChildren, path, state);
|
|
571
|
-
return state;
|
|
595
|
+
return this.fieldStateStore.ensure(path, () => ({ invalid: false, isDirty: false, isTouched: false, isValidating: false, error: undefined }));
|
|
572
596
|
}
|
|
573
597
|
applyFieldState(state, error) {
|
|
574
598
|
state.error = error;
|
|
575
599
|
state.invalid = !!error;
|
|
576
600
|
}
|
|
577
601
|
applyError(path, error) {
|
|
602
|
+
this.isValidOverride.set(undefined);
|
|
578
603
|
if (error)
|
|
579
|
-
this.
|
|
604
|
+
this.errorStore.set(path, error);
|
|
580
605
|
else
|
|
581
|
-
this.
|
|
582
|
-
this.
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
const value = getAtPath(errors, path);
|
|
586
|
-
return value && typeof value === 'object' && 'type' in value ? value : undefined;
|
|
587
|
-
}
|
|
588
|
-
hasError(path) { return this.errorsByPath.has(path); }
|
|
589
|
-
errorPaths(errors, base = '') {
|
|
590
|
-
const paths = [];
|
|
591
|
-
for (const [key, value] of Object.entries(errors)) {
|
|
592
|
-
if (value === undefined)
|
|
593
|
-
continue;
|
|
594
|
-
const path = base ? `${base}.${key}` : key;
|
|
595
|
-
if (value && typeof value === 'object' && 'type' in value)
|
|
596
|
-
paths.push(path);
|
|
597
|
-
if (value && typeof value === 'object')
|
|
598
|
-
paths.push(...this.errorPaths(value, path));
|
|
599
|
-
}
|
|
600
|
-
return paths;
|
|
601
|
-
}
|
|
602
|
-
deleteFieldState(path) {
|
|
603
|
-
this.deletePathStore(this.fieldStatesByPath, this.fieldStatePathCounts, this.fieldStateChildren, path);
|
|
604
|
-
}
|
|
605
|
-
fieldStates() {
|
|
606
|
-
return [...this.fieldStatesByPath.entries()];
|
|
607
|
-
}
|
|
608
|
-
setPathStore(store, counts, children, path, value) {
|
|
609
|
-
if (!store.has(path))
|
|
610
|
-
this.addPathToIndex(counts, children, path);
|
|
611
|
-
store.set(path, value);
|
|
612
|
-
}
|
|
613
|
-
deletePathStore(store, counts, children, path) {
|
|
614
|
-
if (!store.delete(path))
|
|
615
|
-
return;
|
|
616
|
-
const parts = path.split('.');
|
|
617
|
-
for (let index = parts.length; index > 0; index -= 1) {
|
|
618
|
-
const current = parts.slice(0, index).join('.');
|
|
619
|
-
const count = (counts.get(current) ?? 1) - 1;
|
|
620
|
-
if (count > 0) {
|
|
621
|
-
counts.set(current, count);
|
|
622
|
-
continue;
|
|
623
|
-
}
|
|
624
|
-
counts.delete(current);
|
|
625
|
-
const parent = parts.slice(0, index - 1).join('.');
|
|
626
|
-
const siblings = children.get(parent);
|
|
627
|
-
siblings?.delete(parts[index - 1]);
|
|
628
|
-
if (siblings?.size === 0)
|
|
629
|
-
children.delete(parent);
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
clearPathStore(store, counts, children) {
|
|
633
|
-
store.clear();
|
|
634
|
-
counts.clear();
|
|
635
|
-
children.clear();
|
|
636
|
-
}
|
|
637
|
-
addPathToIndex(counts, children, path) {
|
|
638
|
-
const parts = path.split('.');
|
|
639
|
-
for (let index = 1; index <= parts.length; index += 1) {
|
|
640
|
-
const current = parts.slice(0, index).join('.');
|
|
641
|
-
counts.set(current, (counts.get(current) ?? 0) + 1);
|
|
642
|
-
const parent = parts.slice(0, index - 1).join('.');
|
|
643
|
-
let siblings = children.get(parent);
|
|
644
|
-
if (!siblings)
|
|
645
|
-
children.set(parent, siblings = new Set());
|
|
646
|
-
siblings.add(parts[index - 1]);
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
createPathProxy(path, store, counts, children, cache) {
|
|
650
|
-
const cached = cache.get(path);
|
|
651
|
-
if (cached)
|
|
652
|
-
return cached;
|
|
653
|
-
const proxy = new Proxy({}, {
|
|
654
|
-
get: (_, property) => {
|
|
655
|
-
if (typeof property !== 'string')
|
|
656
|
-
return undefined;
|
|
657
|
-
const current = store.get(path);
|
|
658
|
-
if (current && property in current)
|
|
659
|
-
return current[property];
|
|
660
|
-
const childPath = path ? `${path}.${property}` : property;
|
|
661
|
-
const value = store.get(childPath);
|
|
662
|
-
if (value && !children.has(childPath))
|
|
663
|
-
return value;
|
|
664
|
-
return counts.has(childPath) ? this.createPathProxy(childPath, store, counts, children, cache) : undefined;
|
|
665
|
-
},
|
|
666
|
-
ownKeys: () => [...Object.keys(store.get(path) ?? {}), ...(children.get(path) ?? [])],
|
|
667
|
-
getOwnPropertyDescriptor: (_, property) => {
|
|
668
|
-
if (typeof property !== 'string')
|
|
669
|
-
return undefined;
|
|
670
|
-
const value = store.get(path);
|
|
671
|
-
if (value && property in value)
|
|
672
|
-
return { configurable: true, enumerable: true, value: value[property] };
|
|
673
|
-
return children.get(path)?.has(property) ? { configurable: true, enumerable: true } : undefined;
|
|
674
|
-
},
|
|
675
|
-
set: () => false,
|
|
676
|
-
deleteProperty: () => false,
|
|
677
|
-
});
|
|
678
|
-
cache.set(path, proxy);
|
|
679
|
-
return proxy;
|
|
606
|
+
this.errorStore.delete(path);
|
|
607
|
+
const state = this.ensureFieldState(path);
|
|
608
|
+
state.error = error;
|
|
609
|
+
state.invalid = !!error;
|
|
680
610
|
}
|
|
611
|
+
hasError(path) { return this.errorStore.has(path); }
|
|
681
612
|
}
|