form0-core 0.1.12 → 0.1.14
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/package.json +2 -2
- package/src/index.js +4 -0
- package/src/utilities/dataset-descriptors.js +214 -18
- package/src/utilities/structured-record.js +505 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "form0-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"registry": "https://registry.npmjs.org/"
|
|
6
6
|
},
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"main": "src/index.js",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"scripts": {
|
|
11
|
-
"test": "node tests/form0-core.test.js && node tests/dataset-descriptors.test.js && node tests/builtin-metadata.test.js && node tests/calculation-editor.test.js && node tests/form-event-editor.test.js && node tests/security-test.js",
|
|
11
|
+
"test": "node tests/form0-core.test.js && node tests/dataset-descriptors.test.js && node tests/structured-record.test.js && node tests/builtin-metadata.test.js && node tests/calculation-editor.test.js && node tests/form-event-editor.test.js && node tests/security-test.js",
|
|
12
12
|
"postinstall": "node scripts/postinstall.cjs",
|
|
13
13
|
"format": "prettier --write \"**/*.{js,jsx,json,ts,tsx,md}\"",
|
|
14
14
|
"format:check": "prettier --check \"**/*.{js,jsx,json,ts,tsx,md}\""
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export { createFormEngine } from './engine/form-engine.js';
|
|
2
2
|
export { WarningSystem } from './engine/warning-system.js';
|
|
3
3
|
export { createStructuredRecord } from './utilities/record-transformer.js';
|
|
4
|
+
export {
|
|
5
|
+
normalizeStructuredRecord,
|
|
6
|
+
buildFormRecordSnapshot,
|
|
7
|
+
} from './utilities/structured-record.js';
|
|
4
8
|
export { validateSchema } from './schema/schema-validator.js';
|
|
5
9
|
//export { validateSchema, resolveSupportingImagePath } from './schema/schema-validator.js';
|
|
6
10
|
export { generateKey } from './utilities/hash.js';
|
|
@@ -15,6 +15,20 @@ const toTrimmedString = (value) => {
|
|
|
15
15
|
return trimmed.length > 0 ? trimmed : null;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
const toFiniteNumberOrNull = (value) => {
|
|
19
|
+
if (typeof value === 'number') {
|
|
20
|
+
return Number.isFinite(value) ? value : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const trimmed = toTrimmedString(value);
|
|
24
|
+
if (!trimmed) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const parsed = Number(trimmed);
|
|
29
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
30
|
+
};
|
|
31
|
+
|
|
18
32
|
const toSchemaFormNode = (schema) => {
|
|
19
33
|
if (!isRecord(schema)) {
|
|
20
34
|
return null;
|
|
@@ -87,8 +101,11 @@ const QUERY_SEMANTICS_BY_FIELD_TYPE = {
|
|
|
87
101
|
default_operator: 'contains',
|
|
88
102
|
allowed_operators: [
|
|
89
103
|
'contains',
|
|
104
|
+
'not_contains',
|
|
90
105
|
'eq',
|
|
91
106
|
'neq',
|
|
107
|
+
'in',
|
|
108
|
+
'not_in',
|
|
92
109
|
'starts_with',
|
|
93
110
|
'ends_with',
|
|
94
111
|
'is_blank',
|
|
@@ -109,6 +126,8 @@ const QUERY_SEMANTICS_BY_FIELD_TYPE = {
|
|
|
109
126
|
'neq',
|
|
110
127
|
'gt',
|
|
111
128
|
'lt',
|
|
129
|
+
'in',
|
|
130
|
+
'not_in',
|
|
112
131
|
'is_blank',
|
|
113
132
|
'is_not_blank',
|
|
114
133
|
],
|
|
@@ -127,6 +146,8 @@ const QUERY_SEMANTICS_BY_FIELD_TYPE = {
|
|
|
127
146
|
'neq',
|
|
128
147
|
'gt',
|
|
129
148
|
'lt',
|
|
149
|
+
'in',
|
|
150
|
+
'not_in',
|
|
130
151
|
'is_blank',
|
|
131
152
|
'is_not_blank',
|
|
132
153
|
],
|
|
@@ -145,6 +166,8 @@ const QUERY_SEMANTICS_BY_FIELD_TYPE = {
|
|
|
145
166
|
'neq',
|
|
146
167
|
'gt',
|
|
147
168
|
'lt',
|
|
169
|
+
'in',
|
|
170
|
+
'not_in',
|
|
148
171
|
'is_blank',
|
|
149
172
|
'is_not_blank',
|
|
150
173
|
],
|
|
@@ -202,6 +225,8 @@ const getCalculatedFieldSemantics = (field) => {
|
|
|
202
225
|
'neq',
|
|
203
226
|
'gt',
|
|
204
227
|
'lt',
|
|
228
|
+
'in',
|
|
229
|
+
'not_in',
|
|
205
230
|
'is_blank',
|
|
206
231
|
'is_not_blank',
|
|
207
232
|
],
|
|
@@ -223,6 +248,8 @@ const getCalculatedFieldSemantics = (field) => {
|
|
|
223
248
|
'neq',
|
|
224
249
|
'gt',
|
|
225
250
|
'lt',
|
|
251
|
+
'in',
|
|
252
|
+
'not_in',
|
|
226
253
|
'is_blank',
|
|
227
254
|
'is_not_blank',
|
|
228
255
|
],
|
|
@@ -244,6 +271,8 @@ const getCalculatedFieldSemantics = (field) => {
|
|
|
244
271
|
'neq',
|
|
245
272
|
'gt',
|
|
246
273
|
'lt',
|
|
274
|
+
'in',
|
|
275
|
+
'not_in',
|
|
247
276
|
'is_blank',
|
|
248
277
|
'is_not_blank',
|
|
249
278
|
],
|
|
@@ -258,8 +287,11 @@ const getCalculatedFieldSemantics = (field) => {
|
|
|
258
287
|
default_operator: 'contains',
|
|
259
288
|
allowed_operators: [
|
|
260
289
|
'contains',
|
|
290
|
+
'not_contains',
|
|
261
291
|
'eq',
|
|
262
292
|
'neq',
|
|
293
|
+
'in',
|
|
294
|
+
'not_in',
|
|
263
295
|
'starts_with',
|
|
264
296
|
'ends_with',
|
|
265
297
|
'is_blank',
|
|
@@ -293,33 +325,179 @@ const toChoiceValueList = (field) => {
|
|
|
293
325
|
return field.choices.filter((choice) => isRecord(choice));
|
|
294
326
|
};
|
|
295
327
|
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
328
|
+
const isChoicePrimitive = (value) =>
|
|
329
|
+
typeof value === 'string' ||
|
|
330
|
+
typeof value === 'number' ||
|
|
331
|
+
typeof value === 'boolean';
|
|
332
|
+
|
|
333
|
+
const normalizeChoiceEntryValue = (entry) => {
|
|
334
|
+
if (isChoicePrimitive(entry)) {
|
|
335
|
+
return entry;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (!isRecord(entry)) {
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (isChoicePrimitive(entry.value)) {
|
|
343
|
+
return entry.value;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (isChoicePrimitive(entry.label)) {
|
|
347
|
+
return entry.label;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return undefined;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const normalizeChoiceEntryDisplayValue = (entry) => {
|
|
354
|
+
if (isChoicePrimitive(entry)) {
|
|
355
|
+
return entry;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (!isRecord(entry)) {
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (isChoicePrimitive(entry.label)) {
|
|
363
|
+
return entry.label;
|
|
300
364
|
}
|
|
301
365
|
|
|
302
|
-
|
|
303
|
-
|
|
366
|
+
if (isChoicePrimitive(entry.value)) {
|
|
367
|
+
return entry.value;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return undefined;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const normalizeChoiceEntryLabel = (field, entry) => {
|
|
374
|
+
const normalizedValue = normalizeChoiceEntryValue(entry);
|
|
375
|
+
if (typeof normalizedValue === 'undefined') {
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const matched = toChoiceValueList(field).find(
|
|
380
|
+
(choice) =>
|
|
381
|
+
Object.prototype.hasOwnProperty.call(choice, 'value') &&
|
|
382
|
+
choice.value === normalizedValue,
|
|
304
383
|
);
|
|
305
|
-
if (
|
|
306
|
-
return
|
|
384
|
+
if (matched) {
|
|
385
|
+
return toTrimmedString(matched.label) ?? normalizedValue;
|
|
307
386
|
}
|
|
308
387
|
|
|
309
|
-
|
|
388
|
+
if (isRecord(entry) && isChoicePrimitive(entry.label)) {
|
|
389
|
+
return entry.label;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return normalizedValue;
|
|
310
393
|
};
|
|
311
394
|
|
|
312
|
-
const
|
|
313
|
-
if (
|
|
395
|
+
const toSingleChoiceSelectionValues = (rawValue) => {
|
|
396
|
+
if (isRecord(rawValue)) {
|
|
397
|
+
const choiceValues = Array.isArray(rawValue.choice)
|
|
398
|
+
? rawValue.choice
|
|
399
|
+
.map((entry) => normalizeChoiceEntryValue(entry))
|
|
400
|
+
.filter((entry) => typeof entry !== 'undefined')
|
|
401
|
+
: [];
|
|
402
|
+
const otherValues = Array.isArray(rawValue.other)
|
|
403
|
+
? rawValue.other
|
|
404
|
+
.map((entry) => normalizeChoiceEntryValue(entry))
|
|
405
|
+
.filter((entry) => typeof entry !== 'undefined')
|
|
406
|
+
: [];
|
|
407
|
+
|
|
408
|
+
return [...choiceValues, ...otherValues];
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const normalized = normalizeChoiceEntryValue(rawValue);
|
|
412
|
+
return typeof normalized === 'undefined' ? [] : [normalized];
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const toSingleChoiceDisplayValues = (field, rawValue) => {
|
|
416
|
+
if (isRecord(rawValue)) {
|
|
417
|
+
const choiceLabels = Array.isArray(rawValue.choice)
|
|
418
|
+
? rawValue.choice
|
|
419
|
+
.map((entry) => normalizeChoiceEntryLabel(field, entry))
|
|
420
|
+
.filter((entry) => typeof entry !== 'undefined')
|
|
421
|
+
: [];
|
|
422
|
+
const otherLabels = Array.isArray(rawValue.other)
|
|
423
|
+
? rawValue.other
|
|
424
|
+
.map((entry) => normalizeChoiceEntryDisplayValue(entry))
|
|
425
|
+
.filter((entry) => typeof entry !== 'undefined')
|
|
426
|
+
: [];
|
|
427
|
+
|
|
428
|
+
return [...choiceLabels, ...otherLabels];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const normalized = normalizeChoiceEntryLabel(field, rawValue);
|
|
432
|
+
return typeof normalized === 'undefined' ? [] : [normalized];
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const toMultiChoiceSelectionValues = (rawValue) => {
|
|
436
|
+
if (isRecord(rawValue)) {
|
|
437
|
+
const selectedValues = Array.isArray(rawValue.choices)
|
|
438
|
+
? rawValue.choices
|
|
439
|
+
.map((entry) => normalizeChoiceEntryValue(entry))
|
|
440
|
+
.filter((entry) => typeof entry !== 'undefined')
|
|
441
|
+
: [];
|
|
442
|
+
const otherValues = Array.isArray(rawValue.other)
|
|
443
|
+
? rawValue.other
|
|
444
|
+
.map((entry) => normalizeChoiceEntryValue(entry))
|
|
445
|
+
.filter((entry) => typeof entry !== 'undefined')
|
|
446
|
+
: [];
|
|
447
|
+
|
|
448
|
+
return [...selectedValues, ...otherValues];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (Array.isArray(rawValue)) {
|
|
452
|
+
return rawValue
|
|
453
|
+
.map((entry) => normalizeChoiceEntryValue(entry))
|
|
454
|
+
.filter((entry) => typeof entry !== 'undefined');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return [];
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const toMultiChoiceDisplayValues = (field, rawValue) => {
|
|
461
|
+
if (isRecord(rawValue)) {
|
|
462
|
+
const selectedLabels = Array.isArray(rawValue.choices)
|
|
463
|
+
? rawValue.choices
|
|
464
|
+
.map((entry) => normalizeChoiceEntryLabel(field, entry))
|
|
465
|
+
.filter((entry) => typeof entry !== 'undefined')
|
|
466
|
+
: [];
|
|
467
|
+
const otherLabels = Array.isArray(rawValue.other)
|
|
468
|
+
? rawValue.other
|
|
469
|
+
.map((entry) => normalizeChoiceEntryDisplayValue(entry))
|
|
470
|
+
.filter((entry) => typeof entry !== 'undefined')
|
|
471
|
+
: [];
|
|
472
|
+
|
|
473
|
+
return [...selectedLabels, ...otherLabels];
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (Array.isArray(rawValue)) {
|
|
477
|
+
return rawValue
|
|
478
|
+
.map((entry) => normalizeChoiceEntryLabel(field, entry))
|
|
479
|
+
.filter((entry) => typeof entry !== 'undefined');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return [];
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const toSingleChoiceLabel = (field, rawValue) => {
|
|
486
|
+
const labels = toSingleChoiceDisplayValues(field, rawValue);
|
|
487
|
+
if (labels.length === 0) {
|
|
314
488
|
return rawValue;
|
|
315
489
|
}
|
|
316
490
|
|
|
317
|
-
|
|
318
|
-
|
|
491
|
+
return labels.length === 1 ? labels[0] : labels;
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const toMultiChoiceLabels = (field, rawValue) => {
|
|
495
|
+
const labels = toMultiChoiceDisplayValues(field, rawValue);
|
|
496
|
+
if (labels.length === 0) {
|
|
319
497
|
return rawValue;
|
|
320
498
|
}
|
|
321
499
|
|
|
322
|
-
return
|
|
500
|
+
return labels;
|
|
323
501
|
};
|
|
324
502
|
|
|
325
503
|
const toDisplayValue = (field, rawValue) => {
|
|
@@ -497,15 +675,33 @@ const readFirstAliasValue = (value, aliases) => {
|
|
|
497
675
|
return undefined;
|
|
498
676
|
};
|
|
499
677
|
|
|
500
|
-
const normalizeScalarValue = (value) => {
|
|
678
|
+
const normalizeScalarValue = (field, value) => {
|
|
679
|
+
if (
|
|
680
|
+
field?.field_type === 'SingleChoiceField' ||
|
|
681
|
+
field?.field_type === 'BooleanField'
|
|
682
|
+
) {
|
|
683
|
+
return toSingleChoiceSelectionValues(value)[0];
|
|
684
|
+
}
|
|
685
|
+
|
|
501
686
|
if (Array.isArray(value)) {
|
|
502
687
|
return undefined;
|
|
503
688
|
}
|
|
504
689
|
|
|
690
|
+
if (field?.display_kind === 'number') {
|
|
691
|
+
const parsedNumber = toFiniteNumberOrNull(value);
|
|
692
|
+
if (parsedNumber !== null) {
|
|
693
|
+
return parsedNumber;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
505
697
|
return value;
|
|
506
698
|
};
|
|
507
699
|
|
|
508
|
-
const normalizeTermValues = (value) => {
|
|
700
|
+
const normalizeTermValues = (field, value) => {
|
|
701
|
+
if (field?.field_type === 'MultiChoiceField') {
|
|
702
|
+
return toMultiChoiceSelectionValues(value);
|
|
703
|
+
}
|
|
704
|
+
|
|
509
705
|
if (!Array.isArray(value)) {
|
|
510
706
|
return [];
|
|
511
707
|
}
|
|
@@ -539,7 +735,7 @@ export function projectDatasetRowValues(descriptor, rowValues) {
|
|
|
539
735
|
displayValues[field.field_id] = toDisplayValue(field, rawValue);
|
|
540
736
|
|
|
541
737
|
if (field.query_kind === 'scalar') {
|
|
542
|
-
const scalarValue = normalizeScalarValue(rawValue);
|
|
738
|
+
const scalarValue = normalizeScalarValue(field, rawValue);
|
|
543
739
|
if (typeof scalarValue !== 'undefined') {
|
|
544
740
|
scalarValues[field.field_id] = scalarValue;
|
|
545
741
|
}
|
|
@@ -547,7 +743,7 @@ export function projectDatasetRowValues(descriptor, rowValues) {
|
|
|
547
743
|
}
|
|
548
744
|
|
|
549
745
|
if (field.query_kind === 'terms') {
|
|
550
|
-
const terms = normalizeTermValues(rawValue);
|
|
746
|
+
const terms = normalizeTermValues(field, rawValue);
|
|
551
747
|
if (terms.length > 0) {
|
|
552
748
|
termValues[field.field_id] = terms;
|
|
553
749
|
}
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { createFormEngine } from '../engine/form-engine.js';
|
|
2
|
+
import {
|
|
3
|
+
buildDatasetDescriptors,
|
|
4
|
+
buildFieldIdentityMap,
|
|
5
|
+
projectDatasetRowValues,
|
|
6
|
+
} from './dataset-descriptors.js';
|
|
7
|
+
|
|
8
|
+
const CONTAINER_TYPES = new Set(['Section', 'BuildingPlanSection']);
|
|
9
|
+
const TIMESTAMP_KEYS = [
|
|
10
|
+
'created_at_client',
|
|
11
|
+
'updated_at_client',
|
|
12
|
+
'created_at_server',
|
|
13
|
+
'updated_at_server',
|
|
14
|
+
];
|
|
15
|
+
const ROOT_DATASET_ID = '__root__';
|
|
16
|
+
|
|
17
|
+
const isRecordObject = (value) =>
|
|
18
|
+
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
19
|
+
|
|
20
|
+
const cloneJson = (value) => {
|
|
21
|
+
if (typeof structuredClone === 'function') {
|
|
22
|
+
return structuredClone(value);
|
|
23
|
+
}
|
|
24
|
+
return JSON.parse(JSON.stringify(value));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const trimString = (value) => {
|
|
28
|
+
if (typeof value !== 'string') {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const trimmed = value.trim();
|
|
33
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const toNullableString = (value) =>
|
|
37
|
+
value === null || typeof value === 'string' ? value : undefined;
|
|
38
|
+
|
|
39
|
+
const toOutputKey = (field) => trimString(field?.data_name) ?? trimString(field?.key);
|
|
40
|
+
|
|
41
|
+
const toDataName = (field, fallback) => trimString(field?.data_name) ?? fallback;
|
|
42
|
+
|
|
43
|
+
const toAliasList = (field) => {
|
|
44
|
+
const aliases = [];
|
|
45
|
+
const outputKey = toOutputKey(field);
|
|
46
|
+
const dataName = trimString(field?.data_name);
|
|
47
|
+
const key = trimString(field?.key);
|
|
48
|
+
|
|
49
|
+
if (outputKey) {
|
|
50
|
+
aliases.push(outputKey);
|
|
51
|
+
}
|
|
52
|
+
if (dataName && !aliases.includes(dataName)) {
|
|
53
|
+
aliases.push(dataName);
|
|
54
|
+
}
|
|
55
|
+
if (key && !aliases.includes(key)) {
|
|
56
|
+
aliases.push(key);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return aliases;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const findFirstAliasEntry = (value, aliases) => {
|
|
63
|
+
if (!isRecordObject(value)) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const alias of aliases) {
|
|
68
|
+
if (Object.prototype.hasOwnProperty.call(value, alias)) {
|
|
69
|
+
return {
|
|
70
|
+
key: alias,
|
|
71
|
+
value: value[alias],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const buildScopeFromElements = (elements) => {
|
|
80
|
+
const scope = {
|
|
81
|
+
valueFields: [],
|
|
82
|
+
repeatables: [],
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (!Array.isArray(elements)) {
|
|
86
|
+
return scope;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const element of elements) {
|
|
90
|
+
if (!isRecordObject(element) || typeof element.type !== 'string') {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (CONTAINER_TYPES.has(element.type)) {
|
|
95
|
+
const childScope = buildScopeFromElements(
|
|
96
|
+
Array.isArray(element.elements) ? element.elements : []
|
|
97
|
+
);
|
|
98
|
+
scope.valueFields.push(...childScope.valueFields);
|
|
99
|
+
scope.repeatables.push(...childScope.repeatables);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (element.type === 'RepeatableSection') {
|
|
104
|
+
const outputKey = toOutputKey(element);
|
|
105
|
+
if (!outputKey) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
scope.repeatables.push({
|
|
110
|
+
aliases: toAliasList(element),
|
|
111
|
+
outputKey,
|
|
112
|
+
repeatableKey: trimString(element.key) ?? trimString(element.data_name) ?? outputKey,
|
|
113
|
+
childScope: buildScopeFromElements(Array.isArray(element.elements) ? element.elements : []),
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const outputKey = toOutputKey(element);
|
|
119
|
+
if (!outputKey) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
scope.valueFields.push({
|
|
124
|
+
aliases: toAliasList(element),
|
|
125
|
+
dataName: toDataName(element, outputKey),
|
|
126
|
+
outputKey,
|
|
127
|
+
field: element,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return scope;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const resolveChoiceLabel = (field, rawValue, fallbackLabel) => {
|
|
135
|
+
const normalizedValue = trimString(rawValue);
|
|
136
|
+
if (!normalizedValue) {
|
|
137
|
+
return fallbackLabel ?? null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (Array.isArray(field?.choices)) {
|
|
141
|
+
const matched = field.choices.find(
|
|
142
|
+
(choice) =>
|
|
143
|
+
isRecordObject(choice) &&
|
|
144
|
+
Object.prototype.hasOwnProperty.call(choice, 'value') &&
|
|
145
|
+
choice.value === normalizedValue
|
|
146
|
+
);
|
|
147
|
+
if (matched) {
|
|
148
|
+
return trimString(matched.label) ?? normalizedValue;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return fallbackLabel ?? normalizedValue;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const normalizeChoiceEntry = (field, entry) => {
|
|
156
|
+
if (!isRecordObject(entry)) {
|
|
157
|
+
return entry;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const rawValue = trimString(entry.value) ?? trimString(entry.label) ?? trimString(entry.id);
|
|
161
|
+
if (!rawValue) {
|
|
162
|
+
return { ...entry };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
...entry,
|
|
167
|
+
value: rawValue,
|
|
168
|
+
label: resolveChoiceLabel(field, rawValue, trimString(entry.label)),
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const normalizeChoiceList = (field, entries) => {
|
|
173
|
+
if (!Array.isArray(entries)) {
|
|
174
|
+
return entries;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return entries.map((entry) => normalizeChoiceEntry(field, entry));
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const normalizeChoiceStructuredValue = (field, rawValue) => {
|
|
181
|
+
if (!isRecordObject(rawValue)) {
|
|
182
|
+
return rawValue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const normalized = { ...rawValue };
|
|
186
|
+
|
|
187
|
+
if (field?.type === 'SingleChoiceField' || field?.type === 'BooleanField') {
|
|
188
|
+
if (Array.isArray(rawValue.choice_value)) {
|
|
189
|
+
normalized.choice_value = normalizeChoiceList(field, rawValue.choice_value);
|
|
190
|
+
}
|
|
191
|
+
if (Array.isArray(rawValue.choice)) {
|
|
192
|
+
normalized.choice = normalizeChoiceList(field, rawValue.choice);
|
|
193
|
+
}
|
|
194
|
+
if (Array.isArray(rawValue.other_value)) {
|
|
195
|
+
normalized.other_value = rawValue.other_value.map((entry) =>
|
|
196
|
+
isRecordObject(entry) ? { ...entry } : entry
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
if (Array.isArray(rawValue.other)) {
|
|
200
|
+
normalized.other = rawValue.other.map((entry) =>
|
|
201
|
+
isRecordObject(entry) ? { ...entry } : entry
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return normalized;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (field?.type === 'MultiChoiceField') {
|
|
208
|
+
if (Array.isArray(rawValue.choices_value)) {
|
|
209
|
+
normalized.choices_value = normalizeChoiceList(field, rawValue.choices_value);
|
|
210
|
+
}
|
|
211
|
+
if (Array.isArray(rawValue.choices)) {
|
|
212
|
+
normalized.choices = normalizeChoiceList(field, rawValue.choices);
|
|
213
|
+
}
|
|
214
|
+
if (Array.isArray(rawValue.other_value)) {
|
|
215
|
+
normalized.other_value = rawValue.other_value.map((entry) =>
|
|
216
|
+
isRecordObject(entry) ? { ...entry } : entry
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (Array.isArray(rawValue.other)) {
|
|
220
|
+
normalized.other = rawValue.other.map((entry) =>
|
|
221
|
+
isRecordObject(entry) ? { ...entry } : entry
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return normalized;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return normalized;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const buildFieldReferenceMap = (schema) => {
|
|
231
|
+
const identityMap = buildFieldIdentityMap(schema);
|
|
232
|
+
const references = new Map();
|
|
233
|
+
|
|
234
|
+
Object.values(identityMap).forEach((field) => {
|
|
235
|
+
if (!isRecordObject(field)) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
[field.field_id, field.output_key, field.key, field.data_name].forEach((ref) => {
|
|
240
|
+
const normalizedRef = trimString(ref);
|
|
241
|
+
if (normalizedRef && !references.has(normalizedRef)) {
|
|
242
|
+
references.set(normalizedRef, field.field_id);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return references;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const toTitlePart = (value) => {
|
|
251
|
+
if (Array.isArray(value)) {
|
|
252
|
+
const parts = value
|
|
253
|
+
.map((entry) => toTitlePart(entry))
|
|
254
|
+
.filter((entry) => typeof entry === 'string' && entry.length > 0);
|
|
255
|
+
return parts.length > 0 ? parts.join(', ') : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
259
|
+
return String(value);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (typeof value === 'string') {
|
|
263
|
+
const normalized = value.trim();
|
|
264
|
+
return normalized.length > 0 ? normalized : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return null;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const computeStructuredRecordTitle = (schema, record) => {
|
|
271
|
+
const formNode = isRecordObject(schema?.form) ? schema.form : null;
|
|
272
|
+
const titleField = isRecordObject(formNode?.title_field) ? formNode.title_field : null;
|
|
273
|
+
const titleElements = Array.isArray(titleField?.elements) ? titleField.elements : [];
|
|
274
|
+
|
|
275
|
+
if (!titleField || titleField.enabled === false || titleElements.length === 0) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const descriptors = buildDatasetDescriptors(schema);
|
|
280
|
+
const rootDescriptor =
|
|
281
|
+
descriptors.find((descriptor) => descriptor.id === ROOT_DATASET_ID) ?? descriptors[0] ?? null;
|
|
282
|
+
if (!rootDescriptor) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const projected = projectDatasetRowValues(rootDescriptor, record);
|
|
287
|
+
const references = buildFieldReferenceMap(schema);
|
|
288
|
+
const parts = [];
|
|
289
|
+
|
|
290
|
+
titleElements.forEach((entry) => {
|
|
291
|
+
const normalizedEntry = trimString(entry);
|
|
292
|
+
if (!normalizedEntry) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const fieldId = references.get(normalizedEntry);
|
|
297
|
+
if (!fieldId) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const part = toTitlePart(projected.displayValues[fieldId]);
|
|
302
|
+
if (part) {
|
|
303
|
+
parts.push(part);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
return parts.length > 0 ? parts.join(', ') : null;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const normalizeRecordScope = (record, scope) => {
|
|
311
|
+
if (!isRecordObject(record)) {
|
|
312
|
+
return record;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const formValues = isRecordObject(record.form_values) ? record.form_values : null;
|
|
316
|
+
if (!formValues) {
|
|
317
|
+
return record;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
scope.valueFields.forEach((field) => {
|
|
321
|
+
const entry = findFirstAliasEntry(formValues, field.aliases);
|
|
322
|
+
if (!entry) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (
|
|
327
|
+
field.field?.type === 'SingleChoiceField' ||
|
|
328
|
+
field.field?.type === 'BooleanField' ||
|
|
329
|
+
field.field?.type === 'MultiChoiceField'
|
|
330
|
+
) {
|
|
331
|
+
formValues[entry.key] = normalizeChoiceStructuredValue(field.field, entry.value);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
scope.repeatables.forEach((repeatable) => {
|
|
336
|
+
const entry = findFirstAliasEntry(formValues, repeatable.aliases);
|
|
337
|
+
if (!entry || !Array.isArray(entry.value)) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
formValues[entry.key] = entry.value.map((childRecord) => {
|
|
342
|
+
if (!isRecordObject(childRecord)) {
|
|
343
|
+
return childRecord;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const normalizedChild = cloneJson(childRecord);
|
|
347
|
+
normalizeRecordScope(normalizedChild, repeatable.childScope);
|
|
348
|
+
return normalizedChild;
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
return record;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const buildSnapshotState = (formValues, scope) => {
|
|
356
|
+
const rawValues = {};
|
|
357
|
+
const repeatable = {};
|
|
358
|
+
const source = isRecordObject(formValues) ? formValues : {};
|
|
359
|
+
|
|
360
|
+
scope.valueFields.forEach((field) => {
|
|
361
|
+
const entry = findFirstAliasEntry(source, field.aliases);
|
|
362
|
+
if (!entry) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
rawValues[field.dataName] = cloneJson(entry.value);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
scope.repeatables.forEach((repeatableField) => {
|
|
370
|
+
const entry = findFirstAliasEntry(source, repeatableField.aliases);
|
|
371
|
+
if (!entry || !Array.isArray(entry.value)) {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const instances = entry.value
|
|
376
|
+
.map((instance) => buildRepeatableInstanceSnapshot(instance, repeatableField.childScope))
|
|
377
|
+
.filter(Boolean);
|
|
378
|
+
|
|
379
|
+
if (instances.length > 0) {
|
|
380
|
+
repeatable[repeatableField.repeatableKey] = instances;
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
rawValues,
|
|
386
|
+
repeatable,
|
|
387
|
+
};
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const buildRepeatableInstanceSnapshot = (record, scope) => {
|
|
391
|
+
if (!isRecordObject(record)) {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const { rawValues, repeatable } = buildSnapshotState(record.form_values, scope);
|
|
396
|
+
const result = {
|
|
397
|
+
values: rawValues,
|
|
398
|
+
repeatable,
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const recordId = trimString(record.id) ?? trimString(record.record_id);
|
|
402
|
+
if (recordId) {
|
|
403
|
+
result.id = recordId;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const status = toNullableString(record['@status']);
|
|
407
|
+
if (typeof status !== 'undefined') {
|
|
408
|
+
result['@status'] = status;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const title = toNullableString(record['@title']);
|
|
412
|
+
if (typeof title !== 'undefined') {
|
|
413
|
+
result['@title'] = title;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (typeof record.draft === 'boolean') {
|
|
417
|
+
result.draft = record.draft;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
TIMESTAMP_KEYS.forEach((key) => {
|
|
421
|
+
const value = toNullableString(record[key]);
|
|
422
|
+
if (typeof value !== 'undefined') {
|
|
423
|
+
result[key] = value;
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
return result;
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const extractSnapshotTimestamps = (record) => ({
|
|
431
|
+
created_at_client:
|
|
432
|
+
toNullableString(record?.created_at_client) ?? toNullableString(record?.created_at) ?? null,
|
|
433
|
+
updated_at_client:
|
|
434
|
+
toNullableString(record?.updated_at_client) ?? toNullableString(record?.updated_at) ?? null,
|
|
435
|
+
created_at_server: toNullableString(record?.created_at_server) ?? null,
|
|
436
|
+
updated_at_server: toNullableString(record?.updated_at_server) ?? null,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
export function normalizeStructuredRecord(schema, record, options = {}) {
|
|
440
|
+
const normalized = isRecordObject(record) ? cloneJson(record) : {};
|
|
441
|
+
const mode = trimString(options.mode) ?? 'derived';
|
|
442
|
+
|
|
443
|
+
if (mode !== 'derived') {
|
|
444
|
+
return normalized;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const formNode = isRecordObject(schema?.form) ? schema.form : null;
|
|
448
|
+
const scope = buildScopeFromElements(Array.isArray(formNode?.elements) ? formNode.elements : []);
|
|
449
|
+
|
|
450
|
+
normalizeRecordScope(normalized, scope);
|
|
451
|
+
|
|
452
|
+
if (formNode) {
|
|
453
|
+
normalized['@title'] = computeStructuredRecordTitle(schema, normalized);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return normalized;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function buildFormRecordSnapshot(schema, record, options = {}) {
|
|
460
|
+
const normalizedRecord = normalizeStructuredRecord(schema, record, {
|
|
461
|
+
mode: 'derived',
|
|
462
|
+
});
|
|
463
|
+
const formNode = isRecordObject(schema?.form) ? schema.form : null;
|
|
464
|
+
const scope = buildScopeFromElements(Array.isArray(formNode?.elements) ? formNode.elements : []);
|
|
465
|
+
const { rawValues: extractedRawValues, repeatable } = buildSnapshotState(
|
|
466
|
+
normalizedRecord.form_values,
|
|
467
|
+
scope
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
const rawValues = cloneJson(extractedRawValues);
|
|
471
|
+
const statusField = isRecordObject(formNode?.status_field) ? formNode.status_field : null;
|
|
472
|
+
const statusFieldName = trimString(statusField?.data_name);
|
|
473
|
+
if (statusFieldName) {
|
|
474
|
+
const status = toNullableString(normalizedRecord['@status']);
|
|
475
|
+
if (typeof status !== 'undefined') {
|
|
476
|
+
rawValues[statusFieldName] = status;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
TIMESTAMP_KEYS.forEach((key) => {
|
|
481
|
+
const value = toNullableString(normalizedRecord[key]);
|
|
482
|
+
if (typeof value !== 'undefined') {
|
|
483
|
+
rawValues[key] = value;
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const mode = trimString(options.mode) ?? 'default';
|
|
488
|
+
const snapshotRawValues =
|
|
489
|
+
mode === 'editor' && formNode
|
|
490
|
+
? (() => {
|
|
491
|
+
const engine = createFormEngine({
|
|
492
|
+
schema,
|
|
493
|
+
initialValues: rawValues,
|
|
494
|
+
});
|
|
495
|
+
engine.eval();
|
|
496
|
+
return cloneJson(engine.getState().values);
|
|
497
|
+
})()
|
|
498
|
+
: rawValues;
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
raw_values: snapshotRawValues,
|
|
502
|
+
repeatable,
|
|
503
|
+
timestamps: extractSnapshotTimestamps(normalizedRecord),
|
|
504
|
+
};
|
|
505
|
+
}
|