@rokkit/forms 1.7.0 → 1.8.0
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 +4 -2
- package/dist/lib/builder.svelte.d.ts +100 -248
- package/dist/lib/builder.svelte.js +182 -1047
- package/dist/lib/elements.d.ts +81 -0
- package/dist/lib/elements.js +196 -0
- package/dist/lib/schema.d.ts +13 -0
- package/dist/lib/schema.js +27 -0
- package/dist/lib/state/FormDefinition.svelte.d.ts +32 -0
- package/dist/lib/state/FormDefinition.svelte.js +83 -0
- package/dist/lib/state/FormLookups.svelte.d.ts +39 -0
- package/dist/lib/state/FormLookups.svelte.js +93 -0
- package/dist/lib/state/FormSteps.svelte.d.ts +32 -0
- package/dist/lib/state/FormSteps.svelte.js +80 -0
- package/dist/lib/state/FormValidation.svelte.d.ts +52 -0
- package/dist/lib/state/FormValidation.svelte.js +140 -0
- package/dist/lib/state/FormValues.svelte.d.ts +29 -0
- package/dist/lib/state/FormValues.svelte.js +69 -0
- package/dist/lib/validation.js +5 -28
- package/dist/lib/values.d.ts +42 -0
- package/dist/lib/values.js +90 -0
- package/package.json +1 -1
|
@@ -1,1128 +1,263 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
validateAll as validateAllFields
|
|
10
|
-
} from './validation.js'
|
|
1
|
+
import { SvelteSet } from 'svelte/reactivity'
|
|
2
|
+
import { buildElements } from './elements.js'
|
|
3
|
+
import { toFieldPath } from './values.js'
|
|
4
|
+
import { FormValues } from './state/FormValues.svelte.js'
|
|
5
|
+
import { FormDefinition } from './state/FormDefinition.svelte.js'
|
|
6
|
+
import { FormSteps } from './state/FormSteps.svelte.js'
|
|
7
|
+
import { FormValidation } from './state/FormValidation.svelte.js'
|
|
8
|
+
import { FormLookups } from './state/FormLookups.svelte.js'
|
|
11
9
|
|
|
12
10
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Deep equality check for plain values (primitives, plain objects, arrays).
|
|
54
|
-
* @param {any} a
|
|
55
|
-
* @param {any} b
|
|
56
|
-
* @returns {boolean}
|
|
57
|
-
*/
|
|
58
|
-
function deepEqual(a, b) {
|
|
59
|
-
if (a === b) return true
|
|
60
|
-
if (isNullish(a) || isNullish(b)) return false
|
|
61
|
-
return areSameObjectType(a, b) && deepEqualObjects2(a, b)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* @typedef {Object} FormElement
|
|
66
|
-
* @property {string} scope - JSON Pointer path (e.g., '#/email', '#/user/name')
|
|
67
|
-
* @property {string} type - Input type (text, number, range, checkbox, select, etc.)
|
|
68
|
-
* @property {any} value - Current value from data
|
|
69
|
-
* @property {boolean} override - Whether to use custom child snippet (from layout)
|
|
70
|
-
* @property {Object} props - Merged properties from schema + layout + validation
|
|
71
|
-
* @property {string} [props.label] - Display label (from layout)
|
|
72
|
-
* @property {string} [props.description] - Help text (from layout)
|
|
73
|
-
* @property {string} [props.placeholder] - Placeholder text (from layout)
|
|
74
|
-
* @property {boolean} [props.required] - Required flag (from schema)
|
|
75
|
-
* @property {number} [props.min] - Minimum value (from schema)
|
|
76
|
-
* @property {number} [props.max] - Maximum value (from schema)
|
|
77
|
-
* @property {Object} [props.message] - Validation message object
|
|
78
|
-
* @property {string} [props.message.state] - Message state: 'error', 'warning', 'info', 'success'
|
|
79
|
-
* @property {string} [props.message.text] - Message text content
|
|
80
|
-
* @property {boolean} [props.dirty] - Whether field value differs from initial
|
|
81
|
-
*/
|
|
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
|
-
|
|
105
|
-
/**
|
|
106
|
-
* FormBuilder class for dynamically generating forms from data structures
|
|
11
|
+
* The state behind a schema-driven form — a COMPOSITION of job classes (`./state/`), each
|
|
12
|
+
* owning one concern:
|
|
13
|
+
*
|
|
14
|
+
* values → definition → steps → validation → lookups, and `elements` derived from them all
|
|
15
|
+
* by the pure `buildElements` (`./elements.js`).
|
|
16
|
+
*
|
|
17
|
+
* It holds almost no logic itself: `next()` (validate the step, then advance), `updateField()`
|
|
18
|
+
* (write, drop hidden messages, re-fetch dependent lookups) and `getVisibleData()` are the only
|
|
19
|
+
* methods that span two jobs. Everything else is the public API FormRenderer, MultiStep and
|
|
20
|
+
* consumers use, kept as delegations so none of them changed when the class was taken apart.
|
|
107
21
|
*/
|
|
108
22
|
export class FormBuilder {
|
|
109
|
-
/**
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
#layout = $state({})
|
|
120
|
-
|
|
121
|
-
/** @type {Object} */
|
|
122
|
-
#validation = $state({})
|
|
123
|
-
|
|
124
|
-
/** @type {Object<string, import('./lookup.svelte.js').LookupConfig>} */
|
|
125
|
-
#lookupConfigs = $state({})
|
|
126
|
-
|
|
127
|
-
/** @type {ReturnType<typeof createLookupManager>|null} */
|
|
128
|
-
#lookupManager = $state(null)
|
|
129
|
-
|
|
130
|
-
/** @type {number} */
|
|
131
|
-
#currentStep = $state(0)
|
|
132
|
-
|
|
133
|
-
/** @type {FormElement[]} */
|
|
134
|
-
elements = $derived(this.#buildElements())
|
|
135
|
-
|
|
136
|
-
/** True when currentStep can advance to the next step */
|
|
137
|
-
canAdvance = $derived(this.#currentStep < this.totalSteps - 1)
|
|
138
|
-
|
|
139
|
-
/** Combined schema+layout (scoped elements only) */
|
|
140
|
-
get combined() {
|
|
141
|
-
const scopedElements = (this.#layout?.elements ?? []).filter((el) => el.scope)
|
|
142
|
-
const scopedLayout = { ...this.#layout, elements: scopedElements }
|
|
143
|
-
return getSchemaWithLayout(this.#schema, scopedLayout)
|
|
144
|
-
}
|
|
145
|
-
/**
|
|
146
|
-
* Get the current data
|
|
147
|
-
* @returns {Record<string, unknown>} Current data object
|
|
148
|
-
*/
|
|
149
|
-
get data() {
|
|
150
|
-
return this.#data
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Set the data
|
|
155
|
-
* @param {Record<string, unknown>} value - New data object
|
|
156
|
-
*/
|
|
157
|
-
set data(value) {
|
|
158
|
-
this.#data = value
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Get the current schema
|
|
163
|
-
* @returns {Object} Current schema object
|
|
164
|
-
*/
|
|
165
|
-
get schema() {
|
|
166
|
-
return this.#schema
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Set the schema
|
|
171
|
-
* @param {Object} value - New schema object
|
|
172
|
-
*
|
|
173
|
-
* When the caller explicitly provides a schema, that schema is the source
|
|
174
|
-
* of truth for which fields exist — the layout may have been derived
|
|
175
|
-
* earlier from empty/partial data and would silently hide those fields.
|
|
176
|
-
* Re-derive the layout from the new schema so the two stay consistent,
|
|
177
|
-
* unless the caller has already installed an explicit non-empty layout.
|
|
178
|
-
*/
|
|
179
|
-
set schema(value) {
|
|
180
|
-
this.#schema = value ?? deriveSchemaFromValue(this.#data)
|
|
181
|
-
const layout = this.#layout
|
|
182
|
-
const hasExplicitLayout = layout && Array.isArray(layout.elements) && layout.elements.length > 0
|
|
183
|
-
if (!hasExplicitLayout) {
|
|
184
|
-
this.#layout = deriveLayoutFromSchema(this.#schema)
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Get the current layout
|
|
190
|
-
* @returns {Object} Current layout object
|
|
191
|
-
*/
|
|
192
|
-
get layout() {
|
|
193
|
-
return this.#layout
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Set the layout
|
|
198
|
-
* @param {Object} value - New layout object
|
|
199
|
-
*/
|
|
200
|
-
set layout(value) {
|
|
201
|
-
if (value) {
|
|
202
|
-
this.#layout = value
|
|
203
|
-
return
|
|
204
|
-
}
|
|
205
|
-
// No explicit layout — prefer schema-derived over data-derived so
|
|
206
|
-
// declared fields render even when initial data is `{}` (a common
|
|
207
|
-
// pattern for LLM-generated forms and reset forms).
|
|
208
|
-
const schema = this.#schema
|
|
209
|
-
const hasSchemaProperties =
|
|
210
|
-
schema && typeof schema === 'object' && schema.properties &&
|
|
211
|
-
typeof schema.properties === 'object' &&
|
|
212
|
-
Object.keys(schema.properties).length > 0
|
|
213
|
-
this.#layout = hasSchemaProperties
|
|
214
|
-
? deriveLayoutFromSchema(schema)
|
|
215
|
-
: deriveLayoutFromValue(this.#data)
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Get the current validation state
|
|
220
|
-
* @returns {Object} Current validation object
|
|
221
|
-
*/
|
|
222
|
-
get validation() {
|
|
223
|
-
return this.#validation
|
|
224
|
-
}
|
|
23
|
+
/** The form's data and its dirty baseline. */
|
|
24
|
+
values
|
|
25
|
+
/** Schema and layout, and how each derives from the other. */
|
|
26
|
+
definition
|
|
27
|
+
/** Multi-step navigation. */
|
|
28
|
+
steps
|
|
29
|
+
/** Validation messages, and the ways of producing them. Suffixed: `validation` is the public map. */
|
|
30
|
+
validationState
|
|
31
|
+
/** Fields whose options come from elsewhere, and what they depend on. */
|
|
32
|
+
lookups
|
|
225
33
|
|
|
226
|
-
/**
|
|
227
|
-
|
|
228
|
-
* @param {Object} value - Validation object with field paths as keys
|
|
229
|
-
*/
|
|
230
|
-
set validation(value) {
|
|
231
|
-
this.#validation = value
|
|
232
|
-
}
|
|
34
|
+
/** @type {import('./elements.js').FormElement[]} */
|
|
35
|
+
elements = $derived(buildElements(this.steps.activeElements, this.#elementContext()))
|
|
233
36
|
|
|
234
37
|
/**
|
|
235
|
-
* Initialise the lookup manager if any lookups are provided
|
|
236
|
-
* @private
|
|
237
|
-
*/
|
|
238
|
-
#initLookups(lookups) {
|
|
239
|
-
this.#lookupConfigs = lookups
|
|
240
|
-
if (Object.keys(lookups).length > 0) {
|
|
241
|
-
this.#lookupManager = createLookupManager(lookups)
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Create a new FormBuilder instance
|
|
247
38
|
* @param {Record<string, unknown>} [data={}] - Initial data object
|
|
248
|
-
* @param {Object|null} [schema=null] -
|
|
249
|
-
* @param {Object|null} [layout=null] -
|
|
39
|
+
* @param {Object|null} [schema=null] - Schema; derived from the data when omitted
|
|
40
|
+
* @param {Object|null} [layout=null] - Layout; derived from the schema, else the data, when omitted
|
|
250
41
|
* @param {Object<string, import('./lookup.svelte.js').LookupConfig>} [lookups={}] - Lookup configurations
|
|
251
42
|
*/
|
|
252
43
|
constructor(data = {}, schema = null, layout = null, lookups = {}) {
|
|
253
|
-
this
|
|
254
|
-
this
|
|
255
|
-
this.
|
|
256
|
-
this.
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
*/
|
|
264
|
-
get lookupManager() {
|
|
265
|
-
return this.#lookupManager
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Configure lookups for the form
|
|
270
|
-
* @param {Object<string, import('./lookup.svelte.js').LookupConfig>} lookups - Lookup configurations
|
|
271
|
-
*/
|
|
272
|
-
setLookups(lookups) {
|
|
273
|
-
this.#lookupConfigs = lookups
|
|
274
|
-
this.#lookupManager = createLookupManager(lookups)
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Get lookup state for a field
|
|
279
|
-
* @param {string} fieldPath - Field path
|
|
280
|
-
* @returns {{ options: any[], loading: boolean, error: string|null, fields: Object, disabled: boolean }|null}
|
|
281
|
-
*/
|
|
282
|
-
getLookupState(fieldPath) {
|
|
283
|
-
if (!this.#lookupManager) return null
|
|
284
|
-
const lookup = this.#lookupManager.getLookup(fieldPath)
|
|
285
|
-
if (!lookup) return null
|
|
286
|
-
return {
|
|
287
|
-
options: lookup.options,
|
|
288
|
-
loading: lookup.loading,
|
|
289
|
-
error: lookup.error,
|
|
290
|
-
fields: lookup.fields,
|
|
291
|
-
disabled: lookup.disabled
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Check if a field is disabled due to unmet lookup dependencies
|
|
297
|
-
* @param {string} path - Field path
|
|
298
|
-
* @returns {boolean}
|
|
299
|
-
*/
|
|
300
|
-
isFieldDisabled(path) {
|
|
301
|
-
return this.#lookupManager?.getLookup(path)?.disabled ?? false
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* Manually refresh a field's lookup with the current form data
|
|
306
|
-
* @param {string} path - Field path
|
|
307
|
-
* @returns {Promise<void>}
|
|
308
|
-
*/
|
|
309
|
-
async refreshLookup(path) {
|
|
310
|
-
const lookup = this.#lookupManager?.getLookup(path)
|
|
311
|
-
if (lookup) await lookup.fetch(this.#data)
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Check if a field has a lookup configured
|
|
316
|
-
* @param {string} fieldPath - Field path
|
|
317
|
-
* @returns {boolean}
|
|
318
|
-
*/
|
|
319
|
-
hasLookup(fieldPath) {
|
|
320
|
-
return this.#lookupManager?.hasLookup(fieldPath) ?? false
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* Initialize all lookups
|
|
325
|
-
* @returns {Promise<void>}
|
|
326
|
-
*/
|
|
327
|
-
async initializeLookups() {
|
|
328
|
-
if (this.#lookupManager) {
|
|
329
|
-
await this.#lookupManager.initialize(this.#data)
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* Clear dependent field values for a changed path
|
|
335
|
-
* @private
|
|
336
|
-
*/
|
|
337
|
-
#clearDependentFields(path) {
|
|
338
|
-
for (const [depPath, lookup] of this.#lookupManager.lookups) {
|
|
339
|
-
if (!lookup.dependsOn.includes(path)) continue
|
|
340
|
-
const depKeys = depPath.split('/')
|
|
341
|
-
if (depKeys.length === 1) {
|
|
342
|
-
this.#data = { ...this.#data, [depKeys[0]]: null }
|
|
343
|
-
}
|
|
344
|
-
}
|
|
44
|
+
this.values = new FormValues(data)
|
|
45
|
+
this.definition = new FormDefinition(this.values, schema, layout)
|
|
46
|
+
this.steps = new FormSteps(this.definition)
|
|
47
|
+
this.validationState = new FormValidation({
|
|
48
|
+
values: this.values,
|
|
49
|
+
definition: this.definition,
|
|
50
|
+
steps: this.steps,
|
|
51
|
+
visiblePaths: () => this.#visiblePaths()
|
|
52
|
+
})
|
|
53
|
+
this.lookups = new FormLookups(this.values, lookups)
|
|
345
54
|
}
|
|
346
55
|
|
|
347
56
|
/**
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
* @param {any} value - New value
|
|
351
|
-
* @param {boolean} [triggerLookups=true] - Whether to trigger dependent lookups
|
|
57
|
+
* The field paths that currently render — a `showWhen`-hidden field has no element. A group's
|
|
58
|
+
* children count: their messages must survive `validate()` and `clearHidden()`.
|
|
352
59
|
*/
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Clear stale validation errors for fields that are now hidden
|
|
362
|
-
this.#clearHiddenValidation()
|
|
363
|
-
|
|
364
|
-
// Trigger dependent lookups if configured
|
|
365
|
-
if (triggerLookups && this.#lookupManager) {
|
|
366
|
-
this.#clearDependentFields(path)
|
|
367
|
-
this.#lookupManager.handleFieldChange(path, this.#data)
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Get a field value by path
|
|
373
|
-
* @param {string} path - Field path
|
|
374
|
-
* @returns {any} Field value
|
|
375
|
-
*/
|
|
376
|
-
getValue(path) {
|
|
377
|
-
if (!path) return undefined
|
|
378
|
-
|
|
379
|
-
const keys = path.split('/')
|
|
380
|
-
let current = this.#data
|
|
381
|
-
for (const key of keys) {
|
|
382
|
-
if (current && typeof current === 'object') {
|
|
383
|
-
current = current[key]
|
|
384
|
-
} else {
|
|
385
|
-
return undefined
|
|
60
|
+
#visiblePaths() {
|
|
61
|
+
const paths = []
|
|
62
|
+
const walk = (elements) => {
|
|
63
|
+
for (const el of elements) {
|
|
64
|
+
if (el?.scope) paths.push(el.scope.replace(/^#\//, ''))
|
|
65
|
+
if (el?.type === 'group') walk(el.props.elements ?? [])
|
|
386
66
|
}
|
|
387
67
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* Clear validation errors for fields that are no longer visible
|
|
393
|
-
* @private
|
|
394
|
-
*/
|
|
395
|
-
#clearHiddenValidation() {
|
|
396
|
-
const visiblePaths = new SvelteSet(
|
|
397
|
-
this.elements.filter((el) => el.scope).map((el) => el.scope.replace(/^#\//, ''))
|
|
398
|
-
)
|
|
399
|
-
const cleaned = Object.fromEntries(
|
|
400
|
-
Object.entries(this.#validation).filter(([path]) => visiblePaths.has(path))
|
|
401
|
-
)
|
|
402
|
-
if (Object.keys(cleaned).length !== Object.keys(this.#validation).length) {
|
|
403
|
-
this.#validation = cleaned
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Build a display element from the layout entry
|
|
409
|
-
* @private
|
|
410
|
-
*/
|
|
411
|
-
#buildDisplayElement(layoutEl) {
|
|
412
|
-
const scope = layoutEl.scope ?? null
|
|
413
|
-
const fieldPath = scope?.replace(/^#\//, '')
|
|
414
|
-
const value = fieldPath ? this.getValue(fieldPath) : null
|
|
415
|
-
const { type: displayType, scope: _s, ...displayProps } = layoutEl
|
|
416
|
-
return { type: displayType, scope, value, override: false, props: displayProps }
|
|
68
|
+
walk(this.elements)
|
|
69
|
+
return paths
|
|
417
70
|
}
|
|
418
71
|
|
|
419
72
|
/**
|
|
420
|
-
*
|
|
421
|
-
*
|
|
73
|
+
* What the element builder reads. Getters, so the `elements` derivation tracks exactly the
|
|
74
|
+
* state it touches.
|
|
75
|
+
* @returns {import('./elements.js').ElementContext}
|
|
422
76
|
*/
|
|
423
|
-
#
|
|
424
|
-
const {
|
|
77
|
+
#elementContext() {
|
|
78
|
+
const { definition, values, validationState, lookups } = this
|
|
425
79
|
return {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
80
|
+
get schema() {
|
|
81
|
+
return definition.schema
|
|
82
|
+
},
|
|
83
|
+
get layout() {
|
|
84
|
+
return definition.layout
|
|
85
|
+
},
|
|
86
|
+
get data() {
|
|
87
|
+
return values.data
|
|
88
|
+
},
|
|
89
|
+
value: (path) => values.get(path),
|
|
90
|
+
message: (path) => validationState.message(path),
|
|
91
|
+
dirty: (path) => values.isFieldDirty(path),
|
|
92
|
+
applyLookup: (path, props) => lookups.applyTo(path, props)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── values ────────────────────────────────────────────────────────────────
|
|
97
|
+
get data() {
|
|
98
|
+
return this.values.data
|
|
432
99
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
* Process one scoped layout element — returns FormElement or null
|
|
436
|
-
* @private
|
|
437
|
-
*/
|
|
438
|
-
#processScopedElement(layoutEl, combinedMap) {
|
|
439
|
-
if (layoutEl.showWhen && !evaluateCondition(layoutEl.showWhen, this.#data)) return null
|
|
440
|
-
const key = layoutEl.scope.replace(/^#\//, '').split('/').pop()
|
|
441
|
-
const combinedEl = combinedMap.get(key)
|
|
442
|
-
return combinedEl ? this.#convertToFormElement(combinedEl) : null
|
|
100
|
+
set data(value) {
|
|
101
|
+
this.values.data = value
|
|
443
102
|
}
|
|
444
|
-
|
|
445
103
|
/**
|
|
446
|
-
*
|
|
447
|
-
*
|
|
104
|
+
* Every method taking a field path accepts a bare slash path (`'settings/distance'`) or a
|
|
105
|
+
* JSON Forms scope (`'#/settings/distance'`) — see `toFieldPath`.
|
|
448
106
|
*/
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
(el) => el.scope && !el.type?.startsWith('display-')
|
|
452
|
-
)
|
|
453
|
-
const scopedLayout = { ...this.#layout, elements: scopedElements }
|
|
454
|
-
const combined = getSchemaWithLayout(this.#schema, scopedLayout)
|
|
455
|
-
|
|
456
|
-
const combinedMap = new SvelteMap()
|
|
457
|
-
for (const el of combined.elements ?? []) {
|
|
458
|
-
if (el.key) combinedMap.set(el.key, el)
|
|
459
|
-
}
|
|
460
|
-
return combinedMap
|
|
107
|
+
getValue(path) {
|
|
108
|
+
return this.values.get(toFieldPath(path))
|
|
461
109
|
}
|
|
462
|
-
|
|
463
110
|
/**
|
|
464
|
-
*
|
|
465
|
-
*
|
|
111
|
+
* Write a field, then drop messages of fields a `showWhen` just hid, then clear and re-fetch
|
|
112
|
+
* the lookups that depend on it.
|
|
113
|
+
* @param {string} path
|
|
114
|
+
* @param {any} value
|
|
115
|
+
* @param {boolean} [triggerLookups=true]
|
|
466
116
|
*/
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
117
|
+
updateField(scopeOrPath, value, triggerLookups = true) {
|
|
118
|
+
const path = toFieldPath(scopeOrPath)
|
|
119
|
+
this.values.set(path, value)
|
|
120
|
+
this.validationState.clearHidden()
|
|
121
|
+
if (triggerLookups) this.lookups.fieldChanged(path)
|
|
471
122
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
*/
|
|
477
|
-
#collectElements(layoutElements, combinedMap) {
|
|
478
|
-
const result = []
|
|
479
|
-
for (const layoutEl of layoutElements) {
|
|
480
|
-
const formEl = this.#buildOneElement(layoutEl, combinedMap)
|
|
481
|
-
if (formEl) result.push(formEl)
|
|
482
|
-
}
|
|
483
|
-
return result
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* Build form elements from schema and layout using getSchemaWithLayout
|
|
488
|
-
* @private
|
|
489
|
-
* @returns {FormElement[]} Array of form elements
|
|
490
|
-
*/
|
|
491
|
-
#buildElements() {
|
|
492
|
-
try {
|
|
493
|
-
const layoutElements = this.#getActiveElements()
|
|
494
|
-
const combinedMap = this.#buildCombinedMap(layoutElements)
|
|
495
|
-
return this.#collectElements(layoutElements, combinedMap)
|
|
496
|
-
} catch (error) {
|
|
497
|
-
console.warn('Failed to build elements:', error) // eslint-disable-line no-console
|
|
498
|
-
return this.#buildBasicElements()
|
|
499
|
-
}
|
|
123
|
+
/** The data without hidden fields' values. Does not mutate the data. */
|
|
124
|
+
getVisibleData() {
|
|
125
|
+
const visible = new SvelteSet(this.#visiblePaths())
|
|
126
|
+
return Object.fromEntries(Object.entries(this.values.data).filter(([key]) => visible.has(key)))
|
|
500
127
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
* Build basic form elements when getSchemaWithLayout fails
|
|
504
|
-
* @private
|
|
505
|
-
* @returns {FormElement[]} Array of form elements
|
|
506
|
-
*/
|
|
507
|
-
#buildBasicElements() {
|
|
508
|
-
const elements = []
|
|
509
|
-
|
|
510
|
-
if (this.#layout.elements) {
|
|
511
|
-
for (const layoutElement of this.#layout.elements) {
|
|
512
|
-
const formElement = this.#buildBasicElement(layoutElement)
|
|
513
|
-
if (formElement) {
|
|
514
|
-
elements.push(formElement)
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
return elements
|
|
128
|
+
get isDirty() {
|
|
129
|
+
return this.values.isDirty
|
|
520
130
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
* @private
|
|
525
|
-
* @param {Object} layoutElement - Layout element definition
|
|
526
|
-
* @returns {FormElement|null} Form element or null
|
|
527
|
-
*/
|
|
528
|
-
#buildBasicElement(layoutElement) {
|
|
529
|
-
const { scope, label, override = false, ...layoutProps } = layoutElement
|
|
530
|
-
|
|
531
|
-
if (!scope) return null
|
|
532
|
-
|
|
533
|
-
// Extract field name from scope (remove leading '#/')
|
|
534
|
-
const fieldPath = scope.replace(/^#\//, '')
|
|
535
|
-
const value = this.getValue(fieldPath)
|
|
536
|
-
|
|
537
|
-
// Default type is text when no schema is available
|
|
538
|
-
const type = 'text'
|
|
539
|
-
|
|
540
|
-
// Basic props
|
|
541
|
-
const props = {
|
|
542
|
-
label: label || fieldPath,
|
|
543
|
-
...layoutProps,
|
|
544
|
-
message: this.#validation[fieldPath] || null,
|
|
545
|
-
dirty: this.isFieldDirty(fieldPath),
|
|
546
|
-
type
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
return {
|
|
550
|
-
scope,
|
|
551
|
-
type,
|
|
552
|
-
value,
|
|
553
|
-
override,
|
|
554
|
-
props
|
|
555
|
-
}
|
|
131
|
+
/** Top-level fields that differ from the initial snapshot. */
|
|
132
|
+
get dirtyFields() {
|
|
133
|
+
return this.values.dirtyFields
|
|
556
134
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
* Convert a nested (group) element to FormElement format
|
|
560
|
-
* @private
|
|
561
|
-
*/
|
|
562
|
-
#convertNestedElement(element, fieldPath, scope, value) {
|
|
563
|
-
const nestedElements = element.elements.map((child) =>
|
|
564
|
-
this.#convertToFormElement(child, fieldPath)
|
|
565
|
-
)
|
|
566
|
-
const { key: _k, elements: _e, override: _o, props: groupProps, ...topLevelProps } = element
|
|
567
|
-
return {
|
|
568
|
-
scope,
|
|
569
|
-
type: 'group',
|
|
570
|
-
value,
|
|
571
|
-
override: element.override || false,
|
|
572
|
-
props: {
|
|
573
|
-
...topLevelProps,
|
|
574
|
-
...groupProps,
|
|
575
|
-
elements: nestedElements,
|
|
576
|
-
message: this.#validation[fieldPath] || null
|
|
577
|
-
}
|
|
578
|
-
}
|
|
135
|
+
isFieldDirty(fieldPath) {
|
|
136
|
+
return this.values.isFieldDirty(toFieldPath(fieldPath))
|
|
579
137
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
* @private
|
|
584
|
-
*/
|
|
585
|
-
#convertReadonlyElement(element, fieldPath, scope, value) {
|
|
586
|
-
const validationMessage = this.#validation[fieldPath] || null
|
|
587
|
-
return {
|
|
588
|
-
scope,
|
|
589
|
-
type: 'info',
|
|
590
|
-
value,
|
|
591
|
-
override: element.override || false,
|
|
592
|
-
props: { ...element.props, type: 'info', message: validationMessage }
|
|
593
|
-
}
|
|
138
|
+
/** Make the current data the baseline — after a successful save. */
|
|
139
|
+
snapshot() {
|
|
140
|
+
this.values.snapshot()
|
|
594
141
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
*/
|
|
600
|
-
#resolveNumberType(props) {
|
|
601
|
-
return props.min !== undefined && props.max !== undefined ? 'range' : 'number'
|
|
142
|
+
/** Restore the baseline and clear every message. */
|
|
143
|
+
reset() {
|
|
144
|
+
this.values.reset()
|
|
145
|
+
this.validationState.clear()
|
|
602
146
|
}
|
|
603
147
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
#resolveStringType(props) {
|
|
609
|
-
if (props.enum || props.options) {
|
|
610
|
-
// Map enum values to options format expected by select inputs
|
|
611
|
-
if (Array.isArray(props.enum) && !props.options) {
|
|
612
|
-
props.options = props.enum
|
|
613
|
-
}
|
|
614
|
-
return 'select'
|
|
615
|
-
}
|
|
616
|
-
return 'text'
|
|
148
|
+
// ─── definition ────────────────────────────────────────────────────────────
|
|
149
|
+
/** Setting it may re-derive the layout — see FormDefinition. */
|
|
150
|
+
get schema() {
|
|
151
|
+
return this.definition.schema
|
|
617
152
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
* Resolve input type from schema type field
|
|
621
|
-
* @private
|
|
622
|
-
*/
|
|
623
|
-
#resolveTypeFromSchema(props) {
|
|
624
|
-
if (props.type === 'number' || props.type === 'integer') return this.#resolveNumberType(props)
|
|
625
|
-
if (props.type === 'string') return this.#resolveStringType(props)
|
|
626
|
-
return SCHEMA_TYPE_MAP[props.type] ?? 'text'
|
|
153
|
+
set schema(value) {
|
|
154
|
+
this.definition.schema = value
|
|
627
155
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
* @private
|
|
632
|
-
* @param {Object} props - Element props
|
|
633
|
-
* @returns {string}
|
|
634
|
-
*/
|
|
635
|
-
#resolveInputType(props) {
|
|
636
|
-
if (props.renderer) return props.renderer
|
|
637
|
-
if (props.format && !['text', 'number'].includes(props.format)) return props.format
|
|
638
|
-
return this.#resolveTypeFromSchema(props)
|
|
156
|
+
/** A null layout derives from the schema, else the data. */
|
|
157
|
+
get layout() {
|
|
158
|
+
return this.definition.layout
|
|
639
159
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
* Apply lookup state into finalProps (mutates finalProps)
|
|
643
|
-
* @private
|
|
644
|
-
*/
|
|
645
|
-
#applyLookupState(fieldPath, finalProps) {
|
|
646
|
-
const lookupState = this.getLookupState(fieldPath)
|
|
647
|
-
if (!lookupState) return
|
|
648
|
-
applyLookupProps(lookupState, finalProps)
|
|
160
|
+
set layout(value) {
|
|
161
|
+
this.definition.layout = value
|
|
649
162
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
* @private
|
|
654
|
-
*/
|
|
655
|
-
#buildStandardElement(element, fieldPath, scope, value) {
|
|
656
|
-
// `override` is authored on the LAYOUT element (documented in the README and
|
|
657
|
-
// docs/llms as the way to opt a field into the consumer's `child` snippet).
|
|
658
|
-
// getSchemaWithLayout folds unrecognised layout keys into `props`, so reading
|
|
659
|
-
// only `element.override` left it permanently false and the child snippet
|
|
660
|
-
// never rendered. Take it from either position, and keep it out of the props
|
|
661
|
-
// handed to the input component.
|
|
662
|
-
const { override: propsOverride, ...props } = element.props ?? {}
|
|
663
|
-
const type = this.#resolveInputType(props)
|
|
664
|
-
const finalProps = {
|
|
665
|
-
...props,
|
|
666
|
-
type,
|
|
667
|
-
message: this.#validation[fieldPath] || null,
|
|
668
|
-
dirty: this.isFieldDirty(fieldPath)
|
|
669
|
-
}
|
|
670
|
-
this.#applyLookupState(fieldPath, finalProps)
|
|
671
|
-
return {
|
|
672
|
-
scope,
|
|
673
|
-
type,
|
|
674
|
-
value,
|
|
675
|
-
override: element.override || propsOverride || false,
|
|
676
|
-
props: finalProps
|
|
677
|
-
}
|
|
163
|
+
/** Schema merged with the scoped layout elements. */
|
|
164
|
+
get combined() {
|
|
165
|
+
return this.definition.combined
|
|
678
166
|
}
|
|
679
167
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
#resolveFieldPath(key, parentPath) {
|
|
685
|
-
return parentPath ? `${parentPath}/${key}` : key
|
|
168
|
+
// ─── validation ────────────────────────────────────────────────────────────
|
|
169
|
+
/** The validation messages, by field path. */
|
|
170
|
+
get validation() {
|
|
171
|
+
return this.validationState.map
|
|
686
172
|
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
* Convert a combined schema/layout element to FormElement format
|
|
690
|
-
* @private
|
|
691
|
-
* @param {Object} element - Combined element from getSchemaWithLayout
|
|
692
|
-
* @param {string} parentPath - Parent path for nested elements
|
|
693
|
-
* @returns {FormElement} Form element
|
|
694
|
-
*/
|
|
695
|
-
#convertToFormElement(element, parentPath = '') {
|
|
696
|
-
const { key } = element
|
|
697
|
-
/* v8 ignore start -- unreachable: both call sites source elements from the
|
|
698
|
-
combined map, which #buildCombinedMap populates under `if (el.key)`, so a
|
|
699
|
-
keyless element never arrives here. `ignore next` does not fire on a
|
|
700
|
-
single-line `if (...) return`. */
|
|
701
|
-
if (!key) return null
|
|
702
|
-
/* v8 ignore stop */
|
|
703
|
-
|
|
704
|
-
const fieldPath = this.#resolveFieldPath(key, parentPath)
|
|
705
|
-
const scope = `#/${fieldPath}`
|
|
706
|
-
const value = this.getValue(fieldPath)
|
|
707
|
-
|
|
708
|
-
if (element.elements) return this.#convertNestedElement(element, fieldPath, scope, value)
|
|
709
|
-
if (element.props.readonly)
|
|
710
|
-
return this.#convertReadonlyElement(element, fieldPath, scope, value)
|
|
711
|
-
return this.#buildStandardElement(element, fieldPath, scope, value)
|
|
173
|
+
set validation(value) {
|
|
174
|
+
this.validationState.map = value
|
|
712
175
|
}
|
|
713
|
-
|
|
714
|
-
/**
|
|
715
|
-
* Set validation message for a specific field
|
|
716
|
-
* @param {string} fieldPath - Field path (without '#/' prefix)
|
|
717
|
-
* @param {Object|null} message - Validation message object or null to clear
|
|
718
|
-
* @param {string} message.state - Message state: 'error', 'warning', 'info', 'success'
|
|
719
|
-
* @param {string} message.text - Message text content
|
|
720
|
-
*/
|
|
176
|
+
/** @param {string} fieldPath @param {Object|null} message - null clears it */
|
|
721
177
|
setFieldValidation(fieldPath, message) {
|
|
722
|
-
|
|
723
|
-
this.#validation = { ...this.#validation, [fieldPath]: message }
|
|
724
|
-
} else {
|
|
725
|
-
const { [fieldPath]: _, ...rest } = this.#validation
|
|
726
|
-
this.#validation = rest
|
|
727
|
-
}
|
|
178
|
+
this.validationState.set(toFieldPath(fieldPath), message)
|
|
728
179
|
}
|
|
729
|
-
|
|
730
|
-
/**
|
|
731
|
-
* Clear all validation messages
|
|
732
|
-
*/
|
|
733
180
|
clearValidation() {
|
|
734
|
-
this
|
|
181
|
+
this.validationState.clear()
|
|
735
182
|
}
|
|
736
|
-
|
|
737
|
-
/**
|
|
738
|
-
* Validate a single field by path
|
|
739
|
-
* @param {string} fieldPath - Field path (without '#/' prefix)
|
|
740
|
-
* @returns {import('./validation.js').ValidationMessage|null} Validation result
|
|
741
|
-
*/
|
|
183
|
+
/** @returns {import('./validation.js').ValidationMessage|null} */
|
|
742
184
|
validateField(fieldPath) {
|
|
743
|
-
|
|
744
|
-
if (!fieldSchema) return null
|
|
745
|
-
|
|
746
|
-
const value = this.getValue(fieldPath)
|
|
747
|
-
const label = this.#getFieldLabel(fieldPath)
|
|
748
|
-
const result = validateFieldValue(value, fieldSchema, label)
|
|
749
|
-
|
|
750
|
-
this.setFieldValidation(fieldPath, result)
|
|
751
|
-
return result
|
|
185
|
+
return this.validationState.validateField(toFieldPath(fieldPath))
|
|
752
186
|
}
|
|
753
|
-
|
|
754
|
-
/**
|
|
755
|
-
* Validate all fields, populate validation state
|
|
756
|
-
* @returns {Object} Validation results keyed by field path
|
|
757
|
-
*/
|
|
187
|
+
/** Validate the whole form (every step of a wizard; the visible fields of a flat form). */
|
|
758
188
|
validate() {
|
|
759
|
-
|
|
760
|
-
// Flatten all step elements into a synthetic layout for full validation
|
|
761
|
-
const allElements = []
|
|
762
|
-
for (const step of this.#layout?.elements ?? []) {
|
|
763
|
-
if (step.type === 'step') allElements.push(...(step.elements ?? []))
|
|
764
|
-
}
|
|
765
|
-
const flatLayout = { ...this.#layout, elements: allElements }
|
|
766
|
-
const results = validateAllFields(this.#data, this.#schema, flatLayout)
|
|
767
|
-
this.#validation = results
|
|
768
|
-
return results
|
|
769
|
-
}
|
|
770
|
-
const results = validateAllFields(this.#data, this.#schema, this.#layout)
|
|
771
|
-
const visiblePaths = new SvelteSet(
|
|
772
|
-
this.elements.filter((el) => el.scope).map((el) => el.scope.replace(/^#\//, ''))
|
|
773
|
-
)
|
|
774
|
-
const filtered = Object.fromEntries(
|
|
775
|
-
Object.entries(results).filter(([path]) => visiblePaths.has(path))
|
|
776
|
-
)
|
|
777
|
-
this.#validation = filtered
|
|
778
|
-
return filtered
|
|
189
|
+
return this.validationState.validateAll()
|
|
779
190
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
*/
|
|
787
|
-
getVisibleData() {
|
|
788
|
-
const visiblePaths = new SvelteSet(
|
|
789
|
-
this.elements.filter((el) => el.scope).map((el) => el.scope.replace(/^#\//, ''))
|
|
790
|
-
)
|
|
791
|
-
return Object.fromEntries(Object.entries(this.#data).filter(([key]) => visiblePaths.has(key)))
|
|
191
|
+
/** Validate one step's fields. */
|
|
192
|
+
validateStep(index) {
|
|
193
|
+
return this.validationState.validateStep(index)
|
|
194
|
+
}
|
|
195
|
+
isStepValid(index = this.steps.current) {
|
|
196
|
+
return this.validateStep(index)
|
|
792
197
|
}
|
|
793
|
-
|
|
794
|
-
/**
|
|
795
|
-
* Whether all fields pass validation (no error-state messages)
|
|
796
|
-
* @returns {boolean}
|
|
797
|
-
*/
|
|
798
198
|
get isValid() {
|
|
799
|
-
return
|
|
199
|
+
return this.validationState.isValid
|
|
800
200
|
}
|
|
801
|
-
|
|
802
|
-
/**
|
|
803
|
-
* Array of current error messages with paths
|
|
804
|
-
* @returns {Array<{path: string, state: string, text: string}>}
|
|
805
|
-
*/
|
|
201
|
+
/** @returns {Array<{path: string, state: string, text: string}>} */
|
|
806
202
|
get errors() {
|
|
807
|
-
return
|
|
808
|
-
.filter(([, msg]) => msg.state === 'error')
|
|
809
|
-
.map(([path, msg]) => ({ path, ...msg }))
|
|
203
|
+
return this.validationState.errors
|
|
810
204
|
}
|
|
811
|
-
|
|
812
|
-
/**
|
|
813
|
-
* Array of all validation messages with paths, ordered by severity
|
|
814
|
-
* @returns {Array<{path: string, state: string, text: string}>}
|
|
815
|
-
*/
|
|
205
|
+
/** @returns {Array<{path: string, state: string, text: string}>} most severe first */
|
|
816
206
|
get messages() {
|
|
817
|
-
|
|
818
|
-
return Object.entries(this.#validation)
|
|
819
|
-
.filter(([, msg]) => msg !== null && msg !== undefined)
|
|
820
|
-
.map(([path, msg]) => ({ path, ...msg }))
|
|
821
|
-
.sort((a, b) => (order[a.state] ?? 4) - (order[b.state] ?? 4))
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
// ── Dirty Tracking ────────────────────────────────────────
|
|
825
|
-
|
|
826
|
-
/**
|
|
827
|
-
* Whether any field has been modified from its initial value
|
|
828
|
-
* @returns {boolean}
|
|
829
|
-
*/
|
|
830
|
-
get isDirty() {
|
|
831
|
-
return !deepEqual(this.#data, this.#initialData)
|
|
207
|
+
return this.validationState.sorted
|
|
832
208
|
}
|
|
833
209
|
|
|
834
|
-
|
|
835
|
-
* Set of field paths that differ from their initial values
|
|
836
|
-
* @returns {Set<string>}
|
|
837
|
-
*/
|
|
838
|
-
get dirtyFields() {
|
|
839
|
-
const dirty = new SvelteSet()
|
|
840
|
-
this.#collectDirtyFields(this.#data, this.#initialData, '', dirty)
|
|
841
|
-
return dirty
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
/**
|
|
845
|
-
* Check if a single field has been modified from its initial value
|
|
846
|
-
* @param {string} fieldPath - Field path (without '#/' prefix)
|
|
847
|
-
* @returns {boolean}
|
|
848
|
-
*/
|
|
849
|
-
isFieldDirty(fieldPath) {
|
|
850
|
-
const current = this.getValue(fieldPath)
|
|
851
|
-
const initial = this.#getInitialValue(fieldPath)
|
|
852
|
-
return !deepEqual(current, initial)
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
/**
|
|
856
|
-
* Update the initial data snapshot to the current data.
|
|
857
|
-
* Call after a successful save to clear dirty state.
|
|
858
|
-
*/
|
|
859
|
-
snapshot() {
|
|
860
|
-
this.#initialData = deepClone(this.#data)
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
/**
|
|
864
|
-
* Get a field's initial value by path
|
|
865
|
-
* @private
|
|
866
|
-
* @param {string} path - Field path
|
|
867
|
-
* @returns {any} Initial field value
|
|
868
|
-
*/
|
|
869
|
-
#getInitialValue(path) {
|
|
870
|
-
if (!path) return undefined
|
|
871
|
-
|
|
872
|
-
const keys = path.split('/')
|
|
873
|
-
let current = this.#initialData
|
|
874
|
-
for (const key of keys) {
|
|
875
|
-
if (current && typeof current === 'object') {
|
|
876
|
-
current = current[key]
|
|
877
|
-
} else {
|
|
878
|
-
return undefined
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
return current
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
/**
|
|
885
|
-
* Recursively collect dirty field paths by comparing current vs initial
|
|
886
|
-
* @private
|
|
887
|
-
* @param {any} current - Current data (sub)tree
|
|
888
|
-
* @param {any} initial - Initial data (sub)tree
|
|
889
|
-
* @param {string} prefix - Path prefix
|
|
890
|
-
* @param {Set<string>} dirty - Accumulator set
|
|
891
|
-
*/
|
|
892
|
-
#collectDirtyFields(current, initial, prefix, dirty) {
|
|
893
|
-
const allKeys = new SvelteSet([...Object.keys(current ?? {}), ...Object.keys(initial ?? {})])
|
|
894
|
-
for (const key of allKeys) {
|
|
895
|
-
collectIfDirty({ current, initial, prefix, key, dirty })
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
/**
|
|
900
|
-
* Walk schema properties following the key path
|
|
901
|
-
* @private
|
|
902
|
-
*/
|
|
903
|
-
#walkSchemaPath(keys) {
|
|
904
|
-
let current = this.#schema.properties
|
|
905
|
-
for (const key of keys) {
|
|
906
|
-
if (current && current[key]) {
|
|
907
|
-
current = current[key]
|
|
908
|
-
} else {
|
|
909
|
-
return null
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
return current
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
/**
|
|
916
|
-
* Get schema definition for a field path
|
|
917
|
-
* @private
|
|
918
|
-
* @param {string} fieldPath - Field path
|
|
919
|
-
* @returns {Object|null} Field schema
|
|
920
|
-
*/
|
|
921
|
-
#getFieldSchema(fieldPath) {
|
|
922
|
-
if (!this.#schema?.properties) return null
|
|
923
|
-
return this.#walkSchemaPath(fieldPath.split('/'))
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
/**
|
|
927
|
-
* Get label for a field from layout
|
|
928
|
-
* @private
|
|
929
|
-
* @param {string} fieldPath - Field path
|
|
930
|
-
* @returns {string} Field label
|
|
931
|
-
*/
|
|
932
|
-
#getFieldLabel(fieldPath) {
|
|
933
|
-
const scope = `#/${fieldPath}`
|
|
934
|
-
const layoutEl = this.#layout?.elements?.find((el) => el.scope === scope)
|
|
935
|
-
return layoutElLabel(layoutEl, fieldPath)
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
/**
|
|
939
|
-
* Reset form data to initial snapshot and clear validation
|
|
940
|
-
*/
|
|
941
|
-
reset() {
|
|
942
|
-
this.#data = deepClone(this.#initialData)
|
|
943
|
-
this.#validation = {}
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
// ── Multi-Step ────────────────────────────────────────────
|
|
947
|
-
|
|
948
|
-
/** True when the layout contains step elements */
|
|
210
|
+
// ─── steps ─────────────────────────────────────────────────────────────────
|
|
949
211
|
get isMultiStep() {
|
|
950
|
-
return
|
|
212
|
+
return this.steps.isMultiStep
|
|
951
213
|
}
|
|
952
|
-
|
|
953
|
-
/** Number of step elements in the layout */
|
|
954
214
|
get totalSteps() {
|
|
955
|
-
return
|
|
215
|
+
return this.steps.total
|
|
956
216
|
}
|
|
957
|
-
|
|
958
|
-
/** Zero-based index of the active step */
|
|
959
217
|
get currentStep() {
|
|
960
|
-
return this
|
|
218
|
+
return this.steps.current
|
|
961
219
|
}
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
*/
|
|
220
|
+
get canAdvance() {
|
|
221
|
+
return this.steps.canAdvance
|
|
222
|
+
}
|
|
223
|
+
/** Validate the current step, and advance only if it passes. */
|
|
967
224
|
next() {
|
|
968
|
-
if (!this.validateStep(this
|
|
969
|
-
|
|
970
|
-
this.#currentStep++
|
|
971
|
-
return true
|
|
972
|
-
}
|
|
973
|
-
return false
|
|
225
|
+
if (!this.validateStep(this.steps.current)) return false
|
|
226
|
+
return this.steps.advance()
|
|
974
227
|
}
|
|
975
|
-
|
|
976
|
-
/**
|
|
977
|
-
* Move to the previous step without validation.
|
|
978
|
-
* @returns {boolean} true if moved, false if already on first step
|
|
979
|
-
*/
|
|
228
|
+
/** Back one step, without validating. */
|
|
980
229
|
prev() {
|
|
981
|
-
|
|
982
|
-
this.#currentStep--
|
|
983
|
-
return true
|
|
984
|
-
}
|
|
985
|
-
return false
|
|
230
|
+
return this.steps.back()
|
|
986
231
|
}
|
|
987
|
-
|
|
988
|
-
/**
|
|
989
|
-
* Navigate to a previously visited step (index < currentStep).
|
|
990
|
-
* @param {number} index - Target step index
|
|
991
|
-
*/
|
|
232
|
+
/** Return to a visited step. */
|
|
992
233
|
goToStep(index) {
|
|
993
|
-
|
|
994
|
-
this.#currentStep = index
|
|
234
|
+
this.steps.goTo(index)
|
|
995
235
|
}
|
|
996
236
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
*/
|
|
1002
|
-
isStepValid(index = this.#currentStep) {
|
|
1003
|
-
return this.validateStep(index)
|
|
237
|
+
// ─── lookups ───────────────────────────────────────────────────────────────
|
|
238
|
+
/** The underlying lookup manager, or null for a form without lookups. */
|
|
239
|
+
get lookupManager() {
|
|
240
|
+
return this.lookups.manager
|
|
1004
241
|
}
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
* @param {number} index - Step index
|
|
1009
|
-
* @returns {boolean} true if no errors
|
|
1010
|
-
*/
|
|
1011
|
-
validateStep(index) {
|
|
1012
|
-
const stepEl = (this.#layout?.elements ?? [])[index]
|
|
1013
|
-
if (!stepEl || stepEl.type !== 'step') return true
|
|
1014
|
-
const stepLayout = { ...this.#layout, elements: stepEl.elements ?? [] }
|
|
1015
|
-
const results = validateAllFields(this.#data, this.#schema, stepLayout)
|
|
1016
|
-
this.#applyStepValidation(results, stepEl.elements)
|
|
1017
|
-
return isAllValid(results)
|
|
242
|
+
/** @param {Object<string, import('./lookup.svelte.js').LookupConfig>} lookups */
|
|
243
|
+
setLookups(lookups) {
|
|
244
|
+
this.lookups.configure(lookups)
|
|
1018
245
|
}
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
* @private
|
|
1023
|
-
*/
|
|
1024
|
-
#applyStepValidation(results, elements) {
|
|
1025
|
-
const paths = new SvelteSet(this.#collectStepPaths(elements))
|
|
1026
|
-
for (const path of paths) {
|
|
1027
|
-
this.setFieldValidation(path, results[path] ?? null)
|
|
1028
|
-
}
|
|
246
|
+
/** @returns {{ options: any[], loading: boolean, error: string|null, fields: Object, disabled: boolean }|null} */
|
|
247
|
+
getLookupState(fieldPath) {
|
|
248
|
+
return this.lookups.state(toFieldPath(fieldPath))
|
|
1029
249
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
* @private
|
|
1034
|
-
* @param {Object[]} elements - Step element array
|
|
1035
|
-
* @returns {string[]}
|
|
1036
|
-
*/
|
|
1037
|
-
#collectStepPaths(elements) {
|
|
1038
|
-
const paths = []
|
|
1039
|
-
for (const el of elements ?? []) {
|
|
1040
|
-
if (el.scope) paths.push(el.scope.replace(/^#\//, ''))
|
|
1041
|
-
if (el.elements) paths.push(...this.#collectStepPaths(el.elements))
|
|
1042
|
-
}
|
|
1043
|
-
return paths
|
|
250
|
+
/** Disabled because a field its lookup depends on is not set yet. */
|
|
251
|
+
isFieldDisabled(path) {
|
|
252
|
+
return this.lookups.isDisabled(toFieldPath(path))
|
|
1044
253
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
* Collect all field paths across all steps.
|
|
1048
|
-
* @private
|
|
1049
|
-
* @returns {Set<string>}
|
|
1050
|
-
*/
|
|
1051
|
-
/* v8 ignore next 9 — defined for future use; not yet called by any public method */
|
|
1052
|
-
#getAllStepPaths() {
|
|
1053
|
-
const paths = new SvelteSet()
|
|
1054
|
-
for (const step of this.#layout?.elements ?? []) {
|
|
1055
|
-
if (step.type === 'step') {
|
|
1056
|
-
for (const path of this.#collectStepPaths(step.elements)) paths.add(path)
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
return paths
|
|
254
|
+
async refreshLookup(path) {
|
|
255
|
+
await this.lookups.refresh(toFieldPath(path))
|
|
1060
256
|
}
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
*/
|
|
1067
|
-
#getActiveElements() {
|
|
1068
|
-
const elements = this.#layout?.elements ?? []
|
|
1069
|
-
if (!elements.some((el) => el.type === 'step')) return elements
|
|
1070
|
-
return elements[this.#currentStep]?.elements ?? []
|
|
257
|
+
hasLookup(fieldPath) {
|
|
258
|
+
return this.lookups.has(toFieldPath(fieldPath))
|
|
259
|
+
}
|
|
260
|
+
async initializeLookups() {
|
|
261
|
+
await this.lookups.initialize()
|
|
1071
262
|
}
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
// ── Module-level helpers (no `this`) ──────────────────────────────────────────
|
|
1075
|
-
|
|
1076
|
-
/**
|
|
1077
|
-
* Returns true when no result in the map has state === 'error'
|
|
1078
|
-
* @private
|
|
1079
|
-
*/
|
|
1080
|
-
function isAllValid(results) {
|
|
1081
|
-
return Object.values(results).every((msg) => msg?.state !== 'error')
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
/**
|
|
1085
|
-
* Return the display label for a layout element, falling back to fieldPath
|
|
1086
|
-
* @private
|
|
1087
|
-
*/
|
|
1088
|
-
function layoutElLabel(layoutEl, fieldPath) {
|
|
1089
|
-
if (layoutEl?.label) return layoutEl.label
|
|
1090
|
-
return layoutEl?.title ?? fieldPath
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
/**
|
|
1094
|
-
* Apply options/loading state from lookup to finalProps
|
|
1095
|
-
* @private
|
|
1096
|
-
*/
|
|
1097
|
-
function applyLookupData(lookupState, finalProps) {
|
|
1098
|
-
if (lookupState.options?.length > 0) finalProps.options = lookupState.options
|
|
1099
|
-
if (lookupState.loading) finalProps.loading = true
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
/**
|
|
1103
|
-
* Apply disabled/fields state from lookup to finalProps
|
|
1104
|
-
* @private
|
|
1105
|
-
*/
|
|
1106
|
-
function applyLookupMeta(lookupState, finalProps) {
|
|
1107
|
-
if (lookupState.disabled) finalProps.disabled = true
|
|
1108
|
-
if (lookupState.fields && !finalProps.fields) finalProps.fields = lookupState.fields
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
/**
|
|
1112
|
-
* Merge all lookup properties into finalProps
|
|
1113
|
-
* @private
|
|
1114
|
-
*/
|
|
1115
|
-
function applyLookupProps(lookupState, finalProps) {
|
|
1116
|
-
applyLookupData(lookupState, finalProps)
|
|
1117
|
-
applyLookupMeta(lookupState, finalProps)
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
/**
|
|
1121
|
-
* Add path to dirty set if current and initial values differ
|
|
1122
|
-
* @private
|
|
1123
|
-
* @param {{ current: any, initial: any, prefix: string, key: string, dirty: Set<string> }} ctx
|
|
1124
|
-
*/
|
|
1125
|
-
function collectIfDirty({ current, initial, prefix, key, dirty }) {
|
|
1126
|
-
const path = prefix ? `${prefix}/${key}` : key
|
|
1127
|
-
if (!deepEqual(current?.[key], initial?.[key])) dirty.add(path)
|
|
1128
263
|
}
|