form0-core 0.1.25 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,7 +38,11 @@ export function getFormAITasks(form) {
38
38
 
39
39
  export function getFormAINamingPolicy(form) {
40
40
  const metadata = getFormAIMetadata(form);
41
- if (!metadata || typeof metadata.namingPolicy !== 'object' || Array.isArray(metadata.namingPolicy)) {
41
+ if (
42
+ !metadata ||
43
+ typeof metadata.namingPolicy !== 'object' ||
44
+ Array.isArray(metadata.namingPolicy)
45
+ ) {
42
46
  return null;
43
47
  }
44
48
  return metadata.namingPolicy;
@@ -78,7 +82,11 @@ export function isFieldAIInferrable(field) {
78
82
 
79
83
  export function getFieldAIChoicePolicy(field) {
80
84
  const metadata = getFieldAIMetadata(field);
81
- if (!metadata || typeof metadata.choicePolicy !== 'object' || Array.isArray(metadata.choicePolicy)) {
85
+ if (
86
+ !metadata ||
87
+ typeof metadata.choicePolicy !== 'object' ||
88
+ Array.isArray(metadata.choicePolicy)
89
+ ) {
82
90
  return null;
83
91
  }
84
92
  return metadata.choicePolicy;
@@ -86,7 +94,11 @@ export function getFieldAIChoicePolicy(field) {
86
94
 
87
95
  export function getFieldAIProviderHints(field) {
88
96
  const metadata = getFieldAIMetadata(field);
89
- if (!metadata || typeof metadata.providerHints !== 'object' || Array.isArray(metadata.providerHints)) {
97
+ if (
98
+ !metadata ||
99
+ typeof metadata.providerHints !== 'object' ||
100
+ Array.isArray(metadata.providerHints)
101
+ ) {
90
102
  return null;
91
103
  }
92
104
  return metadata.providerHints;
@@ -260,12 +260,7 @@ function extractEvalCallArguments(code) {
260
260
 
261
261
  if (char === "'" && !inDoubleQuote && !inBacktick && !isEscapedCharacter(source, i)) {
262
262
  inSingleQuote = !inSingleQuote;
263
- } else if (
264
- char === '"' &&
265
- !inSingleQuote &&
266
- !inBacktick &&
267
- !isEscapedCharacter(source, i)
268
- ) {
263
+ } else if (char === '"' && !inSingleQuote && !inBacktick && !isEscapedCharacter(source, i)) {
269
264
  inDoubleQuote = !inDoubleQuote;
270
265
  } else if (
271
266
  char === '`' &&
@@ -10,6 +10,16 @@ export function isMultilineCalculationExpression(expression) {
10
10
 
11
11
  export function normalizeInlineCalculationExpression(expression) {
12
12
  const normalizedExpression = typeof expression === 'string' ? expression : '';
13
+ const expressionWithoutTrailingWhitespace = normalizedExpression.trimEnd();
13
14
 
14
- return normalizedExpression.replace(/;+\s*$/u, '');
15
+ if (!expressionWithoutTrailingWhitespace.endsWith(';')) {
16
+ return normalizedExpression;
17
+ }
18
+
19
+ let expressionEnd = expressionWithoutTrailingWhitespace.length;
20
+ while (expressionEnd > 0 && expressionWithoutTrailingWhitespace[expressionEnd - 1] === ';') {
21
+ expressionEnd -= 1;
22
+ }
23
+
24
+ return expressionWithoutTrailingWhitespace.slice(0, expressionEnd);
15
25
  }
@@ -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
  }
@@ -10,7 +10,11 @@ export function flattenFields(elements) {
10
10
  }
11
11
 
12
12
  return elements.flatMap((el) => {
13
- if (el.type === 'Section' || el.type === 'RepeatableSection' || el.type === 'BuildingPlanSection') {
13
+ if (
14
+ el.type === 'Section' ||
15
+ el.type === 'RepeatableSection' ||
16
+ el.type === 'BuildingPlanSection'
17
+ ) {
14
18
  return [el, ...flattenFields(el.elements)];
15
19
  }
16
20
 
@@ -17,11 +17,7 @@ const COMMON_GLOBAL_FUNCTIONS = new Set([
17
17
  'Boolean',
18
18
  ]);
19
19
 
20
- const STRUCTURAL_FIELD_TYPES = new Set([
21
- 'Section',
22
- 'RepeatableSection',
23
- 'BuildingPlanSection',
24
- ]);
20
+ const STRUCTURAL_FIELD_TYPES = new Set(['Section', 'RepeatableSection', 'BuildingPlanSection']);
25
21
 
26
22
  const FORM_EVENT_BUILTIN_STATUS_BY_NAME = Object.freeze({
27
23
  EVAL: 'advanced',
@@ -296,11 +292,7 @@ export function getFormEventReferenceCatalog({ schema }) {
296
292
  * referencedFields: string[],
297
293
  * }}
298
294
  */
299
- export function analyzeFormEventCode({
300
- code,
301
- schema,
302
- securityConfig = DEFAULT_SECURITY_CONFIG,
303
- }) {
295
+ export function analyzeFormEventCode({ code, schema, securityConfig = DEFAULT_SECURITY_CONFIG }) {
304
296
  const issues = [];
305
297
  const issueKeys = new Set();
306
298
  const usedBuiltins = [];
@@ -235,16 +235,22 @@ export function applyLinkedRecordSelection({
235
235
  }
236
236
 
237
237
  if (!values || typeof values !== 'object') {
238
- throw new Error('applyLinkedRecordSelection requires a values object from the form engine state');
238
+ throw new Error(
239
+ 'applyLinkedRecordSelection requires a values object from the form engine state'
240
+ );
239
241
  }
240
242
 
241
243
  const field = resolveFormField(form, fieldIdentifier);
242
244
  if (!field) {
243
- throw new Error(`applyLinkedRecordSelection could not find field "${fieldIdentifier}" in the provided form`);
245
+ throw new Error(
246
+ `applyLinkedRecordSelection could not find field "${fieldIdentifier}" in the provided form`
247
+ );
244
248
  }
245
249
 
246
250
  if (field.type !== 'FormLinkField') {
247
- throw new Error(`applyLinkedRecordSelection can only target FormLinkField fields (received ${field.type})`);
251
+ throw new Error(
252
+ `applyLinkedRecordSelection can only target FormLinkField fields (received ${field.type})`
253
+ );
248
254
  }
249
255
 
250
256
  const normalizedRecords = normalizeLinkedRecords(records);
@@ -255,7 +261,8 @@ export function applyLinkedRecordSelection({
255
261
  );
256
262
  }
257
263
 
258
- const selectedRecords = field.allow_multiple_records === true ? normalizedRecords : normalizedRecords.slice(0, 1);
264
+ const selectedRecords =
265
+ field.allow_multiple_records === true ? normalizedRecords : normalizedRecords.slice(0, 1);
259
266
 
260
267
  values[field.data_name] = selectedRecords.map((record) => ({
261
268
  record_id: record.record_id,
@@ -275,7 +282,11 @@ export function applyLinkedRecordSelection({
275
282
  }
276
283
 
277
284
  if (field.allow_multiple_records === true) {
278
- const aggregated = aggregateDefaultValues(selectedRecords, mapping.source_field_id, delimiter);
285
+ const aggregated = aggregateDefaultValues(
286
+ selectedRecords,
287
+ mapping.source_field_id,
288
+ delimiter
289
+ );
279
290
  values[destinationField.data_name] = aggregated;
280
291
  return;
281
292
  }
@@ -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);
@@ -19,11 +19,12 @@ export function buildRepeatableSectionTree(
19
19
  fieldOwnership = new Map(),
20
20
  sectionFields = new Set()
21
21
  ) {
22
- if (!Array.isArray(elements)) return {
23
- repeatableSectionTree,
24
- fieldOwnership,
25
- sectionFields,
26
- };
22
+ if (!Array.isArray(elements))
23
+ return {
24
+ repeatableSectionTree,
25
+ fieldOwnership,
26
+ sectionFields,
27
+ };
27
28
 
28
29
  elements.forEach((element) => {
29
30
  if (element.type === 'Section' || element.type === 'BuildingPlanSection') {