@rokkit/forms 1.7.0 → 1.8.1
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 +11 -4
- 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/fields.js +14 -1
- package/dist/lib/schema.d.ts +25 -0
- package/dist/lib/schema.js +46 -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 +17 -30
- package/dist/lib/values.d.ts +42 -0
- package/dist/lib/values.js +90 -0
- package/package.json +1 -1
- package/dist/lib/conditions.spec.d.ts +0 -1
- package/dist/lib/conditions.spec.js +0 -58
- package/dist/lib/fields.spec.d.ts +0 -1
- package/dist/lib/fields.spec.js +0 -250
- package/dist/lib/fixtures/combined-schema-layout.json +0 -64
- package/dist/lib/fixtures/input-layout.json +0 -68
- package/dist/lib/fixtures/input-schema.json +0 -16
- package/dist/lib/fixtures/result-with-generated-layout.json +0 -64
- package/dist/lib/fixtures/result-with-generated-schema.json +0 -60
- package/dist/lib/fixtures/result-with-only-data.json +0 -60
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The input type for a field: an explicit renderer, then a non-basic format (`email`, `date`…),
|
|
3
|
+
* then its schema type — a bounded number is a range, a string with choices a select. May set
|
|
4
|
+
* `props.options` from an enum.
|
|
5
|
+
* @param {Record<string, any>} props
|
|
6
|
+
* @returns {string}
|
|
7
|
+
*/
|
|
8
|
+
export function resolveInputType(props: Record<string, any>): string;
|
|
9
|
+
/**
|
|
10
|
+
* The FormElements for the given layout elements (a step's, or a flat layout's).
|
|
11
|
+
*
|
|
12
|
+
* A field hidden by `showWhen` has no element. If building throws — a layout scope the schema
|
|
13
|
+
* does not know is one way — the whole layout falls back to plain text inputs rather than
|
|
14
|
+
* rendering nothing.
|
|
15
|
+
*
|
|
16
|
+
* @param {Object[]} layoutElements
|
|
17
|
+
* @param {ElementContext} ctx
|
|
18
|
+
*/
|
|
19
|
+
export function buildElements(layoutElements: Object[], ctx: ElementContext): any;
|
|
20
|
+
/**
|
|
21
|
+
* Layout + schema + state → the `FormElement`s a `FormRenderer` draws. Pure: everything it needs
|
|
22
|
+
* arrives in `ctx`, so it is testable without a FormBuilder and derivable as one `$derived`.
|
|
23
|
+
*/
|
|
24
|
+
export type ElementContext = {
|
|
25
|
+
schema: Object;
|
|
26
|
+
/**
|
|
27
|
+
* - the whole layout (the fallback path renders all of it)
|
|
28
|
+
*/
|
|
29
|
+
layout: Object;
|
|
30
|
+
data: Record<string, unknown>;
|
|
31
|
+
value: (path: string) => unknown;
|
|
32
|
+
/**
|
|
33
|
+
* - validation message for a field
|
|
34
|
+
*/
|
|
35
|
+
message: (path: string) => Object | null;
|
|
36
|
+
dirty: (path: string) => boolean;
|
|
37
|
+
/**
|
|
38
|
+
* - fold lookup state into props
|
|
39
|
+
*/
|
|
40
|
+
applyLookup: (path: string, props: Object) => void;
|
|
41
|
+
};
|
|
42
|
+
export type FormElement = {
|
|
43
|
+
/**
|
|
44
|
+
* - JSON Pointer path (e.g., '#/email', '#/user/name')
|
|
45
|
+
*/
|
|
46
|
+
scope: string;
|
|
47
|
+
/**
|
|
48
|
+
* - Input type (text, number, range, checkbox, select, etc.)
|
|
49
|
+
*/
|
|
50
|
+
type: string;
|
|
51
|
+
/**
|
|
52
|
+
* - Current value from data
|
|
53
|
+
*/
|
|
54
|
+
value: any;
|
|
55
|
+
/**
|
|
56
|
+
* - Whether to use custom child snippet (from layout)
|
|
57
|
+
*/
|
|
58
|
+
override: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* - Merged properties from schema + layout + validation
|
|
61
|
+
*/
|
|
62
|
+
props: {
|
|
63
|
+
label?: string | undefined;
|
|
64
|
+
description?: string | undefined;
|
|
65
|
+
placeholder?: string | undefined;
|
|
66
|
+
required?: boolean | undefined;
|
|
67
|
+
min?: number | undefined;
|
|
68
|
+
max?: number | undefined;
|
|
69
|
+
message?: {
|
|
70
|
+
/**
|
|
71
|
+
* - Message state: 'error', 'warning', 'info', 'success'
|
|
72
|
+
*/
|
|
73
|
+
state?: string | undefined;
|
|
74
|
+
/**
|
|
75
|
+
* - Message text content
|
|
76
|
+
*/
|
|
77
|
+
text?: string | undefined;
|
|
78
|
+
} | undefined;
|
|
79
|
+
dirty?: boolean | undefined;
|
|
80
|
+
};
|
|
81
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout + schema + state → the `FormElement`s a `FormRenderer` draws. Pure: everything it needs
|
|
3
|
+
* arrives in `ctx`, so it is testable without a FormBuilder and derivable as one `$derived`.
|
|
4
|
+
*
|
|
5
|
+
* @typedef {Object} ElementContext
|
|
6
|
+
* @property {Object} schema
|
|
7
|
+
* @property {Object} layout - the whole layout (the fallback path renders all of it)
|
|
8
|
+
* @property {Record<string, unknown>} data
|
|
9
|
+
* @property {(path: string) => unknown} value
|
|
10
|
+
* @property {(path: string) => Object | null} message - validation message for a field
|
|
11
|
+
* @property {(path: string) => boolean} dirty
|
|
12
|
+
* @property {(path: string, props: Object) => void} applyLookup - fold lookup state into props
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} FormElement
|
|
16
|
+
* @property {string} scope - JSON Pointer path (e.g., '#/email', '#/user/name')
|
|
17
|
+
* @property {string} type - Input type (text, number, range, checkbox, select, etc.)
|
|
18
|
+
* @property {any} value - Current value from data
|
|
19
|
+
* @property {boolean} override - Whether to use custom child snippet (from layout)
|
|
20
|
+
* @property {Object} props - Merged properties from schema + layout + validation
|
|
21
|
+
* @property {string} [props.label] - Display label (from layout)
|
|
22
|
+
* @property {string} [props.description] - Help text (from layout)
|
|
23
|
+
* @property {string} [props.placeholder] - Placeholder text (from layout)
|
|
24
|
+
* @property {boolean} [props.required] - Required flag (from schema)
|
|
25
|
+
* @property {number} [props.min] - Minimum value (from schema)
|
|
26
|
+
* @property {number} [props.max] - Maximum value (from schema)
|
|
27
|
+
* @property {Object} [props.message] - Validation message object
|
|
28
|
+
* @property {string} [props.message.state] - Message state: 'error', 'warning', 'info', 'success'
|
|
29
|
+
* @property {string} [props.message.text] - Message text content
|
|
30
|
+
* @property {boolean} [props.dirty] - Whether field value differs from initial
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { getSchemaWithLayout } from './fields.js'
|
|
34
|
+
import { evaluateCondition } from './conditions.js'
|
|
35
|
+
|
|
36
|
+
/** Schema types with a fixed input type. */
|
|
37
|
+
const SCHEMA_TYPE_MAP = { boolean: 'checkbox', array: 'array' }
|
|
38
|
+
|
|
39
|
+
const pathOf = (scope) => scope.replace(/^#\//, '')
|
|
40
|
+
|
|
41
|
+
function stringInputType(props) {
|
|
42
|
+
if (!props.enum && !props.options) return 'text'
|
|
43
|
+
// A select reads `options`; an enum is the options when none are given.
|
|
44
|
+
if (Array.isArray(props.enum) && !props.options) props.options = props.enum
|
|
45
|
+
return 'select'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function schemaInputType(props) {
|
|
49
|
+
if (props.type === 'number' || props.type === 'integer') {
|
|
50
|
+
return props.min !== undefined && props.max !== undefined ? 'range' : 'number'
|
|
51
|
+
}
|
|
52
|
+
if (props.type === 'string') return stringInputType(props)
|
|
53
|
+
return SCHEMA_TYPE_MAP[props.type] ?? 'text'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The input type for a field: an explicit renderer, then a non-basic format (`email`, `date`…),
|
|
58
|
+
* then its schema type — a bounded number is a range, a string with choices a select. May set
|
|
59
|
+
* `props.options` from an enum.
|
|
60
|
+
* @param {Record<string, any>} props
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export function resolveInputType(props) {
|
|
64
|
+
if (props.renderer) return props.renderer
|
|
65
|
+
if (props.format && !['text', 'number'].includes(props.format)) return props.format
|
|
66
|
+
return schemaInputType(props)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A display element (`display-*`): its own type, its bound value, no input. */
|
|
70
|
+
function displayElement(layoutEl, ctx) {
|
|
71
|
+
const { type, scope: rawScope, ...props } = layoutEl
|
|
72
|
+
const scope = rawScope ?? null
|
|
73
|
+
const value = scope ? ctx.value(pathOf(scope)) : null
|
|
74
|
+
return { type, scope, value, override: false, props }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** An unscoped element — a separator, a spacer, a heading. */
|
|
78
|
+
function unscopedElement(layoutEl) {
|
|
79
|
+
const { type, ...props } = layoutEl
|
|
80
|
+
return { type: type ?? 'separator', scope: null, value: null, override: false, props }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function groupElement(element, path, scope, ctx) {
|
|
84
|
+
const children = element.elements.map((child) => fieldElement(child, ctx, path))
|
|
85
|
+
const {
|
|
86
|
+
key: _key,
|
|
87
|
+
elements: _elements,
|
|
88
|
+
override: _override,
|
|
89
|
+
props: groupProps,
|
|
90
|
+
...topLevel
|
|
91
|
+
} = element
|
|
92
|
+
return {
|
|
93
|
+
scope,
|
|
94
|
+
type: 'group',
|
|
95
|
+
value: ctx.value(path),
|
|
96
|
+
override: element.override || false,
|
|
97
|
+
props: { ...topLevel, ...groupProps, elements: children, message: ctx.message(path) }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readonlyElement(element, path, scope, ctx) {
|
|
102
|
+
return {
|
|
103
|
+
scope,
|
|
104
|
+
type: 'info',
|
|
105
|
+
value: ctx.value(path),
|
|
106
|
+
override: element.override || false,
|
|
107
|
+
props: { ...element.props, type: 'info', message: ctx.message(path) }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function inputElement(element, path, scope, ctx) {
|
|
112
|
+
// `override` is authored on the LAYOUT element, and getSchemaWithLayout folds unknown layout
|
|
113
|
+
// keys into `props` — read only from `element.override` it was always false and the child
|
|
114
|
+
// snippet never rendered. Take it from either place; keep it out of the input's props.
|
|
115
|
+
const { override: propsOverride, ...props } = element.props ?? {}
|
|
116
|
+
const type = resolveInputType(props)
|
|
117
|
+
const finalProps = { ...props, type, message: ctx.message(path), dirty: ctx.dirty(path) }
|
|
118
|
+
ctx.applyLookup(path, finalProps)
|
|
119
|
+
return {
|
|
120
|
+
scope,
|
|
121
|
+
type,
|
|
122
|
+
value: ctx.value(path),
|
|
123
|
+
override: element.override || propsOverride || false,
|
|
124
|
+
props: finalProps
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A combined schema+layout element → FormElement. Null for one without a key. */
|
|
129
|
+
function fieldElement(element, ctx, parentPath = '') {
|
|
130
|
+
/* v8 ignore start -- unreachable: every element arrives from getSchemaWithLayout's output,
|
|
131
|
+
which combinedByKey keeps only when keyed and whose group children are keyed too. Kept as
|
|
132
|
+
the guard FormBuilder always had. `ignore next` does not fire on a one-line `if`. */
|
|
133
|
+
if (!element.key) return null
|
|
134
|
+
/* v8 ignore stop */
|
|
135
|
+
const path = parentPath ? `${parentPath}/${element.key}` : element.key
|
|
136
|
+
const scope = `#/${path}`
|
|
137
|
+
if (element.elements) return groupElement(element, path, scope, ctx)
|
|
138
|
+
if (element.props.readonly) return readonlyElement(element, path, scope, ctx)
|
|
139
|
+
return inputElement(element, path, scope, ctx)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The schema-merged element for each scoped, non-display layout element, by field key. */
|
|
143
|
+
function combinedByKey(layoutElements, ctx) {
|
|
144
|
+
const elements = layoutElements.filter((el) => el.scope && !el.type?.startsWith('display-'))
|
|
145
|
+
const combined = getSchemaWithLayout(ctx.schema, { ...ctx.layout, elements })
|
|
146
|
+
return new Map((combined.elements ?? []).filter((el) => el.key).map((el) => [el.key, el]))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function scopedElement(layoutEl, combined, ctx) {
|
|
150
|
+
if (layoutEl.showWhen && !evaluateCondition(layoutEl.showWhen, ctx.data)) return null
|
|
151
|
+
const merged = combined.get(pathOf(layoutEl.scope).split('/').pop())
|
|
152
|
+
return merged ? fieldElement(merged, ctx) : null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function layoutElement(layoutEl, combined, ctx) {
|
|
156
|
+
if (layoutEl.type?.startsWith('display-')) return displayElement(layoutEl, ctx)
|
|
157
|
+
if (!layoutEl.scope) return unscopedElement(layoutEl)
|
|
158
|
+
return scopedElement(layoutEl, combined, ctx)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Last resort when building fails: every scoped layout element as a plain text input. */
|
|
162
|
+
function plainElements(ctx) {
|
|
163
|
+
return (ctx.layout.elements ?? [])
|
|
164
|
+
.filter((el) => el.scope)
|
|
165
|
+
.map(({ scope, label, override = false, ...layoutProps }) => {
|
|
166
|
+
const path = pathOf(scope)
|
|
167
|
+
const props = {
|
|
168
|
+
label: label || path,
|
|
169
|
+
...layoutProps,
|
|
170
|
+
message: ctx.message(path),
|
|
171
|
+
dirty: ctx.dirty(path),
|
|
172
|
+
type: 'text'
|
|
173
|
+
}
|
|
174
|
+
return { scope, type: 'text', value: ctx.value(path), override, props }
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The FormElements for the given layout elements (a step's, or a flat layout's).
|
|
180
|
+
*
|
|
181
|
+
* A field hidden by `showWhen` has no element. If building throws — a layout scope the schema
|
|
182
|
+
* does not know is one way — the whole layout falls back to plain text inputs rather than
|
|
183
|
+
* rendering nothing.
|
|
184
|
+
*
|
|
185
|
+
* @param {Object[]} layoutElements
|
|
186
|
+
* @param {ElementContext} ctx
|
|
187
|
+
*/
|
|
188
|
+
export function buildElements(layoutElements, ctx) {
|
|
189
|
+
try {
|
|
190
|
+
const combined = combinedByKey(layoutElements, ctx)
|
|
191
|
+
return layoutElements.map((el) => layoutElement(el, combined, ctx)).filter(Boolean)
|
|
192
|
+
} catch (error) {
|
|
193
|
+
console.warn('Failed to build elements:', error) // eslint-disable-line no-console
|
|
194
|
+
return plainElements(ctx)
|
|
195
|
+
}
|
|
196
|
+
}
|
package/dist/lib/fields.js
CHANGED
|
@@ -75,9 +75,11 @@ export function findAttributeByPath(scope, schema) {
|
|
|
75
75
|
|
|
76
76
|
const pathArray = scope.split('/').slice(1)
|
|
77
77
|
let schemaPointer = schema
|
|
78
|
+
let parent = null
|
|
78
79
|
let currentKey = ''
|
|
79
80
|
|
|
80
81
|
pathArray.forEach((key) => {
|
|
82
|
+
parent = schemaPointer
|
|
81
83
|
schemaPointer = schemaPointer.properties[key]
|
|
82
84
|
currentKey = key
|
|
83
85
|
})
|
|
@@ -86,10 +88,21 @@ export function findAttributeByPath(scope, schema) {
|
|
|
86
88
|
|
|
87
89
|
return {
|
|
88
90
|
key: currentKey,
|
|
89
|
-
props:
|
|
91
|
+
props: requiredProps(schemaPointer, parent, currentKey)
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* A field's props with `required` as the input reads it: true when the field says so or its
|
|
97
|
+
* parent's JSON Schema `required` list names it. An object's own list is its children's rule —
|
|
98
|
+
* dropped here so it never reaches an input as a truthy `required`.
|
|
99
|
+
*/
|
|
100
|
+
function requiredProps(fieldSchema, parent, key) {
|
|
101
|
+
const { required, ...props } = fieldSchema
|
|
102
|
+
const listed = Array.isArray(parent?.required) && parent.required.includes(key)
|
|
103
|
+
return required === true || listed ? { ...props, required: true } : props
|
|
104
|
+
}
|
|
105
|
+
|
|
93
106
|
/**
|
|
94
107
|
* Combines an element from layout with schema
|
|
95
108
|
*
|
package/dist/lib/schema.d.ts
CHANGED
|
@@ -5,3 +5,28 @@
|
|
|
5
5
|
* @returns {import('../types').DataSchema}
|
|
6
6
|
*/
|
|
7
7
|
export function deriveSchemaFromValue(data: any): import("../types").DataSchema;
|
|
8
|
+
/**
|
|
9
|
+
* A field's schema by slash path (`'addr/city'`), or null.
|
|
10
|
+
*
|
|
11
|
+
* Each step looks in the current schema's `properties` first — how JSON Schema nests an object's
|
|
12
|
+
* fields — and falls back to the key directly on it, the flat form some schemas here use
|
|
13
|
+
* (`properties.a.b`). `properties` first, so a nested field named like a schema keyword (`type`)
|
|
14
|
+
* resolves to the field, not to its parent's keyword.
|
|
15
|
+
*
|
|
16
|
+
* @param {Object | null | undefined} schema
|
|
17
|
+
* @param {string} path
|
|
18
|
+
* @returns {Object | null}
|
|
19
|
+
*/
|
|
20
|
+
export function schemaAt(schema: Object | null | undefined, path: string): Object | null;
|
|
21
|
+
/**
|
|
22
|
+
* Whether the field at a slash path must have a value.
|
|
23
|
+
*
|
|
24
|
+
* JSON Schema states it on the PARENT object — `{ type: 'object', required: ['city'], … }` — at
|
|
25
|
+
* any depth. The field-level `required: true` form is also honoured. An object's own `required`
|
|
26
|
+
* array is its children's rule, never a claim that the object itself is required.
|
|
27
|
+
*
|
|
28
|
+
* @param {Object | null | undefined} schema
|
|
29
|
+
* @param {string} path
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
export function requiredAt(schema: Object | null | undefined, path: string): boolean;
|
package/dist/lib/schema.js
CHANGED
|
@@ -29,3 +29,49 @@ export function deriveSchemaFromValue(data) {
|
|
|
29
29
|
}
|
|
30
30
|
return schema
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
const isSchemaObject = (value) => Boolean(value) && typeof value === 'object'
|
|
34
|
+
|
|
35
|
+
/** One step down: the JSON Schema child, else the flat one. */
|
|
36
|
+
function childSchema(parent, key) {
|
|
37
|
+
if (!isSchemaObject(parent)) return undefined
|
|
38
|
+
return parent.properties?.[key] ?? parent[key]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A field's schema by slash path (`'addr/city'`), or null.
|
|
43
|
+
*
|
|
44
|
+
* Each step looks in the current schema's `properties` first — how JSON Schema nests an object's
|
|
45
|
+
* fields — and falls back to the key directly on it, the flat form some schemas here use
|
|
46
|
+
* (`properties.a.b`). `properties` first, so a nested field named like a schema keyword (`type`)
|
|
47
|
+
* resolves to the field, not to its parent's keyword.
|
|
48
|
+
*
|
|
49
|
+
* @param {Object | null | undefined} schema
|
|
50
|
+
* @param {string} path
|
|
51
|
+
* @returns {Object | null}
|
|
52
|
+
*/
|
|
53
|
+
export function schemaAt(schema, path) {
|
|
54
|
+
// The root's own keys are the schema's keywords, never fields: only its `properties` count.
|
|
55
|
+
let current = schema?.properties?.[path.split('/')[0]]
|
|
56
|
+
for (const key of path.split('/').slice(1)) current = childSchema(current, key)
|
|
57
|
+
return isSchemaObject(current) ? current : null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether the field at a slash path must have a value.
|
|
62
|
+
*
|
|
63
|
+
* JSON Schema states it on the PARENT object — `{ type: 'object', required: ['city'], … }` — at
|
|
64
|
+
* any depth. The field-level `required: true` form is also honoured. An object's own `required`
|
|
65
|
+
* array is its children's rule, never a claim that the object itself is required.
|
|
66
|
+
*
|
|
67
|
+
* @param {Object | null | undefined} schema
|
|
68
|
+
* @param {string} path
|
|
69
|
+
* @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
export function requiredAt(schema, path) {
|
|
72
|
+
if (schemaAt(schema, path)?.required === true) return true
|
|
73
|
+
const keys = path.split('/')
|
|
74
|
+
const key = keys.pop()
|
|
75
|
+
const parent = keys.length > 0 ? schemaAt(schema, keys.join('/')) : schema
|
|
76
|
+
return Array.isArray(parent?.required) && parent.required.includes(key)
|
|
77
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the form IS: its schema (the fields and their rules) and its layout (how they are
|
|
3
|
+
* arranged), each derivable from the other or from the data.
|
|
4
|
+
*
|
|
5
|
+
* The setters carry the derivation rules, so a caller can set either at any time:
|
|
6
|
+
* - a schema set explicitly is the source of truth for which fields exist — a layout that was
|
|
7
|
+
* only ever derived (perhaps from empty data) is re-derived from it; an explicit, non-empty
|
|
8
|
+
* layout is kept;
|
|
9
|
+
* - a null schema derives from the current data;
|
|
10
|
+
* - a null layout derives from the schema when it declares fields — so declared fields render
|
|
11
|
+
* even when the data starts as `{}`, the norm for LLM-generated and reset forms — else from
|
|
12
|
+
* the data.
|
|
13
|
+
*/
|
|
14
|
+
export class FormDefinition {
|
|
15
|
+
/**
|
|
16
|
+
* @param {import('./FormValues.svelte.js').FormValues} values
|
|
17
|
+
* @param {Object|null} schema
|
|
18
|
+
* @param {Object|null} layout
|
|
19
|
+
*/
|
|
20
|
+
constructor(values: import("./FormValues.svelte.js").FormValues, schema: Object | null, layout: Object | null);
|
|
21
|
+
set schema(value: {});
|
|
22
|
+
get schema(): {};
|
|
23
|
+
set layout(value: {});
|
|
24
|
+
get layout(): {};
|
|
25
|
+
/** Schema merged with the layout's scoped elements. */
|
|
26
|
+
get combined(): any;
|
|
27
|
+
/** The schema of one field by slash path, or null — see `schemaAt`. */
|
|
28
|
+
fieldSchema(path: any): Object | null;
|
|
29
|
+
/** A field's display label: its layout element's label, then title, then the path itself. */
|
|
30
|
+
fieldLabel(path: any): any;
|
|
31
|
+
#private;
|
|
32
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { deriveSchemaFromValue, schemaAt } from '../schema.js'
|
|
2
|
+
import { deriveLayoutFromValue, deriveLayoutFromSchema } from '../layout.js'
|
|
3
|
+
import { getSchemaWithLayout } from '../fields.js'
|
|
4
|
+
|
|
5
|
+
const hasFields = (schema) =>
|
|
6
|
+
Boolean(
|
|
7
|
+
schema &&
|
|
8
|
+
typeof schema === 'object' &&
|
|
9
|
+
schema.properties &&
|
|
10
|
+
typeof schema.properties === 'object' &&
|
|
11
|
+
Object.keys(schema.properties).length > 0
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const hasElements = (layout) =>
|
|
15
|
+
Boolean(layout && Array.isArray(layout.elements) && layout.elements.length > 0)
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What the form IS: its schema (the fields and their rules) and its layout (how they are
|
|
19
|
+
* arranged), each derivable from the other or from the data.
|
|
20
|
+
*
|
|
21
|
+
* The setters carry the derivation rules, so a caller can set either at any time:
|
|
22
|
+
* - a schema set explicitly is the source of truth for which fields exist — a layout that was
|
|
23
|
+
* only ever derived (perhaps from empty data) is re-derived from it; an explicit, non-empty
|
|
24
|
+
* layout is kept;
|
|
25
|
+
* - a null schema derives from the current data;
|
|
26
|
+
* - a null layout derives from the schema when it declares fields — so declared fields render
|
|
27
|
+
* even when the data starts as `{}`, the norm for LLM-generated and reset forms — else from
|
|
28
|
+
* the data.
|
|
29
|
+
*/
|
|
30
|
+
export class FormDefinition {
|
|
31
|
+
#values
|
|
32
|
+
#schema = $state({})
|
|
33
|
+
#layout = $state({})
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {import('./FormValues.svelte.js').FormValues} values
|
|
37
|
+
* @param {Object|null} schema
|
|
38
|
+
* @param {Object|null} layout
|
|
39
|
+
*/
|
|
40
|
+
constructor(values, schema, layout) {
|
|
41
|
+
this.#values = values
|
|
42
|
+
this.schema = schema
|
|
43
|
+
this.layout = layout
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get schema() {
|
|
47
|
+
return this.#schema
|
|
48
|
+
}
|
|
49
|
+
set schema(value) {
|
|
50
|
+
this.#schema = value ?? deriveSchemaFromValue(this.#values.data)
|
|
51
|
+
if (!hasElements(this.#layout)) this.#layout = deriveLayoutFromSchema(this.#schema)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get layout() {
|
|
55
|
+
return this.#layout
|
|
56
|
+
}
|
|
57
|
+
set layout(value) {
|
|
58
|
+
if (value) {
|
|
59
|
+
this.#layout = value
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
this.#layout = hasFields(this.#schema)
|
|
63
|
+
? deriveLayoutFromSchema(this.#schema)
|
|
64
|
+
: deriveLayoutFromValue(this.#values.data)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Schema merged with the layout's scoped elements. */
|
|
68
|
+
get combined() {
|
|
69
|
+
const elements = (this.#layout?.elements ?? []).filter((el) => el.scope)
|
|
70
|
+
return getSchemaWithLayout(this.#schema, { ...this.#layout, elements })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The schema of one field by slash path, or null — see `schemaAt`. */
|
|
74
|
+
fieldSchema(path) {
|
|
75
|
+
return schemaAt(this.#schema, path)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A field's display label: its layout element's label, then title, then the path itself. */
|
|
79
|
+
fieldLabel(path) {
|
|
80
|
+
const el = this.#layout?.elements?.find((e) => e.scope === `#/${path}`)
|
|
81
|
+
return el?.label || (el?.title ?? path)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fields whose options come from elsewhere — a URL, an async hook, or a filtered source — and
|
|
3
|
+
* the fields they depend on.
|
|
4
|
+
*
|
|
5
|
+
* A form with no lookups has no manager at all, and every question here answers safely.
|
|
6
|
+
*/
|
|
7
|
+
export class FormLookups {
|
|
8
|
+
/**
|
|
9
|
+
* @param {import('./FormValues.svelte.js').FormValues} values
|
|
10
|
+
* @param {Object<string, import('../lookup.svelte.js').LookupConfig>} lookups
|
|
11
|
+
*/
|
|
12
|
+
constructor(values: import("./FormValues.svelte.js").FormValues, lookups?: {
|
|
13
|
+
[x: string]: import("../lookup.svelte.js").LookupConfig;
|
|
14
|
+
});
|
|
15
|
+
get manager(): Object | null;
|
|
16
|
+
/** Replace the lookups. Always builds a manager, even for none — as `setLookups` always has. */
|
|
17
|
+
configure(lookups: any): void;
|
|
18
|
+
/** A field's options, loading, error, fields and disabled — or null when it has no lookup. */
|
|
19
|
+
state(path: any): {
|
|
20
|
+
options: any;
|
|
21
|
+
loading: any;
|
|
22
|
+
error: any;
|
|
23
|
+
fields: any;
|
|
24
|
+
disabled: any;
|
|
25
|
+
} | null;
|
|
26
|
+
/** Disabled because a field it depends on is not set yet. */
|
|
27
|
+
isDisabled(path: any): any;
|
|
28
|
+
has(path: any): any;
|
|
29
|
+
refresh(path: any): Promise<void>;
|
|
30
|
+
initialize(): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* A field changed: clear the (top-level) values of lookups that depend on it — their options
|
|
33
|
+
* are about to change under them — then re-fetch those lookups.
|
|
34
|
+
*/
|
|
35
|
+
fieldChanged(path: any): void;
|
|
36
|
+
/** Fold a field's lookup state into an element's props (mutates them). */
|
|
37
|
+
applyTo(path: any, props: any): void;
|
|
38
|
+
#private;
|
|
39
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createLookupManager } from '../lookup.svelte.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fields whose options come from elsewhere — a URL, an async hook, or a filtered source — and
|
|
5
|
+
* the fields they depend on.
|
|
6
|
+
*
|
|
7
|
+
* A form with no lookups has no manager at all, and every question here answers safely.
|
|
8
|
+
*/
|
|
9
|
+
export class FormLookups {
|
|
10
|
+
#values
|
|
11
|
+
/** @type {ReturnType<typeof createLookupManager> | null} */
|
|
12
|
+
#manager = $state(null)
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {import('./FormValues.svelte.js').FormValues} values
|
|
16
|
+
* @param {Object<string, import('../lookup.svelte.js').LookupConfig>} lookups
|
|
17
|
+
*/
|
|
18
|
+
constructor(values, lookups = {}) {
|
|
19
|
+
this.#values = values
|
|
20
|
+
if (Object.keys(lookups).length > 0) this.#manager = createLookupManager(lookups)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get manager() {
|
|
24
|
+
return this.#manager
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Replace the lookups. Always builds a manager, even for none — as `setLookups` always has. */
|
|
28
|
+
configure(lookups) {
|
|
29
|
+
this.#manager = createLookupManager(lookups)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @param {string} path */
|
|
33
|
+
#lookup(path) {
|
|
34
|
+
return this.#manager?.getLookup(path)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A field's options, loading, error, fields and disabled — or null when it has no lookup. */
|
|
38
|
+
state(path) {
|
|
39
|
+
const lookup = this.#lookup(path)
|
|
40
|
+
if (!lookup) return null
|
|
41
|
+
const { options, loading, error, fields, disabled } = lookup
|
|
42
|
+
return { options, loading, error, fields, disabled }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Disabled because a field it depends on is not set yet. */
|
|
46
|
+
isDisabled(path) {
|
|
47
|
+
return this.#lookup(path)?.disabled ?? false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
has(path) {
|
|
51
|
+
return this.#manager?.hasLookup(path) ?? false
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async refresh(path) {
|
|
55
|
+
await this.#lookup(path)?.fetch(this.#values.data)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async initialize() {
|
|
59
|
+
await this.#manager?.initialize(this.#values.data)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A field changed: clear the (top-level) values of lookups that depend on it — their options
|
|
64
|
+
* are about to change under them — then re-fetch those lookups.
|
|
65
|
+
*/
|
|
66
|
+
fieldChanged(path) {
|
|
67
|
+
if (!this.#manager) return
|
|
68
|
+
for (const [depPath, lookup] of this.#manager.lookups) {
|
|
69
|
+
if (lookup.dependsOn.includes(path) && !depPath.includes('/')) this.#values.set(depPath, null)
|
|
70
|
+
}
|
|
71
|
+
this.#manager.handleFieldChange(path, this.#values.data)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Fold a field's lookup state into an element's props (mutates them). */
|
|
75
|
+
applyTo(path, props) {
|
|
76
|
+
const state = this.state(path)
|
|
77
|
+
if (!state) return
|
|
78
|
+
applyOptions(state, props)
|
|
79
|
+
applyAvailability(state, props)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Options and loading. */
|
|
84
|
+
function applyOptions(state, props) {
|
|
85
|
+
if (state.options?.length > 0) props.options = state.options
|
|
86
|
+
if (state.loading) props.loading = true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Disabled, and the option field mapping unless the element sets its own. */
|
|
90
|
+
function applyAvailability(state, props) {
|
|
91
|
+
if (state.disabled) props.disabled = true
|
|
92
|
+
if (state.fields && !props.fields) props.fields = state.fields
|
|
93
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every scoped field path in a list of layout elements, nested groups included.
|
|
3
|
+
* @param {Object[] | undefined} elements
|
|
4
|
+
* @returns {string[]}
|
|
5
|
+
*/
|
|
6
|
+
export function stepPaths(elements: Object[] | undefined): string[];
|
|
7
|
+
/**
|
|
8
|
+
* A multi-step (wizard) form: a layout whose top-level elements are `{ type: 'step' }`. Only the
|
|
9
|
+
* active step's elements render. A flat layout has no steps, and all of it is active.
|
|
10
|
+
*
|
|
11
|
+
* Moving forward is `advance()` here; VALIDATING before moving is the form's job, which is why
|
|
12
|
+
* `FormBuilder.next()` checks the step and then calls this.
|
|
13
|
+
*/
|
|
14
|
+
export class FormSteps {
|
|
15
|
+
/** @param {import('./FormDefinition.svelte.js').FormDefinition} definition */
|
|
16
|
+
constructor(definition: import("./FormDefinition.svelte.js").FormDefinition);
|
|
17
|
+
get isMultiStep(): any;
|
|
18
|
+
get total(): any;
|
|
19
|
+
get current(): number;
|
|
20
|
+
canAdvance: boolean;
|
|
21
|
+
/** The step element at an index, or null when there is none. */
|
|
22
|
+
step(index: any): any;
|
|
23
|
+
/** The layout elements that render now: the active step's, or all of a flat layout's. */
|
|
24
|
+
get activeElements(): any;
|
|
25
|
+
/** Every step's elements, flattened — the whole form, for validating all of it at once. */
|
|
26
|
+
get allStepElements(): any;
|
|
27
|
+
advance(): boolean;
|
|
28
|
+
back(): boolean;
|
|
29
|
+
/** Return to a step already visited. Forward is only reachable through `next()`. */
|
|
30
|
+
goTo(index: any): void;
|
|
31
|
+
#private;
|
|
32
|
+
}
|