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.
@@ -0,0 +1,232 @@
1
+ import { findErrorAtPath, setAtPath } from './utils.js';
2
+ /**
3
+ * Schema, resolver, and rule validation with error delivery for Form.
4
+ *
5
+ * Runs Zod-like, Valibot, and Standard Schema contracts or a custom resolver,
6
+ * checks registration rules with firstError or all criteria modes, relays
7
+ * messages to native DOM validity, and can delay error display.
8
+ */
9
+ export class FormValidator {
10
+ host;
11
+ delayedErrorTimers = new Map();
12
+ delayedErrors = new Map();
13
+ constructor(host) {
14
+ this.host = host;
15
+ }
16
+ /** Validates values with the resolver or schema and returns nested errors and resolver output. */
17
+ async resolveSchemaErrors(paths) {
18
+ const options = this.host.options;
19
+ if (options.resolver) {
20
+ const result = await options.resolver(this.host.snapshot(), options.context, {
21
+ criteriaMode: options.criteriaMode,
22
+ fields: Object.fromEntries(this.host.fieldOptions),
23
+ names: paths,
24
+ shouldUseNativeValidation: options.shouldUseNativeValidation,
25
+ });
26
+ return { errors: result.errors, values: this.resolverResultValues(result) };
27
+ }
28
+ if (!options.schema)
29
+ return { errors: {} };
30
+ const schema = options.schema;
31
+ if ('safeParseAsync' in schema) {
32
+ const result = await schema.safeParseAsync(this.host.snapshot());
33
+ if (result.success)
34
+ return { errors: {}, values: result.data };
35
+ return { errors: this.normalizeSchemaErrors(result.error) };
36
+ }
37
+ if ('~standard' in schema) {
38
+ const result = await schema['~standard'].validate(this.host.snapshot());
39
+ if ('value' in result && !result.issues)
40
+ return { errors: {}, values: result.value };
41
+ return { errors: this.normalizeSchemaErrors({ issues: result.issues ?? [] }) };
42
+ }
43
+ const result = await schema['~run']({ value: this.host.snapshot(), typed: false }, {});
44
+ if (!result.issues?.length)
45
+ return { errors: {}, values: result.value };
46
+ return { errors: this.normalizeSchemaErrors({ issues: result.issues }) };
47
+ }
48
+ /** Checks registration rules for one field and returns the failing error if any. */
49
+ async validateRules(path) {
50
+ const rules = this.host.fieldOptions.get(path);
51
+ if (!rules)
52
+ return undefined;
53
+ const value = this.host.valueAt(path);
54
+ const firstOnly = this.host.options.criteriaMode !== 'all';
55
+ const failures = [];
56
+ /** Collects a failure; in firstError mode short-circuits validation like RHF. */
57
+ const add = (type, messages) => {
58
+ for (const message of messages)
59
+ failures.push({ type, ...(message !== undefined ? { message } : {}) });
60
+ return firstOnly && failures.length ? failures[0] : undefined;
61
+ };
62
+ let stop;
63
+ if (rules.required && (value === undefined || value === null || value === '')) {
64
+ stop = add('required', [typeof rules.required === 'string' ? rules.required : undefined]);
65
+ if (stop)
66
+ return stop;
67
+ }
68
+ if (rules.minLength && String(value ?? '').length < rules.minLength.value) {
69
+ stop = add('minLength', [rules.minLength.message]);
70
+ if (stop)
71
+ return stop;
72
+ }
73
+ if (rules.maxLength && String(value ?? '').length > rules.maxLength.value) {
74
+ stop = add('maxLength', [rules.maxLength.message]);
75
+ if (stop)
76
+ return stop;
77
+ }
78
+ if (rules.min && Number(value) < rules.min.value) {
79
+ stop = add('min', [rules.min.message]);
80
+ if (stop)
81
+ return stop;
82
+ }
83
+ if (rules.max && Number(value) > rules.max.value) {
84
+ stop = add('max', [rules.max.message]);
85
+ if (stop)
86
+ return stop;
87
+ }
88
+ if (rules.pattern && !rules.pattern.value.test(String(value ?? ''))) {
89
+ stop = add('pattern', [rules.pattern.message]);
90
+ if (stop)
91
+ return stop;
92
+ }
93
+ if (rules.validate && (!firstOnly || !failures.length)) {
94
+ const validators = typeof rules.validate === 'function' ? { validate: rules.validate } : rules.validate;
95
+ for (const [type, validate] of Object.entries(validators)) {
96
+ let result;
97
+ try {
98
+ result = await validate(value, this.host.snapshot());
99
+ }
100
+ catch {
101
+ result = 'Validation failed';
102
+ }
103
+ if (result === true || result === undefined)
104
+ continue;
105
+ const messages = Array.isArray(result) ? result : [typeof result === 'string' ? result : undefined];
106
+ if (!messages.length)
107
+ continue;
108
+ stop = add(type, messages);
109
+ if (stop)
110
+ return stop;
111
+ }
112
+ }
113
+ if (!failures.length)
114
+ return undefined;
115
+ return {
116
+ type: failures[0].type,
117
+ message: failures[0].message,
118
+ types: this.collectRuleTypes(failures),
119
+ };
120
+ }
121
+ /** Groups rule failures into the types map; repeated messages of one rule become arrays. */
122
+ collectRuleTypes(failures) {
123
+ const types = {};
124
+ for (const failure of failures) {
125
+ const message = failure.message ?? true;
126
+ const current = types[failure.type];
127
+ if (current === undefined) {
128
+ types[failure.type] = message;
129
+ }
130
+ else if (Array.isArray(current)) {
131
+ if (typeof message === 'string' && !current.includes(message))
132
+ current.push(message);
133
+ }
134
+ else if (current !== message && typeof current === 'string' && typeof message === 'string') {
135
+ types[failure.type] = [current, message];
136
+ }
137
+ }
138
+ return types;
139
+ }
140
+ /** Combines schema and rule errors for a field according to the criteria mode. */
141
+ mergeErrors(schemaError, ruleError) {
142
+ if (!schemaError)
143
+ return ruleError;
144
+ if (!ruleError)
145
+ return schemaError;
146
+ if (this.host.options.criteriaMode !== 'all')
147
+ return ruleError;
148
+ return {
149
+ ...schemaError,
150
+ types: { ...(schemaError.types ?? { [schemaError.type]: schemaError.message || true }), ...(ruleError.types ?? { [ruleError.type]: ruleError.message || true }) },
151
+ };
152
+ }
153
+ /** Delivers a validated error, respecting the delay option and native validity reporting. */
154
+ applyValidationError(path, error) {
155
+ const timer = this.delayedErrorTimers.get(path);
156
+ if (timer)
157
+ clearTimeout(timer);
158
+ this.delayedErrorTimers.delete(path);
159
+ this.applyNativeValidity(path, error);
160
+ if (error && this.host.options.delayError && this.host.options.delayError > 0) {
161
+ this.delayedErrors.set(path, error);
162
+ const next = setTimeout(() => {
163
+ this.delayedErrorTimers.delete(path);
164
+ this.delayedErrors.delete(path);
165
+ this.host.applyError(path, error);
166
+ }, this.host.options.delayError);
167
+ this.delayedErrorTimers.set(path, next);
168
+ return;
169
+ }
170
+ this.delayedErrors.delete(path);
171
+ this.host.applyError(path, error);
172
+ }
173
+ /** Applies a pending delayed error immediately, for example on blur. */
174
+ flushDelayed(path) {
175
+ const timer = this.delayedErrorTimers.get(path);
176
+ if (!timer)
177
+ return;
178
+ clearTimeout(timer);
179
+ this.delayedErrorTimers.delete(path);
180
+ const error = this.delayedErrors.get(path);
181
+ this.delayedErrors.delete(path);
182
+ if (error)
183
+ this.host.applyError(path, error);
184
+ }
185
+ /** Drops a pending delayed error without applying it. */
186
+ cancelDelayed(path) {
187
+ const timer = this.delayedErrorTimers.get(path);
188
+ if (timer)
189
+ clearTimeout(timer);
190
+ this.delayedErrorTimers.delete(path);
191
+ this.delayedErrors.delete(path);
192
+ }
193
+ /** Drops all pending delayed errors without applying them. */
194
+ cancelAllDelayed() {
195
+ for (const timer of this.delayedErrorTimers.values())
196
+ clearTimeout(timer);
197
+ this.delayedErrorTimers.clear();
198
+ this.delayedErrors.clear();
199
+ }
200
+ resolverResultValues(result) {
201
+ return Object.keys(result.errors).length === 0 ? result.values : undefined;
202
+ }
203
+ normalizeSchemaErrors(error) {
204
+ const collectAll = this.host.options.criteriaMode === 'all';
205
+ return error.issues.reduce((errors, issue) => {
206
+ const path = (issue.path ?? []).map((part) => String(typeof part === 'object' && 'key' in part ? part.key : part)).join('.') || 'root';
207
+ const typedIssue = issue;
208
+ const type = typedIssue.code ?? typedIssue.type ?? 'validation';
209
+ const current = findErrorAtPath(errors, path);
210
+ if (!current) {
211
+ setAtPath(errors, path, collectAll
212
+ ? { type, message: issue.message, types: { [type]: issue.message || true } }
213
+ : { type, message: issue.message });
214
+ }
215
+ else if (collectAll) {
216
+ // Errors at this path are created by this method, so in-place mutation is safe.
217
+ const types = current.types ?? (current.types = {});
218
+ types[type] = issue.message || true;
219
+ }
220
+ return errors;
221
+ }, {});
222
+ }
223
+ applyNativeValidity(path, error) {
224
+ if (!this.host.options.shouldUseNativeValidation)
225
+ return;
226
+ const ref = this.host.refs.get(path)?.current;
227
+ if (!ref?.setCustomValidity || !ref.reportValidity)
228
+ return;
229
+ ref.setCustomValidity(error?.message ?? '');
230
+ ref.reportValidity();
231
+ }
232
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobx-formly",
3
- "version": "0.0.3",
3
+ "version": "0.1.0",
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": {
@@ -65,8 +65,16 @@
65
65
  },
66
66
  "devDependencies": {
67
67
  "@changesets/cli": "^3.0.1",
68
+ "@types/node": "^22.20.1",
69
+ "@types/react": "^19.3.0",
70
+ "@types/react-dom": "^19.3.0",
71
+ "@types/react-test-renderer": "^19.3.0",
68
72
  "@vitest/coverage-v8": "^4.1.11",
69
73
  "docusite": "^0.2.0",
74
+ "mobx-react-lite": "^4.1.1",
75
+ "react": "^19.3.0",
76
+ "react-dom": "^19.3.0",
77
+ "react-test-renderer": "^19.3.0",
70
78
  "tsx": "^4.23.12",
71
79
  "typescript": "^7.0.2",
72
80
  "valibot": "^1.4.2",
@@ -77,7 +85,7 @@
77
85
  "build": "tsc -p tsconfig.json",
78
86
  "check": "tsc --noEmit -p tsconfig.json",
79
87
  "test": "vitest run",
80
- "test:performance": "vitest run tests/performance",
88
+ "test:performance": "vitest run tests/performance --reporter=verbose --silent=false",
81
89
  "test:coverage": "vitest run --coverage",
82
90
  "test:types": "tsc --noEmit -p tsconfig.types.json",
83
91
  "docs:dev": "docusite dev",