@rokkit/forms 1.0.0-next.137 → 1.0.0-next.138
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/dist/src/lib/builder.svelte.d.ts +0 -18
- package/dist/src/lib/lookup.svelte.d.ts +18 -0
- package/package.json +1 -1
- package/src/FormRenderer.svelte +26 -53
- package/src/display/DisplayCardGrid.svelte +21 -16
- package/src/display/DisplayTable.svelte +18 -22
- package/src/display/DisplayValue.svelte +10 -16
- package/src/input/InputRadio.svelte +2 -2
- package/src/lib/builder.svelte.js +346 -210
- package/src/lib/lookup.svelte.js +226 -228
- package/src/lib/validation.js +128 -98
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
|
|
1
2
|
import { deriveSchemaFromValue } from './schema.js'
|
|
2
3
|
import { deriveLayoutFromValue } from './layout.js'
|
|
3
4
|
import { getSchemaWithLayout } from './fields.js'
|
|
@@ -19,6 +20,35 @@ function deepClone(value) {
|
|
|
19
20
|
return JSON.parse(JSON.stringify(value))
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/** @private */
|
|
24
|
+
function deepEqualArrays(a, b) {
|
|
25
|
+
if (a.length !== b.length) return false
|
|
26
|
+
return a.every((val, i) => deepEqual(val, b[i]))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @private */
|
|
30
|
+
function deepEqualObjects(a, b) {
|
|
31
|
+
const keysA = Object.keys(a)
|
|
32
|
+
const keysB = Object.keys(b)
|
|
33
|
+
if (keysA.length !== keysB.length) return false
|
|
34
|
+
return keysA.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key]))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @private — true when either value is nullish */
|
|
38
|
+
function isNullish(v) {
|
|
39
|
+
return v === null || v === undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @private — true when both are same-type objects (not mixed array/object) */
|
|
43
|
+
function areSameObjectType(a, b) {
|
|
44
|
+
return typeof a === 'object' && typeof b === 'object' && Array.isArray(a) === Array.isArray(b)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @private — dispatch object comparison to array or plain-object helper */
|
|
48
|
+
function deepEqualObjects2(a, b) {
|
|
49
|
+
return Array.isArray(a) ? deepEqualArrays(a, b) : deepEqualObjects(a, b)
|
|
50
|
+
}
|
|
51
|
+
|
|
22
52
|
/**
|
|
23
53
|
* Deep equality check for plain values (primitives, plain objects, arrays).
|
|
24
54
|
* @param {any} a
|
|
@@ -27,20 +57,8 @@ function deepClone(value) {
|
|
|
27
57
|
*/
|
|
28
58
|
function deepEqual(a, b) {
|
|
29
59
|
if (a === b) return true
|
|
30
|
-
if (a
|
|
31
|
-
|
|
32
|
-
if (typeof a !== 'object') return false
|
|
33
|
-
if (Array.isArray(a) !== Array.isArray(b)) return false
|
|
34
|
-
|
|
35
|
-
if (Array.isArray(a)) {
|
|
36
|
-
if (a.length !== b.length) return false
|
|
37
|
-
return a.every((val, i) => deepEqual(val, b[i]))
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const keysA = Object.keys(a)
|
|
41
|
-
const keysB = Object.keys(b)
|
|
42
|
-
if (keysA.length !== keysB.length) return false
|
|
43
|
-
return keysA.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key]))
|
|
60
|
+
if (isNullish(a) || isNullish(b)) return false
|
|
61
|
+
return areSameObjectType(a, b) && deepEqualObjects2(a, b)
|
|
44
62
|
}
|
|
45
63
|
|
|
46
64
|
/**
|
|
@@ -62,6 +80,28 @@ function deepEqual(a, b) {
|
|
|
62
80
|
* @property {boolean} [props.dirty] - Whether field value differs from initial
|
|
63
81
|
*/
|
|
64
82
|
|
|
83
|
+
/** Maps simple schema types to their input type string */
|
|
84
|
+
const SCHEMA_TYPE_MAP = { boolean: 'checkbox', array: 'array' }
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Update a nested data path and return the updated root object
|
|
88
|
+
* @private
|
|
89
|
+
* @param {Object} data - Current data
|
|
90
|
+
* @param {string[]} keys - Path segments
|
|
91
|
+
* @param {any} value - New value
|
|
92
|
+
* @returns {Object}
|
|
93
|
+
*/
|
|
94
|
+
function setNestedValue(data, keys, value) {
|
|
95
|
+
const updated = { ...data }
|
|
96
|
+
let current = updated
|
|
97
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
98
|
+
current[keys[i]] = { ...current[keys[i]] }
|
|
99
|
+
current = current[keys[i]]
|
|
100
|
+
}
|
|
101
|
+
current[keys[keys.length - 1]] = value
|
|
102
|
+
return updated
|
|
103
|
+
}
|
|
104
|
+
|
|
65
105
|
/**
|
|
66
106
|
* FormBuilder class for dynamically generating forms from data structures
|
|
67
107
|
*/
|
|
@@ -160,6 +200,17 @@ export class FormBuilder {
|
|
|
160
200
|
this.#validation = value
|
|
161
201
|
}
|
|
162
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Initialise the lookup manager if any lookups are provided
|
|
205
|
+
* @private
|
|
206
|
+
*/
|
|
207
|
+
#initLookups(lookups) {
|
|
208
|
+
this.#lookupConfigs = lookups
|
|
209
|
+
if (Object.keys(lookups).length > 0) {
|
|
210
|
+
this.#lookupManager = createLookupManager(lookups)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
163
214
|
/**
|
|
164
215
|
* Create a new FormBuilder instance
|
|
165
216
|
* @param {Object} [data={}] - Initial data object
|
|
@@ -172,10 +223,7 @@ export class FormBuilder {
|
|
|
172
223
|
this.#initialData = deepClone(data)
|
|
173
224
|
this.schema = schema
|
|
174
225
|
this.layout = layout
|
|
175
|
-
this.#
|
|
176
|
-
if (Object.keys(lookups).length > 0) {
|
|
177
|
-
this.#lookupManager = createLookupManager(lookups)
|
|
178
|
-
}
|
|
226
|
+
this.#initLookups(lookups)
|
|
179
227
|
}
|
|
180
228
|
|
|
181
229
|
/**
|
|
@@ -251,6 +299,20 @@ export class FormBuilder {
|
|
|
251
299
|
}
|
|
252
300
|
}
|
|
253
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Clear dependent field values for a changed path
|
|
304
|
+
* @private
|
|
305
|
+
*/
|
|
306
|
+
#clearDependentFields(path) {
|
|
307
|
+
for (const [depPath, lookup] of this.#lookupManager.lookups) {
|
|
308
|
+
if (!lookup.dependsOn.includes(path)) continue
|
|
309
|
+
const depKeys = depPath.split('/')
|
|
310
|
+
if (depKeys.length === 1) {
|
|
311
|
+
this.#data = { ...this.#data, [depKeys[0]]: null }
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
254
316
|
/**
|
|
255
317
|
* Update a specific field value
|
|
256
318
|
* @param {string} path - Field path (e.g., 'count', 'settings/distance')
|
|
@@ -258,20 +320,11 @@ export class FormBuilder {
|
|
|
258
320
|
* @param {boolean} [triggerLookups=true] - Whether to trigger dependent lookups
|
|
259
321
|
*/
|
|
260
322
|
updateField(path, value, triggerLookups = true) {
|
|
261
|
-
// Simple path handling for now - can be enhanced for nested objects
|
|
262
323
|
const keys = path.split('/')
|
|
263
324
|
if (keys.length === 1) {
|
|
264
325
|
this.#data = { ...this.#data, [keys[0]]: value }
|
|
265
326
|
} else {
|
|
266
|
-
|
|
267
|
-
const updatedData = { ...this.#data }
|
|
268
|
-
let current = updatedData
|
|
269
|
-
for (let i = 0; i < keys.length - 1; i++) {
|
|
270
|
-
current[keys[i]] = { ...current[keys[i]] }
|
|
271
|
-
current = current[keys[i]]
|
|
272
|
-
}
|
|
273
|
-
current[keys[keys.length - 1]] = value
|
|
274
|
-
this.#data = updatedData
|
|
327
|
+
this.#data = setNestedValue(this.#data, keys, value)
|
|
275
328
|
}
|
|
276
329
|
|
|
277
330
|
// Clear stale validation errors for fields that are now hidden
|
|
@@ -279,15 +332,7 @@ export class FormBuilder {
|
|
|
279
332
|
|
|
280
333
|
// Trigger dependent lookups if configured
|
|
281
334
|
if (triggerLookups && this.#lookupManager) {
|
|
282
|
-
|
|
283
|
-
for (const [depPath, lookup] of this.#lookupManager.lookups) {
|
|
284
|
-
if (lookup.dependsOn.includes(path)) {
|
|
285
|
-
const depKeys = depPath.split('/')
|
|
286
|
-
if (depKeys.length === 1) {
|
|
287
|
-
this.#data = { ...this.#data, [depKeys[0]]: null }
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
}
|
|
335
|
+
this.#clearDependentFields(path)
|
|
291
336
|
this.#lookupManager.handleFieldChange(path, this.#data)
|
|
292
337
|
}
|
|
293
338
|
}
|
|
@@ -317,7 +362,7 @@ export class FormBuilder {
|
|
|
317
362
|
* @private
|
|
318
363
|
*/
|
|
319
364
|
#clearHiddenValidation() {
|
|
320
|
-
const visiblePaths = new
|
|
365
|
+
const visiblePaths = new SvelteSet(
|
|
321
366
|
this.elements.filter((el) => el.scope).map((el) => el.scope.replace(/^#\//, ''))
|
|
322
367
|
)
|
|
323
368
|
const cleaned = Object.fromEntries(
|
|
@@ -328,6 +373,85 @@ export class FormBuilder {
|
|
|
328
373
|
}
|
|
329
374
|
}
|
|
330
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Build a display element from the layout entry
|
|
378
|
+
* @private
|
|
379
|
+
*/
|
|
380
|
+
#buildDisplayElement(layoutEl) {
|
|
381
|
+
const scope = layoutEl.scope ?? null
|
|
382
|
+
const fieldPath = scope?.replace(/^#\//, '')
|
|
383
|
+
const value = fieldPath ? this.getValue(fieldPath) : null
|
|
384
|
+
const { type: displayType, scope: _s, ...displayProps } = layoutEl
|
|
385
|
+
return { type: displayType, scope, value, override: false, props: displayProps }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Build a non-scoped separator/spacer element from the layout entry
|
|
390
|
+
* @private
|
|
391
|
+
*/
|
|
392
|
+
#buildSeparatorElement(layoutEl) {
|
|
393
|
+
const { type: separatorType, ...separatorProps } = layoutEl
|
|
394
|
+
return {
|
|
395
|
+
type: separatorType ?? 'separator',
|
|
396
|
+
scope: null,
|
|
397
|
+
value: null,
|
|
398
|
+
override: false,
|
|
399
|
+
props: separatorProps
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Process one scoped layout element — returns FormElement or null
|
|
405
|
+
* @private
|
|
406
|
+
*/
|
|
407
|
+
#processScopedElement(layoutEl, combinedMap) {
|
|
408
|
+
if (layoutEl.showWhen && !evaluateCondition(layoutEl.showWhen, this.#data)) return null
|
|
409
|
+
const key = layoutEl.scope.replace(/^#\//, '').split('/').pop()
|
|
410
|
+
const combinedEl = combinedMap.get(key)
|
|
411
|
+
return combinedEl ? this.#convertToFormElement(combinedEl) : null
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Build the combined element map from schema+layout
|
|
416
|
+
* @private
|
|
417
|
+
*/
|
|
418
|
+
#buildCombinedMap(layoutElements) {
|
|
419
|
+
const scopedElements = layoutElements.filter(
|
|
420
|
+
(el) => el.scope && !el.type?.startsWith('display-')
|
|
421
|
+
)
|
|
422
|
+
const scopedLayout = { ...this.#layout, elements: scopedElements }
|
|
423
|
+
const combined = getSchemaWithLayout(this.#schema, scopedLayout)
|
|
424
|
+
|
|
425
|
+
const combinedMap = new SvelteMap()
|
|
426
|
+
for (const el of combined.elements ?? []) {
|
|
427
|
+
if (el.key) combinedMap.set(el.key, el)
|
|
428
|
+
}
|
|
429
|
+
return combinedMap
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Convert one layout element to a form element (or null)
|
|
434
|
+
* @private
|
|
435
|
+
*/
|
|
436
|
+
#buildOneElement(layoutEl, combinedMap) {
|
|
437
|
+
if (layoutEl.type?.startsWith('display-')) return this.#buildDisplayElement(layoutEl)
|
|
438
|
+
if (!layoutEl.scope) return this.#buildSeparatorElement(layoutEl)
|
|
439
|
+
return this.#processScopedElement(layoutEl, combinedMap)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Collect form elements from layout into result array
|
|
444
|
+
* @private
|
|
445
|
+
*/
|
|
446
|
+
#collectElements(layoutElements, combinedMap) {
|
|
447
|
+
const result = []
|
|
448
|
+
for (const layoutEl of layoutElements) {
|
|
449
|
+
const formEl = this.#buildOneElement(layoutEl, combinedMap)
|
|
450
|
+
if (formEl) result.push(formEl)
|
|
451
|
+
}
|
|
452
|
+
return result
|
|
453
|
+
}
|
|
454
|
+
|
|
331
455
|
/**
|
|
332
456
|
* Build form elements from schema and layout using getSchemaWithLayout
|
|
333
457
|
* @private
|
|
@@ -335,65 +459,10 @@ export class FormBuilder {
|
|
|
335
459
|
*/
|
|
336
460
|
#buildElements() {
|
|
337
461
|
try {
|
|
338
|
-
const result = []
|
|
339
462
|
const layoutElements = this.#layout?.elements ?? []
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const scopedElements = layoutElements.filter(
|
|
343
|
-
(el) => el.scope && !el.type?.startsWith('display-')
|
|
344
|
-
)
|
|
345
|
-
const scopedLayout = { ...this.#layout, elements: scopedElements }
|
|
346
|
-
const combined = getSchemaWithLayout(this.#schema, scopedLayout)
|
|
347
|
-
|
|
348
|
-
// Build a map of combined elements by key for lookup
|
|
349
|
-
const combinedMap = new Map()
|
|
350
|
-
for (const el of combined.elements ?? []) {
|
|
351
|
-
if (el.key) combinedMap.set(el.key, el)
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// Iterate original layout order to preserve separators and other non-scoped elements
|
|
355
|
-
for (const layoutEl of layoutElements) {
|
|
356
|
-
if (layoutEl.type?.startsWith('display-')) {
|
|
357
|
-
// Display element — resolve data from scope if present
|
|
358
|
-
const scope = layoutEl.scope ?? null
|
|
359
|
-
const fieldPath = scope?.replace(/^#\//, '')
|
|
360
|
-
const value = fieldPath ? this.getValue(fieldPath) : null
|
|
361
|
-
const { type: displayType, scope: _s, ...displayProps } = layoutEl
|
|
362
|
-
result.push({
|
|
363
|
-
type: displayType,
|
|
364
|
-
scope,
|
|
365
|
-
value,
|
|
366
|
-
override: false,
|
|
367
|
-
props: displayProps
|
|
368
|
-
})
|
|
369
|
-
} else if (!layoutEl.scope) {
|
|
370
|
-
// Non-scoped element (separator, etc.)
|
|
371
|
-
const { type: separatorType, ...separatorProps } = layoutEl
|
|
372
|
-
result.push({
|
|
373
|
-
type: separatorType ?? 'separator',
|
|
374
|
-
scope: null,
|
|
375
|
-
value: null,
|
|
376
|
-
override: false,
|
|
377
|
-
props: separatorProps
|
|
378
|
-
})
|
|
379
|
-
} else {
|
|
380
|
-
// Check showWhen condition before processing scoped field
|
|
381
|
-
if (layoutEl.showWhen && !evaluateCondition(layoutEl.showWhen, this.#data)) {
|
|
382
|
-
continue
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Extract key from scope
|
|
386
|
-
const key = layoutEl.scope.replace(/^#\//, '').split('/').pop()
|
|
387
|
-
const combinedEl = combinedMap.get(key)
|
|
388
|
-
if (combinedEl) {
|
|
389
|
-
const formEl = this.#convertToFormElement(combinedEl)
|
|
390
|
-
if (formEl) result.push(formEl)
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
return result
|
|
463
|
+
const combinedMap = this.#buildCombinedMap(layoutElements)
|
|
464
|
+
return this.#collectElements(layoutElements, combinedMap)
|
|
395
465
|
} catch (error) {
|
|
396
|
-
// If getSchemaWithLayout fails, fall back to basic element creation
|
|
397
466
|
console.warn('Failed to build elements:', error) // eslint-disable-line no-console
|
|
398
467
|
return this.#buildBasicElements()
|
|
399
468
|
}
|
|
@@ -456,122 +525,141 @@ export class FormBuilder {
|
|
|
456
525
|
}
|
|
457
526
|
|
|
458
527
|
/**
|
|
459
|
-
* Convert a
|
|
528
|
+
* Convert a nested (group) element to FormElement format
|
|
460
529
|
* @private
|
|
461
|
-
* @param {Object} element - Combined element from getSchemaWithLayout
|
|
462
|
-
* @param {string} parentPath - Parent path for nested elements
|
|
463
|
-
* @returns {FormElement} Form element
|
|
464
530
|
*/
|
|
465
|
-
#
|
|
466
|
-
const
|
|
531
|
+
#convertNestedElement(element, fieldPath, scope, value) {
|
|
532
|
+
const nestedElements = element.elements.map((child) =>
|
|
533
|
+
this.#convertToFormElement(child, fieldPath)
|
|
534
|
+
)
|
|
535
|
+
const { key: _k, elements: _e, override: _o, props: groupProps, ...topLevelProps } = element
|
|
536
|
+
return {
|
|
537
|
+
scope,
|
|
538
|
+
type: 'group',
|
|
539
|
+
value,
|
|
540
|
+
override: element.override || false,
|
|
541
|
+
props: {
|
|
542
|
+
...topLevelProps,
|
|
543
|
+
...groupProps,
|
|
544
|
+
elements: nestedElements,
|
|
545
|
+
message: this.#validation[fieldPath] || null
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
467
549
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
550
|
+
/**
|
|
551
|
+
* Convert a readonly element to FormElement format
|
|
552
|
+
* @private
|
|
553
|
+
*/
|
|
554
|
+
#convertReadonlyElement(element, fieldPath, scope, value) {
|
|
555
|
+
const validationMessage = this.#validation[fieldPath] || null
|
|
556
|
+
return {
|
|
557
|
+
scope,
|
|
558
|
+
type: 'info',
|
|
559
|
+
value,
|
|
560
|
+
override: element.override || false,
|
|
561
|
+
props: { ...element.props, type: 'info', message: validationMessage }
|
|
471
562
|
}
|
|
563
|
+
}
|
|
472
564
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
565
|
+
/**
|
|
566
|
+
* Resolve number input type (range when both min and max are set, otherwise number)
|
|
567
|
+
* @private
|
|
568
|
+
*/
|
|
569
|
+
#resolveNumberType(props) {
|
|
570
|
+
return props.min !== undefined && props.max !== undefined ? 'range' : 'number'
|
|
571
|
+
}
|
|
477
572
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
return {
|
|
489
|
-
scope,
|
|
490
|
-
type: 'group',
|
|
491
|
-
value,
|
|
492
|
-
override: element.override || false,
|
|
493
|
-
props: {
|
|
494
|
-
...topLevelProps,
|
|
495
|
-
...groupProps,
|
|
496
|
-
elements: nestedElements,
|
|
497
|
-
message: this.#validation[fieldPath] || null
|
|
498
|
-
}
|
|
573
|
+
/**
|
|
574
|
+
* Resolve input type for string schema type
|
|
575
|
+
* @private
|
|
576
|
+
*/
|
|
577
|
+
#resolveStringType(props) {
|
|
578
|
+
if (props.enum || props.options) {
|
|
579
|
+
// Map enum values to options format expected by select inputs
|
|
580
|
+
if (Array.isArray(props.enum) && !props.options) {
|
|
581
|
+
props.options = props.enum
|
|
499
582
|
}
|
|
583
|
+
return 'select'
|
|
500
584
|
}
|
|
585
|
+
return 'text'
|
|
586
|
+
}
|
|
501
587
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
}
|
|
512
|
-
}
|
|
588
|
+
/**
|
|
589
|
+
* Resolve input type from schema type field
|
|
590
|
+
* @private
|
|
591
|
+
*/
|
|
592
|
+
#resolveTypeFromSchema(props) {
|
|
593
|
+
if (props.type === 'number' || props.type === 'integer') return this.#resolveNumberType(props)
|
|
594
|
+
if (props.type === 'string') return this.#resolveStringType(props)
|
|
595
|
+
return SCHEMA_TYPE_MAP[props.type] ?? 'text'
|
|
596
|
+
}
|
|
513
597
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
case 'integer':
|
|
526
|
-
type = props.min !== undefined && props.max !== undefined ? 'range' : 'number'
|
|
527
|
-
break
|
|
528
|
-
case 'boolean':
|
|
529
|
-
type = 'checkbox'
|
|
530
|
-
break
|
|
531
|
-
case 'string':
|
|
532
|
-
if (props.enum || props.options) {
|
|
533
|
-
type = 'select'
|
|
534
|
-
// Map enum values to options format expected by select inputs
|
|
535
|
-
if (Array.isArray(props.enum) && !props.options) {
|
|
536
|
-
props.options = props.enum
|
|
537
|
-
}
|
|
538
|
-
} else {
|
|
539
|
-
type = 'text'
|
|
540
|
-
}
|
|
541
|
-
break
|
|
542
|
-
case 'array':
|
|
543
|
-
type = 'array'
|
|
544
|
-
break
|
|
545
|
-
}
|
|
546
|
-
}
|
|
598
|
+
/**
|
|
599
|
+
* Resolve the input type from element props
|
|
600
|
+
* @private
|
|
601
|
+
* @param {Object} props - Element props
|
|
602
|
+
* @returns {string}
|
|
603
|
+
*/
|
|
604
|
+
#resolveInputType(props) {
|
|
605
|
+
if (props.renderer) return props.renderer
|
|
606
|
+
if (props.format && !['text', 'number'].includes(props.format)) return props.format
|
|
607
|
+
return this.#resolveTypeFromSchema(props)
|
|
608
|
+
}
|
|
547
609
|
|
|
548
|
-
|
|
549
|
-
|
|
610
|
+
/**
|
|
611
|
+
* Apply lookup state into finalProps (mutates finalProps)
|
|
612
|
+
* @private
|
|
613
|
+
*/
|
|
614
|
+
#applyLookupState(fieldPath, finalProps) {
|
|
615
|
+
const lookupState = this.getLookupState(fieldPath)
|
|
616
|
+
if (!lookupState) return
|
|
617
|
+
applyLookupProps(lookupState, finalProps)
|
|
618
|
+
}
|
|
550
619
|
|
|
551
|
-
|
|
620
|
+
/**
|
|
621
|
+
* Build a standard (non-nested, non-readonly) form element
|
|
622
|
+
* @private
|
|
623
|
+
*/
|
|
624
|
+
#buildStandardElement(element, fieldPath, scope, value) {
|
|
625
|
+
const { props } = element
|
|
626
|
+
const type = this.#resolveInputType(props)
|
|
552
627
|
const finalProps = {
|
|
553
628
|
...props,
|
|
554
629
|
type,
|
|
555
|
-
message:
|
|
630
|
+
message: this.#validation[fieldPath] || null,
|
|
556
631
|
dirty: this.isFieldDirty(fieldPath)
|
|
557
632
|
}
|
|
633
|
+
this.#applyLookupState(fieldPath, finalProps)
|
|
634
|
+
return { scope, type, value, override: element.override || false, props: finalProps }
|
|
635
|
+
}
|
|
558
636
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
}
|
|
637
|
+
/**
|
|
638
|
+
* Resolve the field path for an element
|
|
639
|
+
* @private
|
|
640
|
+
*/
|
|
641
|
+
#resolveFieldPath(key, parentPath) {
|
|
642
|
+
return parentPath ? `${parentPath}/${key}` : key
|
|
643
|
+
}
|
|
567
644
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
645
|
+
/**
|
|
646
|
+
* Convert a combined schema/layout element to FormElement format
|
|
647
|
+
* @private
|
|
648
|
+
* @param {Object} element - Combined element from getSchemaWithLayout
|
|
649
|
+
* @param {string} parentPath - Parent path for nested elements
|
|
650
|
+
* @returns {FormElement} Form element
|
|
651
|
+
*/
|
|
652
|
+
#convertToFormElement(element, parentPath = '') {
|
|
653
|
+
const { key } = element
|
|
654
|
+
if (!key) return null
|
|
655
|
+
|
|
656
|
+
const fieldPath = this.#resolveFieldPath(key, parentPath)
|
|
657
|
+
const scope = `#/${fieldPath}`
|
|
658
|
+
const value = this.getValue(fieldPath)
|
|
659
|
+
|
|
660
|
+
if (element.elements) return this.#convertNestedElement(element, fieldPath, scope, value)
|
|
661
|
+
if (element.props.readonly) return this.#convertReadonlyElement(element, fieldPath, scope, value)
|
|
662
|
+
return this.#buildStandardElement(element, fieldPath, scope, value)
|
|
575
663
|
}
|
|
576
664
|
|
|
577
665
|
/**
|
|
@@ -620,7 +708,7 @@ export class FormBuilder {
|
|
|
620
708
|
*/
|
|
621
709
|
validate() {
|
|
622
710
|
const results = validateAllFields(this.#data, this.#schema, this.#layout)
|
|
623
|
-
const visiblePaths = new
|
|
711
|
+
const visiblePaths = new SvelteSet(
|
|
624
712
|
this.elements.filter((el) => el.scope).map((el) => el.scope.replace(/^#\//, ''))
|
|
625
713
|
)
|
|
626
714
|
const filtered = Object.fromEntries(
|
|
@@ -637,7 +725,7 @@ export class FormBuilder {
|
|
|
637
725
|
* @returns {Object} Filtered data containing only visible field keys
|
|
638
726
|
*/
|
|
639
727
|
getVisibleData() {
|
|
640
|
-
const visiblePaths = new
|
|
728
|
+
const visiblePaths = new SvelteSet(
|
|
641
729
|
this.elements
|
|
642
730
|
.filter((el) => el.scope)
|
|
643
731
|
.map((el) => el.scope.replace(/^#\//, ''))
|
|
@@ -746,28 +834,17 @@ export class FormBuilder {
|
|
|
746
834
|
* @param {Set<string>} dirty - Accumulator set
|
|
747
835
|
*/
|
|
748
836
|
#collectDirtyFields(current, initial, prefix, dirty) {
|
|
749
|
-
const allKeys = new
|
|
750
|
-
|
|
837
|
+
const allKeys = new SvelteSet([...Object.keys(current ?? {}), ...Object.keys(initial ?? {})])
|
|
751
838
|
for (const key of allKeys) {
|
|
752
|
-
|
|
753
|
-
const curVal = current?.[key]
|
|
754
|
-
const initVal = initial?.[key]
|
|
755
|
-
|
|
756
|
-
if (!deepEqual(curVal, initVal)) {
|
|
757
|
-
dirty.add(path)
|
|
758
|
-
}
|
|
839
|
+
collectIfDirty({ current, initial, prefix, key, dirty })
|
|
759
840
|
}
|
|
760
841
|
}
|
|
761
842
|
|
|
762
843
|
/**
|
|
763
|
-
*
|
|
844
|
+
* Walk schema properties following the key path
|
|
764
845
|
* @private
|
|
765
|
-
* @param {string} fieldPath - Field path
|
|
766
|
-
* @returns {Object|null} Field schema
|
|
767
846
|
*/
|
|
768
|
-
#
|
|
769
|
-
if (!this.#schema?.properties) return null
|
|
770
|
-
const keys = fieldPath.split('/')
|
|
847
|
+
#walkSchemaPath(keys) {
|
|
771
848
|
let current = this.#schema.properties
|
|
772
849
|
for (const key of keys) {
|
|
773
850
|
if (current && current[key]) {
|
|
@@ -779,6 +856,17 @@ export class FormBuilder {
|
|
|
779
856
|
return current
|
|
780
857
|
}
|
|
781
858
|
|
|
859
|
+
/**
|
|
860
|
+
* Get schema definition for a field path
|
|
861
|
+
* @private
|
|
862
|
+
* @param {string} fieldPath - Field path
|
|
863
|
+
* @returns {Object|null} Field schema
|
|
864
|
+
*/
|
|
865
|
+
#getFieldSchema(fieldPath) {
|
|
866
|
+
if (!this.#schema?.properties) return null
|
|
867
|
+
return this.#walkSchemaPath(fieldPath.split('/'))
|
|
868
|
+
}
|
|
869
|
+
|
|
782
870
|
/**
|
|
783
871
|
* Get label for a field from layout
|
|
784
872
|
* @private
|
|
@@ -788,7 +876,7 @@ export class FormBuilder {
|
|
|
788
876
|
#getFieldLabel(fieldPath) {
|
|
789
877
|
const scope = `#/${fieldPath}`
|
|
790
878
|
const layoutEl = this.#layout?.elements?.find((el) => el.scope === scope)
|
|
791
|
-
return layoutEl
|
|
879
|
+
return layoutElLabel(layoutEl, fieldPath)
|
|
792
880
|
}
|
|
793
881
|
|
|
794
882
|
/**
|
|
@@ -799,3 +887,51 @@ export class FormBuilder {
|
|
|
799
887
|
this.#validation = {}
|
|
800
888
|
}
|
|
801
889
|
}
|
|
890
|
+
|
|
891
|
+
// ── Module-level helpers (no `this`) ──────────────────────────────────────────
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Return the display label for a layout element, falling back to fieldPath
|
|
895
|
+
* @private
|
|
896
|
+
*/
|
|
897
|
+
function layoutElLabel(layoutEl, fieldPath) {
|
|
898
|
+
if (layoutEl?.label) return layoutEl.label
|
|
899
|
+
return layoutEl?.title ?? fieldPath
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Apply options/loading state from lookup to finalProps
|
|
904
|
+
* @private
|
|
905
|
+
*/
|
|
906
|
+
function applyLookupData(lookupState, finalProps) {
|
|
907
|
+
if (lookupState.options?.length > 0) finalProps.options = lookupState.options
|
|
908
|
+
if (lookupState.loading) finalProps.loading = true
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Apply disabled/fields state from lookup to finalProps
|
|
913
|
+
* @private
|
|
914
|
+
*/
|
|
915
|
+
function applyLookupMeta(lookupState, finalProps) {
|
|
916
|
+
if (lookupState.disabled) finalProps.disabled = true
|
|
917
|
+
if (lookupState.fields && !finalProps.fields) finalProps.fields = lookupState.fields
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Merge all lookup properties into finalProps
|
|
922
|
+
* @private
|
|
923
|
+
*/
|
|
924
|
+
function applyLookupProps(lookupState, finalProps) {
|
|
925
|
+
applyLookupData(lookupState, finalProps)
|
|
926
|
+
applyLookupMeta(lookupState, finalProps)
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Add path to dirty set if current and initial values differ
|
|
931
|
+
* @private
|
|
932
|
+
* @param {{ current: any, initial: any, prefix: string, key: string, dirty: Set<string> }} ctx
|
|
933
|
+
*/
|
|
934
|
+
function collectIfDirty({ current, initial, prefix, key, dirty }) {
|
|
935
|
+
const path = prefix ? `${prefix}/${key}` : key
|
|
936
|
+
if (!deepEqual(current?.[key], initial?.[key])) dirty.add(path)
|
|
937
|
+
}
|