@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.
- package/README.md +251 -0
- package/dist/src/display/index.d.ts +5 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/input/index.d.ts +3 -0
- package/dist/src/lib/builder.svelte.d.ts +114 -4
- package/dist/src/lib/lookup.svelte.d.ts +87 -0
- package/dist/src/lib/renderers.d.ts +23 -0
- package/package.json +6 -4
- package/src/FieldLayout.svelte +4 -11
- package/src/FormRenderer.svelte +202 -61
- package/src/InfoField.svelte +26 -0
- package/src/Input.svelte +17 -61
- package/src/InputField.svelte +15 -11
- package/src/ValidationReport.svelte +52 -0
- package/src/display/DisplayCardGrid.svelte +68 -0
- package/src/display/DisplayList.svelte +31 -0
- package/src/display/DisplaySection.svelte +20 -0
- package/src/display/DisplayTable.svelte +68 -0
- package/src/display/DisplayValue.svelte +44 -0
- package/src/display/index.js +5 -0
- package/src/index.js +14 -0
- package/src/input/ArrayEditor.svelte +108 -0
- package/src/input/InputCheckbox.svelte +4 -5
- package/src/input/InputColor.svelte +6 -1
- package/src/input/InputDate.svelte +6 -1
- package/src/input/InputDateTime.svelte +6 -1
- package/src/input/InputEmail.svelte +6 -1
- package/src/input/InputFile.svelte +6 -2
- package/src/input/InputMonth.svelte +6 -1
- package/src/input/InputNumber.svelte +6 -1
- package/src/input/InputPassword.svelte +6 -1
- package/src/input/InputRadio.svelte +3 -3
- package/src/input/InputRange.svelte +6 -1
- package/src/input/InputSelect.svelte +31 -53
- package/src/input/InputSwitch.svelte +4 -15
- package/src/input/InputTel.svelte +6 -1
- package/src/input/InputText.svelte +6 -1
- package/src/input/InputTextArea.svelte +6 -1
- package/src/input/InputTime.svelte +6 -1
- package/src/input/InputToggle.svelte +28 -0
- package/src/input/InputUrl.svelte +6 -1
- package/src/input/InputWeek.svelte +6 -1
- package/src/input/index.js +3 -1
- package/src/lib/Input.svelte +3 -3
- package/src/lib/builder.svelte.js +425 -30
- package/src/lib/fields.js +2 -2
- package/src/lib/layout.js +2 -2
- package/src/lib/lookup.svelte.js +334 -0
- package/src/lib/renderers.js +83 -0
- package/src/lib/schema.js +1 -1
- package/src/types.js +0 -9
- package/dist/src/forms-old/input/types.d.ts +0 -7
- package/dist/src/forms-old/lib/form.d.ts +0 -95
- package/dist/src/forms-old/lib/index.d.ts +0 -1
- package/dist/src/lib/deprecated/nested.d.ts +0 -48
- package/dist/src/lib/deprecated/nested.spec.d.ts +0 -1
- package/dist/src/lib/deprecated/validator.d.ts +0 -30
- package/dist/src/lib/deprecated/validator.spec.d.ts +0 -1
- package/src/DataEditor.svelte +0 -30
- package/src/ListEditor.svelte +0 -44
- package/src/NestedEditor.svelte +0 -85
- package/src/forms-old/CheckBox.svelte +0 -56
- package/src/forms-old/DataEditor.svelte +0 -30
- package/src/forms-old/FieldLayout.svelte +0 -48
- package/src/forms-old/Form.svelte +0 -17
- package/src/forms-old/Icon.svelte +0 -76
- package/src/forms-old/Item.svelte +0 -25
- package/src/forms-old/ListEditor.svelte +0 -44
- package/src/forms-old/Tabs.svelte +0 -57
- package/src/forms-old/Wrapper.svelte +0 -12
- package/src/forms-old/input/Input.svelte +0 -17
- package/src/forms-old/input/InputField.svelte +0 -70
- package/src/forms-old/input/InputSelect.svelte +0 -23
- package/src/forms-old/input/InputSwitch.svelte +0 -19
- package/src/forms-old/input/types.js +0 -29
- package/src/forms-old/lib/form.js +0 -72
- package/src/forms-old/lib/index.js +0 -12
- package/src/forms-old/mocks/CustomField.svelte +0 -7
- package/src/forms-old/mocks/CustomWrapper.svelte +0 -8
- package/src/forms-old/mocks/Register.svelte +0 -25
- package/src/inp/Input.svelte +0 -17
- package/src/inp/InputField.svelte +0 -69
- package/src/inp/InputSelect.svelte +0 -23
- package/src/inp/InputSwitch.svelte +0 -19
- package/src/lib/deprecated/Form.svelte +0 -17
- package/src/lib/deprecated/FormRenderer.svelte +0 -121
- package/src/lib/deprecated/nested.js +0 -192
- package/src/lib/deprecated/nested.spec.js +0 -512
- package/src/lib/deprecated/validator.js +0 -137
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
467
|
+
...topLevelProps,
|
|
468
|
+
...groupProps,
|
|
275
469
|
elements: nestedElements,
|
|
276
470
|
message: this.#validation[fieldPath] || null
|
|
277
471
|
}
|
|
278
472
|
}
|
|
279
473
|
}
|
|
280
474
|
|
|
281
|
-
//
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
+
|
|
44
44
|
const schema = deriveLayoutFromValue(value[0], '#')
|
|
45
45
|
return {
|
|
46
46
|
scope,
|