@rokkit/forms 1.6.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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The form's validation messages, keyed by field path, and the ways of producing them.
3
+ *
4
+ * Which fields are VISIBLE is the element builder's knowledge (a hidden `showWhen` field has
5
+ * no element), so it arrives as a function rather than a dependency on the elements — which in
6
+ * turn read these messages.
7
+ */
8
+ export class FormValidation {
9
+ /**
10
+ * @param {{
11
+ * values: import('./FormValues.svelte.js').FormValues,
12
+ * definition: import('./FormDefinition.svelte.js').FormDefinition,
13
+ * steps: import('./FormSteps.svelte.js').FormSteps,
14
+ * visiblePaths: () => string[]
15
+ * }} parts
16
+ */
17
+ constructor({ values, definition, steps, visiblePaths }: {
18
+ values: import("./FormValues.svelte.js").FormValues;
19
+ definition: import("./FormDefinition.svelte.js").FormDefinition;
20
+ steps: import("./FormSteps.svelte.js").FormSteps;
21
+ visiblePaths: () => string[];
22
+ });
23
+ set map(value: {});
24
+ /** Every message, by field path. */
25
+ get map(): {};
26
+ /** One field's message, or null. */
27
+ message(path: any): any;
28
+ /** Set a field's message; a null message removes it. */
29
+ set(path: any, message: any): void;
30
+ clear(): void;
31
+ /** No message is an error. */
32
+ get isValid(): boolean;
33
+ /** Error messages, with their paths. */
34
+ get errors(): any[];
35
+ /** Every message with its path, most severe first. */
36
+ get sorted(): any[];
37
+ /** Validate one field against its schema, and record the result. Null without a schema. */
38
+ validateField(path: any): import("../validation.js").ValidationMessage | null;
39
+ /**
40
+ * Validate the whole form. A multi-step form validates every step at once; a flat one keeps
41
+ * only the results for fields currently visible.
42
+ */
43
+ validateAll(): Object;
44
+ /**
45
+ * Validate one step's fields, replacing their messages and leaving every other field's alone.
46
+ * A non-step index is trivially valid.
47
+ */
48
+ validateStep(index: any): boolean;
49
+ /** Drop the messages of fields that are no longer visible (a `showWhen` just hid them). */
50
+ clearHidden(): void;
51
+ #private;
52
+ }
@@ -0,0 +1,140 @@
1
+ import { SvelteSet } from 'svelte/reactivity'
2
+ import { validateField as validateOne, validateAll } from '../validation.js'
3
+ import { stepPaths } from './FormSteps.svelte.js'
4
+
5
+ /** Severity order for `sorted`; anything unlisted sorts last. */
6
+ const SEVERITY = { error: 0, warning: 1, info: 2, success: 3 }
7
+
8
+ /**
9
+ * The form's validation messages, keyed by field path, and the ways of producing them.
10
+ *
11
+ * Which fields are VISIBLE is the element builder's knowledge (a hidden `showWhen` field has
12
+ * no element), so it arrives as a function rather than a dependency on the elements — which in
13
+ * turn read these messages.
14
+ */
15
+ export class FormValidation {
16
+ #values
17
+ #definition
18
+ #steps
19
+ #visiblePaths
20
+ #map = $state({})
21
+
22
+ /**
23
+ * @param {{
24
+ * values: import('./FormValues.svelte.js').FormValues,
25
+ * definition: import('./FormDefinition.svelte.js').FormDefinition,
26
+ * steps: import('./FormSteps.svelte.js').FormSteps,
27
+ * visiblePaths: () => string[]
28
+ * }} parts
29
+ */
30
+ constructor({ values, definition, steps, visiblePaths }) {
31
+ this.#values = values
32
+ this.#definition = definition
33
+ this.#steps = steps
34
+ this.#visiblePaths = visiblePaths
35
+ }
36
+
37
+ /** Every message, by field path. */
38
+ get map() {
39
+ return this.#map
40
+ }
41
+ set map(value) {
42
+ this.#map = value
43
+ }
44
+
45
+ /** One field's message, or null. */
46
+ message(path) {
47
+ return this.#map[path] || null
48
+ }
49
+
50
+ /** Set a field's message; a null message removes it. */
51
+ set(path, message) {
52
+ if (message) {
53
+ this.#map = { ...this.#map, [path]: message }
54
+ return
55
+ }
56
+ const { [path]: _removed, ...rest } = this.#map
57
+ this.#map = rest
58
+ }
59
+
60
+ clear() {
61
+ this.#map = {}
62
+ }
63
+
64
+ /** No message is an error. */
65
+ get isValid() {
66
+ return Object.values(this.#map).every((msg) => msg.state !== 'error')
67
+ }
68
+
69
+ /** Error messages, with their paths. */
70
+ get errors() {
71
+ return Object.entries(this.#map)
72
+ .filter(([, msg]) => msg.state === 'error')
73
+ .map(([path, msg]) => ({ path, ...msg }))
74
+ }
75
+
76
+ /** Every message with its path, most severe first. */
77
+ get sorted() {
78
+ return Object.entries(this.#map)
79
+ .filter(([, msg]) => msg !== null && msg !== undefined)
80
+ .map(([path, msg]) => ({ path, ...msg }))
81
+ .sort((a, b) => (SEVERITY[a.state] ?? 4) - (SEVERITY[b.state] ?? 4))
82
+ }
83
+
84
+ /** Validate one field against its schema, and record the result. Null without a schema. */
85
+ validateField(path) {
86
+ const schema = this.#definition.fieldSchema(path)
87
+ if (!schema) return null
88
+ const result = validateOne(this.#values.get(path), schema, this.#definition.fieldLabel(path))
89
+ this.set(path, result)
90
+ return result
91
+ }
92
+
93
+ /**
94
+ * Validate the whole form. A multi-step form validates every step at once; a flat one keeps
95
+ * only the results for fields currently visible.
96
+ */
97
+ validateAll() {
98
+ const { schema, layout } = this.#definition
99
+ if (this.#steps.isMultiStep) {
100
+ const results = validateAll(this.#values.data, schema, {
101
+ ...layout,
102
+ elements: this.#steps.allStepElements
103
+ })
104
+ this.#map = results
105
+ return results
106
+ }
107
+ const visible = new SvelteSet(this.#visiblePaths())
108
+ const results = Object.fromEntries(
109
+ Object.entries(validateAll(this.#values.data, schema, layout)).filter(([path]) =>
110
+ visible.has(path)
111
+ )
112
+ )
113
+ this.#map = results
114
+ return results
115
+ }
116
+
117
+ /**
118
+ * Validate one step's fields, replacing their messages and leaving every other field's alone.
119
+ * A non-step index is trivially valid.
120
+ */
121
+ validateStep(index) {
122
+ const step = this.#steps.step(index)
123
+ if (!step) return true
124
+ const { schema, layout } = this.#definition
125
+ const results = validateAll(this.#values.data, schema, {
126
+ ...layout,
127
+ elements: step.elements ?? []
128
+ })
129
+ for (const path of new SvelteSet(stepPaths(step.elements)))
130
+ this.set(path, results[path] ?? null)
131
+ return Object.values(results).every((msg) => msg?.state !== 'error')
132
+ }
133
+
134
+ /** Drop the messages of fields that are no longer visible (a `showWhen` just hid them). */
135
+ clearHidden() {
136
+ const visible = new SvelteSet(this.#visiblePaths())
137
+ const kept = Object.fromEntries(Object.entries(this.#map).filter(([path]) => visible.has(path)))
138
+ if (Object.keys(kept).length !== Object.keys(this.#map).length) this.#map = kept
139
+ }
140
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A form's data, and how it differs from where it started.
3
+ *
4
+ * The initial snapshot is deliberately NOT reactive: it is a baseline, read by the dirty
5
+ * getters, and taking a new one (after a save) must not by itself re-render anything.
6
+ */
7
+ export class FormValues {
8
+ /** @param {Record<string, unknown>} data */
9
+ constructor(data?: Record<string, unknown>);
10
+ set data(value: {});
11
+ get data(): {};
12
+ /** @param {string} path */
13
+ get(path: string): any;
14
+ /** Write one value; the data gets a new root, so readers of `data` re-run. */
15
+ set(path: any, value: any): void;
16
+ /** The value a path held in the snapshot. */
17
+ initial(path: any): any;
18
+ get isDirty(): boolean;
19
+ /** Top-level keys whose value differs from the snapshot. */
20
+ get dirtyFields(): SvelteSet<string>;
21
+ /** @param {string} path */
22
+ isFieldDirty(path: string): boolean;
23
+ /** Make the current data the new baseline — after a successful save. */
24
+ snapshot(): void;
25
+ /** Restore the baseline. */
26
+ reset(): void;
27
+ #private;
28
+ }
29
+ import { SvelteSet } from 'svelte/reactivity';
@@ -0,0 +1,69 @@
1
+ import { SvelteSet } from 'svelte/reactivity'
2
+ import { deepClone, deepEqual, getPath, setPath } from '../values.js'
3
+
4
+ /**
5
+ * A form's data, and how it differs from where it started.
6
+ *
7
+ * The initial snapshot is deliberately NOT reactive: it is a baseline, read by the dirty
8
+ * getters, and taking a new one (after a save) must not by itself re-render anything.
9
+ */
10
+ export class FormValues {
11
+ #data = $state({})
12
+ #initial = {}
13
+
14
+ /** @param {Record<string, unknown>} data */
15
+ constructor(data = {}) {
16
+ this.#data = data
17
+ this.#initial = deepClone(data)
18
+ }
19
+
20
+ get data() {
21
+ return this.#data
22
+ }
23
+ set data(value) {
24
+ this.#data = value
25
+ }
26
+
27
+ /** @param {string} path */
28
+ get(path) {
29
+ return getPath(this.#data, path)
30
+ }
31
+
32
+ /** Write one value; the data gets a new root, so readers of `data` re-run. */
33
+ set(path, value) {
34
+ this.#data = path.includes('/')
35
+ ? setPath(this.#data, path, value)
36
+ : { ...this.#data, [path]: value }
37
+ }
38
+
39
+ /** The value a path held in the snapshot. */
40
+ initial(path) {
41
+ return getPath(this.#initial, path)
42
+ }
43
+
44
+ get isDirty() {
45
+ return !deepEqual(this.#data, this.#initial)
46
+ }
47
+
48
+ /** Top-level keys whose value differs from the snapshot. */
49
+ get dirtyFields() {
50
+ const current = this.#data ?? {}
51
+ const keys = new SvelteSet([...Object.keys(current), ...Object.keys(this.#initial ?? {})])
52
+ return new SvelteSet([...keys].filter((k) => !deepEqual(current[k], this.#initial?.[k])))
53
+ }
54
+
55
+ /** @param {string} path */
56
+ isFieldDirty(path) {
57
+ return !deepEqual(this.get(path), this.initial(path))
58
+ }
59
+
60
+ /** Make the current data the new baseline — after a successful save. */
61
+ snapshot() {
62
+ this.#initial = deepClone(this.#data)
63
+ }
64
+
65
+ /** Restore the baseline. */
66
+ reset() {
67
+ this.#data = deepClone(this.#initial)
68
+ }
69
+ }
@@ -1,3 +1,5 @@
1
+ import { schemaAt } from './schema.js'
2
+
1
3
  /**
2
4
  * Validation utility for FormBuilder
3
5
  * Provides field validation functions that return message objects
@@ -174,33 +176,6 @@ export const patterns = {
174
176
  creditCard: /^\d{4}\s?\d{4}\s?\d{4}\s?\d{4}$/
175
177
  }
176
178
 
177
- /**
178
- * Get field schema by path
179
- * @private
180
- * @param {string} fieldPath - Field path
181
- * @param {Object} schema - Schema object
182
- * @returns {Object|null} Field schema
183
- */
184
- function getFieldSchema(fieldPath, schema) {
185
- /* v8 ignore start -- unreachable: the only caller is validateElement, reached
186
- from validateAll, which already returns early on !schema.properties. */
187
- if (!schema.properties) return null
188
- /* v8 ignore stop */
189
-
190
- const keys = fieldPath.split('/')
191
- let current = schema.properties
192
-
193
- for (const key of keys) {
194
- if (current && current[key]) {
195
- current = current[key]
196
- } else {
197
- return null
198
- }
199
- }
200
-
201
- return current
202
- }
203
-
204
179
  /**
205
180
  * Get value by path from data object
206
181
  * @private
@@ -273,10 +248,12 @@ export function validateField(value, fieldSchema, fieldLabel = 'Field') {
273
248
  * @private
274
249
  */
275
250
  function validateElement(element, data, schema, results) {
251
+ // A group's children are fields too — validated at any depth, not just the top level.
252
+ for (const child of element.elements ?? []) validateElement(child, data, schema, results)
276
253
  if (!element.scope) return
277
254
 
278
255
  const fieldPath = element.scope.replace(/^#\//, '')
279
- const fieldSchema = getFieldSchema(fieldPath, schema)
256
+ const fieldSchema = schemaAt(schema, fieldPath)
280
257
  const fieldLabel = element.label || element.title || fieldPath
281
258
  const value = getValueByPath(data, fieldPath)
282
259
 
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Pure helpers over plain form values — primitives, plain objects and arrays, addressed by
3
+ * slash paths (`'settings/distance'`).
4
+ */
5
+ /**
6
+ * Deep-clone a plain value. A JSON round-trip, which also unwraps `$state` proxies safely.
7
+ * @param {any} value
8
+ * @returns {any}
9
+ */
10
+ export function deepClone(value: any): any;
11
+ /**
12
+ * Structural equality for plain values. Nullish equals only itself; an array never equals an
13
+ * object.
14
+ * @param {any} a
15
+ * @param {any} b
16
+ * @returns {boolean}
17
+ */
18
+ export function deepEqual(a: any, b: any): boolean;
19
+ /**
20
+ * The value at a slash path, or undefined — also when the path runs through a non-object.
21
+ * @param {any} data
22
+ * @param {string} path
23
+ */
24
+ export function getPath(data: any, path: string): any;
25
+ /**
26
+ * A copy of `data` with `value` at the slash path — every object along the path is copied, so
27
+ * the original is untouched and reactive readers see a new root.
28
+ * @param {Record<string, any>} data
29
+ * @param {string} path
30
+ * @param {any} value
31
+ */
32
+ export function setPath(data: Record<string, any>, path: string, value: any): {
33
+ [x: string]: any;
34
+ };
35
+ /**
36
+ * A field path in its canonical, bare form. Accepts a JSON Forms scope as well — `'#/addr/city'`
37
+ * and `'addr/city'` name the same field — so the convention layouts are written in is also safe
38
+ * to pass to the API. (Unnormalised, `'#/name'` wrote to a `'#'` key.)
39
+ * @param {string} path - anything else (a missing path) passes through unchanged
40
+ * @returns {string}
41
+ */
42
+ export function toFieldPath(path: string): string;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Pure helpers over plain form values — primitives, plain objects and arrays, addressed by
3
+ * slash paths (`'settings/distance'`).
4
+ */
5
+
6
+ /**
7
+ * Deep-clone a plain value. A JSON round-trip, which also unwraps `$state` proxies safely.
8
+ * @param {any} value
9
+ * @returns {any}
10
+ */
11
+ export function deepClone(value) {
12
+ if (value === null || value === undefined || typeof value !== 'object') return value
13
+ return JSON.parse(JSON.stringify(value))
14
+ }
15
+
16
+ const isNullish = (v) => v === null || v === undefined
17
+
18
+ /** Same-shaped containers: both objects, and both arrays or both not. */
19
+ const sameContainer = (a, b) =>
20
+ typeof a === 'object' && typeof b === 'object' && Array.isArray(a) === Array.isArray(b)
21
+
22
+ function equalArrays(a, b) {
23
+ return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]))
24
+ }
25
+
26
+ function equalObjects(a, b) {
27
+ const keys = Object.keys(a)
28
+ return (
29
+ keys.length === Object.keys(b).length &&
30
+ keys.every((k) => Object.hasOwn(b, k) && deepEqual(a[k], b[k]))
31
+ )
32
+ }
33
+
34
+ /**
35
+ * Structural equality for plain values. Nullish equals only itself; an array never equals an
36
+ * object.
37
+ * @param {any} a
38
+ * @param {any} b
39
+ * @returns {boolean}
40
+ */
41
+ export function deepEqual(a, b) {
42
+ if (a === b) return true
43
+ if (isNullish(a) || isNullish(b) || !sameContainer(a, b)) return false
44
+ return Array.isArray(a) ? equalArrays(a, b) : equalObjects(a, b)
45
+ }
46
+
47
+ /**
48
+ * The value at a slash path, or undefined — also when the path runs through a non-object.
49
+ * @param {any} data
50
+ * @param {string} path
51
+ */
52
+ export function getPath(data, path) {
53
+ if (!path) return undefined
54
+ let current = data
55
+ for (const key of path.split('/')) {
56
+ if (!current || typeof current !== 'object') return undefined
57
+ current = current[key]
58
+ }
59
+ return current
60
+ }
61
+
62
+ /**
63
+ * A copy of `data` with `value` at the slash path — every object along the path is copied, so
64
+ * the original is untouched and reactive readers see a new root.
65
+ * @param {Record<string, any>} data
66
+ * @param {string} path
67
+ * @param {any} value
68
+ */
69
+ export function setPath(data, path, value) {
70
+ const keys = path.split('/')
71
+ const root = { ...data }
72
+ let current = root
73
+ for (const key of keys.slice(0, -1)) {
74
+ current[key] = { ...current[key] }
75
+ current = current[key]
76
+ }
77
+ current[keys[keys.length - 1]] = value
78
+ return root
79
+ }
80
+
81
+ /**
82
+ * A field path in its canonical, bare form. Accepts a JSON Forms scope as well — `'#/addr/city'`
83
+ * and `'addr/city'` name the same field — so the convention layouts are written in is also safe
84
+ * to pass to the API. (Unnormalised, `'#/name'` wrote to a `'#'` key.)
85
+ * @param {string} path - anything else (a missing path) passes through unchanged
86
+ * @returns {string}
87
+ */
88
+ export function toFieldPath(path) {
89
+ return typeof path === 'string' && path.startsWith('#/') ? path.slice(2) : path
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/forms",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Form building components for Rokkit applications",
5
5
  "repository": {
6
6
  "type": "git",