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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "form0-core",
3
- "version": "0.1.12",
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 toSingleChoiceLabel = (field, rawValue) => {
297
- const choices = toChoiceValueList(field);
298
- if (choices.length === 0) {
299
- return rawValue;
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
- const matched = choices.find(
303
- (choice) => Object.prototype.hasOwnProperty.call(choice, 'value') && choice.value === rawValue,
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 (!matched) {
306
- return rawValue;
384
+ if (matched) {
385
+ return toTrimmedString(matched.label) ?? normalizedValue;
307
386
  }
308
387
 
309
- return toTrimmedString(matched.label) ?? rawValue;
388
+ if (isRecord(entry) && isChoicePrimitive(entry.label)) {
389
+ return entry.label;
390
+ }
391
+
392
+ return normalizedValue;
310
393
  };
311
394
 
312
- const toMultiChoiceLabels = (field, rawValue) => {
313
- if (!Array.isArray(rawValue)) {
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
- const choices = toChoiceValueList(field);
318
- if (choices.length === 0) {
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 rawValue.map((entry) => toSingleChoiceLabel(field, entry));
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
+ }