mobx-formly 0.0.2 → 0.0.3

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.
@@ -0,0 +1,11 @@
1
+ import { Form } from './form.js';
2
+ import type { FieldValues, FormOptions, FormSchema, SchemaOutput } from './types.js';
3
+ export type InferredFormValues<S> = Extract<SchemaOutput<S>, FieldValues>;
4
+ /**
5
+ * Creates a form with values inferred from its schema.
6
+ *
7
+ * [**Documentation**](https://js2me.github.io/mobx-formly/guide/getting-started.html)
8
+ */
9
+ export declare const createForm: <S extends FormSchema<any>>(options: FormOptions<InferredFormValues<S>> & {
10
+ schema: S;
11
+ }) => Form<InferredFormValues<S>>;
@@ -0,0 +1,7 @@
1
+ import { Form } from './form.js';
2
+ /**
3
+ * Creates a form with values inferred from its schema.
4
+ *
5
+ * [**Documentation**](https://js2me.github.io/mobx-formly/guide/getting-started.html)
6
+ */
7
+ export const createForm = (options) => new Form(options);
package/dist/form.d.ts ADDED
@@ -0,0 +1,213 @@
1
+ import { type Ref } from 'yummies/mobx';
2
+ import type { FieldError, FieldErrors, FieldPath, FieldPathValue, FieldStateTree, FieldValues, FormOptions, RegisterOptions, RegisterReturn, ResetOptions, SetValueConfig, SubmitHandlers } from './types.js';
3
+ export declare class Form<T extends FieldValues = FieldValues> {
4
+ /**
5
+ * Current form values.
6
+ *
7
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#values)
8
+ */
9
+ values: T;
10
+ /**
11
+ * Validation errors nested by field path.
12
+ *
13
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#errors)
14
+ */
15
+ private readonly errorsByPath;
16
+ private readonly errorPathCounts;
17
+ private readonly errorChildren;
18
+ private readonly errorProxyCache;
19
+ private readonly fieldStatesByPath;
20
+ private readonly fieldStatePathCounts;
21
+ private readonly fieldStateChildren;
22
+ private readonly fieldStateProxyCache;
23
+ /** Validation errors nested by field path. */
24
+ get errors(): FieldErrors<T>;
25
+ /**
26
+ * Field paths whose values differ from their defaults.
27
+ *
28
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#dirtyfields)
29
+ */
30
+ dirtyFields: Record<string, true | undefined>;
31
+ /**
32
+ * Field paths that have been touched.
33
+ *
34
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#touchedfields)
35
+ */
36
+ touchedFields: Record<string, true | undefined>;
37
+ /**
38
+ * Field paths that are currently being validated.
39
+ *
40
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#validatingfields)
41
+ */
42
+ validatingFields: Record<string, true | undefined>;
43
+ /**
44
+ * Observable state for each registered field.
45
+ *
46
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#fieldstate)
47
+ */
48
+ /** Observable state for each registered field, nested by field path. */
49
+ get fieldState(): FieldStateTree<T>;
50
+ /**
51
+ * Whether a submission is currently running.
52
+ *
53
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#issubmitting)
54
+ */
55
+ isSubmitting: boolean;
56
+ /**
57
+ * Whether the form has been submitted at least once.
58
+ *
59
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#issubmitted)
60
+ */
61
+ isSubmitted: boolean;
62
+ /**
63
+ * Whether the latest submission succeeded.
64
+ *
65
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#issubmitsuccessful)
66
+ */
67
+ isSubmitSuccessful: boolean;
68
+ /**
69
+ * Number of submission attempts.
70
+ *
71
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#submitcount)
72
+ */
73
+ submitCount: number;
74
+ /**
75
+ * Refs registered for fields.
76
+ *
77
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#refs)
78
+ */
79
+ readonly refs: Map<string, Ref<HTMLElement | null, import("yummies/types").AnyObject>>;
80
+ private readonly defaultValues;
81
+ private readonly options;
82
+ private readonly fieldOptions;
83
+ private valueObservers?;
84
+ private observerTimer?;
85
+ private readonly changedPaths;
86
+ private isMutating;
87
+ private observerTreeChanged;
88
+ private activeSubmissions;
89
+ private resetVersion;
90
+ private validationVersion;
91
+ private readonly fieldValidationVersions;
92
+ /** Creates a form with optional initial values, schema, and validation settings.
93
+ *
94
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#constructor-options)
95
+ */
96
+ constructor(options?: FormOptions<T>);
97
+ /**
98
+ * Whether registered event handlers ignore changes and blur events.
99
+ *
100
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#disabled)
101
+ */
102
+ get disabled(): boolean;
103
+ /**
104
+ * Whether any field is dirty.
105
+ *
106
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#isdirty)
107
+ */
108
+ get isDirty(): boolean;
109
+ /**
110
+ * Whether the form has no errors.
111
+ *
112
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#isvalid)
113
+ */
114
+ get isValid(): boolean;
115
+ /**
116
+ * Registers a field and returns its ref and event handlers.
117
+ *
118
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#registername-options)
119
+ */
120
+ register(name: FieldPath<T>, options?: RegisterOptions<T>): RegisterReturn;
121
+ /**
122
+ * Removes a field, its value, and its associated state.
123
+ *
124
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#unregistername)
125
+ */
126
+ unregister(name: FieldPath<T>): void;
127
+ /**
128
+ * Updates a field value and optionally changes its state or validates it.
129
+ *
130
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#setvaluename-value-config)
131
+ */
132
+ setValue<P extends FieldPath<T>>(name: P, value: FieldPathValue<T, P>, config?: SetValueConfig): void;
133
+ /**
134
+ * Groups direct value changes and processes their changed paths together.
135
+ *
136
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#mutatemutator-config)
137
+ */
138
+ mutate(mutator: () => void, config?: SetValueConfig): void;
139
+ private applyValueChange;
140
+ private ensureValueObservers;
141
+ private scheduleObserverCleanup;
142
+ private disposeValueObservers;
143
+ private observeValueTree;
144
+ /**
145
+ * Sets an error for a field.
146
+ *
147
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#seterrorname-error)
148
+ */
149
+ setError(name: FieldPath<T>, error: FieldError): void;
150
+ /**
151
+ * Clears one, several, or all field errors.
152
+ *
153
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#clearerrorsname)
154
+ */
155
+ clearErrors(name?: FieldPath<T> | FieldPath<T>[]): void;
156
+ /**
157
+ * Validates one field, several fields, or the complete form.
158
+ *
159
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#triggername)
160
+ */
161
+ trigger(name?: FieldPath<T> | FieldPath<T>[]): Promise<boolean>;
162
+ /**
163
+ * Creates an asynchronous submit handler with validation and result callbacks.
164
+ *
165
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#handlesubmithandlers)
166
+ */
167
+ handleSubmit({ onValid, onInvalid }: SubmitHandlers<T>): () => Promise<void>;
168
+ /**
169
+ * Resets values and selected form state.
170
+ *
171
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#resetvalues-options)
172
+ */
173
+ reset(values?: Partial<T>, options?: ResetOptions): void;
174
+ /**
175
+ * Resets one field to its current default value and clears its state.
176
+ *
177
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#resetfieldname)
178
+ */
179
+ resetField(name: FieldPath<T>): void;
180
+ /**
181
+ * Focuses a registered field when its ref points to a focusable element.
182
+ *
183
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#setfocusname)
184
+ */
185
+ setFocus(name: FieldPath<T>): void;
186
+ /**
187
+ * Returns a plain copy of the current values.
188
+ *
189
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#snapshot)
190
+ */
191
+ get snapshot(): T;
192
+ private markTouched;
193
+ private updateDirty;
194
+ private shouldValidateOnChange;
195
+ private isValidationCurrent;
196
+ private transformValue;
197
+ private validateSchema;
198
+ private normalizeSchemaErrors;
199
+ private validateRules;
200
+ private ensureFieldState;
201
+ private applyFieldState;
202
+ private applyError;
203
+ private getError;
204
+ private hasError;
205
+ private errorPaths;
206
+ private deleteFieldState;
207
+ private fieldStates;
208
+ private setPathStore;
209
+ private deletePathStore;
210
+ private clearPathStore;
211
+ private addPathToIndex;
212
+ private createPathProxy;
213
+ }
package/dist/form.js ADDED
@@ -0,0 +1,681 @@
1
+ import { action, computed, makeObservable, observable, observe, runInAction } from 'mobx';
2
+ import { createRef } from 'yummies/mobx';
3
+ import { clone, deleteAtPath, extractValue, getAtPath, isEqual, setAtPath } from './utils.js';
4
+ export class Form {
5
+ /**
6
+ * Current form values.
7
+ *
8
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#values)
9
+ */
10
+ values;
11
+ /**
12
+ * Validation errors nested by field path.
13
+ *
14
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#errors)
15
+ */
16
+ errorsByPath = observable.map();
17
+ errorPathCounts = observable.map();
18
+ errorChildren = new Map();
19
+ errorProxyCache = new Map();
20
+ fieldStatesByPath = observable.map();
21
+ fieldStatePathCounts = observable.map();
22
+ fieldStateChildren = new Map();
23
+ fieldStateProxyCache = new Map();
24
+ /** Validation errors nested by field path. */
25
+ get errors() {
26
+ return this.createPathProxy('', this.errorsByPath, this.errorPathCounts, this.errorChildren, this.errorProxyCache);
27
+ }
28
+ /**
29
+ * Field paths whose values differ from their defaults.
30
+ *
31
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#dirtyfields)
32
+ */
33
+ dirtyFields = {};
34
+ /**
35
+ * Field paths that have been touched.
36
+ *
37
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#touchedfields)
38
+ */
39
+ touchedFields = {};
40
+ /**
41
+ * Field paths that are currently being validated.
42
+ *
43
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#validatingfields)
44
+ */
45
+ validatingFields = {};
46
+ /**
47
+ * Observable state for each registered field.
48
+ *
49
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#fieldstate)
50
+ */
51
+ /** Observable state for each registered field, nested by field path. */
52
+ get fieldState() {
53
+ return this.createPathProxy('', this.fieldStatesByPath, this.fieldStatePathCounts, this.fieldStateChildren, this.fieldStateProxyCache);
54
+ }
55
+ /**
56
+ * Whether a submission is currently running.
57
+ *
58
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#issubmitting)
59
+ */
60
+ isSubmitting = false;
61
+ /**
62
+ * Whether the form has been submitted at least once.
63
+ *
64
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#issubmitted)
65
+ */
66
+ isSubmitted = false;
67
+ /**
68
+ * Whether the latest submission succeeded.
69
+ *
70
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#issubmitsuccessful)
71
+ */
72
+ isSubmitSuccessful = false;
73
+ /**
74
+ * Number of submission attempts.
75
+ *
76
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#submitcount)
77
+ */
78
+ submitCount = 0;
79
+ /**
80
+ * Refs registered for fields.
81
+ *
82
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#refs)
83
+ */
84
+ refs = new Map();
85
+ defaultValues;
86
+ options;
87
+ fieldOptions = new Map();
88
+ valueObservers;
89
+ observerTimer;
90
+ changedPaths = new Set();
91
+ isMutating = false;
92
+ observerTreeChanged = false;
93
+ activeSubmissions = 0;
94
+ resetVersion = 0;
95
+ validationVersion = 0;
96
+ fieldValidationVersions = new Map();
97
+ constructor(options = {}) {
98
+ this.options = {
99
+ ...options,
100
+ mode: options.mode ?? 'onSubmit',
101
+ reValidateMode: options.reValidateMode ?? 'onChange',
102
+ disabled: options.disabled ?? false,
103
+ };
104
+ this.defaultValues = clone((options.defaultValues ?? {}));
105
+ this.values = clone((options.values ?? options.defaultValues ?? {}));
106
+ makeObservable(this, {
107
+ values: observable.deep,
108
+ dirtyFields: observable.shallow,
109
+ touchedFields: observable.shallow,
110
+ validatingFields: observable.shallow,
111
+ isSubmitting: observable,
112
+ isSubmitted: observable,
113
+ isSubmitSuccessful: observable,
114
+ submitCount: observable,
115
+ disabled: computed,
116
+ isDirty: computed,
117
+ isValid: computed,
118
+ snapshot: computed,
119
+ register: action,
120
+ unregister: action,
121
+ setValue: action,
122
+ mutate: action,
123
+ setError: action,
124
+ clearErrors: action,
125
+ trigger: action,
126
+ handleSubmit: action,
127
+ reset: action,
128
+ resetField: action,
129
+ setFocus: action,
130
+ });
131
+ }
132
+ /**
133
+ * Whether registered event handlers ignore changes and blur events.
134
+ *
135
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#disabled)
136
+ */
137
+ get disabled() { return this.options.disabled; }
138
+ /**
139
+ * Whether any field is dirty.
140
+ *
141
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#isdirty)
142
+ */
143
+ get isDirty() { return Object.keys(this.dirtyFields).length > 0; }
144
+ /**
145
+ * Whether the form has no errors.
146
+ *
147
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#isvalid)
148
+ */
149
+ get isValid() { return this.errorsByPath.size === 0; }
150
+ /**
151
+ * Registers a field and returns its ref and event handlers.
152
+ *
153
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#registername-options)
154
+ */
155
+ register(name, options = {}) {
156
+ const path = name;
157
+ this.fieldOptions.set(path, options);
158
+ let ref = this.refs.get(path);
159
+ if (!ref) {
160
+ ref = createRef();
161
+ this.refs.set(path, ref);
162
+ }
163
+ this.ensureFieldState(path);
164
+ return {
165
+ name: path,
166
+ ref,
167
+ onChange: async (eventOrValue) => {
168
+ if (this.disabled)
169
+ return;
170
+ const value = this.transformValue(extractValue(eventOrValue), options);
171
+ const shouldValidate = this.shouldValidateOnChange(path);
172
+ this.setValue(path, value, { shouldDirty: true, shouldValidate: false });
173
+ if (shouldValidate)
174
+ await this.trigger(path);
175
+ },
176
+ onBlur: async () => {
177
+ if (this.disabled)
178
+ return;
179
+ this.markTouched(path);
180
+ if (this.options.mode === 'onBlur' || this.options.mode === 'all' || (this.hasError(path) && this.options.reValidateMode === 'onBlur'))
181
+ await this.trigger(path);
182
+ },
183
+ };
184
+ }
185
+ /**
186
+ * Removes a field, its value, and its associated state.
187
+ *
188
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#unregistername)
189
+ */
190
+ unregister(name) {
191
+ const path = name;
192
+ this.fieldValidationVersions.set(path, (this.fieldValidationVersions.get(path) ?? 0) + 1);
193
+ deleteAtPath(this.values, path);
194
+ this.applyError(path, undefined);
195
+ delete this.dirtyFields[path];
196
+ delete this.touchedFields[path];
197
+ delete this.validatingFields[path];
198
+ this.deleteFieldState(path);
199
+ this.fieldOptions.delete(path);
200
+ this.refs.delete(path);
201
+ }
202
+ /**
203
+ * Updates a field value and optionally changes its state or validates it.
204
+ *
205
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#setvaluename-value-config)
206
+ */
207
+ setValue(name, value, config = {}) {
208
+ const path = name;
209
+ setAtPath(this.values, path, value);
210
+ this.applyValueChange(path, config);
211
+ }
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
+ mutate(mutator, config = {}) {
218
+ this.ensureValueObservers();
219
+ this.changedPaths.clear();
220
+ this.isMutating = true;
221
+ try {
222
+ mutator();
223
+ }
224
+ finally {
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();
238
+ }
239
+ }
240
+ applyValueChange(path, config) {
241
+ if (config.shouldDirty ?? true)
242
+ this.updateDirty(path);
243
+ if (config.shouldTouch)
244
+ this.markTouched(path);
245
+ if (config.shouldValidate)
246
+ void this.trigger(path);
247
+ }
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
+ /**
298
+ * Sets an error for a field.
299
+ *
300
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#seterrorname-error)
301
+ */
302
+ setError(name, error) {
303
+ this.applyError(name, error);
304
+ }
305
+ /**
306
+ * Clears one, several, or all field errors.
307
+ *
308
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#clearerrorsname)
309
+ */
310
+ clearErrors(name) {
311
+ if (!name) {
312
+ this.clearPathStore(this.errorsByPath, this.errorPathCounts, this.errorChildren);
313
+ for (const [, state] of this.fieldStates())
314
+ this.applyFieldState(state, undefined);
315
+ return;
316
+ }
317
+ for (const path of Array.isArray(name) ? name : [name])
318
+ this.applyError(path, undefined);
319
+ }
320
+ /**
321
+ * Validates one field, several fields, or the complete form.
322
+ *
323
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#triggername)
324
+ */
325
+ async trigger(name) {
326
+ const paths = name ? (Array.isArray(name) ? name : [name]).map(String) : undefined;
327
+ const run = ++this.validationVersion;
328
+ const fieldVersions = new Map();
329
+ for (const path of paths ?? this.fieldOptions.keys()) {
330
+ if (this.fieldOptions.has(path))
331
+ fieldVersions.set(path, this.fieldValidationVersions.get(path) ?? 0);
332
+ }
333
+ for (const path of paths ?? this.fieldOptions.keys()) {
334
+ this.validatingFields[path] = true;
335
+ this.ensureFieldState(path).isValidating = true;
336
+ }
337
+ try {
338
+ const schemaErrors = await this.validateSchema();
339
+ const validationPaths = paths ?? [...new Set([...this.fieldOptions.keys(), ...this.errorsByPath.keys(), ...this.errorPaths(schemaErrors)])];
340
+ runInAction(() => {
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
+ });
348
+ for (const path of validationPaths) {
349
+ const ruleError = await this.validateRules(path);
350
+ runInAction(() => {
351
+ if (this.isValidationCurrent(path, run, fieldVersions)) {
352
+ if (ruleError)
353
+ this.applyError(path, ruleError);
354
+ else if (this.getError(schemaErrors, path))
355
+ this.applyError(path, this.getError(schemaErrors, path));
356
+ else
357
+ this.applyError(path, undefined);
358
+ }
359
+ });
360
+ }
361
+ return (paths ?? [...this.errorsByPath.keys()]).every((path) => !this.hasError(path));
362
+ }
363
+ finally {
364
+ runInAction(() => {
365
+ if (this.validationVersion === run) {
366
+ for (const path of paths ?? this.fieldOptions.keys()) {
367
+ if (!this.isValidationCurrent(path, run, fieldVersions))
368
+ continue;
369
+ delete this.validatingFields[path];
370
+ this.ensureFieldState(path).isValidating = false;
371
+ }
372
+ }
373
+ });
374
+ }
375
+ }
376
+ /**
377
+ * Creates an asynchronous submit handler with validation and result callbacks.
378
+ *
379
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#handlesubmithandlers)
380
+ */
381
+ handleSubmit({ onValid, onInvalid }) {
382
+ return async () => {
383
+ this.activeSubmissions += 1;
384
+ this.isSubmitting = true;
385
+ this.isSubmitted = true;
386
+ this.submitCount += 1;
387
+ const submissionResetVersion = this.resetVersion;
388
+ try {
389
+ const valid = await this.trigger();
390
+ if (valid) {
391
+ await onValid(this.snapshot, this);
392
+ runInAction(() => {
393
+ if (this.resetVersion === submissionResetVersion)
394
+ this.isSubmitSuccessful = true;
395
+ });
396
+ }
397
+ else {
398
+ await onInvalid?.(this.errors, this);
399
+ runInAction(() => {
400
+ if (this.resetVersion === submissionResetVersion)
401
+ this.isSubmitSuccessful = false;
402
+ });
403
+ }
404
+ }
405
+ finally {
406
+ runInAction(() => {
407
+ this.activeSubmissions -= 1;
408
+ this.isSubmitting = this.activeSubmissions > 0;
409
+ });
410
+ }
411
+ };
412
+ }
413
+ /**
414
+ * Resets values and selected form state.
415
+ *
416
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#resetvalues-options)
417
+ */
418
+ reset(values, options = {}) {
419
+ this.disposeValueObservers();
420
+ this.resetVersion += 1;
421
+ this.validationVersion += 1;
422
+ for (const path of Object.keys(this.validatingFields)) {
423
+ delete this.validatingFields[path];
424
+ const state = this.fieldStatesByPath.get(path);
425
+ if (state)
426
+ state.isValidating = false;
427
+ }
428
+ const next = clone((values ?? this.defaultValues));
429
+ this.values = next;
430
+ if (!options.keepDefaultValues && values)
431
+ Object.assign(this.defaultValues, clone(values));
432
+ if (!options.keepDirty)
433
+ this.dirtyFields = {};
434
+ if (!options.keepTouched)
435
+ this.touchedFields = {};
436
+ if (!options.keepErrors) {
437
+ this.clearPathStore(this.errorsByPath, this.errorPathCounts, this.errorChildren);
438
+ for (const [, state] of this.fieldStates())
439
+ this.applyFieldState(state, undefined);
440
+ }
441
+ if (!options.keepIsSubmitted) {
442
+ this.isSubmitted = false;
443
+ this.isSubmitSuccessful = false;
444
+ }
445
+ if (!options.keepSubmitCount)
446
+ this.submitCount = 0;
447
+ for (const [path, state] of this.fieldStates()) {
448
+ state.isDirty = !!this.dirtyFields[path];
449
+ state.isTouched = !!this.touchedFields[path];
450
+ }
451
+ }
452
+ /**
453
+ * Resets one field to its current default value and clears its state.
454
+ *
455
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#resetfieldname)
456
+ */
457
+ resetField(name) {
458
+ const path = name;
459
+ this.fieldValidationVersions.set(path, (this.fieldValidationVersions.get(path) ?? 0) + 1);
460
+ setAtPath(this.values, path, clone(getAtPath(this.defaultValues, path)));
461
+ this.applyError(path, undefined);
462
+ delete this.dirtyFields[path];
463
+ delete this.touchedFields[path];
464
+ delete this.validatingFields[path];
465
+ const state = this.ensureFieldState(path);
466
+ this.applyFieldState(state, undefined);
467
+ state.isDirty = false;
468
+ state.isTouched = false;
469
+ state.isValidating = false;
470
+ }
471
+ /**
472
+ * Focuses a registered field when its ref points to a focusable element.
473
+ *
474
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#setfocusname)
475
+ */
476
+ setFocus(name) { this.refs.get(name)?.current?.focus(); }
477
+ /**
478
+ * Returns a plain copy of the current values.
479
+ *
480
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#snapshot)
481
+ */
482
+ get snapshot() { return clone(this.values); }
483
+ markTouched(path) {
484
+ this.touchedFields[path] = true;
485
+ this.ensureFieldState(path).isTouched = true;
486
+ }
487
+ updateDirty(path) {
488
+ if (isEqual(getAtPath(this.values, path), getAtPath(this.defaultValues, path)))
489
+ delete this.dirtyFields[path];
490
+ else
491
+ this.dirtyFields[path] = true;
492
+ this.ensureFieldState(path).isDirty = !!this.dirtyFields[path];
493
+ }
494
+ shouldValidateOnChange(path) { return this.options.mode === 'onChange' || this.options.mode === 'all' || (this.hasError(path) && this.options.reValidateMode === 'onChange'); }
495
+ isValidationCurrent(path, run, fieldVersions) {
496
+ if (this.validationVersion !== run)
497
+ return false;
498
+ const version = fieldVersions.get(path);
499
+ return version === undefined
500
+ || (this.fieldOptions.has(path) && (this.fieldValidationVersions.get(path) ?? 0) === version);
501
+ }
502
+ transformValue(value, options) {
503
+ if (options.setValueAs)
504
+ return options.setValueAs(value);
505
+ if (options.valueAsNumber)
506
+ return value === '' ? Number.NaN : Number(value);
507
+ if (options.valueAsDate)
508
+ return new Date(String(value));
509
+ return value;
510
+ }
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
+ ensureFieldState(path) {
566
+ const existing = this.fieldStatesByPath.get(path);
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;
572
+ }
573
+ applyFieldState(state, error) {
574
+ state.error = error;
575
+ state.invalid = !!error;
576
+ }
577
+ applyError(path, error) {
578
+ if (error)
579
+ this.setPathStore(this.errorsByPath, this.errorPathCounts, this.errorChildren, path, error);
580
+ else
581
+ this.deletePathStore(this.errorsByPath, this.errorPathCounts, this.errorChildren, path);
582
+ this.applyFieldState(this.ensureFieldState(path), error);
583
+ }
584
+ getError(errors, path) {
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;
680
+ }
681
+ }
@@ -0,0 +1,4 @@
1
+ export { Form } from './form.js';
2
+ export { createForm } from './create-form.js';
3
+ export type { InferredFormValues } from './create-form.js';
4
+ export type { FieldError, FieldErrors, FieldPath, FieldPathValue, FieldState, FieldStateTree, FieldValues, FormOptions, FormSchema, SafeParseFormSchema, SchemaIssue, SchemaOutput, SchemaResult, ValibotFormSchema, ValibotRunResult, RegisterOptions, RegisterReturn, ResetOptions, SetValueConfig, SubmitHandlers, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { Form } from './form.js';
2
+ export { createForm } from './create-form.js';
@@ -0,0 +1,140 @@
1
+ import type { Ref } from 'yummies/mobx';
2
+ export type FieldValues = Record<string, unknown>;
3
+ export interface SchemaIssue {
4
+ code?: string;
5
+ type?: string;
6
+ path?: Array<PropertyKey | {
7
+ key: PropertyKey;
8
+ }>;
9
+ message: string;
10
+ }
11
+ export type SchemaResult<T> = {
12
+ success: true;
13
+ data: T;
14
+ } | {
15
+ success: false;
16
+ error: {
17
+ issues: SchemaIssue[];
18
+ };
19
+ };
20
+ /** Safe-parse-compatible schema contract. */
21
+ export interface SafeParseFormSchema<T> {
22
+ safeParseAsync(value: unknown): Promise<SchemaResult<T>>;
23
+ }
24
+ /** Valibot-compatible schema contract. */
25
+ export interface ValibotFormSchema<T> {
26
+ readonly '~types'?: {
27
+ readonly output: T;
28
+ };
29
+ '~run': (...args: any[]) => any;
30
+ }
31
+ export interface ValibotRunResult<T> {
32
+ success?: boolean;
33
+ value?: T;
34
+ issues?: SchemaIssue[];
35
+ }
36
+ /** Schema contract accepted by Form. Both Zod and Valibot schemas fit this interface. */
37
+ export type FormSchema<T> = SafeParseFormSchema<T> | ValibotFormSchema<T>;
38
+ export type SchemaOutput<S> = S extends SafeParseFormSchema<infer T> ? T : S extends {
39
+ readonly '~types'?: {
40
+ readonly output: infer T;
41
+ };
42
+ } ? T : never;
43
+ /** Dot-separated path into the form value tree, for example `user.email`. */
44
+ export type FieldPath<T = FieldValues> = 'root' | {
45
+ [K in Extract<keyof T, string>]: T[K] extends readonly unknown[] ? T[K] extends readonly (infer I)[] ? I extends object ? K | `${K}.${number}` | `${K}.${number}.${FieldPath<I>}` : K | `${K}.${number}` : K : T[K] extends object ? K | `${K}.${FieldPath<T[K]>}` : K;
46
+ }[Extract<keyof T, string>];
47
+ export type FieldPathValue<T, P extends string> = P extends `${infer K}.${infer Rest}` ? K extends keyof T ? FieldPathValue<T[K], Rest> : T extends readonly (infer I)[] ? FieldPathValue<I, Rest> : never : P extends keyof T ? T[P] : T extends readonly (infer I)[] ? P extends `${number}` ? I : never : never;
48
+ export interface FieldError {
49
+ type: string;
50
+ message?: string;
51
+ }
52
+ export type FieldErrors<T extends object = FieldValues> = {
53
+ [K in keyof T]?: T[K] extends readonly (infer I)[] ? FieldError & Array<I extends object ? FieldErrors<I> : FieldError | undefined> : T[K] extends object ? FieldError & FieldErrors<T[K]> : FieldError;
54
+ } & {
55
+ root?: FieldError;
56
+ };
57
+ export interface FieldState {
58
+ invalid: boolean;
59
+ isDirty: boolean;
60
+ isTouched: boolean;
61
+ isValidating: boolean;
62
+ error?: FieldError;
63
+ }
64
+ export type FieldStateTree<T extends object = FieldValues> = {
65
+ [K in keyof T]?: T[K] extends readonly (infer I)[] ? FieldState & Array<I extends object ? FieldStateTree<I> : FieldState | undefined> : T[K] extends object ? FieldState & FieldStateTree<T[K]> : FieldState;
66
+ } & {
67
+ root?: FieldState;
68
+ };
69
+ export interface FormState<T extends FieldValues = FieldValues> {
70
+ errors: FieldErrors<T>;
71
+ dirtyFields: Partial<Record<FieldPath<T>, true>>;
72
+ touchedFields: Partial<Record<FieldPath<T>, true>>;
73
+ validatingFields: Partial<Record<FieldPath<T>, true>>;
74
+ isDirty: boolean;
75
+ isValid: boolean;
76
+ isValidating: boolean;
77
+ isSubmitting: boolean;
78
+ isSubmitted: boolean;
79
+ isSubmitSuccessful: boolean;
80
+ submitCount: number;
81
+ disabled: boolean;
82
+ }
83
+ export interface RegisterOptions<T extends FieldValues = FieldValues> {
84
+ required?: string | boolean;
85
+ minLength?: {
86
+ value: number;
87
+ message?: string;
88
+ };
89
+ maxLength?: {
90
+ value: number;
91
+ message?: string;
92
+ };
93
+ min?: {
94
+ value: number;
95
+ message?: string;
96
+ };
97
+ max?: {
98
+ value: number;
99
+ message?: string;
100
+ };
101
+ pattern?: {
102
+ value: RegExp;
103
+ message?: string;
104
+ };
105
+ validate?: (value: unknown, values: T) => boolean | string | Promise<boolean | string>;
106
+ valueAsNumber?: boolean;
107
+ valueAsDate?: boolean;
108
+ setValueAs?: (value: unknown) => unknown;
109
+ }
110
+ export interface RegisterReturn {
111
+ name: string;
112
+ ref: Ref<HTMLElement | null>;
113
+ onChange: (eventOrValue: unknown) => Promise<void>;
114
+ onBlur: () => Promise<void>;
115
+ }
116
+ export interface SetValueConfig {
117
+ shouldValidate?: boolean;
118
+ shouldDirty?: boolean;
119
+ shouldTouch?: boolean;
120
+ }
121
+ export interface ResetOptions {
122
+ keepDefaultValues?: boolean;
123
+ keepDirty?: boolean;
124
+ keepTouched?: boolean;
125
+ keepErrors?: boolean;
126
+ keepIsSubmitted?: boolean;
127
+ keepSubmitCount?: boolean;
128
+ }
129
+ export interface FormOptions<T extends FieldValues> {
130
+ defaultValues?: Partial<T>;
131
+ values?: Partial<T>;
132
+ schema?: FormSchema<T>;
133
+ mode?: 'onSubmit' | 'onChange' | 'onBlur' | 'all';
134
+ reValidateMode?: 'onChange' | 'onBlur';
135
+ disabled?: boolean;
136
+ }
137
+ export interface SubmitHandlers<T extends FieldValues> {
138
+ onValid: (values: T, form: unknown) => void | Promise<void>;
139
+ onInvalid?: (errors: FieldErrors<T>, form: unknown) => void | Promise<void>;
140
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ export declare const clone: <T>(value: T) => T;
2
+ export declare const getAtPath: (source: unknown, path: string) => unknown;
3
+ export declare const setAtPath: (target: Record<string, unknown>, path: string, value: unknown) => void;
4
+ export declare const deleteAtPath: (target: Record<string, unknown>, path: string) => void;
5
+ export declare const isEqual: (a: unknown, b: unknown) => boolean;
6
+ export declare const extractValue: (eventOrValue: unknown) => unknown;
package/dist/utils.js ADDED
@@ -0,0 +1,61 @@
1
+ import { isUnsafeProperty } from 'yummies/data';
2
+ export const clone = (value) => {
3
+ if (typeof structuredClone === 'function') {
4
+ try {
5
+ return structuredClone(value);
6
+ }
7
+ catch {
8
+ // MobX observable proxies cannot be structured-cloned directly.
9
+ }
10
+ }
11
+ return JSON.parse(JSON.stringify(value));
12
+ };
13
+ export const getAtPath = (source, path) => path.split('.').reduce((value, key) => {
14
+ if (isUnsafeProperty(key) || value === null || value === undefined)
15
+ return undefined;
16
+ return value[key];
17
+ }, source);
18
+ export const setAtPath = (target, path, value) => {
19
+ const keys = path.split('.');
20
+ let current = target;
21
+ for (const [index, key] of keys.slice(0, -1).entries()) {
22
+ if (isUnsafeProperty(key))
23
+ return;
24
+ const next = current[key];
25
+ const nextKey = keys[index + 1];
26
+ if (!next || typeof next !== 'object')
27
+ current[key] = /^\d+$/.test(nextKey) ? [] : {};
28
+ current = current[key];
29
+ }
30
+ const lastKey = keys.at(-1);
31
+ if (lastKey && !isUnsafeProperty(lastKey))
32
+ current[lastKey] = value;
33
+ };
34
+ export const deleteAtPath = (target, path) => {
35
+ const keys = path.split('.');
36
+ const lastKey = keys.pop();
37
+ if (!lastKey || isUnsafeProperty(lastKey))
38
+ return;
39
+ const parent = keys.reduce((value, key) => {
40
+ if (isUnsafeProperty(key) || !value || typeof value !== 'object')
41
+ return undefined;
42
+ return value[key];
43
+ }, target);
44
+ if (!parent || typeof parent !== 'object')
45
+ return;
46
+ delete parent[lastKey];
47
+ };
48
+ export const isEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b);
49
+ export const extractValue = (eventOrValue) => {
50
+ if (!eventOrValue || typeof eventOrValue !== 'object' || !('target' in eventOrValue))
51
+ return eventOrValue;
52
+ const target = eventOrValue.target;
53
+ if (target.type === 'checkbox')
54
+ return target.checked;
55
+ if (target.type === 'radio')
56
+ return target.checked ? target.value : undefined;
57
+ if (target.multiple && typeof HTMLSelectElement !== 'undefined' && target instanceof HTMLSelectElement) {
58
+ return Array.from(target.selectedOptions, (option) => option.value);
59
+ }
60
+ return target.value;
61
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobx-formly",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Framework-agnostic observable forms for MobX with granular field state and Zod or Valibot validation",
5
5
  "homepage": "https://js2me.github.io/mobx-formly/",
6
6
  "repository": {