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.25",
3
+ "version": "0.1.26",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/"
6
6
  },
7
- "description": "Schema-driven engine that powers form0 ecosystem. Looking for the full form0 ecosystem? Install the CLI: `npm install -g form0-cli`",
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
- "form",
26
- "form-builder",
27
- "form-generator",
25
+ "form0",
26
+ "paqu-io",
28
27
  "form-engine",
29
- "schema",
28
+ "schema-driven-forms",
30
29
  "json-schema",
31
- "react",
32
- "react-native",
33
- "dynamic-forms"
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
@@ -39,6 +39,7 @@ export {
39
39
  getFieldExportKind,
40
40
  getFieldQuerySemantics,
41
41
  projectDatasetRowValues,
42
+ resolveDatasetRowTitle,
42
43
  resolveDatasetDescriptorById,
43
44
  } from './utilities/dataset-descriptors.js';
44
45
  export { generateUuidV7 } from './utilities/uuid.js';
@@ -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
- if (form.title_field) {
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 (!entry.form_id || typeof entry.form_id !== 'string' || entry.form_id.trim() === '') {
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 (!entry.form_id || typeof entry.form_id !== 'string' || entry.form_id.trim() === '') {
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
- ? fields
194
- : [];
195
-
196
- const {
197
- repeatableSectionTree,
198
- fieldOwnership,
199
- sectionFields,
200
- } = buildRepeatableMetadata(elementsSource);
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
- // Compute @title from TitleField elements if provided in options
604
- const titleField = options.title_field;
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(Array.isArray(element.elements) ? element.elements : []),
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 scope = buildScopeFromElements(Array.isArray(formNode?.elements) ? formNode.elements : []);
364
-
365
- normalizeRecordScope(normalized, scope);
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
- if (formNode) {
368
- normalized['@title'] = computeStructuredRecordTitle(schema, normalized);
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