@fogpipe/forma-core 0.6.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/README.md +169 -0
- package/dist/chunk-IRLYWN3R.js +790 -0
- package/dist/chunk-IRLYWN3R.js.map +1 -0
- package/dist/chunk-QTLXVG6P.js +135 -0
- package/dist/chunk-QTLXVG6P.js.map +1 -0
- package/dist/engine/index.cjs +885 -0
- package/dist/engine/index.cjs.map +1 -0
- package/dist/engine/index.d.cts +258 -0
- package/dist/engine/index.d.ts +258 -0
- package/dist/engine/index.js +32 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/feel/index.cjs +165 -0
- package/dist/feel/index.cjs.map +1 -0
- package/dist/feel/index.d.cts +105 -0
- package/dist/feel/index.d.ts +105 -0
- package/dist/feel/index.js +19 -0
- package/dist/feel/index.js.map +1 -0
- package/dist/index.cjs +962 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/types-Bs3CG9JZ.d.cts +283 -0
- package/dist/types-Bs3CG9JZ.d.ts +283 -0
- package/package.json +82 -0
- package/src/engine/calculate.ts +363 -0
- package/src/engine/enabled.ts +147 -0
- package/src/engine/index.ts +54 -0
- package/src/engine/required.ts +151 -0
- package/src/engine/validate.ts +647 -0
- package/src/engine/visibility.ts +228 -0
- package/src/feel/index.ts +299 -0
- package/src/index.ts +15 -0
- package/src/types.ts +364 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation Engine
|
|
3
|
+
*
|
|
4
|
+
* Validates form data against Forma rules including:
|
|
5
|
+
* - JSON Schema type validation
|
|
6
|
+
* - Required field validation (with conditional requiredWhen)
|
|
7
|
+
* - Custom FEEL validation rules
|
|
8
|
+
* - Array item validation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { evaluateBoolean } from "../feel/index.js";
|
|
12
|
+
import type {
|
|
13
|
+
Forma,
|
|
14
|
+
FieldDefinition,
|
|
15
|
+
ValidationRule,
|
|
16
|
+
EvaluationContext,
|
|
17
|
+
ValidationResult,
|
|
18
|
+
FieldError,
|
|
19
|
+
JSONSchemaProperty,
|
|
20
|
+
} from "../types.js";
|
|
21
|
+
import { calculate } from "./calculate.js";
|
|
22
|
+
import { getVisibility } from "./visibility.js";
|
|
23
|
+
import { isFieldRequired } from "./required.js";
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Types
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
export interface ValidateOptions {
|
|
30
|
+
/** Pre-calculated computed values */
|
|
31
|
+
computed?: Record<string, unknown>;
|
|
32
|
+
/** Pre-calculated visibility */
|
|
33
|
+
visibility?: Record<string, boolean>;
|
|
34
|
+
/** Only validate visible fields (default: true) */
|
|
35
|
+
onlyVisible?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Main Function
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validate form data against a Forma
|
|
44
|
+
*
|
|
45
|
+
* Performs comprehensive validation including:
|
|
46
|
+
* - Required field checks (respecting conditional requiredWhen)
|
|
47
|
+
* - JSON Schema type validation
|
|
48
|
+
* - Custom FEEL validation rules
|
|
49
|
+
* - Array min/max items validation
|
|
50
|
+
* - Array item field validation
|
|
51
|
+
*
|
|
52
|
+
* By default, only visible fields are validated.
|
|
53
|
+
*
|
|
54
|
+
* @param data - Current form data
|
|
55
|
+
* @param spec - Form specification
|
|
56
|
+
* @param options - Validation options
|
|
57
|
+
* @returns Validation result with valid flag and errors array
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* const result = validate(
|
|
61
|
+
* { name: "", age: 15 },
|
|
62
|
+
* forma
|
|
63
|
+
* );
|
|
64
|
+
* // => {
|
|
65
|
+
* // valid: false,
|
|
66
|
+
* // errors: [
|
|
67
|
+
* // { field: "name", message: "Name is required", severity: "error" },
|
|
68
|
+
* // { field: "age", message: "Must be 18 or older", severity: "error" }
|
|
69
|
+
* // ]
|
|
70
|
+
* // }
|
|
71
|
+
*/
|
|
72
|
+
export function validate(
|
|
73
|
+
data: Record<string, unknown>,
|
|
74
|
+
spec: Forma,
|
|
75
|
+
options: ValidateOptions = {}
|
|
76
|
+
): ValidationResult {
|
|
77
|
+
const { onlyVisible = true } = options;
|
|
78
|
+
|
|
79
|
+
// Calculate computed values
|
|
80
|
+
const computed = options.computed ?? calculate(data, spec);
|
|
81
|
+
|
|
82
|
+
// Calculate visibility
|
|
83
|
+
const visibility = options.visibility ?? getVisibility(data, spec, { computed });
|
|
84
|
+
|
|
85
|
+
// Collect errors
|
|
86
|
+
const errors: FieldError[] = [];
|
|
87
|
+
|
|
88
|
+
// Validate each field
|
|
89
|
+
for (const fieldPath of spec.fieldOrder) {
|
|
90
|
+
const fieldDef = spec.fields[fieldPath];
|
|
91
|
+
if (!fieldDef) continue;
|
|
92
|
+
|
|
93
|
+
// Skip hidden fields if onlyVisible is true
|
|
94
|
+
if (onlyVisible && visibility[fieldPath] === false) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Get schema property for type validation
|
|
99
|
+
const schemaProperty = spec.schema.properties[fieldPath];
|
|
100
|
+
|
|
101
|
+
// Validate this field
|
|
102
|
+
const fieldErrors = validateField(
|
|
103
|
+
fieldPath,
|
|
104
|
+
data[fieldPath],
|
|
105
|
+
fieldDef,
|
|
106
|
+
schemaProperty,
|
|
107
|
+
spec,
|
|
108
|
+
data,
|
|
109
|
+
computed,
|
|
110
|
+
visibility,
|
|
111
|
+
onlyVisible
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
errors.push(...fieldErrors);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
valid: errors.filter((e) => e.severity === "error").length === 0,
|
|
119
|
+
errors,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ============================================================================
|
|
124
|
+
// Field Validation
|
|
125
|
+
// ============================================================================
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Validate a single field and its nested fields
|
|
129
|
+
*/
|
|
130
|
+
function validateField(
|
|
131
|
+
path: string,
|
|
132
|
+
value: unknown,
|
|
133
|
+
fieldDef: FieldDefinition,
|
|
134
|
+
schemaProperty: JSONSchemaProperty | undefined,
|
|
135
|
+
spec: Forma,
|
|
136
|
+
data: Record<string, unknown>,
|
|
137
|
+
computed: Record<string, unknown>,
|
|
138
|
+
visibility: Record<string, boolean>,
|
|
139
|
+
onlyVisible: boolean
|
|
140
|
+
): FieldError[] {
|
|
141
|
+
const errors: FieldError[] = [];
|
|
142
|
+
const context: EvaluationContext = {
|
|
143
|
+
data,
|
|
144
|
+
computed,
|
|
145
|
+
referenceData: spec.referenceData,
|
|
146
|
+
value,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// 1. Required validation
|
|
150
|
+
const required = isFieldRequired(path, fieldDef, spec, context);
|
|
151
|
+
if (required && isEmpty(value)) {
|
|
152
|
+
errors.push({
|
|
153
|
+
field: path,
|
|
154
|
+
message: fieldDef.label
|
|
155
|
+
? `${fieldDef.label} is required`
|
|
156
|
+
: "This field is required",
|
|
157
|
+
severity: "error",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 2. Type validation (only if value is present)
|
|
162
|
+
if (!isEmpty(value) && schemaProperty) {
|
|
163
|
+
const typeError = validateType(path, value, schemaProperty, fieldDef);
|
|
164
|
+
if (typeError) {
|
|
165
|
+
errors.push(typeError);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 3. Custom FEEL validation rules
|
|
170
|
+
if (fieldDef.validations && !isEmpty(value)) {
|
|
171
|
+
const customErrors = validateCustomRules(path, fieldDef.validations, context);
|
|
172
|
+
errors.push(...customErrors);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 4. Array validation
|
|
176
|
+
if (Array.isArray(value) && fieldDef.itemFields) {
|
|
177
|
+
const arrayErrors = validateArray(
|
|
178
|
+
path,
|
|
179
|
+
value,
|
|
180
|
+
fieldDef,
|
|
181
|
+
spec,
|
|
182
|
+
data,
|
|
183
|
+
computed,
|
|
184
|
+
visibility,
|
|
185
|
+
onlyVisible
|
|
186
|
+
);
|
|
187
|
+
errors.push(...arrayErrors);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return errors;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Check if a value is empty
|
|
195
|
+
*/
|
|
196
|
+
function isEmpty(value: unknown): boolean {
|
|
197
|
+
if (value === null || value === undefined) return true;
|
|
198
|
+
if (typeof value === "string" && value.trim() === "") return true;
|
|
199
|
+
if (Array.isArray(value) && value.length === 0) return true;
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ============================================================================
|
|
204
|
+
// Type Validation
|
|
205
|
+
// ============================================================================
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Validate value against JSON Schema type
|
|
209
|
+
*/
|
|
210
|
+
function validateType(
|
|
211
|
+
path: string,
|
|
212
|
+
value: unknown,
|
|
213
|
+
schema: JSONSchemaProperty,
|
|
214
|
+
fieldDef: FieldDefinition
|
|
215
|
+
): FieldError | null {
|
|
216
|
+
const label = fieldDef.label ?? path;
|
|
217
|
+
|
|
218
|
+
switch (schema.type) {
|
|
219
|
+
case "string": {
|
|
220
|
+
if (typeof value !== "string") {
|
|
221
|
+
return {
|
|
222
|
+
field: path,
|
|
223
|
+
message: `${label} must be a string`,
|
|
224
|
+
severity: "error",
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// String-specific validations
|
|
229
|
+
if ("minLength" in schema && schema.minLength !== undefined) {
|
|
230
|
+
if (value.length < schema.minLength) {
|
|
231
|
+
return {
|
|
232
|
+
field: path,
|
|
233
|
+
message: `${label} must be at least ${schema.minLength} characters`,
|
|
234
|
+
severity: "error",
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if ("maxLength" in schema && schema.maxLength !== undefined) {
|
|
240
|
+
if (value.length > schema.maxLength) {
|
|
241
|
+
return {
|
|
242
|
+
field: path,
|
|
243
|
+
message: `${label} must be no more than ${schema.maxLength} characters`,
|
|
244
|
+
severity: "error",
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if ("pattern" in schema && schema.pattern) {
|
|
250
|
+
const regex = new RegExp(schema.pattern);
|
|
251
|
+
if (!regex.test(value)) {
|
|
252
|
+
return {
|
|
253
|
+
field: path,
|
|
254
|
+
message: `${label} format is invalid`,
|
|
255
|
+
severity: "error",
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if ("enum" in schema && schema.enum) {
|
|
261
|
+
if (!schema.enum.includes(value)) {
|
|
262
|
+
return {
|
|
263
|
+
field: path,
|
|
264
|
+
message: `${label} must be one of: ${schema.enum.join(", ")}`,
|
|
265
|
+
severity: "error",
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if ("format" in schema && schema.format) {
|
|
271
|
+
const formatError = validateFormat(path, value, schema.format, label);
|
|
272
|
+
if (formatError) return formatError;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
case "number":
|
|
279
|
+
case "integer": {
|
|
280
|
+
if (typeof value !== "number") {
|
|
281
|
+
return {
|
|
282
|
+
field: path,
|
|
283
|
+
message: `${label} must be a number`,
|
|
284
|
+
severity: "error",
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (schema.type === "integer" && !Number.isInteger(value)) {
|
|
289
|
+
return {
|
|
290
|
+
field: path,
|
|
291
|
+
message: `${label} must be a whole number`,
|
|
292
|
+
severity: "error",
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if ("minimum" in schema && schema.minimum !== undefined) {
|
|
297
|
+
if (value < schema.minimum) {
|
|
298
|
+
return {
|
|
299
|
+
field: path,
|
|
300
|
+
message: `${label} must be at least ${schema.minimum}`,
|
|
301
|
+
severity: "error",
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if ("maximum" in schema && schema.maximum !== undefined) {
|
|
307
|
+
if (value > schema.maximum) {
|
|
308
|
+
return {
|
|
309
|
+
field: path,
|
|
310
|
+
message: `${label} must be no more than ${schema.maximum}`,
|
|
311
|
+
severity: "error",
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if ("exclusiveMinimum" in schema && schema.exclusiveMinimum !== undefined) {
|
|
317
|
+
if (value <= schema.exclusiveMinimum) {
|
|
318
|
+
return {
|
|
319
|
+
field: path,
|
|
320
|
+
message: `${label} must be greater than ${schema.exclusiveMinimum}`,
|
|
321
|
+
severity: "error",
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if ("exclusiveMaximum" in schema && schema.exclusiveMaximum !== undefined) {
|
|
327
|
+
if (value >= schema.exclusiveMaximum) {
|
|
328
|
+
return {
|
|
329
|
+
field: path,
|
|
330
|
+
message: `${label} must be less than ${schema.exclusiveMaximum}`,
|
|
331
|
+
severity: "error",
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
case "boolean": {
|
|
340
|
+
if (typeof value !== "boolean") {
|
|
341
|
+
return {
|
|
342
|
+
field: path,
|
|
343
|
+
message: `${label} must be true or false`,
|
|
344
|
+
severity: "error",
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
case "array": {
|
|
351
|
+
if (!Array.isArray(value)) {
|
|
352
|
+
return {
|
|
353
|
+
field: path,
|
|
354
|
+
message: `${label} must be a list`,
|
|
355
|
+
severity: "error",
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
case "object": {
|
|
362
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
363
|
+
return {
|
|
364
|
+
field: path,
|
|
365
|
+
message: `${label} must be an object`,
|
|
366
|
+
severity: "error",
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
default:
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Validate string format
|
|
379
|
+
*/
|
|
380
|
+
function validateFormat(
|
|
381
|
+
path: string,
|
|
382
|
+
value: string,
|
|
383
|
+
format: string,
|
|
384
|
+
label: string
|
|
385
|
+
): FieldError | null {
|
|
386
|
+
switch (format) {
|
|
387
|
+
case "email": {
|
|
388
|
+
// Simple email regex
|
|
389
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
390
|
+
if (!emailRegex.test(value)) {
|
|
391
|
+
return {
|
|
392
|
+
field: path,
|
|
393
|
+
message: `${label} must be a valid email address`,
|
|
394
|
+
severity: "error",
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
case "date": {
|
|
401
|
+
// ISO date format YYYY-MM-DD
|
|
402
|
+
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
|
403
|
+
if (!dateRegex.test(value)) {
|
|
404
|
+
return {
|
|
405
|
+
field: path,
|
|
406
|
+
message: `${label} must be a valid date`,
|
|
407
|
+
severity: "error",
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
// Verify the date is actually valid (e.g., not Feb 30)
|
|
411
|
+
const parsed = new Date(value + "T00:00:00Z");
|
|
412
|
+
if (isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) {
|
|
413
|
+
return {
|
|
414
|
+
field: path,
|
|
415
|
+
message: `${label} must be a valid date`,
|
|
416
|
+
severity: "error",
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
case "date-time": {
|
|
423
|
+
if (isNaN(Date.parse(value))) {
|
|
424
|
+
return {
|
|
425
|
+
field: path,
|
|
426
|
+
message: `${label} must be a valid date and time`,
|
|
427
|
+
severity: "error",
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
case "uri": {
|
|
434
|
+
try {
|
|
435
|
+
new URL(value);
|
|
436
|
+
return null;
|
|
437
|
+
} catch {
|
|
438
|
+
return {
|
|
439
|
+
field: path,
|
|
440
|
+
message: `${label} must be a valid URL`,
|
|
441
|
+
severity: "error",
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
case "uuid": {
|
|
447
|
+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
448
|
+
if (!uuidRegex.test(value)) {
|
|
449
|
+
return {
|
|
450
|
+
field: path,
|
|
451
|
+
message: `${label} must be a valid UUID`,
|
|
452
|
+
severity: "error",
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
default:
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ============================================================================
|
|
464
|
+
// Custom Rule Validation
|
|
465
|
+
// ============================================================================
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Validate custom FEEL validation rules
|
|
469
|
+
*/
|
|
470
|
+
function validateCustomRules(
|
|
471
|
+
path: string,
|
|
472
|
+
rules: ValidationRule[],
|
|
473
|
+
context: EvaluationContext
|
|
474
|
+
): FieldError[] {
|
|
475
|
+
const errors: FieldError[] = [];
|
|
476
|
+
|
|
477
|
+
for (const rule of rules) {
|
|
478
|
+
const isValid = evaluateBoolean(rule.rule, context);
|
|
479
|
+
|
|
480
|
+
if (!isValid) {
|
|
481
|
+
errors.push({
|
|
482
|
+
field: path,
|
|
483
|
+
message: rule.message,
|
|
484
|
+
severity: rule.severity ?? "error",
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return errors;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ============================================================================
|
|
493
|
+
// Array Validation
|
|
494
|
+
// ============================================================================
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Validate array field including items
|
|
498
|
+
*/
|
|
499
|
+
function validateArray(
|
|
500
|
+
path: string,
|
|
501
|
+
value: unknown[],
|
|
502
|
+
fieldDef: FieldDefinition,
|
|
503
|
+
spec: Forma,
|
|
504
|
+
data: Record<string, unknown>,
|
|
505
|
+
computed: Record<string, unknown>,
|
|
506
|
+
visibility: Record<string, boolean>,
|
|
507
|
+
onlyVisible: boolean
|
|
508
|
+
): FieldError[] {
|
|
509
|
+
const errors: FieldError[] = [];
|
|
510
|
+
const label = fieldDef.label ?? path;
|
|
511
|
+
|
|
512
|
+
// Check min/max items
|
|
513
|
+
if (fieldDef.minItems !== undefined && value.length < fieldDef.minItems) {
|
|
514
|
+
errors.push({
|
|
515
|
+
field: path,
|
|
516
|
+
message: `${label} must have at least ${fieldDef.minItems} items`,
|
|
517
|
+
severity: "error",
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (fieldDef.maxItems !== undefined && value.length > fieldDef.maxItems) {
|
|
522
|
+
errors.push({
|
|
523
|
+
field: path,
|
|
524
|
+
message: `${label} must have no more than ${fieldDef.maxItems} items`,
|
|
525
|
+
severity: "error",
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Validate each item's fields
|
|
530
|
+
if (fieldDef.itemFields) {
|
|
531
|
+
for (let i = 0; i < value.length; i++) {
|
|
532
|
+
const item = value[i] as Record<string, unknown>;
|
|
533
|
+
const itemErrors = validateArrayItem(
|
|
534
|
+
path,
|
|
535
|
+
i,
|
|
536
|
+
item,
|
|
537
|
+
fieldDef.itemFields,
|
|
538
|
+
spec,
|
|
539
|
+
data,
|
|
540
|
+
computed,
|
|
541
|
+
visibility,
|
|
542
|
+
onlyVisible
|
|
543
|
+
);
|
|
544
|
+
errors.push(...itemErrors);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return errors;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Validate fields within a single array item
|
|
553
|
+
*/
|
|
554
|
+
function validateArrayItem(
|
|
555
|
+
arrayPath: string,
|
|
556
|
+
index: number,
|
|
557
|
+
item: Record<string, unknown>,
|
|
558
|
+
itemFields: Record<string, FieldDefinition>,
|
|
559
|
+
spec: Forma,
|
|
560
|
+
data: Record<string, unknown>,
|
|
561
|
+
computed: Record<string, unknown>,
|
|
562
|
+
visibility: Record<string, boolean>,
|
|
563
|
+
onlyVisible: boolean
|
|
564
|
+
): FieldError[] {
|
|
565
|
+
const errors: FieldError[] = [];
|
|
566
|
+
|
|
567
|
+
for (const [fieldName, fieldDef] of Object.entries(itemFields)) {
|
|
568
|
+
const itemFieldPath = `${arrayPath}[${index}].${fieldName}`;
|
|
569
|
+
|
|
570
|
+
// Skip hidden fields
|
|
571
|
+
if (onlyVisible && visibility[itemFieldPath] === false) {
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const value = item[fieldName];
|
|
576
|
+
const context: EvaluationContext = {
|
|
577
|
+
data,
|
|
578
|
+
computed,
|
|
579
|
+
referenceData: spec.referenceData,
|
|
580
|
+
item,
|
|
581
|
+
itemIndex: index,
|
|
582
|
+
value,
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
// Required check
|
|
586
|
+
const isRequired = fieldDef.requiredWhen
|
|
587
|
+
? evaluateBoolean(fieldDef.requiredWhen, context)
|
|
588
|
+
: false;
|
|
589
|
+
|
|
590
|
+
if (isRequired && isEmpty(value)) {
|
|
591
|
+
errors.push({
|
|
592
|
+
field: itemFieldPath,
|
|
593
|
+
message: fieldDef.label
|
|
594
|
+
? `${fieldDef.label} is required`
|
|
595
|
+
: "This field is required",
|
|
596
|
+
severity: "error",
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Custom validations
|
|
601
|
+
if (fieldDef.validations && !isEmpty(value)) {
|
|
602
|
+
const customErrors = validateCustomRules(itemFieldPath, fieldDef.validations, context);
|
|
603
|
+
errors.push(...customErrors);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return errors;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ============================================================================
|
|
611
|
+
// Single Field Validation
|
|
612
|
+
// ============================================================================
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Validate a single field
|
|
616
|
+
*
|
|
617
|
+
* @param fieldPath - Path to the field
|
|
618
|
+
* @param data - Current form data
|
|
619
|
+
* @param spec - Form specification
|
|
620
|
+
* @returns Array of errors for this field
|
|
621
|
+
*/
|
|
622
|
+
export function validateSingleField(
|
|
623
|
+
fieldPath: string,
|
|
624
|
+
data: Record<string, unknown>,
|
|
625
|
+
spec: Forma
|
|
626
|
+
): FieldError[] {
|
|
627
|
+
const fieldDef = spec.fields[fieldPath];
|
|
628
|
+
if (!fieldDef) {
|
|
629
|
+
return [];
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const computed = calculate(data, spec);
|
|
633
|
+
const visibility = getVisibility(data, spec, { computed });
|
|
634
|
+
const schemaProperty = spec.schema.properties[fieldPath];
|
|
635
|
+
|
|
636
|
+
return validateField(
|
|
637
|
+
fieldPath,
|
|
638
|
+
data[fieldPath],
|
|
639
|
+
fieldDef,
|
|
640
|
+
schemaProperty,
|
|
641
|
+
spec,
|
|
642
|
+
data,
|
|
643
|
+
computed,
|
|
644
|
+
visibility,
|
|
645
|
+
true
|
|
646
|
+
);
|
|
647
|
+
}
|