@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/AUTHORING.md +133 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/xeplr-factory.js +98 -0
- package/package.json +59 -0
- package/src/controls.js +238 -0
- package/src/designs/BuilderSample.jsx +117 -0
- package/src/designs/ControlView.jsx +192 -0
- package/src/designs/Palette.jsx +34 -0
- package/src/designs/PropertyPanel.jsx +228 -0
- package/src/designs/ScreenSample.jsx +65 -0
- package/src/designs/factory.css +177 -0
- package/src/designs/index.js +7 -0
- package/src/document.js +215 -0
- package/src/generate.js +167 -0
- package/src/index.js +19 -0
- package/src/model.js +15 -0
- package/src/pages.jsx +42 -0
- package/src/propertyPath.js +35 -0
- package/src/useFactoryBuilder.js +209 -0
- package/src/useFactoryScreen.js +155 -0
- package/src/validateDocument.js +202 -0
- package/src/values.js +148 -0
|
@@ -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
|
+
}
|