form0-core 0.1.25 → 0.1.26
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
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "form0-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"registry": "https://registry.npmjs.org/"
|
|
6
6
|
},
|
|
7
|
-
"description": "
|
|
7
|
+
"description": "The schema-driven form engine behind form0, the open-source form ecosystem by paqu.io. Framework-agnostic: conditional visibility, validation and calculations from a JSON schema, in Node.js, browsers and React Native.",
|
|
8
8
|
"main": "src/index.js",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"scripts": {
|
|
11
|
-
"test": "node tests/form0-core.test.js && node tests/numeric-field-schema.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",
|
|
11
|
+
"test": "node tests/form0-core.test.js && node tests/numeric-field-schema.test.js && node tests/dataset-descriptors.test.js && node tests/structured-record.test.js && node tests/title-fields.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
|
"format": "prettier --write \"**/*.{js,jsx,json,ts,tsx,md}\"",
|
|
13
13
|
"format:check": "prettier --check \"**/*.{js,jsx,json,ts,tsx,md}\""
|
|
14
14
|
},
|
|
@@ -22,15 +22,17 @@
|
|
|
22
22
|
"url": "git+https://github.com/paqu-io/form0-core.git"
|
|
23
23
|
},
|
|
24
24
|
"keywords": [
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"form-generator",
|
|
25
|
+
"form0",
|
|
26
|
+
"paqu-io",
|
|
28
27
|
"form-engine",
|
|
29
|
-
"schema",
|
|
28
|
+
"schema-driven-forms",
|
|
30
29
|
"json-schema",
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
30
|
+
"dynamic-forms",
|
|
31
|
+
"conditional-logic",
|
|
32
|
+
"form-validation",
|
|
33
|
+
"calculated-fields",
|
|
34
|
+
"headless-forms",
|
|
35
|
+
"framework-agnostic"
|
|
34
36
|
],
|
|
35
37
|
"author": "paqu.io <hello@paqu.io> (https://paqu.io/)",
|
|
36
38
|
"contributors": [
|
package/src/index.js
CHANGED
|
@@ -932,6 +932,7 @@ export const FIELD_SPECS = {
|
|
|
932
932
|
visible_conditions: { type: 'object', required: true, nullable: true },
|
|
933
933
|
location_enabled: { type: 'boolean', required: true, nullable: false },
|
|
934
934
|
location_required: { type: 'boolean', required: true, nullable: false },
|
|
935
|
+
title_field: { type: 'object', required: false, nullable: true },
|
|
935
936
|
elements: { type: 'array', required: true, nullable: false },
|
|
936
937
|
},
|
|
937
938
|
schemaValidators: [
|
|
@@ -3,6 +3,73 @@ import { validateFieldSchema, validateDefaultValue } from './field-schema-regist
|
|
|
3
3
|
import { isSupportedFieldType } from '../utilities/field-types.js';
|
|
4
4
|
import { validateFieldConditions } from './operators.js';
|
|
5
5
|
import { validateFormAIMetadata } from './ai-metadata-validator.js';
|
|
6
|
+
import { buildDatasetDescriptors } from '../utilities/dataset-descriptors.js';
|
|
7
|
+
|
|
8
|
+
const toNonEmptyString = (value) =>
|
|
9
|
+
typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
10
|
+
|
|
11
|
+
const getDescriptorFieldReferences = (field) =>
|
|
12
|
+
new Set(
|
|
13
|
+
[field?.field_id, field?.output_key, field?.key, field?.data_name, ...(field?.aliases || [])]
|
|
14
|
+
.map(toNonEmptyString)
|
|
15
|
+
.filter(Boolean)
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const validateScopedTitleFields = (form, errors) => {
|
|
19
|
+
const descriptors = buildDatasetDescriptors({ form });
|
|
20
|
+
const allReferenceDatasets = new Map();
|
|
21
|
+
|
|
22
|
+
descriptors.forEach((descriptor) => {
|
|
23
|
+
(descriptor.fields || []).forEach((field) => {
|
|
24
|
+
getDescriptorFieldReferences(field).forEach((reference) => {
|
|
25
|
+
if (!allReferenceDatasets.has(reference)) {
|
|
26
|
+
allReferenceDatasets.set(reference, new Set());
|
|
27
|
+
}
|
|
28
|
+
allReferenceDatasets.get(reference).add(descriptor.id);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
descriptors.forEach((descriptor) => {
|
|
34
|
+
const titleField = descriptor.title_field;
|
|
35
|
+
if (!titleField) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const scopeLabel = descriptor.kind === 'root' ? 'title_field' : `RepeatableSection title_field`;
|
|
40
|
+
const titleValidation = validateFieldSchema(titleField);
|
|
41
|
+
if (!titleValidation.isValid) {
|
|
42
|
+
errors.push(...titleValidation.errors.map((error) => `${scopeLabel}: ${error}`));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const scopedFields = descriptor.fields || [];
|
|
47
|
+
titleField.elements.forEach((entry) => {
|
|
48
|
+
const reference = toNonEmptyString(entry);
|
|
49
|
+
if (!reference) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const matchedField = scopedFields.find((field) =>
|
|
54
|
+
getDescriptorFieldReferences(field).has(reference)
|
|
55
|
+
);
|
|
56
|
+
if (!matchedField) {
|
|
57
|
+
if (allReferenceDatasets.has(reference)) {
|
|
58
|
+
errors.push(`${scopeLabel} reference "${reference}" is outside its record scope`);
|
|
59
|
+
} else {
|
|
60
|
+
errors.push(`${scopeLabel} reference "${reference}" does not match any field`);
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (matchedField.title_eligible !== true) {
|
|
66
|
+
errors.push(
|
|
67
|
+
`${scopeLabel} reference "${reference}" uses unsupported field type ${matchedField.field_type}`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
};
|
|
6
73
|
|
|
7
74
|
export function validateSchema(form) {
|
|
8
75
|
const fields = flattenFields(form.elements);
|
|
@@ -180,12 +247,7 @@ export function validateSchema(form) {
|
|
|
180
247
|
}
|
|
181
248
|
}
|
|
182
249
|
}
|
|
183
|
-
|
|
184
|
-
const titleValidation = validateFieldSchema(form.title_field);
|
|
185
|
-
if (!titleValidation.isValid) {
|
|
186
|
-
errors.push(...titleValidation.errors.map((e) => `title_field: ${e}`));
|
|
187
|
-
}
|
|
188
|
-
}
|
|
250
|
+
validateScopedTitleFields(form, errors);
|
|
189
251
|
|
|
190
252
|
// Add duplicate errors to the main errors array
|
|
191
253
|
if (duplicateDataNames.size > 0) {
|
|
@@ -225,7 +287,11 @@ export function validateSchema(form) {
|
|
|
225
287
|
}
|
|
226
288
|
}
|
|
227
289
|
|
|
228
|
-
if (
|
|
290
|
+
if (
|
|
291
|
+
!entry.form_id ||
|
|
292
|
+
typeof entry.form_id !== 'string' ||
|
|
293
|
+
entry.form_id.trim() === ''
|
|
294
|
+
) {
|
|
229
295
|
errors.push(`form_links.to[${index}].form_id must be a non-empty string`);
|
|
230
296
|
}
|
|
231
297
|
});
|
|
@@ -242,7 +308,11 @@ export function validateSchema(form) {
|
|
|
242
308
|
return;
|
|
243
309
|
}
|
|
244
310
|
|
|
245
|
-
if (
|
|
311
|
+
if (
|
|
312
|
+
!entry.form_id ||
|
|
313
|
+
typeof entry.form_id !== 'string' ||
|
|
314
|
+
entry.form_id.trim() === ''
|
|
315
|
+
) {
|
|
246
316
|
errors.push(`form_links.from[${index}].form_id must be a non-empty string`);
|
|
247
317
|
}
|
|
248
318
|
|
|
@@ -6,6 +6,16 @@ import {
|
|
|
6
6
|
|
|
7
7
|
const ROOT_DATASET_ID = '__root__';
|
|
8
8
|
const CONTAINER_TYPES = new Set(['Section', 'BuildingPlanSection']);
|
|
9
|
+
const TITLE_ELIGIBLE_FIELD_TYPES = new Set([
|
|
10
|
+
'TextField',
|
|
11
|
+
'NumericField',
|
|
12
|
+
'DateField',
|
|
13
|
+
'TimeField',
|
|
14
|
+
'SingleChoiceField',
|
|
15
|
+
'MultiChoiceField',
|
|
16
|
+
'BooleanField',
|
|
17
|
+
'CalculatedField',
|
|
18
|
+
]);
|
|
9
19
|
|
|
10
20
|
const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
11
21
|
|
|
@@ -79,6 +89,8 @@ const toRepeatableLabel = (field) =>
|
|
|
79
89
|
toTrimmedString(field?.key) ??
|
|
80
90
|
'Repeatable section';
|
|
81
91
|
|
|
92
|
+
const cloneTitleField = (value) => (isRecord(value) ? { ...value } : null);
|
|
93
|
+
|
|
82
94
|
const normalizeCalculatedDisplayStyle = (value) => {
|
|
83
95
|
const normalized = toTrimmedString(value);
|
|
84
96
|
if (!normalized) {
|
|
@@ -435,6 +447,7 @@ const buildDatasetFieldDescriptor = (field, dataset) => {
|
|
|
435
447
|
data_name: toTrimmedString(field.data_name),
|
|
436
448
|
label: toFieldLabel(field),
|
|
437
449
|
field_type: toTrimmedString(field.type) ?? 'UnknownField',
|
|
450
|
+
title_eligible: TITLE_ELIGIBLE_FIELD_TYPES.has(field.type),
|
|
438
451
|
export_kind: getFieldExportKind(field),
|
|
439
452
|
choices: Array.isArray(field.choices)
|
|
440
453
|
? field.choices.filter((choice) => isRecord(choice)).map((choice) => ({ ...choice }))
|
|
@@ -510,6 +523,7 @@ const collectRepeatableDatasets = (elements, context, datasets) => {
|
|
|
510
523
|
root_dataset_id: ROOT_DATASET_ID,
|
|
511
524
|
location_enabled: toBooleanOrDefault(element.location_enabled),
|
|
512
525
|
location_required: toBooleanOrDefault(element.location_required),
|
|
526
|
+
title_field: cloneTitleField(element.title_field),
|
|
513
527
|
fields: [],
|
|
514
528
|
};
|
|
515
529
|
|
|
@@ -548,6 +562,7 @@ export function buildDatasetDescriptors(schema) {
|
|
|
548
562
|
root_dataset_id: ROOT_DATASET_ID,
|
|
549
563
|
location_enabled: toBooleanOrDefault(formNode.location_enabled),
|
|
550
564
|
location_required: toBooleanOrDefault(formNode.location_required),
|
|
565
|
+
title_field: cloneTitleField(formNode.title_field),
|
|
551
566
|
fields: [],
|
|
552
567
|
};
|
|
553
568
|
|
|
@@ -669,6 +684,138 @@ export function projectDatasetRowValues(descriptor, rowValues) {
|
|
|
669
684
|
};
|
|
670
685
|
}
|
|
671
686
|
|
|
687
|
+
const toLiveChoiceDisplayValues = (field, rawValue) => {
|
|
688
|
+
if (rawValue === null || typeof rawValue === 'undefined') {
|
|
689
|
+
return [];
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (typeof rawValue !== 'object' || Array.isArray(rawValue)) {
|
|
693
|
+
const matched = Array.isArray(field?.choices)
|
|
694
|
+
? field.choices.find((choice) => choice?.value === rawValue)
|
|
695
|
+
: null;
|
|
696
|
+
return [matched?.label ?? rawValue];
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const selectionKey = field?.field_type === 'MultiChoiceField' ? 'choices' : 'choice';
|
|
700
|
+
const selected = Array.isArray(rawValue[selectionKey]) ? rawValue[selectionKey] : [];
|
|
701
|
+
const other = Array.isArray(rawValue.other) ? rawValue.other : [];
|
|
702
|
+
const labels = selected.map((entry) => {
|
|
703
|
+
const value = isRecord(entry) ? entry.value : entry;
|
|
704
|
+
const matched = Array.isArray(field?.choices)
|
|
705
|
+
? field.choices.find((choice) => choice?.value === value)
|
|
706
|
+
: null;
|
|
707
|
+
return matched?.label ?? (isRecord(entry) ? (entry.label ?? entry.value) : entry);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
other.forEach((entry) => {
|
|
711
|
+
labels.push(isRecord(entry) ? (entry.label ?? entry.value) : entry);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
return labels.filter((entry) => entry !== null && typeof entry !== 'undefined');
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
const toTitleDisplayValue = (field, rawValue) => {
|
|
718
|
+
if (
|
|
719
|
+
isRecord(rawValue) &&
|
|
720
|
+
['choice', 'choices', 'other'].some((key) =>
|
|
721
|
+
Object.prototype.hasOwnProperty.call(rawValue, key)
|
|
722
|
+
)
|
|
723
|
+
) {
|
|
724
|
+
return toLiveChoiceDisplayValues(field, rawValue);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (
|
|
728
|
+
field?.field_type === 'SingleChoiceField' ||
|
|
729
|
+
field?.field_type === 'MultiChoiceField' ||
|
|
730
|
+
field?.field_type === 'BooleanField'
|
|
731
|
+
) {
|
|
732
|
+
if (!isRecord(rawValue)) {
|
|
733
|
+
return toLiveChoiceDisplayValues(field, rawValue);
|
|
734
|
+
}
|
|
735
|
+
return readStoredChoiceDisplayValues(field, rawValue, {
|
|
736
|
+
context: 'resolveDatasetRowTitle',
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return rawValue;
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
const toTitlePart = (value) => {
|
|
744
|
+
if (Array.isArray(value)) {
|
|
745
|
+
const parts = value.map(toTitlePart).filter(Boolean);
|
|
746
|
+
return parts.length > 0 ? parts.join(', ') : null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (typeof value === 'string') {
|
|
750
|
+
return toTrimmedString(value);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
754
|
+
return String(value);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (value instanceof Date) {
|
|
758
|
+
return value.toISOString();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (isRecord(value) && Object.prototype.hasOwnProperty.call(value, 'value')) {
|
|
762
|
+
return toTitlePart(value.value);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return null;
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Resolve a derived title for one root or repeatable dataset row.
|
|
770
|
+
* Supports renderer/live values and canonical stored record values.
|
|
771
|
+
*/
|
|
772
|
+
export function resolveDatasetRowTitle(descriptor, rowValues) {
|
|
773
|
+
const titleField = isRecord(descriptor?.title_field) ? descriptor.title_field : null;
|
|
774
|
+
const references = Array.isArray(titleField?.elements) ? titleField.elements : [];
|
|
775
|
+
if (!titleField || titleField.enabled === false || references.length === 0) {
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const sourceRecord = isRecord(rowValues) ? rowValues : {};
|
|
780
|
+
const source = isRecord(sourceRecord.form_values) ? sourceRecord.form_values : sourceRecord;
|
|
781
|
+
const fields = Array.isArray(descriptor?.fields) ? descriptor.fields : [];
|
|
782
|
+
const fieldByReference = new Map();
|
|
783
|
+
|
|
784
|
+
fields.forEach((field) => {
|
|
785
|
+
if (field?.title_eligible !== true) {
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
[
|
|
789
|
+
field.field_id,
|
|
790
|
+
field.output_key,
|
|
791
|
+
field.key,
|
|
792
|
+
field.data_name,
|
|
793
|
+
...(field.aliases ?? []),
|
|
794
|
+
].forEach((reference) => {
|
|
795
|
+
const normalized = toTrimmedString(reference);
|
|
796
|
+
if (normalized && !fieldByReference.has(normalized)) {
|
|
797
|
+
fieldByReference.set(normalized, field);
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
const parts = [];
|
|
803
|
+
references.forEach((reference) => {
|
|
804
|
+
const normalizedReference = toTrimmedString(reference);
|
|
805
|
+
const field = normalizedReference ? fieldByReference.get(normalizedReference) : null;
|
|
806
|
+
if (!field) {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const rawValue = readFirstAliasValue(source, field.aliases ?? []);
|
|
810
|
+
const part = toTitlePart(toTitleDisplayValue(field, rawValue));
|
|
811
|
+
if (part) {
|
|
812
|
+
parts.push(part);
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
return parts.length > 0 ? parts.join(', ') : null;
|
|
817
|
+
}
|
|
818
|
+
|
|
672
819
|
export function resolveDatasetDescriptorById(schema, datasetId) {
|
|
673
820
|
return buildDatasetDescriptors(schema).find((dataset) => dataset.id === datasetId) ?? null;
|
|
674
821
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { FIELD_SPECS } from '../schema/field-specs.js';
|
|
7
7
|
import { recordVersion } from './version-utils.js';
|
|
8
8
|
import { buildRepeatableMetadata } from './repeatable-helpers.js';
|
|
9
|
+
import { buildDatasetDescriptors, resolveDatasetRowTitle } from './dataset-descriptors.js';
|
|
9
10
|
|
|
10
11
|
const hasMeaningfulValue = (value) => {
|
|
11
12
|
if (value === null || value === undefined) {
|
|
@@ -178,26 +179,44 @@ export function createStructuredRecord(state, fields = null, options = {}, id =
|
|
|
178
179
|
dataNameToFieldMap.set(field.data_name, field);
|
|
179
180
|
});
|
|
180
181
|
}
|
|
181
|
-
// Create mapping from key to field for title resolution
|
|
182
|
-
const keyToFieldMap = new Map();
|
|
183
|
-
if (Array.isArray(fields)) {
|
|
184
|
-
fields.forEach((field) => {
|
|
185
|
-
if (field.key) keyToFieldMap.set(field.key, field);
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
|
|
189
182
|
// Build a comprehensive tree structure for nested RepeatableSections
|
|
190
183
|
const elementsSource = Array.isArray(options.originalElements)
|
|
191
184
|
? options.originalElements
|
|
192
185
|
: Array.isArray(fields)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
186
|
+
? fields
|
|
187
|
+
: [];
|
|
188
|
+
|
|
189
|
+
const { repeatableSectionTree, fieldOwnership, sectionFields } =
|
|
190
|
+
buildRepeatableMetadata(elementsSource);
|
|
191
|
+
|
|
192
|
+
const titleDatasetDescriptors = buildDatasetDescriptors({
|
|
193
|
+
form: {
|
|
194
|
+
elements: elementsSource,
|
|
195
|
+
title_field: options.title_field || null,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
const rootTitleDescriptor = titleDatasetDescriptors.find(
|
|
199
|
+
(descriptor) => descriptor.kind === 'root'
|
|
200
|
+
);
|
|
201
|
+
const repeatableTitleDescriptors = new Map();
|
|
202
|
+
titleDatasetDescriptors.forEach((descriptor) => {
|
|
203
|
+
if (descriptor.kind !== 'repeatable') {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
[descriptor.repeatable_field_id, descriptor.repeatable_output_key].forEach((reference) => {
|
|
207
|
+
if (reference) {
|
|
208
|
+
repeatableTitleDescriptors.set(reference, descriptor);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const resolveRepeatableTitle = (repInfo, rowValues) => {
|
|
214
|
+
const descriptor =
|
|
215
|
+
repeatableTitleDescriptors.get(repInfo?.preferredKey) ||
|
|
216
|
+
repeatableTitleDescriptors.get(repInfo?.field?.key) ||
|
|
217
|
+
repeatableTitleDescriptors.get(repInfo?.field?.data_name);
|
|
218
|
+
return descriptor ? resolveDatasetRowTitle(descriptor, rowValues) : null;
|
|
219
|
+
};
|
|
201
220
|
|
|
202
221
|
const hasStructuredRepeatableState =
|
|
203
222
|
state &&
|
|
@@ -237,12 +256,7 @@ export function createStructuredRecord(state, fields = null, options = {}, id =
|
|
|
237
256
|
|
|
238
257
|
// Apply output formatting if field spec exists
|
|
239
258
|
let processedValue = value;
|
|
240
|
-
if (
|
|
241
|
-
field &&
|
|
242
|
-
field.type &&
|
|
243
|
-
FIELD_SPECS[field.type] &&
|
|
244
|
-
FIELD_SPECS[field.type].outputProducer
|
|
245
|
-
) {
|
|
259
|
+
if (field && field.type && FIELD_SPECS[field.type] && FIELD_SPECS[field.type].outputProducer) {
|
|
246
260
|
try {
|
|
247
261
|
processedValue = FIELD_SPECS[field.type].outputProducer(field, value);
|
|
248
262
|
} catch (err) {
|
|
@@ -384,6 +398,10 @@ export function createStructuredRecord(state, fields = null, options = {}, id =
|
|
|
384
398
|
childRecord.form_values[childOutputKey] = childRepeatableArray;
|
|
385
399
|
}
|
|
386
400
|
|
|
401
|
+
if (repInfo.field?.title_field) {
|
|
402
|
+
childRecord['@title'] = resolveRepeatableTitle(repInfo, instanceValues);
|
|
403
|
+
}
|
|
404
|
+
|
|
387
405
|
if (hasMeaningfulRecord(childRecord)) {
|
|
388
406
|
childRecords.push(childRecord);
|
|
389
407
|
}
|
|
@@ -498,6 +516,13 @@ export function createStructuredRecord(state, fields = null, options = {}, id =
|
|
|
498
516
|
}
|
|
499
517
|
}
|
|
500
518
|
|
|
519
|
+
if (repInfo.field?.title_field) {
|
|
520
|
+
childRecord['@title'] = resolveRepeatableTitle(
|
|
521
|
+
repInfo,
|
|
522
|
+
i === 0 ? state.values : childRecord.form_values
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
501
526
|
if (hasMeaningfulRecord(childRecord)) {
|
|
502
527
|
childRecords.push(childRecord);
|
|
503
528
|
}
|
|
@@ -600,70 +625,8 @@ export function createStructuredRecord(state, fields = null, options = {}, id =
|
|
|
600
625
|
// Add top-level @title and @status
|
|
601
626
|
// ==============================
|
|
602
627
|
try {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
if (titleField && Array.isArray(titleField.elements)) {
|
|
606
|
-
const parts = [];
|
|
607
|
-
for (const ref of titleField.elements) {
|
|
608
|
-
let fieldDef = null;
|
|
609
|
-
// Prefer key lookup
|
|
610
|
-
if (keyToFieldMap.has(ref)) {
|
|
611
|
-
fieldDef = keyToFieldMap.get(ref);
|
|
612
|
-
} else if (dataNameToFieldMap.has(ref)) {
|
|
613
|
-
fieldDef = dataNameToFieldMap.get(ref);
|
|
614
|
-
}
|
|
615
|
-
if (!fieldDef) continue;
|
|
616
|
-
const value = state.values[fieldDef.data_name];
|
|
617
|
-
if (value == null) continue;
|
|
618
|
-
// Extract display text by field type
|
|
619
|
-
if (fieldDef.type === 'SingleChoiceField') {
|
|
620
|
-
let labels = [];
|
|
621
|
-
if (value.choice && Array.isArray(value.choice) && value.choice.length > 0) {
|
|
622
|
-
const v = value.choice[0].value;
|
|
623
|
-
const found = (fieldDef.choices || []).find((c) => c.value === v);
|
|
624
|
-
labels.push(found?.label || value.choice[0].label || v);
|
|
625
|
-
}
|
|
626
|
-
if (value.other && Array.isArray(value.other)) {
|
|
627
|
-
for (const o of value.other) {
|
|
628
|
-
if (o && (o.label || o.value)) labels.push(o.label || o.value);
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
const text = labels.filter(Boolean).join(', ');
|
|
632
|
-
if (text) parts.push(text);
|
|
633
|
-
} else if (fieldDef.type === 'MultiChoiceField') {
|
|
634
|
-
let labels = [];
|
|
635
|
-
if (value.choices && Array.isArray(value.choices)) {
|
|
636
|
-
for (const c of value.choices) {
|
|
637
|
-
const found = (fieldDef.choices || []).find((cc) => cc.value === c.value);
|
|
638
|
-
labels.push(found?.label || c.label || c.value);
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
if (value.other && Array.isArray(value.other)) {
|
|
642
|
-
for (const o of value.other) {
|
|
643
|
-
if (o && (o.label || o.value)) labels.push(o.label || o.value);
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
const text = labels.filter(Boolean).join(', ');
|
|
647
|
-
if (text) parts.push(text);
|
|
648
|
-
} else if (fieldDef.type === 'BooleanField') {
|
|
649
|
-
let label = '';
|
|
650
|
-
if (value.choice && Array.isArray(value.choice) && value.choice.length > 0) {
|
|
651
|
-
const v = value.choice[0].value;
|
|
652
|
-
const found = (fieldDef.choices || []).find((c) => c.value === v);
|
|
653
|
-
label = found?.label || value.choice[0].label || v;
|
|
654
|
-
}
|
|
655
|
-
if (label) parts.push(label);
|
|
656
|
-
} else {
|
|
657
|
-
const text = typeof value === 'object' ? null : String(value);
|
|
658
|
-
if (text && text.trim() !== '') parts.push(text);
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
const titleText = parts.join(', ');
|
|
662
|
-
if (titleText) {
|
|
663
|
-
record['@title'] = titleText;
|
|
664
|
-
} else {
|
|
665
|
-
record['@title'] = null;
|
|
666
|
-
}
|
|
628
|
+
if (options.title_field && rootTitleDescriptor) {
|
|
629
|
+
record['@title'] = resolveDatasetRowTitle(rootTitleDescriptor, state.values);
|
|
667
630
|
}
|
|
668
631
|
} catch (err) {
|
|
669
632
|
console.warn('[form0] createStructuredRecord: failed to compute @title:', err);
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { createFormEngine } from '../engine/form-engine.js';
|
|
2
|
-
import {
|
|
3
|
-
buildDatasetDescriptors,
|
|
4
|
-
buildFieldIdentityMap,
|
|
5
|
-
projectDatasetRowValues,
|
|
6
|
-
} from './dataset-descriptors.js';
|
|
2
|
+
import { buildDatasetDescriptors, resolveDatasetRowTitle } from './dataset-descriptors.js';
|
|
7
3
|
import {
|
|
8
4
|
isChoiceFieldLike,
|
|
9
5
|
normalizeStoredChoiceValue,
|
|
@@ -81,10 +77,11 @@ const findFirstAliasEntry = (value, aliases) => {
|
|
|
81
77
|
return null;
|
|
82
78
|
};
|
|
83
79
|
|
|
84
|
-
const buildScopeFromElements = (elements) => {
|
|
80
|
+
const buildScopeFromElements = (elements, descriptorByRepeatable = new Map()) => {
|
|
85
81
|
const scope = {
|
|
86
82
|
valueFields: [],
|
|
87
83
|
repeatables: [],
|
|
84
|
+
datasetDescriptor: null,
|
|
88
85
|
};
|
|
89
86
|
|
|
90
87
|
if (!Array.isArray(elements)) {
|
|
@@ -98,7 +95,8 @@ const buildScopeFromElements = (elements) => {
|
|
|
98
95
|
|
|
99
96
|
if (CONTAINER_TYPES.has(element.type)) {
|
|
100
97
|
const childScope = buildScopeFromElements(
|
|
101
|
-
Array.isArray(element.elements) ? element.elements : []
|
|
98
|
+
Array.isArray(element.elements) ? element.elements : [],
|
|
99
|
+
descriptorByRepeatable
|
|
102
100
|
);
|
|
103
101
|
scope.valueFields.push(...childScope.valueFields);
|
|
104
102
|
scope.repeatables.push(...childScope.repeatables);
|
|
@@ -115,8 +113,15 @@ const buildScopeFromElements = (elements) => {
|
|
|
115
113
|
aliases: toAliasList(element),
|
|
116
114
|
outputKey,
|
|
117
115
|
repeatableKey: trimString(element.key) ?? trimString(element.data_name) ?? outputKey,
|
|
118
|
-
childScope: buildScopeFromElements(
|
|
116
|
+
childScope: buildScopeFromElements(
|
|
117
|
+
Array.isArray(element.elements) ? element.elements : [],
|
|
118
|
+
descriptorByRepeatable
|
|
119
|
+
),
|
|
119
120
|
});
|
|
121
|
+
scope.repeatables[scope.repeatables.length - 1].childScope.datasetDescriptor =
|
|
122
|
+
descriptorByRepeatable.get(trimString(element.key)) ||
|
|
123
|
+
descriptorByRepeatable.get(trimString(element.data_name)) ||
|
|
124
|
+
null;
|
|
120
125
|
continue;
|
|
121
126
|
}
|
|
122
127
|
|
|
@@ -136,86 +141,6 @@ const buildScopeFromElements = (elements) => {
|
|
|
136
141
|
return scope;
|
|
137
142
|
};
|
|
138
143
|
|
|
139
|
-
const buildFieldReferenceMap = (schema) => {
|
|
140
|
-
const identityMap = buildFieldIdentityMap(schema);
|
|
141
|
-
const references = new Map();
|
|
142
|
-
|
|
143
|
-
Object.values(identityMap).forEach((field) => {
|
|
144
|
-
if (!isRecordObject(field)) {
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
[field.field_id, field.output_key, field.key, field.data_name].forEach((ref) => {
|
|
149
|
-
const normalizedRef = trimString(ref);
|
|
150
|
-
if (normalizedRef && !references.has(normalizedRef)) {
|
|
151
|
-
references.set(normalizedRef, field.field_id);
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
return references;
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
const toTitlePart = (value) => {
|
|
160
|
-
if (Array.isArray(value)) {
|
|
161
|
-
const parts = value
|
|
162
|
-
.map((entry) => toTitlePart(entry))
|
|
163
|
-
.filter((entry) => typeof entry === 'string' && entry.length > 0);
|
|
164
|
-
return parts.length > 0 ? parts.join(', ') : null;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
168
|
-
return String(value);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
if (typeof value === 'string') {
|
|
172
|
-
const normalized = value.trim();
|
|
173
|
-
return normalized.length > 0 ? normalized : null;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
return null;
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
const computeStructuredRecordTitle = (schema, record) => {
|
|
180
|
-
const formNode = isRecordObject(schema?.form) ? schema.form : null;
|
|
181
|
-
const titleField = isRecordObject(formNode?.title_field) ? formNode.title_field : null;
|
|
182
|
-
const titleElements = Array.isArray(titleField?.elements) ? titleField.elements : [];
|
|
183
|
-
|
|
184
|
-
if (!titleField || titleField.enabled === false || titleElements.length === 0) {
|
|
185
|
-
return null;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const descriptors = buildDatasetDescriptors(schema);
|
|
189
|
-
const rootDescriptor =
|
|
190
|
-
descriptors.find((descriptor) => descriptor.id === ROOT_DATASET_ID) ?? descriptors[0] ?? null;
|
|
191
|
-
if (!rootDescriptor) {
|
|
192
|
-
return null;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const projected = projectDatasetRowValues(rootDescriptor, record);
|
|
196
|
-
const references = buildFieldReferenceMap(schema);
|
|
197
|
-
const parts = [];
|
|
198
|
-
|
|
199
|
-
titleElements.forEach((entry) => {
|
|
200
|
-
const normalizedEntry = trimString(entry);
|
|
201
|
-
if (!normalizedEntry) {
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const fieldId = references.get(normalizedEntry);
|
|
206
|
-
if (!fieldId) {
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const part = toTitlePart(projected.displayValues[fieldId]);
|
|
211
|
-
if (part) {
|
|
212
|
-
parts.push(part);
|
|
213
|
-
}
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
return parts.length > 0 ? parts.join(', ') : null;
|
|
217
|
-
};
|
|
218
|
-
|
|
219
144
|
const normalizeRecordScope = (record, scope) => {
|
|
220
145
|
if (!isRecordObject(record)) {
|
|
221
146
|
return record;
|
|
@@ -256,9 +181,43 @@ const normalizeRecordScope = (record, scope) => {
|
|
|
256
181
|
});
|
|
257
182
|
});
|
|
258
183
|
|
|
184
|
+
if (scope.datasetDescriptor) {
|
|
185
|
+
record['@title'] = resolveDatasetRowTitle(scope.datasetDescriptor, record);
|
|
186
|
+
}
|
|
187
|
+
|
|
259
188
|
return record;
|
|
260
189
|
};
|
|
261
190
|
|
|
191
|
+
const findRepeatableElement = (elements, descriptor) => {
|
|
192
|
+
if (!Array.isArray(elements)) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const element of elements) {
|
|
197
|
+
if (!isRecordObject(element)) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (
|
|
201
|
+
element.type === 'RepeatableSection' &&
|
|
202
|
+
[element.key, element.data_name].some(
|
|
203
|
+
(reference) =>
|
|
204
|
+
reference === descriptor.repeatable_field_id ||
|
|
205
|
+
reference === descriptor.repeatable_output_key
|
|
206
|
+
)
|
|
207
|
+
) {
|
|
208
|
+
return element;
|
|
209
|
+
}
|
|
210
|
+
if (CONTAINER_TYPES.has(element.type) || element.type === 'RepeatableSection') {
|
|
211
|
+
const match = findRepeatableElement(element.elements, descriptor);
|
|
212
|
+
if (match) {
|
|
213
|
+
return match;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return null;
|
|
219
|
+
};
|
|
220
|
+
|
|
262
221
|
const buildSnapshotState = (formValues, scope) => {
|
|
263
222
|
const rawValues = {};
|
|
264
223
|
const repeatable = {};
|
|
@@ -360,14 +319,42 @@ export function normalizeStructuredRecord(schema, record, options = {}) {
|
|
|
360
319
|
}
|
|
361
320
|
|
|
362
321
|
const formNode = isRecordObject(schema?.form) ? schema.form : null;
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
322
|
+
const descriptors = buildDatasetDescriptors(schema);
|
|
323
|
+
const descriptorByRepeatable = new Map();
|
|
324
|
+
descriptors.forEach((descriptor) => {
|
|
325
|
+
if (descriptor.kind !== 'repeatable') {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
[descriptor.repeatable_field_id, descriptor.repeatable_output_key].forEach((reference) => {
|
|
329
|
+
if (reference) {
|
|
330
|
+
descriptorByRepeatable.set(reference, descriptor);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
});
|
|
366
334
|
|
|
367
|
-
|
|
368
|
-
|
|
335
|
+
const requestedDatasetId = trimString(options.datasetId) ?? ROOT_DATASET_ID;
|
|
336
|
+
const datasetDescriptor = descriptors.find((descriptor) => descriptor.id === requestedDatasetId);
|
|
337
|
+
if (!datasetDescriptor) {
|
|
338
|
+
throw new Error(`[form0] normalizeStructuredRecord: unknown dataset "${requestedDatasetId}"`);
|
|
369
339
|
}
|
|
370
340
|
|
|
341
|
+
const datasetElement =
|
|
342
|
+
datasetDescriptor.kind === 'repeatable'
|
|
343
|
+
? findRepeatableElement(formNode?.elements, datasetDescriptor)
|
|
344
|
+
: null;
|
|
345
|
+
const scopeElements =
|
|
346
|
+
datasetDescriptor.kind === 'root'
|
|
347
|
+
? Array.isArray(formNode?.elements)
|
|
348
|
+
? formNode.elements
|
|
349
|
+
: []
|
|
350
|
+
: Array.isArray(datasetElement?.elements)
|
|
351
|
+
? datasetElement.elements
|
|
352
|
+
: [];
|
|
353
|
+
const scope = buildScopeFromElements(scopeElements, descriptorByRepeatable);
|
|
354
|
+
scope.datasetDescriptor = datasetDescriptor;
|
|
355
|
+
|
|
356
|
+
normalizeRecordScope(normalized, scope);
|
|
357
|
+
|
|
371
358
|
return normalized;
|
|
372
359
|
}
|
|
373
360
|
|