form0-core 0.1.11 → 0.1.12

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.11",
3
+ "version": "0.1.12",
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/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/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
@@ -23,6 +23,13 @@ export { getValidOperators, isValidOperator, validateFieldConditions } from './s
23
23
 
24
24
  export { recordVersion, formVersion } from './utilities/version-utils.js';
25
25
  export { buildRepeatableMetadata } from './utilities/repeatable-helpers.js';
26
+ export {
27
+ buildDatasetDescriptors,
28
+ buildFieldIdentityMap,
29
+ getFieldQuerySemantics,
30
+ projectDatasetRowValues,
31
+ resolveDatasetDescriptorById,
32
+ } from './utilities/dataset-descriptors.js';
26
33
  export { generateUuidV7 } from './utilities/uuid.js';
27
34
  export {
28
35
  FORM_LINK_VALUE_DELIMITER,
@@ -0,0 +1,598 @@
1
+ import { flattenFields } from './field-helpers.js';
2
+
3
+ const ROOT_DATASET_ID = '__root__';
4
+ const CONTAINER_TYPES = new Set(['Section', 'BuildingPlanSection']);
5
+
6
+ const isRecord = (value) =>
7
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value);
8
+
9
+ const toTrimmedString = (value) => {
10
+ if (typeof value !== 'string') {
11
+ return null;
12
+ }
13
+
14
+ const trimmed = value.trim();
15
+ return trimmed.length > 0 ? trimmed : null;
16
+ };
17
+
18
+ const toSchemaFormNode = (schema) => {
19
+ if (!isRecord(schema)) {
20
+ return null;
21
+ }
22
+
23
+ if (isRecord(schema.form)) {
24
+ return schema.form;
25
+ }
26
+
27
+ if (Array.isArray(schema.elements)) {
28
+ return schema;
29
+ }
30
+
31
+ return null;
32
+ };
33
+
34
+ const uniqueAliases = (...values) => {
35
+ const aliases = [];
36
+ for (const value of values) {
37
+ const normalized = toTrimmedString(value);
38
+ if (!normalized || aliases.includes(normalized)) {
39
+ continue;
40
+ }
41
+ aliases.push(normalized);
42
+ }
43
+ return aliases;
44
+ };
45
+
46
+ const toFieldId = (field) =>
47
+ toTrimmedString(field?.key) ?? toTrimmedString(field?.data_name);
48
+
49
+ const toOutputKey = (field) =>
50
+ toTrimmedString(field?.data_name) ?? toTrimmedString(field?.key);
51
+
52
+ const toFieldLabel = (field) =>
53
+ toTrimmedString(field?.label) ??
54
+ toTrimmedString(field?.data_name) ??
55
+ toTrimmedString(field?.key) ??
56
+ 'Field';
57
+
58
+ const toRepeatableLabel = (field) =>
59
+ toTrimmedString(field?.label) ??
60
+ toTrimmedString(field?.data_name) ??
61
+ toTrimmedString(field?.key) ??
62
+ 'Repeatable section';
63
+
64
+ const getCalculatedDisplayStyle = (field) => {
65
+ if (!isRecord(field)) {
66
+ return null;
67
+ }
68
+
69
+ const display = field.display;
70
+ if (typeof display === 'string') {
71
+ return toTrimmedString(display);
72
+ }
73
+
74
+ if (isRecord(display)) {
75
+ return toTrimmedString(display.style);
76
+ }
77
+
78
+ return null;
79
+ };
80
+
81
+ const QUERY_SEMANTICS_BY_FIELD_TYPE = {
82
+ TextField: {
83
+ query_kind: 'scalar',
84
+ display_kind: 'text',
85
+ sortable: true,
86
+ filterable: true,
87
+ default_operator: 'contains',
88
+ allowed_operators: [
89
+ 'contains',
90
+ 'eq',
91
+ 'neq',
92
+ 'starts_with',
93
+ 'ends_with',
94
+ 'is_blank',
95
+ 'is_not_blank',
96
+ ],
97
+ },
98
+ NumericField: {
99
+ query_kind: 'scalar',
100
+ display_kind: 'number',
101
+ sortable: true,
102
+ filterable: true,
103
+ default_operator: 'between',
104
+ allowed_operators: [
105
+ 'between',
106
+ 'gte',
107
+ 'lte',
108
+ 'eq',
109
+ 'neq',
110
+ 'gt',
111
+ 'lt',
112
+ 'is_blank',
113
+ 'is_not_blank',
114
+ ],
115
+ },
116
+ DateField: {
117
+ query_kind: 'scalar',
118
+ display_kind: 'date',
119
+ sortable: true,
120
+ filterable: true,
121
+ default_operator: 'between',
122
+ allowed_operators: [
123
+ 'between',
124
+ 'gte',
125
+ 'lte',
126
+ 'eq',
127
+ 'neq',
128
+ 'gt',
129
+ 'lt',
130
+ 'is_blank',
131
+ 'is_not_blank',
132
+ ],
133
+ },
134
+ TimeField: {
135
+ query_kind: 'scalar',
136
+ display_kind: 'time',
137
+ sortable: true,
138
+ filterable: true,
139
+ default_operator: 'between',
140
+ allowed_operators: [
141
+ 'between',
142
+ 'gte',
143
+ 'lte',
144
+ 'eq',
145
+ 'neq',
146
+ 'gt',
147
+ 'lt',
148
+ 'is_blank',
149
+ 'is_not_blank',
150
+ ],
151
+ },
152
+ SingleChoiceField: {
153
+ query_kind: 'scalar',
154
+ display_kind: 'enum',
155
+ sortable: true,
156
+ filterable: true,
157
+ default_operator: 'in',
158
+ allowed_operators: ['in', 'eq', 'neq', 'not_in', 'is_blank', 'is_not_blank'],
159
+ },
160
+ BooleanField: {
161
+ query_kind: 'scalar',
162
+ display_kind: 'boolean',
163
+ sortable: true,
164
+ filterable: true,
165
+ default_operator: 'in',
166
+ allowed_operators: ['in', 'eq', 'neq', 'not_in', 'is_blank', 'is_not_blank'],
167
+ },
168
+ MultiChoiceField: {
169
+ query_kind: 'terms',
170
+ display_kind: 'enum_multi',
171
+ sortable: false,
172
+ filterable: true,
173
+ default_operator: 'has_any',
174
+ allowed_operators: ['has_any', 'has_all', 'has_none', 'is_blank', 'is_not_blank'],
175
+ },
176
+ };
177
+
178
+ const DISPLAY_ONLY_SEMANTICS = {
179
+ query_kind: 'display_only',
180
+ display_kind: 'json',
181
+ sortable: false,
182
+ filterable: false,
183
+ default_operator: null,
184
+ allowed_operators: [],
185
+ };
186
+
187
+ const getCalculatedFieldSemantics = (field) => {
188
+ const style = getCalculatedDisplayStyle(field);
189
+
190
+ if (style === 'numeric' || style === 'currency') {
191
+ return {
192
+ query_kind: 'scalar',
193
+ display_kind: 'number',
194
+ sortable: true,
195
+ filterable: true,
196
+ default_operator: 'between',
197
+ allowed_operators: [
198
+ 'between',
199
+ 'gte',
200
+ 'lte',
201
+ 'eq',
202
+ 'neq',
203
+ 'gt',
204
+ 'lt',
205
+ 'is_blank',
206
+ 'is_not_blank',
207
+ ],
208
+ };
209
+ }
210
+
211
+ if (style === 'date') {
212
+ return {
213
+ query_kind: 'scalar',
214
+ display_kind: 'date',
215
+ sortable: true,
216
+ filterable: true,
217
+ default_operator: 'between',
218
+ allowed_operators: [
219
+ 'between',
220
+ 'gte',
221
+ 'lte',
222
+ 'eq',
223
+ 'neq',
224
+ 'gt',
225
+ 'lt',
226
+ 'is_blank',
227
+ 'is_not_blank',
228
+ ],
229
+ };
230
+ }
231
+
232
+ if (style === 'time') {
233
+ return {
234
+ query_kind: 'scalar',
235
+ display_kind: 'time',
236
+ sortable: true,
237
+ filterable: true,
238
+ default_operator: 'between',
239
+ allowed_operators: [
240
+ 'between',
241
+ 'gte',
242
+ 'lte',
243
+ 'eq',
244
+ 'neq',
245
+ 'gt',
246
+ 'lt',
247
+ 'is_blank',
248
+ 'is_not_blank',
249
+ ],
250
+ };
251
+ }
252
+
253
+ return {
254
+ query_kind: 'scalar',
255
+ display_kind: 'text',
256
+ sortable: true,
257
+ filterable: true,
258
+ default_operator: 'contains',
259
+ allowed_operators: [
260
+ 'contains',
261
+ 'eq',
262
+ 'neq',
263
+ 'starts_with',
264
+ 'ends_with',
265
+ 'is_blank',
266
+ 'is_not_blank',
267
+ ],
268
+ };
269
+ };
270
+
271
+ export function getFieldQuerySemantics(field) {
272
+ if (!isRecord(field)) {
273
+ return { ...DISPLAY_ONLY_SEMANTICS };
274
+ }
275
+
276
+ if (field.type === 'CalculatedField') {
277
+ return getCalculatedFieldSemantics(field);
278
+ }
279
+
280
+ const semantics = QUERY_SEMANTICS_BY_FIELD_TYPE[field.type];
281
+ if (!semantics) {
282
+ return { ...DISPLAY_ONLY_SEMANTICS };
283
+ }
284
+
285
+ return { ...semantics };
286
+ }
287
+
288
+ const toChoiceValueList = (field) => {
289
+ if (!Array.isArray(field?.choices)) {
290
+ return [];
291
+ }
292
+
293
+ return field.choices.filter((choice) => isRecord(choice));
294
+ };
295
+
296
+ const toSingleChoiceLabel = (field, rawValue) => {
297
+ const choices = toChoiceValueList(field);
298
+ if (choices.length === 0) {
299
+ return rawValue;
300
+ }
301
+
302
+ const matched = choices.find(
303
+ (choice) => Object.prototype.hasOwnProperty.call(choice, 'value') && choice.value === rawValue,
304
+ );
305
+ if (!matched) {
306
+ return rawValue;
307
+ }
308
+
309
+ return toTrimmedString(matched.label) ?? rawValue;
310
+ };
311
+
312
+ const toMultiChoiceLabels = (field, rawValue) => {
313
+ if (!Array.isArray(rawValue)) {
314
+ return rawValue;
315
+ }
316
+
317
+ const choices = toChoiceValueList(field);
318
+ if (choices.length === 0) {
319
+ return rawValue;
320
+ }
321
+
322
+ return rawValue.map((entry) => toSingleChoiceLabel(field, entry));
323
+ };
324
+
325
+ const toDisplayValue = (field, rawValue) => {
326
+ if (typeof rawValue === 'undefined') {
327
+ return undefined;
328
+ }
329
+
330
+ if (field?.field_type === 'SingleChoiceField' || field?.field_type === 'BooleanField') {
331
+ return toSingleChoiceLabel(field, rawValue);
332
+ }
333
+
334
+ if (field?.field_type === 'MultiChoiceField') {
335
+ return toMultiChoiceLabels(field, rawValue);
336
+ }
337
+
338
+ return rawValue;
339
+ };
340
+
341
+ const buildDatasetFieldDescriptor = (field, dataset) => {
342
+ const fieldId = toFieldId(field);
343
+ const outputKey = toOutputKey(field);
344
+ if (!fieldId || !outputKey) {
345
+ return null;
346
+ }
347
+
348
+ const semantics = getFieldQuerySemantics(field);
349
+ return {
350
+ dataset_id: dataset.id,
351
+ dataset_path: [...dataset.dataset_path],
352
+ field_id: fieldId,
353
+ output_key: outputKey,
354
+ key: toTrimmedString(field.key),
355
+ data_name: toTrimmedString(field.data_name),
356
+ label: toFieldLabel(field),
357
+ field_type: toTrimmedString(field.type) ?? 'UnknownField',
358
+ choices: Array.isArray(field.choices)
359
+ ? field.choices.filter((choice) => isRecord(choice)).map((choice) => ({ ...choice }))
360
+ : undefined,
361
+ aliases: uniqueAliases(outputKey, field.data_name, field.key, fieldId),
362
+ repeatable_depth: dataset.dataset_path.length,
363
+ ...semantics,
364
+ };
365
+ };
366
+
367
+ const collectFieldsForDataset = (elements, dataset) => {
368
+ if (!Array.isArray(elements)) {
369
+ return;
370
+ }
371
+
372
+ elements.forEach((element) => {
373
+ if (!isRecord(element) || typeof element.type !== 'string') {
374
+ return;
375
+ }
376
+
377
+ if (CONTAINER_TYPES.has(element.type)) {
378
+ collectFieldsForDataset(element.elements, dataset);
379
+ return;
380
+ }
381
+
382
+ if (element.type === 'RepeatableSection') {
383
+ return;
384
+ }
385
+
386
+ const descriptor = buildDatasetFieldDescriptor(element, dataset);
387
+ if (descriptor) {
388
+ dataset.fields.push(descriptor);
389
+ }
390
+ });
391
+ };
392
+
393
+ const collectRepeatableDatasets = (elements, context, datasets) => {
394
+ if (!Array.isArray(elements)) {
395
+ return;
396
+ }
397
+
398
+ elements.forEach((element) => {
399
+ if (!isRecord(element) || typeof element.type !== 'string') {
400
+ return;
401
+ }
402
+
403
+ if (CONTAINER_TYPES.has(element.type)) {
404
+ collectRepeatableDatasets(element.elements, context, datasets);
405
+ return;
406
+ }
407
+
408
+ if (element.type !== 'RepeatableSection') {
409
+ return;
410
+ }
411
+
412
+ const repeatableFieldId = toFieldId(element);
413
+ const repeatableOutputKey = toOutputKey(element);
414
+ if (!repeatableFieldId || !repeatableOutputKey) {
415
+ return;
416
+ }
417
+
418
+ const datasetPath = [...context.dataset_path, repeatableFieldId];
419
+ const descriptor = {
420
+ id: datasetPath.join('.'),
421
+ kind: 'repeatable',
422
+ label: [...context.dataset_labels, toRepeatableLabel(element)].join(' / '),
423
+ dataset_path: datasetPath,
424
+ dataset_labels: [...context.dataset_labels, toRepeatableLabel(element)],
425
+ repeatable_output_path: [...context.repeatable_output_path, repeatableOutputKey],
426
+ parent_dataset_id: context.parent_dataset_id,
427
+ repeatable_field_id: repeatableFieldId,
428
+ repeatable_output_key: repeatableOutputKey,
429
+ root_dataset_id: ROOT_DATASET_ID,
430
+ fields: [],
431
+ };
432
+
433
+ collectFieldsForDataset(element.elements, descriptor);
434
+ datasets.push(descriptor);
435
+
436
+ collectRepeatableDatasets(
437
+ element.elements,
438
+ {
439
+ dataset_path: descriptor.dataset_path,
440
+ dataset_labels: descriptor.dataset_labels,
441
+ repeatable_output_path: descriptor.repeatable_output_path,
442
+ parent_dataset_id: descriptor.id,
443
+ },
444
+ datasets,
445
+ );
446
+ });
447
+ };
448
+
449
+ export function buildDatasetDescriptors(schema) {
450
+ const formNode = toSchemaFormNode(schema);
451
+ if (!formNode) {
452
+ return [];
453
+ }
454
+
455
+ const rootDataset = {
456
+ id: ROOT_DATASET_ID,
457
+ kind: 'root',
458
+ label: toTrimmedString(formNode.name) ?? 'Root form',
459
+ dataset_path: [],
460
+ dataset_labels: [],
461
+ repeatable_output_path: [],
462
+ parent_dataset_id: null,
463
+ repeatable_field_id: null,
464
+ repeatable_output_key: null,
465
+ root_dataset_id: ROOT_DATASET_ID,
466
+ fields: [],
467
+ };
468
+
469
+ collectFieldsForDataset(formNode.elements, rootDataset);
470
+
471
+ const datasets = [rootDataset];
472
+ collectRepeatableDatasets(
473
+ formNode.elements,
474
+ {
475
+ dataset_path: [],
476
+ dataset_labels: [],
477
+ repeatable_output_path: [],
478
+ parent_dataset_id: ROOT_DATASET_ID,
479
+ },
480
+ datasets,
481
+ );
482
+
483
+ return datasets;
484
+ }
485
+
486
+ const readFirstAliasValue = (value, aliases) => {
487
+ if (!isRecord(value)) {
488
+ return undefined;
489
+ }
490
+
491
+ for (const alias of aliases) {
492
+ if (Object.prototype.hasOwnProperty.call(value, alias)) {
493
+ return value[alias];
494
+ }
495
+ }
496
+
497
+ return undefined;
498
+ };
499
+
500
+ const normalizeScalarValue = (value) => {
501
+ if (Array.isArray(value)) {
502
+ return undefined;
503
+ }
504
+
505
+ return value;
506
+ };
507
+
508
+ const normalizeTermValues = (value) => {
509
+ if (!Array.isArray(value)) {
510
+ return [];
511
+ }
512
+
513
+ return value.filter(
514
+ (entry) =>
515
+ typeof entry === 'string' ||
516
+ typeof entry === 'number' ||
517
+ typeof entry === 'boolean',
518
+ );
519
+ };
520
+
521
+ export function projectDatasetRowValues(descriptor, rowValues) {
522
+ const sourceRecord = isRecord(rowValues) ? rowValues : {};
523
+ const source =
524
+ isRecord(sourceRecord.form_values) && !Array.isArray(sourceRecord.form_values)
525
+ ? sourceRecord.form_values
526
+ : sourceRecord;
527
+ const datasetFields = Array.isArray(descriptor?.fields) ? descriptor.fields : [];
528
+
529
+ const displayValues = {};
530
+ const scalarValues = {};
531
+ const termValues = {};
532
+
533
+ datasetFields.forEach((field) => {
534
+ const rawValue = readFirstAliasValue(source, field.aliases ?? []);
535
+ if (typeof rawValue === 'undefined') {
536
+ return;
537
+ }
538
+
539
+ displayValues[field.field_id] = toDisplayValue(field, rawValue);
540
+
541
+ if (field.query_kind === 'scalar') {
542
+ const scalarValue = normalizeScalarValue(rawValue);
543
+ if (typeof scalarValue !== 'undefined') {
544
+ scalarValues[field.field_id] = scalarValue;
545
+ }
546
+ return;
547
+ }
548
+
549
+ if (field.query_kind === 'terms') {
550
+ const terms = normalizeTermValues(rawValue);
551
+ if (terms.length > 0) {
552
+ termValues[field.field_id] = terms;
553
+ }
554
+ }
555
+ });
556
+
557
+ return {
558
+ displayValues,
559
+ scalarValues,
560
+ termValues,
561
+ };
562
+ }
563
+
564
+ export function resolveDatasetDescriptorById(schema, datasetId) {
565
+ return buildDatasetDescriptors(schema).find((dataset) => dataset.id === datasetId) ?? null;
566
+ }
567
+
568
+ export function buildFieldIdentityMap(schema) {
569
+ const formNode = toSchemaFormNode(schema);
570
+ if (!formNode) {
571
+ return {};
572
+ }
573
+
574
+ const fields = flattenFields(formNode.elements);
575
+ const map = {};
576
+ fields.forEach((field) => {
577
+ if (!isRecord(field)) {
578
+ return;
579
+ }
580
+
581
+ const fieldId = toFieldId(field);
582
+ const outputKey = toOutputKey(field);
583
+ if (!fieldId || !outputKey) {
584
+ return;
585
+ }
586
+
587
+ map[fieldId] = {
588
+ field_id: fieldId,
589
+ output_key: outputKey,
590
+ key: toTrimmedString(field.key),
591
+ data_name: toTrimmedString(field.data_name),
592
+ label: toFieldLabel(field),
593
+ field_type: toTrimmedString(field.type) ?? 'UnknownField',
594
+ };
595
+ });
596
+
597
+ return map;
598
+ }