form0-core 0.1.14 → 0.1.16
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/README.md +20 -0
- package/package.json +1 -1
- package/src/utilities/choice-value-shapes.js +290 -0
- package/src/utilities/dataset-descriptors.js +55 -187
- package/src/utilities/structured-record.js +22 -103
package/README.md
CHANGED
|
@@ -40,6 +40,26 @@ scope fields like `main_org_id`, and media or location settings. Operational cou
|
|
|
40
40
|
application or API boundaries, not treated as engine-authored schema. A top-level
|
|
41
41
|
`form.version` may still exist, but the engine does not bump or consume it.
|
|
42
42
|
|
|
43
|
+
### Record-side contract
|
|
44
|
+
|
|
45
|
+
`form0-core` intentionally uses two different choice-value shapes:
|
|
46
|
+
|
|
47
|
+
- Live engine / renderer values use renderer shape:
|
|
48
|
+
- single / boolean: `{ choice, other }`
|
|
49
|
+
- multi: `{ choices, other }`
|
|
50
|
+
- Structured records use canonical stored shape:
|
|
51
|
+
- single / boolean: `{ choice_value, other_value }`
|
|
52
|
+
- multi: `{ choices_value, other_value }`
|
|
53
|
+
|
|
54
|
+
Record-side utilities follow this contract:
|
|
55
|
+
|
|
56
|
+
- `createStructuredRecord()` outputs canonical stored records
|
|
57
|
+
- `normalizeStructuredRecord()` consumes and returns canonical stored records
|
|
58
|
+
- `buildFormRecordSnapshot()` consumes canonical stored records and returns renderer snapshot values
|
|
59
|
+
- `projectDatasetRowValues()` consumes canonical stored rows
|
|
60
|
+
|
|
61
|
+
Record status remains top-level as `@status`; it is not stored inside `form_values`.
|
|
62
|
+
|
|
43
63
|
## Security
|
|
44
64
|
|
|
45
65
|
See `SECURITY.md` for security modes and configuration.
|
package/package.json
CHANGED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
const SINGLE_CHOICE_TYPES = new Set(['SingleChoiceField', 'BooleanField']);
|
|
2
|
+
const MULTI_CHOICE_TYPES = new Set(['MultiChoiceField']);
|
|
3
|
+
const RENDERER_ALIAS_KEYS = ['choice', 'choices', 'other'];
|
|
4
|
+
|
|
5
|
+
const isRecordObject = (value) =>
|
|
6
|
+
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
|
|
8
|
+
const cloneJson = (value) => {
|
|
9
|
+
if (typeof structuredClone === 'function') {
|
|
10
|
+
return structuredClone(value);
|
|
11
|
+
}
|
|
12
|
+
return JSON.parse(JSON.stringify(value));
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const trimString = (value) => {
|
|
16
|
+
if (typeof value !== 'string') {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const trimmed = value.trim();
|
|
21
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const isChoicePrimitive = (value) =>
|
|
25
|
+
typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
|
|
26
|
+
|
|
27
|
+
const normalizeChoiceLabel = (value) => {
|
|
28
|
+
if (typeof value === 'string') {
|
|
29
|
+
const trimmed = value.trim();
|
|
30
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return isChoicePrimitive(value) ? value : null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const toFieldType = (field) => trimString(field?.type) ?? trimString(field?.field_type);
|
|
37
|
+
|
|
38
|
+
const toFieldIdentifier = (field) =>
|
|
39
|
+
trimString(field?.data_name) ??
|
|
40
|
+
trimString(field?.output_key) ??
|
|
41
|
+
trimString(field?.key) ??
|
|
42
|
+
trimString(field?.field_id) ??
|
|
43
|
+
trimString(field?.label) ??
|
|
44
|
+
'choice field';
|
|
45
|
+
|
|
46
|
+
const getChoiceKind = (field) => {
|
|
47
|
+
const fieldType = toFieldType(field);
|
|
48
|
+
if (SINGLE_CHOICE_TYPES.has(fieldType)) {
|
|
49
|
+
return 'single';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (MULTI_CHOICE_TYPES.has(fieldType)) {
|
|
53
|
+
return 'multi';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const getCanonicalSelectionKey = (field) =>
|
|
60
|
+
getChoiceKind(field) === 'multi' ? 'choices_value' : 'choice_value';
|
|
61
|
+
|
|
62
|
+
const createChoiceShapeError = (context, field, message) => {
|
|
63
|
+
const prefix = trimString(context) ?? 'record-side choice utilities';
|
|
64
|
+
return new Error(`[form0] ${prefix}: field "${toFieldIdentifier(field)}" ${message}`);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const assertCanonicalChoiceShape = (field, rawValue, context) => {
|
|
68
|
+
if (rawValue === null || typeof rawValue === 'undefined') {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!isRecordObject(rawValue)) {
|
|
73
|
+
throw createChoiceShapeError(
|
|
74
|
+
context,
|
|
75
|
+
field,
|
|
76
|
+
'must be a canonical stored object using *_value arrays'
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const rendererKeys = RENDERER_ALIAS_KEYS.filter((key) =>
|
|
81
|
+
Object.prototype.hasOwnProperty.call(rawValue, key)
|
|
82
|
+
);
|
|
83
|
+
if (rendererKeys.length > 0) {
|
|
84
|
+
throw createChoiceShapeError(
|
|
85
|
+
context,
|
|
86
|
+
field,
|
|
87
|
+
`must not use renderer keys (${rendererKeys.join(', ')})`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const kind = getChoiceKind(field);
|
|
92
|
+
if (kind === 'single' && Object.prototype.hasOwnProperty.call(rawValue, 'choices_value')) {
|
|
93
|
+
throw createChoiceShapeError(
|
|
94
|
+
context,
|
|
95
|
+
field,
|
|
96
|
+
'must use "choice_value" instead of "choices_value"'
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (kind === 'multi' && Object.prototype.hasOwnProperty.call(rawValue, 'choice_value')) {
|
|
101
|
+
throw createChoiceShapeError(
|
|
102
|
+
context,
|
|
103
|
+
field,
|
|
104
|
+
'must use "choices_value" instead of "choice_value"'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const resolveSchemaChoice = (field, rawValue) => {
|
|
110
|
+
if (!Array.isArray(field?.choices)) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
field.choices.find(
|
|
116
|
+
(choice) =>
|
|
117
|
+
isRecordObject(choice) &&
|
|
118
|
+
Object.prototype.hasOwnProperty.call(choice, 'value') &&
|
|
119
|
+
choice.value === rawValue
|
|
120
|
+
) ?? null
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const normalizeStoredSelectedEntry = (field, entry, context) => {
|
|
125
|
+
if (!isRecordObject(entry)) {
|
|
126
|
+
throw createChoiceShapeError(context, field, 'must store selected entries as objects');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!Object.prototype.hasOwnProperty.call(entry, 'value')) {
|
|
130
|
+
throw createChoiceShapeError(context, field, 'must store selected entries with a "value"');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const matchedChoice = resolveSchemaChoice(field, entry.value);
|
|
134
|
+
const resolvedLabel = matchedChoice
|
|
135
|
+
? (normalizeChoiceLabel(matchedChoice.label) ?? entry.value)
|
|
136
|
+
: (normalizeChoiceLabel(entry.label) ?? entry.value);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
...entry,
|
|
140
|
+
value: entry.value,
|
|
141
|
+
label: resolvedLabel,
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const normalizeStoredOtherEntry = (field, entry, context) => {
|
|
146
|
+
if (!isRecordObject(entry)) {
|
|
147
|
+
throw createChoiceShapeError(context, field, 'must store other entries as objects');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const hasPrimitiveValue = isChoicePrimitive(entry.value);
|
|
151
|
+
const normalizedLabel = normalizeChoiceLabel(entry.label);
|
|
152
|
+
|
|
153
|
+
if (!hasPrimitiveValue && normalizedLabel === null) {
|
|
154
|
+
throw createChoiceShapeError(
|
|
155
|
+
context,
|
|
156
|
+
field,
|
|
157
|
+
'must store other entries with a primitive "label" or "value"'
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
...entry,
|
|
163
|
+
...(hasPrimitiveValue ? { value: entry.value } : {}),
|
|
164
|
+
label: normalizedLabel ?? entry.value,
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const normalizeChoiceEntryArray = (field, entries, normalizer, context, keyName) => {
|
|
169
|
+
if (typeof entries === 'undefined') {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!Array.isArray(entries)) {
|
|
174
|
+
throw createChoiceShapeError(context, field, `"${keyName}" must be an array`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return entries.map((entry) => normalizer(field, entry, context));
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export function isChoiceFieldLike(field) {
|
|
181
|
+
return getChoiceKind(field) !== null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function normalizeStoredChoiceValue(field, rawValue, options = {}) {
|
|
185
|
+
const context = options.context;
|
|
186
|
+
const kind = getChoiceKind(field);
|
|
187
|
+
|
|
188
|
+
if (!kind || rawValue === null || typeof rawValue === 'undefined') {
|
|
189
|
+
return rawValue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
assertCanonicalChoiceShape(field, rawValue, context);
|
|
193
|
+
|
|
194
|
+
const selectionKey = getCanonicalSelectionKey(field);
|
|
195
|
+
const normalized = {
|
|
196
|
+
...rawValue,
|
|
197
|
+
[selectionKey]: normalizeChoiceEntryArray(
|
|
198
|
+
field,
|
|
199
|
+
rawValue[selectionKey],
|
|
200
|
+
normalizeStoredSelectedEntry,
|
|
201
|
+
context,
|
|
202
|
+
selectionKey
|
|
203
|
+
),
|
|
204
|
+
other_value: normalizeChoiceEntryArray(
|
|
205
|
+
field,
|
|
206
|
+
rawValue.other_value,
|
|
207
|
+
normalizeStoredOtherEntry,
|
|
208
|
+
context,
|
|
209
|
+
'other_value'
|
|
210
|
+
),
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
return normalized;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function toRendererChoiceValue(field, rawValue, options = {}) {
|
|
217
|
+
const kind = getChoiceKind(field);
|
|
218
|
+
if (!kind || rawValue === null || typeof rawValue === 'undefined') {
|
|
219
|
+
return rawValue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const normalized = normalizeStoredChoiceValue(field, rawValue, options);
|
|
223
|
+
const selectionKey = getCanonicalSelectionKey(field);
|
|
224
|
+
|
|
225
|
+
if (kind === 'multi') {
|
|
226
|
+
return {
|
|
227
|
+
choices: cloneJson(normalized[selectionKey]),
|
|
228
|
+
other: cloneJson(normalized.other_value),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
choice: cloneJson(normalized[selectionKey]),
|
|
234
|
+
other: cloneJson(normalized.other_value),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function readStoredChoiceSelectionValues(field, rawValue, options = {}) {
|
|
239
|
+
const kind = getChoiceKind(field);
|
|
240
|
+
if (!kind) {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const normalized = normalizeStoredChoiceValue(field, rawValue, options);
|
|
245
|
+
if (normalized === null || typeof normalized === 'undefined') {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const selectionKey = getCanonicalSelectionKey(field);
|
|
250
|
+
const selectedValues = normalized[selectionKey]
|
|
251
|
+
.map((entry) => entry.value)
|
|
252
|
+
.filter((entry) => typeof entry !== 'undefined');
|
|
253
|
+
const otherValues = normalized.other_value
|
|
254
|
+
.map((entry) =>
|
|
255
|
+
isChoicePrimitive(entry.value) ? entry.value : normalizeChoiceLabel(entry.label)
|
|
256
|
+
)
|
|
257
|
+
.filter((entry) => typeof entry !== 'undefined' && entry !== null);
|
|
258
|
+
|
|
259
|
+
return [...selectedValues, ...otherValues];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function readStoredChoiceDisplayValues(field, rawValue, options = {}) {
|
|
263
|
+
const kind = getChoiceKind(field);
|
|
264
|
+
if (!kind) {
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const normalized = normalizeStoredChoiceValue(field, rawValue, options);
|
|
269
|
+
if (normalized === null || typeof normalized === 'undefined') {
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const selectionKey = getCanonicalSelectionKey(field);
|
|
274
|
+
const selectedLabels = normalized[selectionKey]
|
|
275
|
+
.map(
|
|
276
|
+
(entry) =>
|
|
277
|
+
normalizeChoiceLabel(entry.label) ??
|
|
278
|
+
(isChoicePrimitive(entry.value) ? entry.value : undefined)
|
|
279
|
+
)
|
|
280
|
+
.filter((entry) => typeof entry !== 'undefined' && entry !== null);
|
|
281
|
+
const otherLabels = normalized.other_value
|
|
282
|
+
.map(
|
|
283
|
+
(entry) =>
|
|
284
|
+
normalizeChoiceLabel(entry.label) ??
|
|
285
|
+
(isChoicePrimitive(entry.value) ? entry.value : undefined)
|
|
286
|
+
)
|
|
287
|
+
.filter((entry) => typeof entry !== 'undefined' && entry !== null);
|
|
288
|
+
|
|
289
|
+
return [...selectedLabels, ...otherLabels];
|
|
290
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { flattenFields } from './field-helpers.js';
|
|
2
|
+
import {
|
|
3
|
+
readStoredChoiceDisplayValues,
|
|
4
|
+
readStoredChoiceSelectionValues,
|
|
5
|
+
} from './choice-value-shapes.js';
|
|
2
6
|
|
|
3
7
|
const ROOT_DATASET_ID = '__root__';
|
|
4
8
|
const CONTAINER_TYPES = new Set(['Section', 'BuildingPlanSection']);
|
|
5
9
|
|
|
6
|
-
const isRecord = (value) =>
|
|
7
|
-
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
10
|
+
const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
8
11
|
|
|
9
12
|
const toTrimmedString = (value) => {
|
|
10
13
|
if (typeof value !== 'string') {
|
|
@@ -57,11 +60,9 @@ const uniqueAliases = (...values) => {
|
|
|
57
60
|
return aliases;
|
|
58
61
|
};
|
|
59
62
|
|
|
60
|
-
const toFieldId = (field) =>
|
|
61
|
-
toTrimmedString(field?.key) ?? toTrimmedString(field?.data_name);
|
|
63
|
+
const toFieldId = (field) => toTrimmedString(field?.key) ?? toTrimmedString(field?.data_name);
|
|
62
64
|
|
|
63
|
-
const toOutputKey = (field) =>
|
|
64
|
-
toTrimmedString(field?.data_name) ?? toTrimmedString(field?.key);
|
|
65
|
+
const toOutputKey = (field) => toTrimmedString(field?.data_name) ?? toTrimmedString(field?.key);
|
|
65
66
|
|
|
66
67
|
const toFieldLabel = (field) =>
|
|
67
68
|
toTrimmedString(field?.label) ??
|
|
@@ -75,18 +76,44 @@ const toRepeatableLabel = (field) =>
|
|
|
75
76
|
toTrimmedString(field?.key) ??
|
|
76
77
|
'Repeatable section';
|
|
77
78
|
|
|
79
|
+
const normalizeCalculatedDisplayStyle = (value) => {
|
|
80
|
+
const normalized = toTrimmedString(value);
|
|
81
|
+
if (!normalized) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
switch (normalized.toLowerCase()) {
|
|
86
|
+
case 'number':
|
|
87
|
+
return 'numeric';
|
|
88
|
+
case 'datetime':
|
|
89
|
+
return 'date';
|
|
90
|
+
default:
|
|
91
|
+
return normalized.toLowerCase();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
78
95
|
const getCalculatedDisplayStyle = (field) => {
|
|
79
96
|
if (!isRecord(field)) {
|
|
80
97
|
return null;
|
|
81
98
|
}
|
|
82
99
|
|
|
100
|
+
const directStyle =
|
|
101
|
+
normalizeCalculatedDisplayStyle(field.display_mode) ??
|
|
102
|
+
normalizeCalculatedDisplayStyle(field.display_style);
|
|
103
|
+
if (directStyle) {
|
|
104
|
+
return directStyle;
|
|
105
|
+
}
|
|
106
|
+
|
|
83
107
|
const display = field.display;
|
|
84
108
|
if (typeof display === 'string') {
|
|
85
|
-
return
|
|
109
|
+
return normalizeCalculatedDisplayStyle(display);
|
|
86
110
|
}
|
|
87
111
|
|
|
88
112
|
if (isRecord(display)) {
|
|
89
|
-
return
|
|
113
|
+
return (
|
|
114
|
+
normalizeCalculatedDisplayStyle(display.style) ??
|
|
115
|
+
normalizeCalculatedDisplayStyle(display.type)
|
|
116
|
+
);
|
|
90
117
|
}
|
|
91
118
|
|
|
92
119
|
return null;
|
|
@@ -317,173 +344,10 @@ export function getFieldQuerySemantics(field) {
|
|
|
317
344
|
return { ...semantics };
|
|
318
345
|
}
|
|
319
346
|
|
|
320
|
-
const toChoiceValueList = (field) => {
|
|
321
|
-
if (!Array.isArray(field?.choices)) {
|
|
322
|
-
return [];
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
return field.choices.filter((choice) => isRecord(choice));
|
|
326
|
-
};
|
|
327
|
-
|
|
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;
|
|
364
|
-
}
|
|
365
|
-
|
|
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,
|
|
383
|
-
);
|
|
384
|
-
if (matched) {
|
|
385
|
-
return toTrimmedString(matched.label) ?? normalizedValue;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
if (isRecord(entry) && isChoicePrimitive(entry.label)) {
|
|
389
|
-
return entry.label;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
return normalizedValue;
|
|
393
|
-
};
|
|
394
|
-
|
|
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
347
|
const toSingleChoiceLabel = (field, rawValue) => {
|
|
486
|
-
const labels =
|
|
348
|
+
const labels = readStoredChoiceDisplayValues(field, rawValue, {
|
|
349
|
+
context: 'projectDatasetRowValues',
|
|
350
|
+
});
|
|
487
351
|
if (labels.length === 0) {
|
|
488
352
|
return rawValue;
|
|
489
353
|
}
|
|
@@ -492,7 +356,9 @@ const toSingleChoiceLabel = (field, rawValue) => {
|
|
|
492
356
|
};
|
|
493
357
|
|
|
494
358
|
const toMultiChoiceLabels = (field, rawValue) => {
|
|
495
|
-
const labels =
|
|
359
|
+
const labels = readStoredChoiceDisplayValues(field, rawValue, {
|
|
360
|
+
context: 'projectDatasetRowValues',
|
|
361
|
+
});
|
|
496
362
|
if (labels.length === 0) {
|
|
497
363
|
return rawValue;
|
|
498
364
|
}
|
|
@@ -619,7 +485,7 @@ const collectRepeatableDatasets = (elements, context, datasets) => {
|
|
|
619
485
|
repeatable_output_path: descriptor.repeatable_output_path,
|
|
620
486
|
parent_dataset_id: descriptor.id,
|
|
621
487
|
},
|
|
622
|
-
datasets
|
|
488
|
+
datasets
|
|
623
489
|
);
|
|
624
490
|
});
|
|
625
491
|
};
|
|
@@ -655,7 +521,7 @@ export function buildDatasetDescriptors(schema) {
|
|
|
655
521
|
repeatable_output_path: [],
|
|
656
522
|
parent_dataset_id: ROOT_DATASET_ID,
|
|
657
523
|
},
|
|
658
|
-
datasets
|
|
524
|
+
datasets
|
|
659
525
|
);
|
|
660
526
|
|
|
661
527
|
return datasets;
|
|
@@ -676,11 +542,10 @@ const readFirstAliasValue = (value, aliases) => {
|
|
|
676
542
|
};
|
|
677
543
|
|
|
678
544
|
const normalizeScalarValue = (field, value) => {
|
|
679
|
-
if (
|
|
680
|
-
field
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
return toSingleChoiceSelectionValues(value)[0];
|
|
545
|
+
if (field?.field_type === 'SingleChoiceField' || field?.field_type === 'BooleanField') {
|
|
546
|
+
return readStoredChoiceSelectionValues(field, value, {
|
|
547
|
+
context: 'projectDatasetRowValues',
|
|
548
|
+
})[0];
|
|
684
549
|
}
|
|
685
550
|
|
|
686
551
|
if (Array.isArray(value)) {
|
|
@@ -699,7 +564,9 @@ const normalizeScalarValue = (field, value) => {
|
|
|
699
564
|
|
|
700
565
|
const normalizeTermValues = (field, value) => {
|
|
701
566
|
if (field?.field_type === 'MultiChoiceField') {
|
|
702
|
-
return
|
|
567
|
+
return readStoredChoiceSelectionValues(field, value, {
|
|
568
|
+
context: 'projectDatasetRowValues',
|
|
569
|
+
});
|
|
703
570
|
}
|
|
704
571
|
|
|
705
572
|
if (!Array.isArray(value)) {
|
|
@@ -707,13 +574,14 @@ const normalizeTermValues = (field, value) => {
|
|
|
707
574
|
}
|
|
708
575
|
|
|
709
576
|
return value.filter(
|
|
710
|
-
(entry) =>
|
|
711
|
-
typeof entry === 'string' ||
|
|
712
|
-
typeof entry === 'number' ||
|
|
713
|
-
typeof entry === 'boolean',
|
|
577
|
+
(entry) => typeof entry === 'string' || typeof entry === 'number' || typeof entry === 'boolean'
|
|
714
578
|
);
|
|
715
579
|
};
|
|
716
580
|
|
|
581
|
+
/**
|
|
582
|
+
* Project a canonical stored row into dataset display/scalar/term values.
|
|
583
|
+
* Choice fields must already use the stored `*_value` record shape.
|
|
584
|
+
*/
|
|
717
585
|
export function projectDatasetRowValues(descriptor, rowValues) {
|
|
718
586
|
const sourceRecord = isRecord(rowValues) ? rowValues : {};
|
|
719
587
|
const source =
|
|
@@ -4,6 +4,11 @@ import {
|
|
|
4
4
|
buildFieldIdentityMap,
|
|
5
5
|
projectDatasetRowValues,
|
|
6
6
|
} from './dataset-descriptors.js';
|
|
7
|
+
import {
|
|
8
|
+
isChoiceFieldLike,
|
|
9
|
+
normalizeStoredChoiceValue,
|
|
10
|
+
toRendererChoiceValue,
|
|
11
|
+
} from './choice-value-shapes.js';
|
|
7
12
|
|
|
8
13
|
const CONTAINER_TYPES = new Set(['Section', 'BuildingPlanSection']);
|
|
9
14
|
const TIMESTAMP_KEYS = [
|
|
@@ -131,102 +136,6 @@ const buildScopeFromElements = (elements) => {
|
|
|
131
136
|
return scope;
|
|
132
137
|
};
|
|
133
138
|
|
|
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
139
|
const buildFieldReferenceMap = (schema) => {
|
|
231
140
|
const identityMap = buildFieldIdentityMap(schema);
|
|
232
141
|
const references = new Map();
|
|
@@ -323,12 +232,10 @@ const normalizeRecordScope = (record, scope) => {
|
|
|
323
232
|
return;
|
|
324
233
|
}
|
|
325
234
|
|
|
326
|
-
if (
|
|
327
|
-
field.field
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
) {
|
|
331
|
-
formValues[entry.key] = normalizeChoiceStructuredValue(field.field, entry.value);
|
|
235
|
+
if (isChoiceFieldLike(field.field)) {
|
|
236
|
+
formValues[entry.key] = normalizeStoredChoiceValue(field.field, entry.value, {
|
|
237
|
+
context: 'normalizeStructuredRecord',
|
|
238
|
+
});
|
|
332
239
|
}
|
|
333
240
|
});
|
|
334
241
|
|
|
@@ -363,7 +270,11 @@ const buildSnapshotState = (formValues, scope) => {
|
|
|
363
270
|
return;
|
|
364
271
|
}
|
|
365
272
|
|
|
366
|
-
rawValues[field.dataName] =
|
|
273
|
+
rawValues[field.dataName] = isChoiceFieldLike(field.field)
|
|
274
|
+
? toRendererChoiceValue(field.field, entry.value, {
|
|
275
|
+
context: 'buildFormRecordSnapshot',
|
|
276
|
+
})
|
|
277
|
+
: cloneJson(entry.value);
|
|
367
278
|
});
|
|
368
279
|
|
|
369
280
|
scope.repeatables.forEach((repeatableField) => {
|
|
@@ -436,6 +347,10 @@ const extractSnapshotTimestamps = (record) => ({
|
|
|
436
347
|
updated_at_server: toNullableString(record?.updated_at_server) ?? null,
|
|
437
348
|
});
|
|
438
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Normalize a canonical stored record for downstream record-side consumers.
|
|
352
|
+
* Renderer/live aliases are rejected; known choice labels are refreshed from the supplied schema.
|
|
353
|
+
*/
|
|
439
354
|
export function normalizeStructuredRecord(schema, record, options = {}) {
|
|
440
355
|
const normalized = isRecordObject(record) ? cloneJson(record) : {};
|
|
441
356
|
const mode = trimString(options.mode) ?? 'derived';
|
|
@@ -456,6 +371,10 @@ export function normalizeStructuredRecord(schema, record, options = {}) {
|
|
|
456
371
|
return normalized;
|
|
457
372
|
}
|
|
458
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Build a renderer snapshot from a canonical stored record.
|
|
376
|
+
* Choice fields are converted back to renderer/live shape while record metadata stays top-level.
|
|
377
|
+
*/
|
|
459
378
|
export function buildFormRecordSnapshot(schema, record, options = {}) {
|
|
460
379
|
const normalizedRecord = normalizeStructuredRecord(schema, record, {
|
|
461
380
|
mode: 'derived',
|