@moatamed-sce/form-engine 0.1.5
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,1144 @@
|
|
|
1
|
+
import { Validators, FormControl, FormArray } from '@angular/forms';
|
|
2
|
+
import { isFormField, isFormGroupNode, isFormArrayNode, isFieldCondition, PATTERN_SOURCE_MAX_LENGTH } from '@moatamed-sce/form-schema';
|
|
3
|
+
import { shortAddressValidator, crNumberValidator, vatNumberValidator, ibanValidator, saudiMobile05Validator, iqamaValidator, saudiIdValidator } from '@moatamed-sce/core';
|
|
4
|
+
import { InjectionToken } from '@angular/core';
|
|
5
|
+
import { of } from 'rxjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The value shape each control type holds.
|
|
9
|
+
*
|
|
10
|
+
* A `FormGroup` built from a definition has to be typed before any data arrives, and getting
|
|
11
|
+
* this wrong is subtle: a multi-select seeded with `''` instead of `[]` makes `value.length`
|
|
12
|
+
* read 0 either way, then throws the first time something calls `.map`. A currency field seeded
|
|
13
|
+
* with `''` submits an empty string where the API expects a number or null.
|
|
14
|
+
*/
|
|
15
|
+
/** Types whose value is an array rather than a scalar. */
|
|
16
|
+
const MULTI_VALUE_TYPES = new Set([
|
|
17
|
+
'multiselect',
|
|
18
|
+
'checkbox',
|
|
19
|
+
// A searchable checkbox list is still a checkbox list. Leaving it out seeded it with `''`,
|
|
20
|
+
// so `.length` read 0 like an empty selection and the first `.map` threw.
|
|
21
|
+
'checkbox-search',
|
|
22
|
+
'file',
|
|
23
|
+
]);
|
|
24
|
+
/**
|
|
25
|
+
* Types whose value is structured and must not be coerced at all.
|
|
26
|
+
*
|
|
27
|
+
* `geo` holds `{ lat, lng, address }`. The default branch below runs `String(provided)`, which
|
|
28
|
+
* turns that into the literal text `[object Object]` — the coordinates are gone, and nothing
|
|
29
|
+
* downstream can tell that they ever existed. A structured value is passed through untouched.
|
|
30
|
+
*/
|
|
31
|
+
const OPAQUE_TYPES = new Set(['geo']);
|
|
32
|
+
/** The empty value a type holds before any input. */
|
|
33
|
+
function typeFallback(type) {
|
|
34
|
+
if (MULTI_VALUE_TYPES.has(type)) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
switch (type) {
|
|
38
|
+
case 'switch':
|
|
39
|
+
return false;
|
|
40
|
+
case 'rating':
|
|
41
|
+
// 0 stars, not `''` — the widget takes a number, and `''` renders as no rating while
|
|
42
|
+
// failing a `min` check with a type error rather than a rating error.
|
|
43
|
+
return 0;
|
|
44
|
+
case 'currency':
|
|
45
|
+
// null, not 0: an untouched amount is "not stated", and 0 is a real figure.
|
|
46
|
+
return null;
|
|
47
|
+
case 'geo':
|
|
48
|
+
// null: no location picked. `''` is not a place.
|
|
49
|
+
return null;
|
|
50
|
+
default:
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Coerces a provided value into the shape the type expects. */
|
|
55
|
+
function coerceValue(type, provided) {
|
|
56
|
+
if (MULTI_VALUE_TYPES.has(type)) {
|
|
57
|
+
return Array.isArray(provided) ? provided : provided == null || provided === '' ? [] : [provided];
|
|
58
|
+
}
|
|
59
|
+
if (OPAQUE_TYPES.has(type)) {
|
|
60
|
+
return provided ?? null;
|
|
61
|
+
}
|
|
62
|
+
switch (type) {
|
|
63
|
+
case 'switch':
|
|
64
|
+
return Boolean(provided);
|
|
65
|
+
case 'rating': {
|
|
66
|
+
const parsed = Number(provided);
|
|
67
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
68
|
+
}
|
|
69
|
+
case 'currency':
|
|
70
|
+
case 'number': {
|
|
71
|
+
if (provided == null || provided === '') {
|
|
72
|
+
return type === 'currency' ? null : '';
|
|
73
|
+
}
|
|
74
|
+
const parsed = Number(provided);
|
|
75
|
+
return Number.isFinite(parsed) ? parsed : type === 'currency' ? null : String(provided);
|
|
76
|
+
}
|
|
77
|
+
default:
|
|
78
|
+
return provided == null ? '' : String(provided);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The field paths one row of a repeater declares, relative to the row. */
|
|
83
|
+
function rowKeys(node) {
|
|
84
|
+
const keys = new Set();
|
|
85
|
+
const walk = (nodes, prefix) => {
|
|
86
|
+
for (const inner of nodes) {
|
|
87
|
+
const at = prefix ? `${prefix}.${inner.key}` : inner.key;
|
|
88
|
+
if (isFormField(inner)) {
|
|
89
|
+
keys.add(at);
|
|
90
|
+
}
|
|
91
|
+
else if (isFormGroupNode(inner)) {
|
|
92
|
+
walk(inner.nodes, at);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
walk(node.nodes, '');
|
|
97
|
+
return keys;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Every field a rule can address, with its control path.
|
|
101
|
+
*
|
|
102
|
+
* Repeater rows are walked only when `values` are supplied, because how many rows exist is a
|
|
103
|
+
* fact about the data and never about the definition — the same reason `buildFormGroup` seeds
|
|
104
|
+
* `max(rows.length, minItems)` rather than a number the schema could state. Called without
|
|
105
|
+
* values this returns the static tree, which is what a caller wants when it needs the fields a
|
|
106
|
+
* definition declares rather than the controls a form currently holds.
|
|
107
|
+
*/
|
|
108
|
+
function fieldPaths(definition, values) {
|
|
109
|
+
const out = [];
|
|
110
|
+
const walk = (nodes, prefix, local, row) => {
|
|
111
|
+
for (const node of nodes) {
|
|
112
|
+
const path = prefix ? `${prefix}.${node.key}` : node.key;
|
|
113
|
+
if (isFormField(node)) {
|
|
114
|
+
out.push(row ? { field: node, path, row } : { field: node, path });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (isFormGroupNode(node)) {
|
|
118
|
+
walk(node.nodes, path, asRecord(local[node.key]), row);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (!values || !isFormArrayNode(node)) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const rows = Array.isArray(local[node.key]) ? local[node.key] : [];
|
|
125
|
+
// The same count `buildFormGroup` seeds, so the state map describes the controls that
|
|
126
|
+
// exist rather than the ones the data happened to arrive with.
|
|
127
|
+
const rowCount = Math.max(rows.length, node.minItems ?? 0);
|
|
128
|
+
const keys = rowKeys(node);
|
|
129
|
+
for (let index = 0; index < rowCount; index += 1) {
|
|
130
|
+
const rowPrefix = `${path}.${index}`;
|
|
131
|
+
walk(node.nodes, rowPrefix, asRecord(rows[index]), { prefix: rowPrefix, keys });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const root = values ?? {};
|
|
136
|
+
for (const page of definition.pages) {
|
|
137
|
+
walk(page.nodes, '', root);
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
/** A nested value as a record, so the walk can keep descending without a null check per branch. */
|
|
142
|
+
function asRecord(value) {
|
|
143
|
+
return value !== null && typeof value === 'object' ? value : {};
|
|
144
|
+
}
|
|
145
|
+
/** Reads a control value by key, or by dotted path for a field inside a group or a row. */
|
|
146
|
+
function readPath(values, field) {
|
|
147
|
+
if (!field.includes('.')) {
|
|
148
|
+
return values[field];
|
|
149
|
+
}
|
|
150
|
+
let current = values;
|
|
151
|
+
for (const segment of field.split('.')) {
|
|
152
|
+
if (current === null || typeof current !== 'object') {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
current = current[segment];
|
|
156
|
+
}
|
|
157
|
+
return current;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Reads the value a condition names, from the row when the row declares that field.
|
|
161
|
+
*
|
|
162
|
+
* Membership decides this, not whether the row happens to hold a value: `undefined` is a real
|
|
163
|
+
* answer to an unanswered question, and choosing by value would make a rule read the *form's*
|
|
164
|
+
* `type` for the first row and the row's own `type` for the second.
|
|
165
|
+
*/
|
|
166
|
+
function readValue$1(values, field, scope) {
|
|
167
|
+
return scope?.keys.has(field) ? readPath(values, `${scope.prefix}.${field}`) : readPath(values, field);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Evaluates a condition against the live form values.
|
|
171
|
+
*
|
|
172
|
+
* `scope` is the row a rule is being resolved for. Inside one, a condition naming a field the
|
|
173
|
+
* row declares reads *that row's* answer — which is the only reading that lets "when this
|
|
174
|
+
* partner is a company, require this partner's CR number" be written at all. Every other field
|
|
175
|
+
* it names is read from the form, so a row can still depend on a question asked once above it.
|
|
176
|
+
*/
|
|
177
|
+
function evaluateCondition(condition, values, scope) {
|
|
178
|
+
if (!isFieldCondition(condition)) {
|
|
179
|
+
return condition.op === 'and'
|
|
180
|
+
? condition.clauses.every(clause => evaluateCondition(clause, values, scope))
|
|
181
|
+
: condition.clauses.some(clause => evaluateCondition(clause, values, scope));
|
|
182
|
+
}
|
|
183
|
+
const value = readValue$1(values, condition.field, scope);
|
|
184
|
+
switch (condition.op) {
|
|
185
|
+
case 'eq':
|
|
186
|
+
return value === condition.value;
|
|
187
|
+
case 'neq':
|
|
188
|
+
return value !== condition.value;
|
|
189
|
+
case 'in':
|
|
190
|
+
return Array.isArray(condition.value) && condition.value.some(candidate => candidate === value);
|
|
191
|
+
case 'truthy':
|
|
192
|
+
// An empty array is falsy here even though `Boolean([])` is true: a multi-select with
|
|
193
|
+
// nothing chosen has not been answered, and every rule that reads one means it that way.
|
|
194
|
+
return Array.isArray(value) ? value.length > 0 : Boolean(value);
|
|
195
|
+
case 'falsy':
|
|
196
|
+
return Array.isArray(value) ? value.length === 0 : !value;
|
|
197
|
+
default:
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const APPLY = {
|
|
202
|
+
show: state => ({ ...state, hidden: false }),
|
|
203
|
+
hide: state => ({ ...state, hidden: true }),
|
|
204
|
+
enable: state => ({ ...state, disabled: false }),
|
|
205
|
+
disable: state => ({ ...state, disabled: true }),
|
|
206
|
+
require: state => ({ ...state, required: true }),
|
|
207
|
+
unrequire: state => ({ ...state, required: false }),
|
|
208
|
+
readonly: state => ({ ...state, readonly: true }),
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* Resolves every field's visibility, enablement and requirement for the current values.
|
|
212
|
+
*
|
|
213
|
+
* Starts from the definition's static state — `hiddenByDefault` — then applies each matching
|
|
214
|
+
* rule in document order, so a later rule wins over an earlier one. That ordering is the reason
|
|
215
|
+
* rules live on the definition rather than the field: it is deterministic and reviewable in one
|
|
216
|
+
* place, where a per-field predicate is neither.
|
|
217
|
+
*
|
|
218
|
+
* A rule that reaches into a repeater is resolved **once per row**, against that row's answers.
|
|
219
|
+
* Evaluating the condition once for the whole form instead would make every row share one
|
|
220
|
+
* verdict — the first partner declared a company would require a CR number from all of them.
|
|
221
|
+
*/
|
|
222
|
+
function evaluateRules(definition, values) {
|
|
223
|
+
const state = new Map();
|
|
224
|
+
const paths = fieldPaths(definition, values);
|
|
225
|
+
const scopes = new Map();
|
|
226
|
+
for (const { field, path, row } of paths) {
|
|
227
|
+
state.set(path, {
|
|
228
|
+
hidden: field.hiddenByDefault === true,
|
|
229
|
+
disabled: field.props?.['disabled'] === true,
|
|
230
|
+
readonly: field.props?.['readonly'] === true,
|
|
231
|
+
});
|
|
232
|
+
scopes.set(path, row);
|
|
233
|
+
}
|
|
234
|
+
for (const rule of definition.rules ?? []) {
|
|
235
|
+
// One verdict per scope, not per target: a rule with four effects on the same row must not
|
|
236
|
+
// evaluate its condition four times, and two rows must not share one.
|
|
237
|
+
const verdicts = new Map();
|
|
238
|
+
for (const effect of rule.then) {
|
|
239
|
+
for (const path of resolveTarget(effect.target, state, paths)) {
|
|
240
|
+
const scope = scopes.get(path);
|
|
241
|
+
const key = scope?.prefix ?? '';
|
|
242
|
+
let holds = verdicts.get(key);
|
|
243
|
+
if (holds === undefined) {
|
|
244
|
+
holds = evaluateCondition(rule.when, values, scope);
|
|
245
|
+
verdicts.set(key, holds);
|
|
246
|
+
}
|
|
247
|
+
if (holds) {
|
|
248
|
+
state.set(path, APPLY[effect.action](state.get(path)));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return state;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The control paths a rule effect names.
|
|
257
|
+
*
|
|
258
|
+
* An effect may address a field by full path or by bare key. The bare key is what every mapper
|
|
259
|
+
* writes, because a `Requirement` knows its own id and not which group it landed in — resolving
|
|
260
|
+
* it here is what lets the mapper stay ignorant of the tree it produced. A key matching several
|
|
261
|
+
* groups applies to all of them, which is right for a definition that repeats the same question
|
|
262
|
+
* per scope, and it is also how a rule reaches a repeater: a row's controls are indexed, so
|
|
263
|
+
* `partners.0.crNumber` is a path no author can write and `crNumber` has to mean every row.
|
|
264
|
+
*
|
|
265
|
+
* A target matching nothing is skipped: a rule pointing at a deleted field is an authoring
|
|
266
|
+
* mistake, not a reason to stop rendering the rest of the form.
|
|
267
|
+
*/
|
|
268
|
+
function resolveTarget(target, state, paths) {
|
|
269
|
+
if (state.has(target)) {
|
|
270
|
+
return [target];
|
|
271
|
+
}
|
|
272
|
+
return paths.filter(({ field }) => field.key === target).map(({ path }) => path);
|
|
273
|
+
}
|
|
274
|
+
/** Convenience default for a field the rules never mention. */
|
|
275
|
+
const DEFAULT_FIELD_RULE_STATE = { hidden: false, disabled: false, readonly: false };
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Resolves the names on a `FormDefinition` to the real validators.
|
|
279
|
+
*
|
|
280
|
+
* This is the seam that makes named validators worth having. The Saudi rules already exist,
|
|
281
|
+
* tested, in `libs/core/src/lib/validation/` — the problem was that a form had to import each
|
|
282
|
+
* one and attach it as a function, so a JSON document could not describe them and pages kept
|
|
283
|
+
* re-typing the regexes instead.
|
|
284
|
+
*
|
|
285
|
+
* Deliberately NOT wired: `libs/core/src/lib/utils/validation.utils.ts` also exports
|
|
286
|
+
* `isValidSaudiId` and `isValidIBAN`, and they disagree with the validators above — the utils
|
|
287
|
+
* version applies a Luhn check and only accepts a `1` prefix, rejecting the Iqama-holder `2`
|
|
288
|
+
* prefix the validator accepts. The validator directory is canonical here; see ADR 0002.
|
|
289
|
+
*/
|
|
290
|
+
/** Reads a numeric parameter, tolerating the string form an API may send. */
|
|
291
|
+
function numberParam(params, key) {
|
|
292
|
+
const raw = params?.[key];
|
|
293
|
+
const value = typeof raw === 'string' ? Number(raw) : raw;
|
|
294
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Flags that make a `RegExp` carry state between calls.
|
|
298
|
+
*
|
|
299
|
+
* `Validators.pattern` uses a supplied `RegExp` object directly, and `.test()` on a `g` or `y`
|
|
300
|
+
* regex advances `lastIndex` — so validating the *same unchanged value* returned valid, invalid,
|
|
301
|
+
* valid, invalid on successive passes. A field flickered between states on every keystroke, and
|
|
302
|
+
* whether a form could be submitted depended on how many times it had been validated. Dropping
|
|
303
|
+
* the flag is the only reading that makes a stored pattern mean one thing.
|
|
304
|
+
*/
|
|
305
|
+
const STATEFUL_REGEX_FLAGS = /[gy]/g;
|
|
306
|
+
/** Rebuilds a `RegExp` from the serialized `{ source, flags }` a JSON definition carries. */
|
|
307
|
+
function patternParam(params) {
|
|
308
|
+
const source = params?.['source'];
|
|
309
|
+
if (typeof source !== 'string' || source === '') {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
// A pathological pattern from an untrusted definition is a ReDoS vector. The cap is the
|
|
313
|
+
// schema's, imported rather than restated: two copies of a security bound drift, and the one
|
|
314
|
+
// that drifts down is the one nobody notices.
|
|
315
|
+
if (source.length > PATTERN_SOURCE_MAX_LENGTH) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
const flags = params?.['flags'];
|
|
319
|
+
const safeFlags = typeof flags === 'string' ? flags.replace(STATEFUL_REGEX_FLAGS, '') : '';
|
|
320
|
+
try {
|
|
321
|
+
return new RegExp(source, safeFlags);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** At least `minSelected` entries chosen in a multi-value control. */
|
|
328
|
+
function minSelectedValidator(min) {
|
|
329
|
+
return control => {
|
|
330
|
+
const value = control.value;
|
|
331
|
+
const count = Array.isArray(value) ? value.length : value == null || value === '' ? 0 : 1;
|
|
332
|
+
return count >= min ? null : { minSelected: { required: min, actual: count } };
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/** Value must differ from `expected`. */
|
|
336
|
+
function notEqualValidator(expected) {
|
|
337
|
+
return control => (control.value === expected ? { notEqual: { disallowed: expected } } : null);
|
|
338
|
+
}
|
|
339
|
+
/** Every selected file is at or under `bytes`. */
|
|
340
|
+
function fileSizeValidator(bytes) {
|
|
341
|
+
return control => {
|
|
342
|
+
const files = Array.isArray(control.value) ? control.value : [];
|
|
343
|
+
const tooBig = files.filter(file => file instanceof File && file.size > bytes);
|
|
344
|
+
return tooBig.length ? { fileSize: { max: bytes, files: tooBig.map(file => file.name) } } : null;
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* One `accept` entry against one file: an extension, an exact MIME type, or a `type/*` wildcard.
|
|
349
|
+
*
|
|
350
|
+
* The wildcard is not a nicety. `sce-file-upload-zone` has always honoured `image/*`, so a
|
|
351
|
+
* definition written with it let the user pick a PNG, accepted it into the control — and then
|
|
352
|
+
* this validator marked the control invalid, because it compared `'image/png' === 'image/*'`.
|
|
353
|
+
* The file was allowed in and made the form unsubmittable, with an error naming a type the file
|
|
354
|
+
* plainly was. The two checks have to agree.
|
|
355
|
+
*/
|
|
356
|
+
function fileMatches(file, entry) {
|
|
357
|
+
if (entry.startsWith('.')) {
|
|
358
|
+
return file.name.toLowerCase().endsWith(entry);
|
|
359
|
+
}
|
|
360
|
+
const type = file.type.toLowerCase();
|
|
361
|
+
return entry.endsWith('/*') ? type.startsWith(entry.slice(0, -1)) : type === entry;
|
|
362
|
+
}
|
|
363
|
+
/** Every selected file matches one of the accepted extensions or MIME types. */
|
|
364
|
+
function fileTypeValidator(accept) {
|
|
365
|
+
const normalized = accept.map(entry => entry.trim().toLowerCase()).filter(Boolean);
|
|
366
|
+
return control => {
|
|
367
|
+
const files = Array.isArray(control.value) ? control.value : [];
|
|
368
|
+
const rejected = files.filter(file => file instanceof File && !normalized.some(entry => fileMatches(file, entry)));
|
|
369
|
+
return rejected.length ? { fileType: { accept: normalized, files: rejected.map(file => file.name) } } : null;
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* The accepted types, from either shape a definition writes them in.
|
|
374
|
+
*
|
|
375
|
+
* An array is the schema's own form. A comma-separated string is the **HTML `accept` attribute**,
|
|
376
|
+
* which is what `sce-file-upload-zone` takes and what `fromLegacyConfig` carries across from a
|
|
377
|
+
* legacy config — so a definition written that way is not malformed, it is written the way the
|
|
378
|
+
* widget is. Accepting only the array made `resolveNamedValidator` return `null`, and an
|
|
379
|
+
* unresolved validator is not a validator that fails: it is one that never runs. The field
|
|
380
|
+
* advertised `.pdf,.png,.jpg`, accepted an `.exe`, and submitted valid.
|
|
381
|
+
*/
|
|
382
|
+
function acceptParam(params) {
|
|
383
|
+
const accept = params?.['accept'];
|
|
384
|
+
const entries = Array.isArray(accept) ? accept : typeof accept === 'string' ? accept.split(',') : [];
|
|
385
|
+
// Empties are dropped here rather than inside the validator, so that a param carrying nothing
|
|
386
|
+
// usable — `''`, or `','` — resolves to no validator at all instead of to one that rejects
|
|
387
|
+
// every file for not matching a type nobody named.
|
|
388
|
+
return entries.filter((entry) => typeof entry === 'string' && entry.trim() !== '');
|
|
389
|
+
}
|
|
390
|
+
/** `YYYY-MM-DD` — the shape `sce-date-field` emits and every date bound in a definition is written in. */
|
|
391
|
+
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
392
|
+
/**
|
|
393
|
+
* Orders a value against a date bound: negative before, zero same, positive after.
|
|
394
|
+
*
|
|
395
|
+
* A date-only bound is compared by **calendar day**, not by instant. Comparing timestamps meant
|
|
396
|
+
* `max: '2026-08-21'` parsed as midnight UTC while a value of `2026-08-21T14:00` parsed as local
|
|
397
|
+
* time that afternoon — so the last day the bound permits rejected every answer given on it. The
|
|
398
|
+
* user is told the date is too late while looking at the date they were told to use.
|
|
399
|
+
*
|
|
400
|
+
* `null` when either side cannot be read. A bound nobody can parse must not fail every value,
|
|
401
|
+
* and an unparseable value is a different validator's complaint.
|
|
402
|
+
*/
|
|
403
|
+
function compareToDateBound(value, bound) {
|
|
404
|
+
const text = String(value);
|
|
405
|
+
if (DATE_ONLY.test(bound)) {
|
|
406
|
+
const day = text.slice(0, 10);
|
|
407
|
+
if (DATE_ONLY.test(day)) {
|
|
408
|
+
return Math.sign(day.localeCompare(bound));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const left = new Date(text).getTime();
|
|
412
|
+
const right = new Date(bound).getTime();
|
|
413
|
+
return Number.isNaN(left) || Number.isNaN(right) ? null : Math.sign(left - right);
|
|
414
|
+
}
|
|
415
|
+
/** A date at or after `dateMin`. */
|
|
416
|
+
function dateMinValidator(min) {
|
|
417
|
+
return control => {
|
|
418
|
+
if (!control.value) {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
const order = compareToDateBound(control.value, min);
|
|
422
|
+
return order === null || order >= 0 ? null : { dateMin: { min } };
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
/** A date at or before `dateMax`. */
|
|
426
|
+
function dateMaxValidator(max) {
|
|
427
|
+
return control => {
|
|
428
|
+
if (!control.value) {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
const order = compareToDateBound(control.value, max);
|
|
432
|
+
return order === null || order <= 0 ? null : { dateMax: { max } };
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
/** Validators that take no parameters, resolved by name alone. */
|
|
436
|
+
const ZERO_ARG = {
|
|
437
|
+
required: () => Validators.required,
|
|
438
|
+
requiredTrue: () => Validators.requiredTrue,
|
|
439
|
+
email: () => Validators.email,
|
|
440
|
+
saudiId: saudiIdValidator,
|
|
441
|
+
iqama: iqamaValidator,
|
|
442
|
+
saudiMobile05: saudiMobile05Validator,
|
|
443
|
+
iban: ibanValidator,
|
|
444
|
+
vat: vatNumberValidator,
|
|
445
|
+
cr: crNumberValidator,
|
|
446
|
+
shortAddress: shortAddressValidator,
|
|
447
|
+
};
|
|
448
|
+
/**
|
|
449
|
+
* Resolves one named validator, or `null` when its parameters are missing or unusable.
|
|
450
|
+
*
|
|
451
|
+
* Returning `null` rather than throwing is deliberate: a definition with a malformed `min` should
|
|
452
|
+
* render the form without that one check, not blank the screen. Callers that need to know use
|
|
453
|
+
* {@link resolveNamedValidators}, which reports what it could not build.
|
|
454
|
+
*/
|
|
455
|
+
function resolveNamedValidator(validator) {
|
|
456
|
+
const zeroArg = ZERO_ARG[validator.name];
|
|
457
|
+
if (zeroArg) {
|
|
458
|
+
return zeroArg();
|
|
459
|
+
}
|
|
460
|
+
const { name, params } = validator;
|
|
461
|
+
switch (name) {
|
|
462
|
+
case 'min': {
|
|
463
|
+
const min = numberParam(params, 'min');
|
|
464
|
+
return min === null ? null : Validators.min(min);
|
|
465
|
+
}
|
|
466
|
+
case 'max': {
|
|
467
|
+
const max = numberParam(params, 'max');
|
|
468
|
+
return max === null ? null : Validators.max(max);
|
|
469
|
+
}
|
|
470
|
+
case 'minLength': {
|
|
471
|
+
const min = numberParam(params, 'minLength');
|
|
472
|
+
return min === null ? null : Validators.minLength(min);
|
|
473
|
+
}
|
|
474
|
+
case 'maxLength': {
|
|
475
|
+
const max = numberParam(params, 'maxLength');
|
|
476
|
+
return max === null ? null : Validators.maxLength(max);
|
|
477
|
+
}
|
|
478
|
+
case 'pattern': {
|
|
479
|
+
const pattern = patternParam(params);
|
|
480
|
+
return pattern === null ? null : Validators.pattern(pattern);
|
|
481
|
+
}
|
|
482
|
+
case 'minSelected': {
|
|
483
|
+
const min = numberParam(params, 'min');
|
|
484
|
+
return min === null ? null : minSelectedValidator(min);
|
|
485
|
+
}
|
|
486
|
+
case 'notEqual':
|
|
487
|
+
return params && 'value' in params ? notEqualValidator(params['value']) : null;
|
|
488
|
+
case 'fileSize': {
|
|
489
|
+
const bytes = numberParam(params, 'bytes');
|
|
490
|
+
return bytes === null ? null : fileSizeValidator(bytes);
|
|
491
|
+
}
|
|
492
|
+
case 'fileType': {
|
|
493
|
+
const accept = acceptParam(params);
|
|
494
|
+
return accept.length ? fileTypeValidator(accept) : null;
|
|
495
|
+
}
|
|
496
|
+
case 'dateMin': {
|
|
497
|
+
const min = params?.['min'];
|
|
498
|
+
return typeof min === 'string' ? dateMinValidator(min) : null;
|
|
499
|
+
}
|
|
500
|
+
case 'dateMax': {
|
|
501
|
+
const max = params?.['max'];
|
|
502
|
+
return typeof max === 'string' ? dateMaxValidator(max) : null;
|
|
503
|
+
}
|
|
504
|
+
default:
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/** Resolves a list of named validators, reporting the names that could not be built. */
|
|
509
|
+
function resolveNamedValidators(validators = []) {
|
|
510
|
+
const fns = [];
|
|
511
|
+
const unresolved = [];
|
|
512
|
+
for (const validator of validators) {
|
|
513
|
+
const fn = resolveNamedValidator(validator);
|
|
514
|
+
if (fn) {
|
|
515
|
+
fns.push(fn);
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
unresolved.push(validator.name);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return { fns, unresolved };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function fieldValidators(field) {
|
|
525
|
+
const { fns, unresolved } = resolveNamedValidators(field.validators);
|
|
526
|
+
return { fns, unresolved };
|
|
527
|
+
}
|
|
528
|
+
/** True when the definition itself marks a field required, before any rule speaks. */
|
|
529
|
+
function isStaticallyRequired(field) {
|
|
530
|
+
return field.validators?.some(validator => validator.name === 'required') === true;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Whether a field is required right now.
|
|
534
|
+
*
|
|
535
|
+
* A rule wins when it has something to say and the definition applies otherwise — which is what
|
|
536
|
+
* makes `unrequire` work at all. Toggling instead of reconciling would leave a field that a rule
|
|
537
|
+
* un-required stuck that way after the condition stopped matching.
|
|
538
|
+
*/
|
|
539
|
+
function isRequiredNow(field, ruleState) {
|
|
540
|
+
return ruleState?.required ?? isStaticallyRequired(field);
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Adds or removes `Validators.required` to match the rules, leaving every other validator alone.
|
|
544
|
+
*
|
|
545
|
+
* `require` and `unrequire` were evaluated and then dropped on the floor: `evaluateRules` wrote
|
|
546
|
+
* the flag into its state map and no one read it, so a definition saying "when the answer is
|
|
547
|
+
* yes, require the follow-up" produced a form where the follow-up was never required. It looked
|
|
548
|
+
* right — the renderer draws its asterisk from the same flag — and submitted empty.
|
|
549
|
+
*
|
|
550
|
+
* Reference identity is what makes this safe to do repeatedly: `Validators.required` is the same
|
|
551
|
+
* function object every time, and `resolveNamedValidator` hands back that exact reference for
|
|
552
|
+
* the `required` name, so adding and removing it cannot disturb a `saudiId` or `maxLength` sharing
|
|
553
|
+
* the control. `emitEvent: false` for the same reason every `disable` here uses it — re-validating
|
|
554
|
+
* inside a `valueChanges` handler otherwise re-enters the rule pass mid-flight.
|
|
555
|
+
*/
|
|
556
|
+
function syncRequired(control, required) {
|
|
557
|
+
if (required === control.hasValidator(Validators.required)) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (required) {
|
|
561
|
+
control.addValidators(Validators.required);
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
control.removeValidators(Validators.required);
|
|
565
|
+
}
|
|
566
|
+
control.updateValueAndValidity({ emitEvent: false });
|
|
567
|
+
}
|
|
568
|
+
/** Resolves a field's starting value: supplied initial → definition default → type fallback. */
|
|
569
|
+
function initialValue(field, initial) {
|
|
570
|
+
if (Object.prototype.hasOwnProperty.call(initial, field.key)) {
|
|
571
|
+
return coerceValue(field.type, initial[field.key]);
|
|
572
|
+
}
|
|
573
|
+
if (field.defaultValue !== undefined && field.defaultValue !== null) {
|
|
574
|
+
return coerceValue(field.type, field.defaultValue);
|
|
575
|
+
}
|
|
576
|
+
return typeFallback(field.type);
|
|
577
|
+
}
|
|
578
|
+
function buildField(field, path, initial, state, fb, unresolved) {
|
|
579
|
+
const { fns, unresolved: missing } = fieldValidators(field);
|
|
580
|
+
unresolved.push(...missing);
|
|
581
|
+
const control = fb.control(initialValue(field, initial), { validators: fns });
|
|
582
|
+
const ruleState = state.get(path);
|
|
583
|
+
syncRequired(control, isRequiredNow(field, ruleState));
|
|
584
|
+
if (ruleState?.hidden || ruleState?.disabled || ruleState?.readonly || field.type === 'display') {
|
|
585
|
+
control.disable({ emitEvent: false });
|
|
586
|
+
}
|
|
587
|
+
return control;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Builds one repeater row: a group of the array node's fields, stated for *that* row.
|
|
591
|
+
*
|
|
592
|
+
* The prefix is the whole point. A row built at prefix `''` looked its state up by bare key, so
|
|
593
|
+
* `state.get('name')` inside a row resolved to whatever *top-level* field happened to be called
|
|
594
|
+
* `name` — a partner's name disabled because an unrelated question shared its key. Passing
|
|
595
|
+
* `partners.0` down means every lookup addresses the control it is actually building, and a
|
|
596
|
+
* rule that resolved per row reaches it.
|
|
597
|
+
*/
|
|
598
|
+
function buildRow(node, rowPath, initial, state, fb, unresolved) {
|
|
599
|
+
return fb.group(buildNodes(node.nodes, rowPath, initial, state, fb, unresolved));
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Builds a row for an existing repeater, so a consumer can append one.
|
|
603
|
+
*
|
|
604
|
+
* Exported because `addArrayRow` has to produce a row indistinguishable from the ones the
|
|
605
|
+
* initial build produced — a second implementation would drift the moment a field type gained a
|
|
606
|
+
* coercion rule.
|
|
607
|
+
*
|
|
608
|
+
* A fresh row carries the definition's static state only. Rule state cannot be known here: the
|
|
609
|
+
* row does not exist in the values yet, so there is nothing for a condition to read. The caller
|
|
610
|
+
* settles it by running {@link applyRuleState} once the row is in the array, which
|
|
611
|
+
* {@link addArrayRow} documents as the step that must follow.
|
|
612
|
+
*/
|
|
613
|
+
function buildArrayRow(node, initial, fb, rowPath = '') {
|
|
614
|
+
// Unresolved names are discarded: the row's validators are the same set the definition
|
|
615
|
+
// declares, which `buildFormGroup` already reported when it built the form.
|
|
616
|
+
return buildRow(node, rowPath, initial, staticRowState(node, rowPath), fb, []);
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* A row's state before any rule speaks: what the fields themselves declare.
|
|
620
|
+
*
|
|
621
|
+
* Without this a row added by the user starts every `hiddenByDefault` field visible and every
|
|
622
|
+
* statically-disabled one editable, until the next rule pass corrects it — a flash of controls
|
|
623
|
+
* the definition says are not there, on a row the user just created and is looking at.
|
|
624
|
+
*/
|
|
625
|
+
function staticRowState(node, rowPath) {
|
|
626
|
+
const state = new Map();
|
|
627
|
+
const walk = (nodes, prefix) => {
|
|
628
|
+
for (const inner of nodes) {
|
|
629
|
+
const path = prefix ? `${prefix}.${inner.key}` : inner.key;
|
|
630
|
+
if (isFormField(inner)) {
|
|
631
|
+
state.set(path, {
|
|
632
|
+
hidden: inner.hiddenByDefault === true,
|
|
633
|
+
disabled: inner.props?.['disabled'] === true,
|
|
634
|
+
readonly: inner.props?.['readonly'] === true,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
else if (isFormGroupNode(inner)) {
|
|
638
|
+
// Positively narrowed rather than "not an array": a double negative treats anything
|
|
639
|
+
// unrecognised as a group and reads `.nodes` off it, which throws on the first node
|
|
640
|
+
// shape that is neither.
|
|
641
|
+
walk(inner.nodes, path);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
walk(node.nodes, rowPath);
|
|
646
|
+
return state;
|
|
647
|
+
}
|
|
648
|
+
function buildNodes(nodes, prefix, initial, state, fb, unresolved) {
|
|
649
|
+
const controls = {};
|
|
650
|
+
for (const node of nodes) {
|
|
651
|
+
const path = prefix ? `${prefix}.${node.key}` : node.key;
|
|
652
|
+
if (isFormField(node)) {
|
|
653
|
+
controls[node.key] = buildField(node, path, initial, state, fb, unresolved);
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (isFormArrayNode(node)) {
|
|
657
|
+
const rows = Array.isArray(initial[node.key]) ? initial[node.key] : [];
|
|
658
|
+
// A minItems array with no data still renders its required rows, so the user sees the
|
|
659
|
+
// shape of what is being asked for rather than an empty list with an add button.
|
|
660
|
+
const rowCount = Math.max(rows.length, node.minItems ?? 0);
|
|
661
|
+
const array = fb.array(Array.from({ length: rowCount }, (_, index) => buildRow(node, `${path}.${index}`, rows[index] ?? {}, state, fb, unresolved)));
|
|
662
|
+
controls[node.key] = array;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
const nested = (initial[node.key] ?? {});
|
|
666
|
+
controls[node.key] = fb.group(buildNodes(node.nodes, path, nested, state, fb, unresolved));
|
|
667
|
+
}
|
|
668
|
+
return controls;
|
|
669
|
+
}
|
|
670
|
+
/** Builds a typed, non-nullable `FormGroup` from a definition and its initial values. */
|
|
671
|
+
function buildFormGroup(definition, initial, fb) {
|
|
672
|
+
const state = evaluateRules(definition, initial);
|
|
673
|
+
const unresolved = [];
|
|
674
|
+
const controls = {};
|
|
675
|
+
for (const page of definition.pages) {
|
|
676
|
+
Object.assign(controls, buildNodes(page.nodes, '', initial, state, fb, unresolved));
|
|
677
|
+
}
|
|
678
|
+
return { group: fb.group(controls), unresolvedValidators: unresolved };
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Re-applies rule state to an existing group after a value change.
|
|
682
|
+
*
|
|
683
|
+
* Rebuilding the group instead would discard `touched`, `dirty` and focus — which is exactly
|
|
684
|
+
* what users report as "the form reset itself while I was typing".
|
|
685
|
+
*/
|
|
686
|
+
function applyRuleState(definition, group) {
|
|
687
|
+
const values = group.getRawValue();
|
|
688
|
+
const state = evaluateRules(definition, values);
|
|
689
|
+
// Expanded against the same values the rules were evaluated with, so a row's fields are in the
|
|
690
|
+
// map. Walking the definition alone would leave every `partners.0.name` unmatched, and the
|
|
691
|
+
// loop below skips what it cannot name — the row would keep whatever state it was built with.
|
|
692
|
+
const fields = new Map(fieldPaths(definition, values).map(({ field, path }) => [path, field]));
|
|
693
|
+
for (const [path, fieldState] of state) {
|
|
694
|
+
const control = group.get(path);
|
|
695
|
+
if (!control) {
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
const field = fields.get(path);
|
|
699
|
+
// A display field is never editable, so a rule that merely un-hides it must not hand the
|
|
700
|
+
// user a live control — nor let its (absent) value re-enter the payload.
|
|
701
|
+
const shouldDisable = fieldState.hidden || fieldState.disabled || fieldState.readonly || field?.type === 'display';
|
|
702
|
+
// Requirement is reconciled against the definition on every pass, not toggled: a field a
|
|
703
|
+
// rule un-required has to become required again when the condition stops matching.
|
|
704
|
+
if (field && control instanceof FormControl) {
|
|
705
|
+
syncRequired(control, isRequiredNow(field, fieldState));
|
|
706
|
+
}
|
|
707
|
+
if (shouldDisable && control.enabled) {
|
|
708
|
+
control.disable({ emitEvent: false });
|
|
709
|
+
}
|
|
710
|
+
else if (!shouldDisable && control.disabled) {
|
|
711
|
+
control.enable({ emitEvent: false });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return state;
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* The values a submit should carry.
|
|
718
|
+
*
|
|
719
|
+
* Hidden fields are omitted rather than sent as empty: a conditional branch the user never saw
|
|
720
|
+
* should not appear in the payload at all, which is different from appearing as `""`.
|
|
721
|
+
*/
|
|
722
|
+
function pickActiveValues(definition, group, state) {
|
|
723
|
+
const raw = group.getRawValue();
|
|
724
|
+
const prune = (nodes, values, prefix) => {
|
|
725
|
+
const out = {};
|
|
726
|
+
for (const node of nodes) {
|
|
727
|
+
const path = prefix ? `${prefix}.${node.key}` : node.key;
|
|
728
|
+
if (isFormField(node)) {
|
|
729
|
+
if (!state.get(path)?.hidden) {
|
|
730
|
+
out[node.key] = values[node.key];
|
|
731
|
+
}
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
if (isFormArrayNode(node)) {
|
|
735
|
+
const rows = Array.isArray(values[node.key]) ? values[node.key] : [];
|
|
736
|
+
// Each row prunes against its own indexed prefix. Pruning at `''` looked every row field
|
|
737
|
+
// up by bare key, so a row's `name` was dropped from the payload whenever an unrelated
|
|
738
|
+
// top-level `name` happened to be hidden.
|
|
739
|
+
out[node.key] = rows.map((row, index) => prune(node.nodes, row ?? {}, `${path}.${index}`));
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
// A group is kept even when empty: its key is part of the payload shape the API expects,
|
|
743
|
+
// and an absent group reads as "not submitted" rather than "nothing was asked here".
|
|
744
|
+
out[node.key] = prune(node.nodes, (values[node.key] ?? {}), path);
|
|
745
|
+
}
|
|
746
|
+
return out;
|
|
747
|
+
};
|
|
748
|
+
const out = {};
|
|
749
|
+
for (const page of definition.pages) {
|
|
750
|
+
Object.assign(out, prune(page.nodes, raw, ''));
|
|
751
|
+
}
|
|
752
|
+
return out;
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* A signature of everything about a definition that requires a *rebuild* rather than a patch.
|
|
756
|
+
*
|
|
757
|
+
* Options are deliberately excluded. Including them meant every lookup fetch changed the
|
|
758
|
+
* signature, rebuilt the group and wiped whatever the user had already typed — options change
|
|
759
|
+
* what a select offers, never the shape of the control holding it. Keys are sorted so that two
|
|
760
|
+
* definitions differing only in property order produce the same signature.
|
|
761
|
+
*/
|
|
762
|
+
function structureSignature(definition) {
|
|
763
|
+
const parts = [];
|
|
764
|
+
const walk = (nodes, prefix) => {
|
|
765
|
+
for (const node of nodes) {
|
|
766
|
+
const path = prefix ? `${prefix}.${node.key}` : node.key;
|
|
767
|
+
if (isFormField(node)) {
|
|
768
|
+
const validators = (node.validators ?? [])
|
|
769
|
+
.map(validator => `${validator.name}(${stableStringify(validator.params)})`)
|
|
770
|
+
.sort()
|
|
771
|
+
.join(',');
|
|
772
|
+
// `hiddenByDefault` and a static `disabled` both decide whether the control is built
|
|
773
|
+
// enabled, so a definition that flips either has to rebuild rather than wait for the
|
|
774
|
+
// next value change to notice.
|
|
775
|
+
const flags = `${node.hiddenByDefault ? 'h' : ''}${node.props?.['disabled'] === true ? 'd' : ''}`;
|
|
776
|
+
parts.push(`${path}:${node.type}:${validators}:${flags}`);
|
|
777
|
+
}
|
|
778
|
+
else if (isFormArrayNode(node)) {
|
|
779
|
+
// The bounds are structural: `minItems` decides how many rows a fresh build seeds, so a
|
|
780
|
+
// definition raising it must rebuild or the rows it now requires never appear.
|
|
781
|
+
parts.push(`${path}:array:${node.minItems ?? ''}:${node.maxItems ?? ''}`);
|
|
782
|
+
walk(node.nodes, path);
|
|
783
|
+
}
|
|
784
|
+
else {
|
|
785
|
+
parts.push(`${path}:${node.kind}`);
|
|
786
|
+
walk(node.nodes, path);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
for (const page of definition.pages) {
|
|
791
|
+
walk(page.nodes, '');
|
|
792
|
+
}
|
|
793
|
+
return parts.join('|');
|
|
794
|
+
}
|
|
795
|
+
/** `JSON.stringify` with sorted keys, so property order cannot change the result. */
|
|
796
|
+
function stableStringify(value) {
|
|
797
|
+
if (value === undefined) {
|
|
798
|
+
return '';
|
|
799
|
+
}
|
|
800
|
+
if (value === null || typeof value !== 'object') {
|
|
801
|
+
return JSON.stringify(value) ?? '';
|
|
802
|
+
}
|
|
803
|
+
if (Array.isArray(value)) {
|
|
804
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
805
|
+
}
|
|
806
|
+
const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
|
|
807
|
+
return `{${entries.map(([key, entry]) => `${key}:${stableStringify(entry)}`).join(',')}}`;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* The `array` node a control path names.
|
|
812
|
+
*
|
|
813
|
+
* Arrays nested inside arrays are not walked: their controls are indexed (`partners.0.phones`),
|
|
814
|
+
* and v1 has no way to address one. A definition that nests them still builds — only this lookup
|
|
815
|
+
* declines to find the inner one.
|
|
816
|
+
*/
|
|
817
|
+
function arrayNodeAt(definition, path) {
|
|
818
|
+
let byKey;
|
|
819
|
+
const walk = (nodes, prefix) => {
|
|
820
|
+
for (const node of nodes) {
|
|
821
|
+
const at = prefix ? `${prefix}.${node.key}` : node.key;
|
|
822
|
+
if (isFormArrayNode(node)) {
|
|
823
|
+
if (at === path) {
|
|
824
|
+
return node;
|
|
825
|
+
}
|
|
826
|
+
// A repeater inside a group is declared at `applicant.partners` but a renderer that
|
|
827
|
+
// flattens groups holds its control at `partners`. Remembered rather than returned, so
|
|
828
|
+
// an exact path always wins over a bare key that happens to match deeper.
|
|
829
|
+
if (node.key === path) {
|
|
830
|
+
byKey ??= node;
|
|
831
|
+
}
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
if (isFormGroupNode(node)) {
|
|
835
|
+
const found = walk(node.nodes, at);
|
|
836
|
+
if (found) {
|
|
837
|
+
return found;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return undefined;
|
|
842
|
+
};
|
|
843
|
+
for (const page of definition.pages) {
|
|
844
|
+
const found = walk(page.nodes, '');
|
|
845
|
+
if (found) {
|
|
846
|
+
return found;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return byKey;
|
|
850
|
+
}
|
|
851
|
+
/** The node and control a path names, when both are really a repeater. */
|
|
852
|
+
function repeaterAt(definition, group, path) {
|
|
853
|
+
const node = arrayNodeAt(definition, path);
|
|
854
|
+
const control = group.get(path);
|
|
855
|
+
return node && control instanceof FormArray ? { node, array: control } : null;
|
|
856
|
+
}
|
|
857
|
+
/** True when the repeater has room for another row. An absent `maxItems` means no ceiling. */
|
|
858
|
+
function canAddArrayRow(definition, group, path) {
|
|
859
|
+
const repeater = repeaterAt(definition, group, path);
|
|
860
|
+
if (!repeater) {
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
return repeater.node.maxItems === undefined || repeater.array.length < repeater.node.maxItems;
|
|
864
|
+
}
|
|
865
|
+
/** True when a row can be taken away without dropping below `minItems`. */
|
|
866
|
+
function canRemoveArrayRow(definition, group, path) {
|
|
867
|
+
const repeater = repeaterAt(definition, group, path);
|
|
868
|
+
if (!repeater) {
|
|
869
|
+
return false;
|
|
870
|
+
}
|
|
871
|
+
return repeater.array.length > (repeater.node.minItems ?? 0);
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Appends a row, seeded from `initial` where it has values for the row's fields.
|
|
875
|
+
*
|
|
876
|
+
* The row is built by the same code that built the ones already there, so a field's coercion and
|
|
877
|
+
* validators cannot drift between "was in the initial data" and "was added by the user".
|
|
878
|
+
*/
|
|
879
|
+
function addArrayRow(definition, group, path, fb, initial = {}) {
|
|
880
|
+
const repeater = repeaterAt(definition, group, path);
|
|
881
|
+
if (!repeater) {
|
|
882
|
+
return { changed: false, rows: 0, refused: 'no-such-array' };
|
|
883
|
+
}
|
|
884
|
+
const { node, array } = repeater;
|
|
885
|
+
if (node.maxItems !== undefined && array.length >= node.maxItems) {
|
|
886
|
+
return { changed: false, rows: array.length, refused: 'at-max' };
|
|
887
|
+
}
|
|
888
|
+
array.push(buildArrayRow(node, initial, fb, `${path}.${array.length}`));
|
|
889
|
+
return { changed: true, rows: array.length };
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Removes one row.
|
|
893
|
+
*
|
|
894
|
+
* A missing row is reported before the `minItems` check, so a stale index from a double click
|
|
895
|
+
* does not come back as "you cannot remove any more rows" — which would be a lie the user would
|
|
896
|
+
* act on.
|
|
897
|
+
*/
|
|
898
|
+
function removeArrayRow(definition, group, path, index) {
|
|
899
|
+
const repeater = repeaterAt(definition, group, path);
|
|
900
|
+
if (!repeater) {
|
|
901
|
+
return { changed: false, rows: 0, refused: 'no-such-array' };
|
|
902
|
+
}
|
|
903
|
+
const { node, array } = repeater;
|
|
904
|
+
if (!Number.isInteger(index) || index < 0 || index >= array.length) {
|
|
905
|
+
return { changed: false, rows: array.length, refused: 'no-such-row' };
|
|
906
|
+
}
|
|
907
|
+
if (array.length <= (node.minItems ?? 0)) {
|
|
908
|
+
return { changed: false, rows: array.length, refused: 'at-min' };
|
|
909
|
+
}
|
|
910
|
+
array.removeAt(index);
|
|
911
|
+
return { changed: true, rows: array.length };
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Provided in **features**, never in `ui-components`.
|
|
916
|
+
*
|
|
917
|
+
* The renderer injects it `{ optional: true }`: a form built entirely from static sources has
|
|
918
|
+
* no reason to force every host that renders one to supply a resolver.
|
|
919
|
+
*/
|
|
920
|
+
const FORM_DATA_SOURCE = new InjectionToken('sce.form-engine.dataSource');
|
|
921
|
+
/** No options, for every case that resolves to nothing. */
|
|
922
|
+
const NONE = of([]);
|
|
923
|
+
/** The source a field's `dataSourceId` names, if the definition declares one. */
|
|
924
|
+
function dataSourceById(definition, id) {
|
|
925
|
+
if (!id) {
|
|
926
|
+
return undefined;
|
|
927
|
+
}
|
|
928
|
+
return definition.dataSources?.find(source => source.id === id);
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* A dependency's value is "set" when it would pass a `truthy` condition.
|
|
932
|
+
*
|
|
933
|
+
* Same rule as the rule evaluator's `truthy`, deliberately: an empty multi-select has not been
|
|
934
|
+
* answered, so a city list keyed on it must not fetch with an empty region.
|
|
935
|
+
*/
|
|
936
|
+
function isSet(value) {
|
|
937
|
+
return Array.isArray(value) ? value.length > 0 : value !== '' && value !== null && value !== undefined;
|
|
938
|
+
}
|
|
939
|
+
/** Reads a control value by key, or by dotted path for a field inside a group. */
|
|
940
|
+
function readValue(values, field) {
|
|
941
|
+
let current = values;
|
|
942
|
+
for (const segment of field.split('.')) {
|
|
943
|
+
if (current === null || typeof current !== 'object') {
|
|
944
|
+
return undefined;
|
|
945
|
+
}
|
|
946
|
+
current = current[segment];
|
|
947
|
+
}
|
|
948
|
+
return current;
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Resolves the options for one data source id.
|
|
952
|
+
*
|
|
953
|
+
* `source` is the injected port, or `null` when the host provided none. Passing it in rather
|
|
954
|
+
* than injecting it here keeps this function callable from a test — and from a mapper — without
|
|
955
|
+
* an injection context.
|
|
956
|
+
*/
|
|
957
|
+
function resolveDataSource(definition, dataSourceId, values, source) {
|
|
958
|
+
if (!dataSourceId) {
|
|
959
|
+
return { options$: NONE };
|
|
960
|
+
}
|
|
961
|
+
const ref = dataSourceById(definition, dataSourceId);
|
|
962
|
+
if (!ref) {
|
|
963
|
+
return { options$: NONE, issue: `"${dataSourceId}" names no data source on the definition` };
|
|
964
|
+
}
|
|
965
|
+
if (ref.type === 'static') {
|
|
966
|
+
return { options$: of(ref.options ?? []) };
|
|
967
|
+
}
|
|
968
|
+
if (!ref.lookupKey) {
|
|
969
|
+
return { options$: NONE, issue: `lookup source "${ref.id}" carries no lookupKey` };
|
|
970
|
+
}
|
|
971
|
+
if (!source) {
|
|
972
|
+
return { options$: NONE, issue: `lookup "${ref.lookupKey}" needs FORM_DATA_SOURCE, which no provider supplied` };
|
|
973
|
+
}
|
|
974
|
+
// A dependent source with nothing chosen yet is not an error: the list is empty until the
|
|
975
|
+
// parent is answered, which is exactly what a region/city pair should do on first paint.
|
|
976
|
+
if (ref.dependsOn) {
|
|
977
|
+
const value = readValue(values, ref.dependsOn);
|
|
978
|
+
if (!isSet(value)) {
|
|
979
|
+
return { options$: NONE };
|
|
980
|
+
}
|
|
981
|
+
return {
|
|
982
|
+
options$: source.options({
|
|
983
|
+
lookupKey: ref.lookupKey,
|
|
984
|
+
definitionId: definition.id,
|
|
985
|
+
dependsOn: { field: ref.dependsOn, value },
|
|
986
|
+
}),
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
return { options$: source.options({ lookupKey: ref.lookupKey, definitionId: definition.id }) };
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* The value that decides whether a source must be re-fetched.
|
|
993
|
+
*
|
|
994
|
+
* A renderer re-resolves a dependent source when this changes and leaves every other source
|
|
995
|
+
* alone — refetching all of them on each keystroke is how a form ends up firing a request per
|
|
996
|
+
* character typed into an unrelated field.
|
|
997
|
+
*/
|
|
998
|
+
function dataSourceDependencyValue(definition, dataSourceId, values) {
|
|
999
|
+
const ref = dataSourceById(definition, dataSourceId);
|
|
1000
|
+
return ref?.dependsOn ? readValue(values, ref.dependsOn) : undefined;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Resolves a definition's steps, or falls back to one step per page.
|
|
1005
|
+
*
|
|
1006
|
+
* The fallback is what makes a step-aware host safe to point at any definition: a form that
|
|
1007
|
+
* declares no steps is a one-page-per-step wizard, which for the single-page case is exactly the
|
|
1008
|
+
* flat form it already was. A host that had to branch on `definition.steps` being present would
|
|
1009
|
+
* end up with two layouts to keep in step.
|
|
1010
|
+
*
|
|
1011
|
+
* A page no step claims is reported rather than appended. Appending it would invent a step the
|
|
1012
|
+
* author never wrote; dropping it in silence would lose the questions on it. The caller has an
|
|
1013
|
+
* issues list to render, which is how the rest of this platform reports a document it cannot
|
|
1014
|
+
* take literally.
|
|
1015
|
+
*/
|
|
1016
|
+
function resolveSteps(definition, values = {}) {
|
|
1017
|
+
const issues = [];
|
|
1018
|
+
const pagesById = new Map(definition.pages.map(page => [page.id, page]));
|
|
1019
|
+
const pathsByPage = pagePaths(definition, values);
|
|
1020
|
+
if (!definition.steps?.length) {
|
|
1021
|
+
return {
|
|
1022
|
+
steps: definition.pages.map((page, index) => ({
|
|
1023
|
+
id: page.id,
|
|
1024
|
+
index,
|
|
1025
|
+
title: page.title ?? { ar: '', en: '' },
|
|
1026
|
+
pages: [page],
|
|
1027
|
+
paths: pathsByPage.get(page.id) ?? [],
|
|
1028
|
+
})),
|
|
1029
|
+
issues,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
const claimed = new Set();
|
|
1033
|
+
const steps = [];
|
|
1034
|
+
definition.steps.forEach((step, index) => {
|
|
1035
|
+
const pages = [];
|
|
1036
|
+
for (const pageId of step.pageIds) {
|
|
1037
|
+
const page = pagesById.get(pageId);
|
|
1038
|
+
if (!page) {
|
|
1039
|
+
issues.push({ id: step.id, reason: `step names page "${pageId}", which the definition does not contain` });
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
if (claimed.has(pageId)) {
|
|
1043
|
+
// Two steps sharing a page means answering it twice and validating it in two places.
|
|
1044
|
+
issues.push({ id: step.id, reason: `page "${pageId}" is already claimed by an earlier step` });
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
claimed.add(pageId);
|
|
1048
|
+
pages.push(page);
|
|
1049
|
+
}
|
|
1050
|
+
steps.push({
|
|
1051
|
+
id: step.id,
|
|
1052
|
+
index,
|
|
1053
|
+
title: step.title,
|
|
1054
|
+
pages,
|
|
1055
|
+
paths: pages.flatMap(page => pathsByPage.get(page.id) ?? []),
|
|
1056
|
+
});
|
|
1057
|
+
});
|
|
1058
|
+
for (const page of definition.pages) {
|
|
1059
|
+
if (!claimed.has(page.id)) {
|
|
1060
|
+
issues.push({ id: page.id, reason: 'page belongs to no step, so a stepped render never reaches it' });
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return { steps, issues };
|
|
1064
|
+
}
|
|
1065
|
+
/** The control paths each page contributes, expanded for the rows the values carry. */
|
|
1066
|
+
function pagePaths(definition, values) {
|
|
1067
|
+
const byPage = new Map();
|
|
1068
|
+
for (const page of definition.pages) {
|
|
1069
|
+
// One page at a time, so a path can be attributed to the page that declared it. Walking the
|
|
1070
|
+
// whole definition would give the paths and lose which page each came from.
|
|
1071
|
+
const single = { ...definition, pages: [page] };
|
|
1072
|
+
byPage.set(page.id, fieldPaths(single, values).map(entry => entry.path));
|
|
1073
|
+
}
|
|
1074
|
+
return byPage;
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* The controls a step holds, skipping paths the group does not have.
|
|
1078
|
+
*
|
|
1079
|
+
* A missing control is not an error here: a renderer that flattens groups holds `q-5` where the
|
|
1080
|
+
* definition says `riyadh.q-5`, and a step asserting on every path would call such a form
|
|
1081
|
+
* permanently incomplete.
|
|
1082
|
+
*/
|
|
1083
|
+
function stepControls(step, group) {
|
|
1084
|
+
return step.paths.map(path => group.get(path)).filter((control) => control !== null);
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* True when nothing in the step is blocking.
|
|
1088
|
+
*
|
|
1089
|
+
* Disabled controls are skipped rather than counted valid, because Angular reports a disabled
|
|
1090
|
+
* control's `valid` as **false** — a step whose conditional half is currently hidden would never
|
|
1091
|
+
* pass, and the Next button would sit dead with no error to explain why.
|
|
1092
|
+
*
|
|
1093
|
+
* A step holding no controls at all is complete: a page of `display` copy is something the user
|
|
1094
|
+
* reads, not something they answer.
|
|
1095
|
+
*/
|
|
1096
|
+
function isStepComplete(step, group) {
|
|
1097
|
+
return stepControls(step, group).every(control => control.disabled || control.valid);
|
|
1098
|
+
}
|
|
1099
|
+
/** Marks everything in the step touched, so `Next` reveals the errors instead of doing nothing. */
|
|
1100
|
+
function markStepTouched(step, group) {
|
|
1101
|
+
for (const control of stepControls(step, group)) {
|
|
1102
|
+
control.markAllAsTouched();
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* Whether the user may move on from `index`.
|
|
1107
|
+
*
|
|
1108
|
+
* False on the last step: advancing past the end is a submit, and a wizard that treats it as a
|
|
1109
|
+
* step change is how a form gets a seventh page nobody wrote.
|
|
1110
|
+
*/
|
|
1111
|
+
function canAdvance(steps, group, index) {
|
|
1112
|
+
const step = steps[index];
|
|
1113
|
+
return step !== undefined && index < steps.length - 1 && isStepComplete(step, group);
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* The furthest step the user has earned.
|
|
1117
|
+
*
|
|
1118
|
+
* Every step up to it is complete. This is what a stepper binds its clickable steps to — letting
|
|
1119
|
+
* someone jump to step 5 from an unfinished step 2 produces a review page summarising answers
|
|
1120
|
+
* that were never given.
|
|
1121
|
+
*/
|
|
1122
|
+
function furthestReachableStep(steps, group) {
|
|
1123
|
+
let reached = 0;
|
|
1124
|
+
while (reached < steps.length - 1 && isStepComplete(steps[reached], group)) {
|
|
1125
|
+
reached += 1;
|
|
1126
|
+
}
|
|
1127
|
+
return reached;
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* The first step that is not complete, or `-1` when all of them are.
|
|
1131
|
+
*
|
|
1132
|
+
* A submit that fails should land the user on the question that failed it, not on step 1 with a
|
|
1133
|
+
* toast — this is the index to navigate to.
|
|
1134
|
+
*/
|
|
1135
|
+
function firstIncompleteStep(steps, group) {
|
|
1136
|
+
return steps.findIndex(step => !isStepComplete(step, group));
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Generated bundle index. Do not edit.
|
|
1141
|
+
*/
|
|
1142
|
+
|
|
1143
|
+
export { DEFAULT_FIELD_RULE_STATE, FORM_DATA_SOURCE, MULTI_VALUE_TYPES, addArrayRow, applyRuleState, arrayNodeAt, buildArrayRow, buildFormGroup, canAddArrayRow, canAdvance, canRemoveArrayRow, coerceValue, dataSourceById, dataSourceDependencyValue, evaluateCondition, evaluateRules, fieldPaths, firstIncompleteStep, furthestReachableStep, isStepComplete, markStepTouched, pickActiveValues, removeArrayRow, resolveDataSource, resolveNamedValidator, resolveNamedValidators, resolveSteps, stepControls, structureSignature, typeFallback };
|
|
1144
|
+
//# sourceMappingURL=moatamed-sce-form-engine.mjs.map
|