form0-core 0.1.13 → 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.13",
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;
@@ -673,6 +687,13 @@ const normalizeScalarValue = (field, value) => {
673
687
  return undefined;
674
688
  }
675
689
 
690
+ if (field?.display_kind === 'number') {
691
+ const parsedNumber = toFiniteNumberOrNull(value);
692
+ if (parsedNumber !== null) {
693
+ return parsedNumber;
694
+ }
695
+ }
696
+
676
697
  return value;
677
698
  };
678
699
 
@@ -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
+ }