@xeplr/ui-factory 1.0.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/src/values.js ADDED
@@ -0,0 +1,148 @@
1
+ // A screen's VALUES: what it starts with, what a keystroke becomes, and whether
2
+ // what was entered is acceptable.
3
+ //
4
+ // The rules come from the controls' props, and formSchema() turns them into a
5
+ // @xeplr/schema-handler schema. That is the point of it: the consuming app's
6
+ // server can check a submission with
7
+ //
8
+ // applySchema(formSchema(document), body)
9
+ //
10
+ // against the very rules the screen enforced, instead of a second copy of
11
+ // them. validateValues() below applies the same rules in the browser, with
12
+ // messages written for the person filling the form in rather than for a log.
13
+
14
+ import { CONTROLS } from './controls.js'
15
+ import { inputNodes } from './document.js'
16
+
17
+ /** schema-handler field schema for the screen's inputs, in reading order. */
18
+ export function formSchema(doc, controls = CONTROLS) {
19
+ return inputNodes(doc, controls).map((node) => {
20
+ const def = controls[node.type]
21
+ const p = node.props
22
+ const field = { name: p.name }
23
+ if (def.valueType) field.type = def.valueType
24
+ if (p.label) field.description = p.label
25
+ if (p.required) field.required = true
26
+ if (p.default !== undefined) field.default = p.default
27
+ if (node.type === 'dropdown' && p.data?.source === 'static') {
28
+ field.options = p.data.options.map((o) => o.id)
29
+ }
30
+ const v = p.validation && { ...p.validation }
31
+ if (v) {
32
+ delete v.patternMessage
33
+ if (Object.keys(v).length) field.validation = v
34
+ }
35
+ return field
36
+ })
37
+ }
38
+
39
+ /** Each input's starting value: the caller's, else the control's default. */
40
+ export function initialValues(doc, provided = {}, controls = CONTROLS) {
41
+ const out = {}
42
+ inputNodes(doc, controls).forEach((node) => {
43
+ const name = node.props.name
44
+ if (provided[name] !== undefined) out[name] = provided[name]
45
+ else if (node.props.default !== undefined) out[name] = node.props.default
46
+ else if (node.type === 'checkbox') out[name] = false
47
+ })
48
+ return out
49
+ }
50
+
51
+ /**
52
+ * What the raw value from an input element becomes. An emptied box is
53
+ * `undefined` — not '' and not 0 — so "required" means the same thing for
54
+ * every type, and a cleared number is not silently saved as zero.
55
+ */
56
+ export function parseInput(node, raw) {
57
+ if (node.type === 'checkbox') return Boolean(raw)
58
+ if (raw === undefined || raw === null || raw === '') return undefined
59
+ if (node.type === 'number') {
60
+ const n = typeof raw === 'number' ? raw : Number(String(raw).trim())
61
+ return Number.isFinite(n) ? n : raw // left as typed; validation says why
62
+ }
63
+ if (node.type === 'dropdown') return raw
64
+ return String(raw)
65
+ }
66
+
67
+ /**
68
+ * @returns {Object<string, string>} field name → message, for the fields that
69
+ * fail. Empty when everything passes.
70
+ */
71
+ export function validateValues(doc, values, controls = CONTROLS, options = {}) {
72
+ const errors = {}
73
+ inputNodes(doc, controls).forEach((node) => {
74
+ const msg = fieldError(node, values ? values[node.props.name] : undefined, options[node.id])
75
+ if (msg) errors[node.props.name] = msg
76
+ })
77
+ return errors
78
+ }
79
+
80
+ /**
81
+ * One field's message, or null.
82
+ * @param loadedOptions for a dropdown whose options are loaded (a table), the
83
+ * rows — so a stale id is caught here rather than by the server
84
+ */
85
+ export function fieldError(node, value, loadedOptions) {
86
+ const p = node.props
87
+ const label = p.label || p.name
88
+ const v = p.validation || {}
89
+
90
+ if (node.type === 'checkbox') {
91
+ return p.required && value !== true ? `${label} must be ticked` : null
92
+ }
93
+ if (value === undefined || value === null || value === '') {
94
+ return p.required ? `${label} is required` : null
95
+ }
96
+
97
+ switch (node.type) {
98
+ case 'text':
99
+ case 'textarea': {
100
+ const s = String(value)
101
+ if (v.minLength != null && s.length < v.minLength) return `${label} must be at least ${v.minLength} character${v.minLength === 1 ? '' : 's'}`
102
+ if (v.maxLength != null && s.length > v.maxLength) return `${label} must be at most ${v.maxLength} character${v.maxLength === 1 ? '' : 's'}`
103
+ if (v.pattern && !safeRegExp(v.pattern).test(s)) return v.patternMessage || `${label} is not in the expected format`
104
+ return null
105
+ }
106
+ case 'number': {
107
+ if (typeof value !== 'number' || !Number.isFinite(value)) return `${label} must be a number`
108
+ if (v.integer && !Number.isInteger(value)) return `${label} must be a whole number`
109
+ if (v.min != null && value < v.min) return `${label} must be at least ${v.min}`
110
+ if (v.max != null && value > v.max) return `${label} must be at most ${v.max}`
111
+ return null
112
+ }
113
+ case 'date': {
114
+ if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) return `${label} must be a date`
115
+ // ISO dates compare correctly as strings, and comparing strings avoids a
116
+ // timezone shifting "2026-01-01" into the previous day.
117
+ const day = value.slice(0, 10)
118
+ if (v.min && day < v.min) return `${label} must be on or after ${v.min}`
119
+ if (v.max && day > v.max) return `${label} must be on or before ${v.max}`
120
+ return null
121
+ }
122
+ case 'dropdown': {
123
+ const opts = p.data?.source === 'static' ? p.data.options : loadedOptions
124
+ if (Array.isArray(opts) && !opts.some((o) => sameId(o.id, value))) return `${label} must be one of the options`
125
+ return null
126
+ }
127
+ default:
128
+ return null
129
+ }
130
+ }
131
+
132
+ /**
133
+ * <select> hands back a string, while a table's ids may be numbers. The id is
134
+ * matched loosely and then saved in the OPTION's own type — see optionValue.
135
+ */
136
+ export function sameId(a, b) {
137
+ return a === b || String(a) === String(b)
138
+ }
139
+
140
+ /** The option's own id for what a <select> reported. */
141
+ export function optionValue(options, raw) {
142
+ const hit = (options || []).find((o) => sameId(o.id, raw))
143
+ return hit ? hit.id : raw
144
+ }
145
+
146
+ function safeRegExp(pattern) {
147
+ try { return new RegExp(pattern) } catch (_) { return { test: () => true } }
148
+ }