@rokkit/forms 1.0.0-next.125 → 1.0.0-next.128

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.
Files changed (90) hide show
  1. package/README.md +251 -0
  2. package/dist/src/display/index.d.ts +5 -0
  3. package/dist/src/index.d.ts +9 -0
  4. package/dist/src/input/index.d.ts +3 -0
  5. package/dist/src/lib/builder.svelte.d.ts +114 -4
  6. package/dist/src/lib/lookup.svelte.d.ts +87 -0
  7. package/dist/src/lib/renderers.d.ts +23 -0
  8. package/package.json +6 -4
  9. package/src/FieldLayout.svelte +4 -11
  10. package/src/FormRenderer.svelte +202 -61
  11. package/src/InfoField.svelte +26 -0
  12. package/src/Input.svelte +17 -61
  13. package/src/InputField.svelte +15 -11
  14. package/src/ValidationReport.svelte +52 -0
  15. package/src/display/DisplayCardGrid.svelte +68 -0
  16. package/src/display/DisplayList.svelte +31 -0
  17. package/src/display/DisplaySection.svelte +20 -0
  18. package/src/display/DisplayTable.svelte +68 -0
  19. package/src/display/DisplayValue.svelte +44 -0
  20. package/src/display/index.js +5 -0
  21. package/src/index.js +14 -0
  22. package/src/input/ArrayEditor.svelte +108 -0
  23. package/src/input/InputCheckbox.svelte +4 -5
  24. package/src/input/InputColor.svelte +6 -1
  25. package/src/input/InputDate.svelte +6 -1
  26. package/src/input/InputDateTime.svelte +6 -1
  27. package/src/input/InputEmail.svelte +6 -1
  28. package/src/input/InputFile.svelte +6 -2
  29. package/src/input/InputMonth.svelte +6 -1
  30. package/src/input/InputNumber.svelte +6 -1
  31. package/src/input/InputPassword.svelte +6 -1
  32. package/src/input/InputRadio.svelte +3 -3
  33. package/src/input/InputRange.svelte +6 -1
  34. package/src/input/InputSelect.svelte +31 -53
  35. package/src/input/InputSwitch.svelte +4 -15
  36. package/src/input/InputTel.svelte +6 -1
  37. package/src/input/InputText.svelte +6 -1
  38. package/src/input/InputTextArea.svelte +6 -1
  39. package/src/input/InputTime.svelte +6 -1
  40. package/src/input/InputToggle.svelte +28 -0
  41. package/src/input/InputUrl.svelte +6 -1
  42. package/src/input/InputWeek.svelte +6 -1
  43. package/src/input/index.js +3 -1
  44. package/src/lib/Input.svelte +3 -3
  45. package/src/lib/builder.svelte.js +425 -30
  46. package/src/lib/fields.js +2 -2
  47. package/src/lib/layout.js +2 -2
  48. package/src/lib/lookup.svelte.js +334 -0
  49. package/src/lib/renderers.js +83 -0
  50. package/src/lib/schema.js +1 -1
  51. package/src/types.js +0 -9
  52. package/dist/src/forms-old/input/types.d.ts +0 -7
  53. package/dist/src/forms-old/lib/form.d.ts +0 -95
  54. package/dist/src/forms-old/lib/index.d.ts +0 -1
  55. package/dist/src/lib/deprecated/nested.d.ts +0 -48
  56. package/dist/src/lib/deprecated/nested.spec.d.ts +0 -1
  57. package/dist/src/lib/deprecated/validator.d.ts +0 -30
  58. package/dist/src/lib/deprecated/validator.spec.d.ts +0 -1
  59. package/src/DataEditor.svelte +0 -30
  60. package/src/ListEditor.svelte +0 -44
  61. package/src/NestedEditor.svelte +0 -85
  62. package/src/forms-old/CheckBox.svelte +0 -56
  63. package/src/forms-old/DataEditor.svelte +0 -30
  64. package/src/forms-old/FieldLayout.svelte +0 -48
  65. package/src/forms-old/Form.svelte +0 -17
  66. package/src/forms-old/Icon.svelte +0 -76
  67. package/src/forms-old/Item.svelte +0 -25
  68. package/src/forms-old/ListEditor.svelte +0 -44
  69. package/src/forms-old/Tabs.svelte +0 -57
  70. package/src/forms-old/Wrapper.svelte +0 -12
  71. package/src/forms-old/input/Input.svelte +0 -17
  72. package/src/forms-old/input/InputField.svelte +0 -70
  73. package/src/forms-old/input/InputSelect.svelte +0 -23
  74. package/src/forms-old/input/InputSwitch.svelte +0 -19
  75. package/src/forms-old/input/types.js +0 -29
  76. package/src/forms-old/lib/form.js +0 -72
  77. package/src/forms-old/lib/index.js +0 -12
  78. package/src/forms-old/mocks/CustomField.svelte +0 -7
  79. package/src/forms-old/mocks/CustomWrapper.svelte +0 -8
  80. package/src/forms-old/mocks/Register.svelte +0 -25
  81. package/src/inp/Input.svelte +0 -17
  82. package/src/inp/InputField.svelte +0 -69
  83. package/src/inp/InputSelect.svelte +0 -23
  84. package/src/inp/InputSwitch.svelte +0 -19
  85. package/src/lib/deprecated/Form.svelte +0 -17
  86. package/src/lib/deprecated/FormRenderer.svelte +0 -121
  87. package/src/lib/deprecated/nested.js +0 -192
  88. package/src/lib/deprecated/nested.spec.js +0 -512
  89. package/src/lib/deprecated/validator.js +0 -137
  90. package/src/lib/deprecated/validator.spec.js +0 -348
@@ -1,7 +1,43 @@
1
1
  import { deriveSchemaFromValue } from './schema.js'
2
2
  import { deriveLayoutFromValue } from './layout.js'
3
3
  import { getSchemaWithLayout } from './fields.js'
4
- import { omit } from 'ramda'
4
+ import { createLookupManager } from './lookup.svelte.js'
5
+ import { validateField as validateFieldValue, validateAll as validateAllFields } from './validation.js'
6
+
7
+ /**
8
+ * Deep-clone a plain value (primitives, plain objects, arrays).
9
+ * Uses JSON round-trip which handles $state proxies safely.
10
+ * @param {any} value
11
+ * @returns {any}
12
+ */
13
+ function deepClone(value) {
14
+ if (value === null || value === undefined || typeof value !== 'object') return value
15
+ return JSON.parse(JSON.stringify(value))
16
+ }
17
+
18
+ /**
19
+ * Deep equality check for plain values (primitives, plain objects, arrays).
20
+ * @param {any} a
21
+ * @param {any} b
22
+ * @returns {boolean}
23
+ */
24
+ function deepEqual(a, b) {
25
+ if (a === b) return true
26
+ if (a === null || a === undefined || b === null || b === undefined) return a === b
27
+ if (typeof a !== typeof b) return false
28
+ if (typeof a !== 'object') return false
29
+ if (Array.isArray(a) !== Array.isArray(b)) return false
30
+
31
+ if (Array.isArray(a)) {
32
+ if (a.length !== b.length) return false
33
+ return a.every((val, i) => deepEqual(val, b[i]))
34
+ }
35
+
36
+ const keysA = Object.keys(a)
37
+ const keysB = Object.keys(b)
38
+ if (keysA.length !== keysB.length) return false
39
+ return keysA.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key]))
40
+ }
5
41
 
6
42
  /**
7
43
  * @typedef {Object} FormElement
@@ -19,6 +55,7 @@ import { omit } from 'ramda'
19
55
  * @property {Object} [props.message] - Validation message object
20
56
  * @property {string} [props.message.state] - Message state: 'error', 'warning', 'info', 'success'
21
57
  * @property {string} [props.message.text] - Message text content
58
+ * @property {boolean} [props.dirty] - Whether field value differs from initial
22
59
  */
23
60
 
24
61
  /**
@@ -28,6 +65,9 @@ export class FormBuilder {
28
65
  /** @type {Object} */
29
66
  #data = $state({})
30
67
 
68
+ /** @type {Object} - Snapshot of data at construction (or last snapshot()) */
69
+ #initialData = {}
70
+
31
71
  /** @type {Object} */
32
72
  #schema = $state({})
33
73
 
@@ -37,9 +77,21 @@ export class FormBuilder {
37
77
  /** @type {Object} */
38
78
  #validation = $state({})
39
79
 
80
+ /** @type {Object<string, import('./lookup.svelte.js').LookupConfig>} */
81
+ #lookupConfigs = $state({})
82
+
83
+ /** @type {ReturnType<typeof createLookupManager>|null} */
84
+ #lookupManager = $state(null)
85
+
40
86
  /** @type {FormElement[]} */
41
87
  elements = $derived(this.#buildElements())
42
- combined = $derived(getSchemaWithLayout(this.schema, this.layout))
88
+
89
+ /** Combined schema+layout (scoped elements only) */
90
+ get combined() {
91
+ const scopedElements = (this.#layout?.elements ?? []).filter((el) => el.scope)
92
+ const scopedLayout = { ...this.#layout, elements: scopedElements }
93
+ return getSchemaWithLayout(this.#schema, scopedLayout)
94
+ }
43
95
  /**
44
96
  * Get the current data
45
97
  * @returns {Object} Current data object
@@ -109,19 +161,99 @@ export class FormBuilder {
109
161
  * @param {Object} [data={}] - Initial data object
110
162
  * @param {Object|null} [schema=null] - Optional schema override
111
163
  * @param {Object|null} [layout=null] - Optional layout override
164
+ * @param {Object<string, import('./lookup.svelte.js').LookupConfig>} [lookups={}] - Lookup configurations
112
165
  */
113
- constructor(data = {}, schema = null, layout = null) {
166
+ constructor(data = {}, schema = null, layout = null, lookups = {}) {
114
167
  this.#data = data
168
+ this.#initialData = deepClone(data)
115
169
  this.schema = schema
116
170
  this.layout = layout
171
+ this.#lookupConfigs = lookups
172
+ if (Object.keys(lookups).length > 0) {
173
+ this.#lookupManager = createLookupManager(lookups)
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Get the lookup manager
179
+ * @returns {ReturnType<typeof createLookupManager>|null}
180
+ */
181
+ get lookupManager() {
182
+ return this.#lookupManager
183
+ }
184
+
185
+ /**
186
+ * Configure lookups for the form
187
+ * @param {Object<string, import('./lookup.svelte.js').LookupConfig>} lookups - Lookup configurations
188
+ */
189
+ setLookups(lookups) {
190
+ this.#lookupConfigs = lookups
191
+ this.#lookupManager = createLookupManager(lookups)
192
+ }
193
+
194
+ /**
195
+ * Get lookup state for a field
196
+ * @param {string} fieldPath - Field path
197
+ * @returns {{ options: any[], loading: boolean, error: string|null, fields: Object, disabled: boolean }|null}
198
+ */
199
+ getLookupState(fieldPath) {
200
+ if (!this.#lookupManager) return null
201
+ const lookup = this.#lookupManager.getLookup(fieldPath)
202
+ if (!lookup) return null
203
+ return {
204
+ options: lookup.options,
205
+ loading: lookup.loading,
206
+ error: lookup.error,
207
+ fields: lookup.fields,
208
+ disabled: lookup.disabled
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Check if a field is disabled due to unmet lookup dependencies
214
+ * @param {string} path - Field path
215
+ * @returns {boolean}
216
+ */
217
+ isFieldDisabled(path) {
218
+ return this.#lookupManager?.getLookup(path)?.disabled ?? false
219
+ }
220
+
221
+ /**
222
+ * Manually refresh a field's lookup with the current form data
223
+ * @param {string} path - Field path
224
+ * @returns {Promise<void>}
225
+ */
226
+ async refreshLookup(path) {
227
+ const lookup = this.#lookupManager?.getLookup(path)
228
+ if (lookup) await lookup.fetch(this.#data)
229
+ }
230
+
231
+ /**
232
+ * Check if a field has a lookup configured
233
+ * @param {string} fieldPath - Field path
234
+ * @returns {boolean}
235
+ */
236
+ hasLookup(fieldPath) {
237
+ return this.#lookupManager?.hasLookup(fieldPath) ?? false
238
+ }
239
+
240
+ /**
241
+ * Initialize all lookups
242
+ * @returns {Promise<void>}
243
+ */
244
+ async initializeLookups() {
245
+ if (this.#lookupManager) {
246
+ await this.#lookupManager.initialize(this.#data)
247
+ }
117
248
  }
118
249
 
119
250
  /**
120
251
  * Update a specific field value
121
252
  * @param {string} path - Field path (e.g., 'count', 'settings/distance')
122
253
  * @param {any} value - New value
254
+ * @param {boolean} [triggerLookups=true] - Whether to trigger dependent lookups
123
255
  */
124
- updateField(path, value) {
256
+ updateField(path, value, triggerLookups = true) {
125
257
  // Simple path handling for now - can be enhanced for nested objects
126
258
  const keys = path.split('/')
127
259
  if (keys.length === 1) {
@@ -137,6 +269,20 @@ export class FormBuilder {
137
269
  current[keys[keys.length - 1]] = value
138
270
  this.#data = updatedData
139
271
  }
272
+
273
+ // Trigger dependent lookups if configured
274
+ if (triggerLookups && this.#lookupManager) {
275
+ // Clear dependent field values synchronously before lookup re-fetch
276
+ for (const [depPath, lookup] of this.#lookupManager.lookups) {
277
+ if (lookup.dependsOn.includes(path)) {
278
+ const depKeys = depPath.split('/')
279
+ if (depKeys.length === 1) {
280
+ this.#data = { ...this.#data, [depKeys[0]]: null }
281
+ }
282
+ }
283
+ }
284
+ this.#lookupManager.handleFieldChange(path, this.#data)
285
+ }
140
286
  }
141
287
 
142
288
  /**
@@ -165,20 +311,62 @@ export class FormBuilder {
165
311
  * @returns {FormElement[]} Array of form elements
166
312
  */
167
313
  #buildElements() {
168
- // if (!this.#schema || !this.#layout || !this.#layout.elements) {
169
- // return []
170
- // }
171
-
172
314
  try {
173
- // Use getSchemaWithLayout to combine schema and layout
174
- // const combined = getSchemaWithLayout(this.#schema, this.#layout)
175
- // Convert combined elements to FormElement format
176
- return this.combined.elements
177
- .map((element) => this.#convertToFormElement(element))
178
- .filter((element) => element !== null)
315
+ const result = []
316
+ const layoutElements = this.#layout?.elements ?? []
317
+ // Track which layout elements have scopes (for schema merge)
318
+ // Exclude display-* elements — they are handled separately
319
+ const scopedElements = layoutElements.filter(
320
+ (el) => el.scope && !el.type?.startsWith('display-')
321
+ )
322
+ const scopedLayout = { ...this.#layout, elements: scopedElements }
323
+ const combined = getSchemaWithLayout(this.#schema, scopedLayout)
324
+
325
+ // Build a map of combined elements by key for lookup
326
+ const combinedMap = new Map()
327
+ for (const el of combined.elements ?? []) {
328
+ if (el.key) combinedMap.set(el.key, el)
329
+ }
330
+
331
+ // Iterate original layout order to preserve separators and other non-scoped elements
332
+ for (const layoutEl of layoutElements) {
333
+ if (layoutEl.type?.startsWith('display-')) {
334
+ // Display element — resolve data from scope if present
335
+ const scope = layoutEl.scope ?? null
336
+ const fieldPath = scope?.replace(/^#\//, '')
337
+ const value = fieldPath ? this.getValue(fieldPath) : null
338
+ const { type: displayType, scope: _s, ...displayProps } = layoutEl
339
+ result.push({
340
+ type: displayType,
341
+ scope,
342
+ value,
343
+ override: false,
344
+ props: displayProps
345
+ })
346
+ } else if (!layoutEl.scope) {
347
+ // Non-scoped element (separator, etc.)
348
+ const { type: separatorType, ...separatorProps } = layoutEl
349
+ result.push({
350
+ type: separatorType ?? 'separator',
351
+ scope: null,
352
+ value: null,
353
+ override: false,
354
+ props: separatorProps
355
+ })
356
+ } else {
357
+ // Extract key from scope
358
+ const key = layoutEl.scope.replace(/^#\//, '').split('/').pop()
359
+ const combinedEl = combinedMap.get(key)
360
+ if (combinedEl) {
361
+ const formEl = this.#convertToFormElement(combinedEl)
362
+ if (formEl) result.push(formEl)
363
+ }
364
+ }
365
+ }
366
+ return result
179
367
  } catch (error) {
180
368
  // If getSchemaWithLayout fails, fall back to basic element creation
181
- console.warn('Failed to build elements:', error)
369
+ console.warn('Failed to build elements:', error) // eslint-disable-line no-console
182
370
  return this.#buildBasicElements()
183
371
  }
184
372
  }
@@ -226,12 +414,13 @@ export class FormBuilder {
226
414
  label: label || fieldPath,
227
415
  ...layoutProps,
228
416
  message: this.#validation[fieldPath] || null,
417
+ dirty: this.isFieldDirty(fieldPath),
229
418
  type
230
419
  }
231
420
 
232
421
  return {
233
422
  scope,
234
- // type,
423
+ type,
235
424
  value,
236
425
  override,
237
426
  props
@@ -265,22 +454,45 @@ export class FormBuilder {
265
454
  this.#convertToFormElement(child, fieldPath)
266
455
  )
267
456
 
457
+ // Group elements have top-level properties (label, etc.) from combineNestedElementsWithSchema
458
+ const { key: _k, elements: _e, override: _o, props: groupProps, ...topLevelProps } =
459
+ element
460
+
268
461
  return {
269
462
  scope,
270
463
  type: 'group',
271
464
  value,
272
465
  override: element.override || false,
273
466
  props: {
274
- ...props,
467
+ ...topLevelProps,
468
+ ...groupProps,
275
469
  elements: nestedElements,
276
470
  message: this.#validation[fieldPath] || null
277
471
  }
278
472
  }
279
473
  }
280
474
 
281
- // Determine input type based on schema type
475
+ // Readonly fields render as info display
476
+ if (props.readonly) {
477
+ const validationMessage = this.#validation[fieldPath] || null
478
+ return {
479
+ scope,
480
+ type: 'info',
481
+ value,
482
+ override: element.override || false,
483
+ props: { ...props, type: 'info', message: validationMessage }
484
+ }
485
+ }
486
+
487
+ // Determine input type — renderer hint takes priority
282
488
  let type = 'text'
283
- if (props.type) {
489
+ if (props.renderer) {
490
+ // Explicit renderer override — use as-is, resolveRenderer handles lookup
491
+ type = props.renderer
492
+ } else if (props.format && !['text', 'number'].includes(props.format)) {
493
+ // Format hint maps to input type (email, url, tel, color, date, etc.)
494
+ type = props.format
495
+ } else if (props.type) {
284
496
  switch (props.type) {
285
497
  case 'number':
286
498
  case 'integer':
@@ -290,11 +502,11 @@ export class FormBuilder {
290
502
  type = 'checkbox'
291
503
  break
292
504
  case 'string':
293
- if (props.enum) {
505
+ if (props.enum || props.options) {
294
506
  type = 'select'
295
507
  // Map enum values to options format expected by select inputs
296
- if (Array.isArray(props.enum)) {
297
- props.options = props.enum //.map((val) => ({ value: val, label: val }))
508
+ if (Array.isArray(props.enum) && !props.options) {
509
+ props.options = props.enum
298
510
  }
299
511
  } else {
300
512
  type = 'text'
@@ -306,19 +518,29 @@ export class FormBuilder {
306
518
  }
307
519
  }
308
520
 
309
- // Add validation message if exists
521
+ // Add validation message and dirty state
310
522
  const validationMessage = this.#validation[fieldPath] || null
311
523
 
312
524
  // Compose final props
313
525
  const finalProps = {
314
526
  ...props,
315
527
  type,
316
- message: validationMessage
528
+ message: validationMessage,
529
+ dirty: this.isFieldDirty(fieldPath)
530
+ }
531
+
532
+ // Inject lookup state (options, loading, disabled, fields) when present
533
+ const lookupState = this.getLookupState(fieldPath)
534
+ if (lookupState) {
535
+ if (lookupState.options?.length > 0) finalProps.options = lookupState.options
536
+ if (lookupState.loading) finalProps.loading = true
537
+ if (lookupState.disabled) finalProps.disabled = true
538
+ if (lookupState.fields && !finalProps.fields) finalProps.fields = lookupState.fields
317
539
  }
318
540
 
319
541
  return {
320
542
  scope,
321
- // type,
543
+ type,
322
544
  value,
323
545
  override: element.override || false,
324
546
  props: finalProps
@@ -336,7 +558,8 @@ export class FormBuilder {
336
558
  if (message) {
337
559
  this.#validation = { ...this.#validation, [fieldPath]: message }
338
560
  } else {
339
- this.#validation = omit([fieldPath], this.#validation)
561
+ const { [fieldPath]: _, ...rest } = this.#validation
562
+ this.#validation = rest
340
563
  }
341
564
  }
342
565
 
@@ -348,12 +571,184 @@ export class FormBuilder {
348
571
  }
349
572
 
350
573
  /**
351
- * Reset form to initial state
574
+ * Validate a single field by path
575
+ * @param {string} fieldPath - Field path (without '#/' prefix)
576
+ * @returns {import('./validation.js').ValidationMessage|null} Validation result
577
+ */
578
+ validateField(fieldPath) {
579
+ const fieldSchema = this.#getFieldSchema(fieldPath)
580
+ if (!fieldSchema) return null
581
+
582
+ const value = this.getValue(fieldPath)
583
+ const label = this.#getFieldLabel(fieldPath)
584
+ const result = validateFieldValue(value, fieldSchema, label)
585
+
586
+ this.setFieldValidation(fieldPath, result)
587
+ return result
588
+ }
589
+
590
+ /**
591
+ * Validate all fields, populate validation state
592
+ * @returns {Object} Validation results keyed by field path
593
+ */
594
+ validate() {
595
+ const results = validateAllFields(this.#data, this.#schema, this.#layout)
596
+ this.#validation = results
597
+ return results
598
+ }
599
+
600
+ /**
601
+ * Whether all fields pass validation (no error-state messages)
602
+ * @returns {boolean}
603
+ */
604
+ get isValid() {
605
+ return Object.values(this.#validation).every((msg) => msg.state !== 'error')
606
+ }
607
+
608
+ /**
609
+ * Array of current error messages with paths
610
+ * @returns {Array<{path: string, state: string, text: string}>}
611
+ */
612
+ get errors() {
613
+ return Object.entries(this.#validation)
614
+ .filter(([, msg]) => msg.state === 'error')
615
+ .map(([path, msg]) => ({ path, ...msg }))
616
+ }
617
+
618
+ /**
619
+ * Array of all validation messages with paths, ordered by severity
620
+ * @returns {Array<{path: string, state: string, text: string}>}
621
+ */
622
+ get messages() {
623
+ const order = { error: 0, warning: 1, info: 2, success: 3 }
624
+ return Object.entries(this.#validation)
625
+ .filter(([, msg]) => msg !== null && msg !== undefined)
626
+ .map(([path, msg]) => ({ path, ...msg }))
627
+ .sort((a, b) => (order[a.state] ?? 4) - (order[b.state] ?? 4))
628
+ }
629
+
630
+ // ── Dirty Tracking ────────────────────────────────────────
631
+
632
+ /**
633
+ * Whether any field has been modified from its initial value
634
+ * @returns {boolean}
635
+ */
636
+ get isDirty() {
637
+ return !deepEqual(this.#data, this.#initialData)
638
+ }
639
+
640
+ /**
641
+ * Set of field paths that differ from their initial values
642
+ * @returns {Set<string>}
643
+ */
644
+ get dirtyFields() {
645
+ const dirty = new Set()
646
+ this.#collectDirtyFields(this.#data, this.#initialData, '', dirty)
647
+ return dirty
648
+ }
649
+
650
+ /**
651
+ * Check if a single field has been modified from its initial value
652
+ * @param {string} fieldPath - Field path (without '#/' prefix)
653
+ * @returns {boolean}
654
+ */
655
+ isFieldDirty(fieldPath) {
656
+ const current = this.getValue(fieldPath)
657
+ const initial = this.#getInitialValue(fieldPath)
658
+ return !deepEqual(current, initial)
659
+ }
660
+
661
+ /**
662
+ * Update the initial data snapshot to the current data.
663
+ * Call after a successful save to clear dirty state.
664
+ */
665
+ snapshot() {
666
+ this.#initialData = deepClone(this.#data)
667
+ }
668
+
669
+ /**
670
+ * Get a field's initial value by path
671
+ * @private
672
+ * @param {string} path - Field path
673
+ * @returns {any} Initial field value
674
+ */
675
+ #getInitialValue(path) {
676
+ if (!path) return undefined
677
+
678
+ const keys = path.split('/')
679
+ let current = this.#initialData
680
+ for (const key of keys) {
681
+ if (current && typeof current === 'object') {
682
+ current = current[key]
683
+ } else {
684
+ return undefined
685
+ }
686
+ }
687
+ return current
688
+ }
689
+
690
+ /**
691
+ * Recursively collect dirty field paths by comparing current vs initial
692
+ * @private
693
+ * @param {any} current - Current data (sub)tree
694
+ * @param {any} initial - Initial data (sub)tree
695
+ * @param {string} prefix - Path prefix
696
+ * @param {Set<string>} dirty - Accumulator set
697
+ */
698
+ #collectDirtyFields(current, initial, prefix, dirty) {
699
+ const allKeys = new Set([
700
+ ...Object.keys(current ?? {}),
701
+ ...Object.keys(initial ?? {})
702
+ ])
703
+
704
+ for (const key of allKeys) {
705
+ const path = prefix ? `${prefix}/${key}` : key
706
+ const curVal = current?.[key]
707
+ const initVal = initial?.[key]
708
+
709
+ if (!deepEqual(curVal, initVal)) {
710
+ dirty.add(path)
711
+ }
712
+ }
713
+ }
714
+
715
+ /**
716
+ * Get schema definition for a field path
717
+ * @private
718
+ * @param {string} fieldPath - Field path
719
+ * @returns {Object|null} Field schema
720
+ */
721
+ #getFieldSchema(fieldPath) {
722
+ if (!this.#schema?.properties) return null
723
+ const keys = fieldPath.split('/')
724
+ let current = this.#schema.properties
725
+ for (const key of keys) {
726
+ if (current && current[key]) {
727
+ current = current[key]
728
+ } else {
729
+ return null
730
+ }
731
+ }
732
+ return current
733
+ }
734
+
735
+ /**
736
+ * Get label for a field from layout
737
+ * @private
738
+ * @param {string} fieldPath - Field path
739
+ * @returns {string} Field label
740
+ */
741
+ #getFieldLabel(fieldPath) {
742
+ const scope = `#/${fieldPath}`
743
+ const layoutEl = this.#layout?.elements?.find((el) => el.scope === scope)
744
+ return layoutEl?.label || layoutEl?.title || fieldPath
745
+ }
746
+
747
+ /**
748
+ * Reset form data to initial snapshot and clear validation
352
749
  */
353
750
  reset() {
354
- this.#data = {}
355
- // this.#schema = {}
356
- // this.#layout = {}
751
+ this.#data = deepClone(this.#initialData)
357
752
  this.#validation = {}
358
753
  }
359
754
  }
package/src/lib/fields.js CHANGED
@@ -7,7 +7,7 @@ import { omit, pick } from 'ramda'
7
7
  * @param {import('../types').LayoutSchema} attribute
8
8
  */
9
9
  function combineArrayElementsWithSchema(element, attribute) {
10
- // eslint-disable-next-line no-use-before-define
10
+
11
11
  const schema = getSchemaWithLayout(attribute.props.items, element.schema)
12
12
  return {
13
13
  ...attribute,
@@ -76,7 +76,7 @@ function combineElementWithSchema(element, schema) {
76
76
  let attribute = findAttributeByPath(scope, schema)
77
77
 
78
78
  if (Array.isArray(element.elements)) {
79
- // eslint-disable-next-line no-use-before-define
79
+
80
80
  attribute = combineNestedElementsWithSchema(element, attribute, schema)
81
81
  } else if (element.schema || attribute.props?.type === 'array') {
82
82
  attribute = combineArrayElementsWithSchema(element, attribute)
package/src/lib/layout.js CHANGED
@@ -13,7 +13,7 @@ function deriveElementLayout(val, scope, label) {
13
13
  return {
14
14
  title: label,
15
15
  scope: path,
16
- // eslint-disable-next-line no-use-before-define
16
+
17
17
  ...deriveLayoutFromValue(val, path)
18
18
  }
19
19
  }
@@ -40,7 +40,7 @@ function deriveObjectLayout(value, scope) {
40
40
  * @returns {import('../types').DataLayout}
41
41
  */
42
42
  function deriveArrayLayout(value, scope) {
43
- // eslint-disable-next-line no-use-before-define
43
+
44
44
  const schema = deriveLayoutFromValue(value[0], '#')
45
45
  return {
46
46
  scope,