@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.
@@ -0,0 +1,209 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { CONTROLS, controlGroups } from './controls.js'
3
+ import {
4
+ createScreen, renameScreen, addControl as addControlTo, moveNode, setNodeProperty,
5
+ removeNodes
6
+ } from './document.js'
7
+ import { validateDocument } from './validateDocument.js'
8
+
9
+ // The builder's controller: the document being edited, what is selected, and
10
+ // every change the builder can make. No JSX — designs/BuilderSample.jsx draws
11
+ // it, and a host with its own design can use this hook directly.
12
+ //
13
+ // STORAGE IS THE HOST'S. This never loads or persists anything: it starts from
14
+ // the `document` it is handed and gives the result to `onSave`. A document
15
+ // handed in LATER (Claude regenerated the screen, another record was opened)
16
+ // replaces what is being edited.
17
+
18
+ /**
19
+ * @param document the screen to edit; omitted → a new empty screen
20
+ * @param name the name for a new screen (when no document)
21
+ * @param onSave async (document) → void; called only with a valid document
22
+ * @param onChange (document) → void; every edit, for hosts that autosave or preview
23
+ * @param listTables async () → [{ id, name }] | string[]; offered in a dropdown's
24
+ * "from a table" picker. Omitted → table sources can still be typed.
25
+ * @param controls the control registry (default: the built-ins)
26
+ */
27
+ export function useFactoryBuilder({ document: given, name, onSave, onChange, listTables, controls = CONTROLS } = {}) {
28
+ const [doc, setDoc] = useState(() => given || createScreen({ name }))
29
+ const [selected, setSelected] = useState(() => new Set())
30
+ const [dirty, setDirty] = useState(false)
31
+ const [saving, setSaving] = useState(false)
32
+ const [saveError, setSaveError] = useState(null)
33
+ const [showErrors, setShowErrors] = useState(false)
34
+ const docRef = useRef(doc); docRef.current = doc
35
+
36
+ // A NEW document from outside replaces the draft. Compared by identity: the
37
+ // host re-rendering with the same object must not throw away edits.
38
+ const lastGiven = useRef(given)
39
+ useEffect(() => {
40
+ if (!given || given === lastGiven.current) return
41
+ lastGiven.current = given
42
+ docRef.current = given
43
+ setDoc(given)
44
+ setSelected(new Set())
45
+ setDirty(false)
46
+ setShowErrors(false)
47
+ }, [given])
48
+
49
+ // Every edit goes through here. It works on a REF of the current document,
50
+ // not a state updater: an edit that needs its own result (the id of the
51
+ // control just added) gets it synchronously, and two edits in one event
52
+ // build on each other instead of both starting from the same render.
53
+ const onChangeRef = useRef(onChange); onChangeRef.current = onChange
54
+ const update = useCallback((fn) => {
55
+ const current = docRef.current
56
+ const next = fn(current)
57
+ if (next === current) return current
58
+ docRef.current = next
59
+ setDoc(next)
60
+ setDirty(true)
61
+ onChangeRef.current?.(next)
62
+ return next
63
+ }, [])
64
+
65
+ // ── tables for the dropdown source picker ─────────────────────────────
66
+ const [tables, setTables] = useState({ loading: Boolean(listTables), items: [], error: null })
67
+ const listTablesRef = useRef(listTables); listTablesRef.current = listTables
68
+ const hasListTables = Boolean(listTables)
69
+ useEffect(() => {
70
+ if (!hasListTables) return undefined
71
+ let cancelled = false
72
+ Promise.resolve()
73
+ .then(() => listTablesRef.current())
74
+ .then((rows) => {
75
+ if (cancelled) return
76
+ const items = (rows || []).map((t) => (typeof t === 'string' ? { id: t, name: t } : t))
77
+ setTables({ loading: false, items, error: null })
78
+ })
79
+ .catch((err) => { if (!cancelled) setTables({ loading: false, items: [], error: err.message || 'Could not list tables' }) })
80
+ return () => { cancelled = true }
81
+ }, [hasListTables])
82
+
83
+ // ── edits ─────────────────────────────────────────────────────────────
84
+ const rename = useCallback((value) => update((d) => renameScreen(d, value)), [update])
85
+
86
+ /** Adds a control and selects it, so its properties are open straight away. */
87
+ const addControl = useCallback((type, at) => {
88
+ let added = null
89
+ update((d) => {
90
+ const r = addControlTo(d, type, { at }, controls)
91
+ added = r.node
92
+ return r.document
93
+ })
94
+ if (added) setSelected(new Set([added.id]))
95
+ return added
96
+ }, [update, controls])
97
+
98
+ const moveControl = useCallback((id, patch) => update((d) => moveNode(d, id, patch)), [update])
99
+
100
+ const setProperty = useCallback((id, path, value) => update((d) => setNodeProperty(d, id, path, value, controls)), [update, controls])
101
+
102
+ const selectedRef = useRef(selected); selectedRef.current = selected
103
+ const removeSelected = useCallback(() => {
104
+ const ids = [...selectedRef.current]
105
+ if (!ids.length) return
106
+ update((d) => removeNodes(d, ids))
107
+ setSelected(new Set())
108
+ }, [update])
109
+
110
+ /** Everything selected moves to a copy, just below the original. */
111
+ const duplicateSelected = useCallback(() => {
112
+ const ids = [...selectedRef.current]
113
+ if (!ids.length) return
114
+ const created = []
115
+ update((d) => {
116
+ let next = d
117
+ ids.forEach((id) => {
118
+ const src = d.nodes.find((n) => n.id === id)
119
+ if (!src) return
120
+ const props = JSON.parse(JSON.stringify(src.props))
121
+ delete props.name
122
+ const r = addControlTo(next, src.type, { at: { x: src.x, y: src.y + src.h + 0.02 }, size: { w: src.w, h: src.h }, props }, controls)
123
+ next = r.document
124
+ created.push(r.node.id)
125
+ })
126
+ return next
127
+ })
128
+ setSelected(new Set(created))
129
+ }, [update, controls])
130
+
131
+ // ── validation + save ─────────────────────────────────────────────────
132
+ const validation = useMemo(() => validateDocument(doc, controls), [doc, controls])
133
+
134
+ /** Errors grouped by node id, for the canvas and the property panel. */
135
+ const errorsByNode = useMemo(() => {
136
+ const map = {}
137
+ validation.errors.forEach((e) => {
138
+ const m = /^nodes\[(\d+)\]\.?(.*)$/.exec(e.path)
139
+ const node = m && doc.nodes[Number(m[1])]
140
+ const key = node ? node.id : '_document'
141
+ ;(map[key] = map[key] || []).push({ ...e, field: m ? m[2] : e.path })
142
+ })
143
+ return map
144
+ }, [validation, doc.nodes])
145
+
146
+ const onSaveRef = useRef(onSave); onSaveRef.current = onSave
147
+ const save = useCallback(async () => {
148
+ setShowErrors(true)
149
+ setSaveError(null)
150
+ if (!validation.ok) return { ok: false, errors: validation.errors }
151
+ if (!onSaveRef.current) return { ok: false, errors: [{ path: '', message: 'No onSave was provided' }] }
152
+ setSaving(true)
153
+ try {
154
+ await onSaveRef.current(doc)
155
+ setDirty(false)
156
+ setShowErrors(false)
157
+ return { ok: true }
158
+ } catch (err) {
159
+ setSaveError(err.message || 'Could not save')
160
+ return { ok: false, errors: [{ path: '', message: err.message }] }
161
+ } finally {
162
+ setSaving(false)
163
+ }
164
+ }, [doc, validation])
165
+
166
+ // ── keyboard: Delete / Backspace removes, Cmd/Ctrl+D duplicates ────────
167
+ useEffect(() => {
168
+ function onKey(e) {
169
+ const t = e.target
170
+ const typing = t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))
171
+ if (typing) return
172
+ if ((e.key === 'Delete' || e.key === 'Backspace') && selected.size) {
173
+ e.preventDefault()
174
+ removeSelected()
175
+ } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'd' && selected.size) {
176
+ e.preventDefault()
177
+ duplicateSelected()
178
+ }
179
+ }
180
+ window.addEventListener('keydown', onKey)
181
+ return () => window.removeEventListener('keydown', onKey)
182
+ }, [selected, removeSelected, duplicateSelected])
183
+
184
+ const selectedNode = selected.size === 1 ? doc.nodes.find((n) => selected.has(n.id)) || null : null
185
+
186
+ return {
187
+ document: doc,
188
+ controls,
189
+ palette: useMemo(() => controlGroups(controls), [controls]),
190
+ selected,
191
+ setSelected,
192
+ selectedNode,
193
+ selectedControl: selectedNode ? controls[selectedNode.type] : null,
194
+ rename,
195
+ addControl,
196
+ moveControl,
197
+ setProperty,
198
+ removeSelected,
199
+ duplicateSelected,
200
+ tables,
201
+ validation,
202
+ errorsByNode,
203
+ showErrors,
204
+ dirty,
205
+ saving,
206
+ saveError,
207
+ save
208
+ }
209
+ }
@@ -0,0 +1,155 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { CONTROLS } from './controls.js'
3
+ import { inputNodes } from './document.js'
4
+ import { validateDocument } from './validateDocument.js'
5
+ import { initialValues, parseInput, validateValues, fieldError, optionValue } from './values.js'
6
+
7
+ // A saved screen, running: its values, its dropdown options, validation and
8
+ // submit. No JSX — designs/ScreenSample.jsx draws it.
9
+ //
10
+ // The DATABASE IS THE HOST'S. A dropdown reading a table asks the host for its
11
+ // rows — `fetchOptions({ table })` → [{ id, name }] — and a submit hands the
12
+ // values to `onSubmit`. Nothing here knows where either goes.
13
+
14
+ /**
15
+ * @param document a valid screen document
16
+ * @param fetchOptions async ({ table, node }) → [{ id, name }] — for table dropdowns
17
+ * @param onSubmit async (values, { document }) → void
18
+ * @param values initial values, e.g. the record being edited
19
+ * @param controls the control registry (default: the built-ins)
20
+ */
21
+ export function useFactoryScreen({ document: doc, fetchOptions, onSubmit, values: provided, controls = CONTROLS } = {}) {
22
+ const check = useMemo(() => validateDocument(doc, controls), [doc, controls])
23
+ const inputs = useMemo(() => (check.ok ? inputNodes(doc, controls) : []), [doc, controls, check.ok])
24
+
25
+ const [values, setValues] = useState(() => (check.ok ? initialValues(doc, provided, controls) : {}))
26
+ const [errors, setErrors] = useState({})
27
+ const [submitted, setSubmitted] = useState(false)
28
+ const [submitting, setSubmitting] = useState(false)
29
+ const [submitError, setSubmitError] = useState(null)
30
+
31
+ // A different document or record starts the form over.
32
+ const providedRef = useRef(provided); providedRef.current = provided
33
+ const firstRun = useRef(true)
34
+ useEffect(() => {
35
+ if (firstRun.current) { firstRun.current = false; return }
36
+ if (!check.ok) return
37
+ setValues(initialValues(doc, providedRef.current, controls))
38
+ setErrors({})
39
+ setSubmitted(false)
40
+ setSubmitError(null)
41
+ }, [doc, provided, controls, check.ok])
42
+
43
+ // ── options for table dropdowns, fetched once per table ───────────────
44
+ const [options, setOptions] = useState({}) // node id → { loading, items, error }
45
+ const fetchRef = useRef(fetchOptions); fetchRef.current = fetchOptions
46
+ useEffect(() => {
47
+ let cancelled = false
48
+ const tableNodes = inputs.filter((n) => n.type === 'dropdown' && n.props.data?.source === 'table')
49
+ if (!tableNodes.length) { setOptions({}); return undefined }
50
+ setOptions(Object.fromEntries(tableNodes.map((n) => [n.id, { loading: true, items: [], error: null }])))
51
+ const byTable = new Map()
52
+ tableNodes.forEach((n) => {
53
+ const t = n.props.data.table
54
+ if (!byTable.has(t)) byTable.set(t, [])
55
+ byTable.get(t).push(n)
56
+ })
57
+ byTable.forEach((nodes, table) => {
58
+ const run = fetchRef.current
59
+ ? Promise.resolve().then(() => fetchRef.current({ table, node: nodes[0] }))
60
+ : Promise.reject(new Error(`No fetchOptions was provided to read "${table}"`))
61
+ run
62
+ .then((rows) => {
63
+ const items = normaliseOptions(rows)
64
+ if (!cancelled) setOptions((o) => ({ ...o, ...Object.fromEntries(nodes.map((n) => [n.id, { loading: false, items, error: null }])) }))
65
+ })
66
+ .catch((err) => {
67
+ if (!cancelled) setOptions((o) => ({ ...o, ...Object.fromEntries(nodes.map((n) => [n.id, { loading: false, items: [], error: err.message || 'Could not load options' }])) }))
68
+ })
69
+ })
70
+ return () => { cancelled = true }
71
+ }, [inputs])
72
+
73
+ /** The options a dropdown shows, whichever source they come from. */
74
+ const optionsFor = useCallback((node) => {
75
+ if (node.props.data?.source === 'static') return { loading: false, items: node.props.data.options, error: null }
76
+ return options[node.id] || { loading: false, items: [], error: null }
77
+ }, [options])
78
+
79
+ const loadedItems = useMemo(() => {
80
+ const map = {}
81
+ Object.entries(options).forEach(([id, o]) => { if (!o.loading && !o.error) map[id] = o.items })
82
+ return map
83
+ }, [options])
84
+
85
+ // ── editing ───────────────────────────────────────────────────────────
86
+ const setValue = useCallback((node, raw) => {
87
+ let value = parseInput(node, raw)
88
+ if (node.type === 'dropdown' && value !== undefined) value = optionValue(optionsFor(node).items, value)
89
+ setValues((v) => {
90
+ const next = { ...v }
91
+ if (value === undefined) delete next[node.props.name]
92
+ else next[node.props.name] = value
93
+ return next
94
+ })
95
+ // Once a submit has failed, errors follow the typing — the message goes
96
+ // away the moment the field is fixed, not on the next click of Save.
97
+ if (submitted) {
98
+ setErrors((e) => {
99
+ const msg = fieldError(node, value, loadedItems[node.id])
100
+ const next = { ...e }
101
+ if (msg) next[node.props.name] = msg
102
+ else delete next[node.props.name]
103
+ return next
104
+ })
105
+ }
106
+ }, [submitted, optionsFor, loadedItems])
107
+
108
+ const reset = useCallback(() => {
109
+ setValues(initialValues(doc, providedRef.current, controls))
110
+ setErrors({})
111
+ setSubmitted(false)
112
+ setSubmitError(null)
113
+ }, [doc, controls])
114
+
115
+ const onSubmitRef = useRef(onSubmit); onSubmitRef.current = onSubmit
116
+ const submit = useCallback(async (e) => {
117
+ e?.preventDefault?.()
118
+ setSubmitted(true)
119
+ setSubmitError(null)
120
+ const found = validateValues(doc, values, controls, loadedItems)
121
+ setErrors(found)
122
+ if (Object.keys(found).length) return { ok: false, errors: found }
123
+ if (!onSubmitRef.current) return { ok: true, values }
124
+ setSubmitting(true)
125
+ try {
126
+ await onSubmitRef.current(values, { document: doc })
127
+ return { ok: true, values }
128
+ } catch (err) {
129
+ setSubmitError(err.message || 'Could not submit')
130
+ return { ok: false, error: err }
131
+ } finally {
132
+ setSubmitting(false)
133
+ }
134
+ }, [doc, values, controls, loadedItems])
135
+
136
+ return {
137
+ document: doc,
138
+ documentErrors: check.errors,
139
+ values,
140
+ errors,
141
+ setValue,
142
+ optionsFor,
143
+ submit,
144
+ reset,
145
+ submitting,
146
+ submitError
147
+ }
148
+ }
149
+
150
+ /** Rows from the host → [{ id, name }]. Anything else on a row is ignored. */
151
+ export function normaliseOptions(rows) {
152
+ return (Array.isArray(rows) ? rows : [])
153
+ .filter((r) => r && r.id !== undefined && r.id !== null)
154
+ .map((r) => ({ id: r.id, name: r.name == null ? String(r.id) : String(r.name) }))
155
+ }
@@ -0,0 +1,202 @@
1
+ // Checks a screen document BEFORE anything renders it, and says exactly what
2
+ // is wrong and where.
3
+ //
4
+ // Two readers matter. A person saving from the builder needs to know which
5
+ // control to fix. And Claude, which writes these documents from a plain-English
6
+ // request, needs errors precise enough to correct its own output without
7
+ // guessing — so every error names a path (`nodes[3].props.data.table`) and
8
+ // what was expected there.
9
+ //
10
+ // Loud by design: a screen that half-renders — a dropdown with no options, two
11
+ // fields writing the same key — looks like a data problem to whoever uses it.
12
+
13
+ import { CONTROLS, LABEL_VARIANTS, BUTTON_ACTIONS } from './controls.js'
14
+ import { DOCUMENT_KIND, DOCUMENT_VERSION } from './document.js'
15
+
16
+ const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
17
+
18
+ /**
19
+ * @returns {{ ok: boolean, errors: Array<{ path: string, message: string }> }}
20
+ */
21
+ export function validateDocument(doc, controls = CONTROLS) {
22
+ const errors = []
23
+ const err = (path, message) => errors.push({ path, message })
24
+
25
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
26
+ err('', 'A screen document must be an object')
27
+ return { ok: false, errors }
28
+ }
29
+ if (doc.kind !== DOCUMENT_KIND) err('kind', `must be "${DOCUMENT_KIND}"`)
30
+ if (doc.version !== DOCUMENT_VERSION) err('version', `must be ${DOCUMENT_VERSION}`)
31
+ if (!nonEmptyString(doc.id)) err('id', 'must be a non-empty string')
32
+ if (!nonEmptyString(doc.name)) err('name', 'must be a non-empty string — the screen\'s title, e.g. "New employee"')
33
+ if (doc.units !== 'fraction') err('units', 'must be "fraction"')
34
+ if (!(typeof doc.aspect === 'number' && doc.aspect > 0)) err('aspect', 'must be a number greater than 0 (1 = page as tall as it is wide)')
35
+ if (!Array.isArray(doc.nodes)) {
36
+ err('nodes', 'must be an array of controls')
37
+ return { ok: false, errors }
38
+ }
39
+
40
+ const ids = new Map()
41
+ const names = new Map()
42
+
43
+ doc.nodes.forEach((node, i) => {
44
+ const at = `nodes[${i}]`
45
+ if (!node || typeof node !== 'object') { err(at, 'must be an object'); return }
46
+
47
+ if (!nonEmptyString(node.id)) err(`${at}.id`, 'must be a non-empty string')
48
+ else if (ids.has(node.id)) err(`${at}.id`, `"${node.id}" is already used by nodes[${ids.get(node.id)}]`)
49
+ else ids.set(node.id, i)
50
+
51
+ const def = controls[node.type]
52
+ if (!def) {
53
+ err(`${at}.type`, `unknown control "${node.type}" — one of: ${Object.keys(controls).join(', ')}`)
54
+ return
55
+ }
56
+
57
+ checkGeometry(node, at, err)
58
+
59
+ const props = node.props
60
+ if (!props || typeof props !== 'object' || Array.isArray(props)) {
61
+ err(`${at}.props`, 'must be an object')
62
+ return
63
+ }
64
+ Object.keys(props).forEach((key) => {
65
+ if (!def.props.includes(key)) err(`${at}.props.${key}`, `is not a property of "${node.type}" — allowed: ${def.props.join(', ')}`)
66
+ })
67
+
68
+ if (def.input) {
69
+ if (!nonEmptyString(props.name)) err(`${at}.props.name`, 'is required — the key the value is saved under')
70
+ else if (!FIELD_NAME.test(props.name)) err(`${at}.props.name`, `"${props.name}" must start with a letter or _ and contain only letters, digits and _`)
71
+ else if (names.has(props.name)) err(`${at}.props.name`, `"${props.name}" is already used by nodes[${names.get(props.name)}] — two fields cannot save to one key`)
72
+ else names.set(props.name, i)
73
+
74
+ if (!nonEmptyString(props.label)) err(`${at}.props.label`, 'is required — the text shown above the field')
75
+ if (props.required !== undefined && typeof props.required !== 'boolean') err(`${at}.props.required`, 'must be true or false')
76
+ if (props.placeholder !== undefined && typeof props.placeholder !== 'string') err(`${at}.props.placeholder`, 'must be a string')
77
+ checkDefault(node, def, at, err)
78
+ checkValidation(node, def, at, err)
79
+ }
80
+
81
+ if (node.type === 'dropdown') checkDataSource(props.data, `${at}.props.data`, err)
82
+ if (node.type === 'label') {
83
+ if (typeof props.text !== 'string') err(`${at}.props.text`, 'must be a string')
84
+ if (props.variant !== undefined && !LABEL_VARIANTS.includes(props.variant)) err(`${at}.props.variant`, `must be one of: ${LABEL_VARIANTS.join(', ')}`)
85
+ }
86
+ if (node.type === 'button') {
87
+ if (!nonEmptyString(props.label)) err(`${at}.props.label`, 'is required — the text on the button')
88
+ if (props.action !== undefined && !BUTTON_ACTIONS.includes(props.action)) err(`${at}.props.action`, `must be one of: ${BUTTON_ACTIONS.join(', ')}`)
89
+ }
90
+ })
91
+
92
+ return { ok: errors.length === 0, errors }
93
+ }
94
+
95
+ /** Throws with every error listed. For code that must not continue with a bad document. */
96
+ export function assertValidDocument(doc, controls) {
97
+ const { ok, errors } = validateDocument(doc, controls)
98
+ if (!ok) {
99
+ const e = new Error('Invalid screen document:\n' + errors.map((x) => ` ${x.path || '(document)'}: ${x.message}`).join('\n'))
100
+ e.errors = errors
101
+ throw e
102
+ }
103
+ return doc
104
+ }
105
+
106
+ function checkGeometry(node, at, err) {
107
+ ;['x', 'y', 'w', 'h'].forEach((k) => {
108
+ if (typeof node[k] !== 'number' || !Number.isFinite(node[k])) err(`${at}.${k}`, 'must be a number (a fraction — see "units")')
109
+ })
110
+ if (typeof node.x === 'number' && (node.x < 0 || node.x > 1)) err(`${at}.x`, 'must be between 0 and 1 (fraction of the width)')
111
+ if (typeof node.w === 'number' && (node.w <= 0 || node.w > 1)) err(`${at}.w`, 'must be greater than 0 and at most 1 (fraction of the width)')
112
+ if (typeof node.x === 'number' && typeof node.w === 'number' && node.x + node.w > 1.0001) err(`${at}.w`, `x + w is ${round(node.x + node.w)} — the control runs off the right edge (must be ≤ 1)`)
113
+ if (typeof node.y === 'number' && node.y < 0) err(`${at}.y`, 'must be 0 or more (fraction of a page)')
114
+ if (typeof node.h === 'number' && node.h <= 0) err(`${at}.h`, 'must be greater than 0 (fraction of a page)')
115
+ if (node.z !== undefined && !Number.isInteger(node.z)) err(`${at}.z`, 'must be a whole number')
116
+ }
117
+
118
+ function checkDefault(node, def, at, err) {
119
+ const v = node.props.default
120
+ if (v === undefined) return
121
+ const ok = {
122
+ string: () => typeof v === 'string',
123
+ number: () => typeof v === 'number' && Number.isFinite(v),
124
+ boolean: () => typeof v === 'boolean',
125
+ date: () => typeof v === 'string' && ISO_DATE.test(v)
126
+ }[def.valueType]
127
+ if (ok && !ok()) err(`${at}.props.default`, `must be a ${def.valueType === 'date' ? 'date as "YYYY-MM-DD"' : def.valueType}`)
128
+ }
129
+
130
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
131
+
132
+ function checkValidation(node, def, at, err) {
133
+ const v = node.props.validation
134
+ if (v === undefined) return
135
+ if (!v || typeof v !== 'object' || Array.isArray(v)) { err(`${at}.props.validation`, 'must be an object'); return }
136
+ Object.keys(v).forEach((key) => {
137
+ const path = `${at}.props.validation.${key}`
138
+ if (!def.validation.includes(key)) {
139
+ err(path, def.validation.length
140
+ ? `is not a rule for "${node.type}" — allowed: ${def.validation.join(', ')}`
141
+ : `"${node.type}" takes no validation rules`)
142
+ return
143
+ }
144
+ const val = v[key]
145
+ if (key === 'minLength' || key === 'maxLength') {
146
+ if (!(Number.isInteger(val) && val >= 0)) err(path, 'must be a whole number, 0 or more')
147
+ } else if (key === 'integer') {
148
+ if (typeof val !== 'boolean') err(path, 'must be true or false')
149
+ } else if (key === 'pattern') {
150
+ try { new RegExp(val) } catch (_) { err(path, `"${val}" is not a valid regular expression`) }
151
+ if (typeof val !== 'string') err(path, 'must be a string')
152
+ } else if (key === 'patternMessage') {
153
+ if (typeof val !== 'string') err(path, 'must be a string')
154
+ } else if (def.valueType === 'number') {
155
+ if (!(typeof val === 'number' && Number.isFinite(val))) err(path, 'must be a number')
156
+ } else if (def.valueType === 'date') {
157
+ if (!(typeof val === 'string' && ISO_DATE.test(val))) err(path, 'must be a date as "YYYY-MM-DD"')
158
+ }
159
+ })
160
+ if (v.minLength != null && v.maxLength != null && v.minLength > v.maxLength) err(`${at}.props.validation`, 'minLength is greater than maxLength — nothing could pass')
161
+ if (typeof v.min === 'number' && typeof v.max === 'number' && v.min > v.max) err(`${at}.props.validation`, 'min is greater than max — nothing could pass')
162
+ if (typeof v.min === 'string' && typeof v.max === 'string' && v.min > v.max) err(`${at}.props.validation`, 'min is after max — no date could pass')
163
+ }
164
+
165
+ /**
166
+ * A dropdown's options: typed in, or read from a table. Either way each option
167
+ * is { id, name } — the id is what is saved, the name is what is shown.
168
+ */
169
+ function checkDataSource(data, at, err) {
170
+ if (!data || typeof data !== 'object') {
171
+ err(at, 'is required — { "source": "static", "options": [{ "id", "name" }] } or { "source": "table", "table": "<name>" }')
172
+ return
173
+ }
174
+ if (data.source === 'static') {
175
+ if (!Array.isArray(data.options)) { err(`${at}.options`, 'must be an array of { id, name }'); return }
176
+ const seen = new Set()
177
+ data.options.forEach((o, j) => {
178
+ const p = `${at}.options[${j}]`
179
+ if (!o || typeof o !== 'object') { err(p, 'must be { id, name }'); return }
180
+ if (!(typeof o.id === 'string' || typeof o.id === 'number') || o.id === '') err(`${p}.id`, 'must be a non-empty string or a number')
181
+ else if (seen.has(o.id)) err(`${p}.id`, `"${o.id}" appears twice`)
182
+ else seen.add(o.id)
183
+ if (!nonEmptyString(o.name)) err(`${p}.name`, 'must be a non-empty string — what the user sees')
184
+ const extra = Object.keys(o).filter((k) => k !== 'id' && k !== 'name')
185
+ if (extra.length) err(p, `only "id" and "name" are used — remove: ${extra.join(', ')}`)
186
+ })
187
+ } else if (data.source === 'table') {
188
+ if (!nonEmptyString(data.table)) err(`${at}.table`, 'must name the table to read { id, name } rows from')
189
+ const extra = Object.keys(data).filter((k) => k !== 'source' && k !== 'table')
190
+ if (extra.length) err(at, `a table source takes only "table" — remove: ${extra.join(', ')}`)
191
+ } else {
192
+ err(`${at}.source`, 'must be "static" or "table"')
193
+ }
194
+ }
195
+
196
+ function nonEmptyString(v) {
197
+ return typeof v === 'string' && v.trim() !== ''
198
+ }
199
+
200
+ function round(v) {
201
+ return Math.round(v * 10000) / 10000
202
+ }