@xeplr/ui-factory 1.0.1 → 1.0.2
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 +100 -12
- package/README.md +69 -22
- package/bin/xeplr-factory.js +67 -1
- package/package.json +6 -2
- package/src/controls.js +142 -45
- package/src/designs/BuilderSample.jsx +60 -36
- package/src/designs/ControlView.jsx +43 -36
- package/src/designs/ListView.jsx +95 -0
- package/src/designs/PropertyPanel.jsx +187 -13
- package/src/designs/ScreenModal.jsx +42 -0
- package/src/designs/ScreenSample.jsx +49 -9
- package/src/designs/factory.css +68 -20
- package/src/designs/index.js +2 -0
- package/src/designs/styles.js +59 -0
- package/src/document.js +31 -4
- package/src/generate.js +136 -25
- package/src/index.js +2 -2
- package/src/model.js +11 -5
- package/src/pages.jsx +60 -10
- package/src/remote.js +57 -0
- package/src/scaffold.js +73 -0
- package/src/tableSchema.js +225 -0
- package/src/useFactoryBuilder.js +91 -17
- package/src/useFactoryScreen.js +265 -78
- package/src/validateDocument.js +73 -5
- package/src/values.js +61 -0
package/src/useFactoryScreen.js
CHANGED
|
@@ -2,50 +2,91 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
2
2
|
import { CONTROLS } from './controls.js'
|
|
3
3
|
import { inputNodes } from './document.js'
|
|
4
4
|
import { validateDocument } from './validateDocument.js'
|
|
5
|
-
import { initialValues, parseInput,
|
|
5
|
+
import { initialValues, parseInput, saveState, fieldError, optionValue, recordValues, listSource } from './values.js'
|
|
6
6
|
|
|
7
|
-
// A saved screen, running
|
|
8
|
-
// submit. No JSX — designs/ScreenSample.jsx draws it.
|
|
7
|
+
// A saved screen, running. No JSX — designs/ScreenSample.jsx draws it.
|
|
9
8
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// ── NO SUBMIT ────────────────────────────────────────────────────────────
|
|
10
|
+
// The screen saves ITSELF, in the background, a moment after the person stops
|
|
11
|
+
// changing something — once what is entered is acceptable. There is no button
|
|
12
|
+
// to press and no page to post. The first save of a new record creates it
|
|
13
|
+
// (the host hands back its id); every save after that updates the same record.
|
|
14
|
+
//
|
|
15
|
+
// ── THE DATABASE IS THE HOST'S ───────────────────────────────────────────
|
|
16
|
+
// Everything that reads or writes data is a call the host supplies:
|
|
17
|
+
// onSave(values, { id, source, screen, document }) → the saved record (with its id)
|
|
18
|
+
// fetchRecords({ source, screen, node }) → rows, for a list
|
|
19
|
+
// onDelete({ id, source, screen, record })
|
|
20
|
+
// fetchOptions({ table, node }) → [{ id, name }], for a table dropdown
|
|
21
|
+
//
|
|
22
|
+
// ── LIST → EDIT IN A POPUP ───────────────────────────────────────────────
|
|
23
|
+
// A list whose `editScreen` names another screen opens that screen in a popup
|
|
24
|
+
// for Edit and New. The screen comes from `screens` ({ id → document }) or the
|
|
25
|
+
// host's `loadScreen(id)`. The list refreshes as the popup saves.
|
|
26
|
+
|
|
27
|
+
/** Quiet time after the last change before a save goes out. */
|
|
28
|
+
export const AUTOSAVE_DELAY = 700
|
|
13
29
|
|
|
14
30
|
/**
|
|
15
31
|
* @param document a valid screen document
|
|
16
|
-
* @param
|
|
17
|
-
* @param
|
|
18
|
-
* @param
|
|
32
|
+
* @param record the record to open, e.g. { id: 7, firstName: 'Ada' } — omit for a new one
|
|
33
|
+
* @param recordKey the id field on a record (default 'id')
|
|
34
|
+
* @param onSave async (values, { id, source, document }) → saved record | void
|
|
35
|
+
* @param fetchOptions async ({ table, node }) → [{ id, name }]
|
|
36
|
+
* @param fetchRecords async ({ source, node }) → records, for lists
|
|
37
|
+
* @param onDelete async ({ id, source, record }) → void
|
|
38
|
+
* @param onChange (values) → void — every change, before any save
|
|
39
|
+
* @param screens { id → document } — screens a list's Edit / New can open
|
|
40
|
+
* @param loadScreen async (id) → document — for screens not in `screens`
|
|
41
|
+
* @param autosaveDelay ms (default 700)
|
|
19
42
|
* @param controls the control registry (default: the built-ins)
|
|
20
43
|
*/
|
|
21
|
-
export function useFactoryScreen({
|
|
44
|
+
export function useFactoryScreen({
|
|
45
|
+
document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, onDelete, onChange, screens, loadScreen,
|
|
46
|
+
autosaveDelay = AUTOSAVE_DELAY, controls = CONTROLS
|
|
47
|
+
} = {}) {
|
|
22
48
|
const check = useMemo(() => validateDocument(doc, controls), [doc, controls])
|
|
23
49
|
const inputs = useMemo(() => (check.ok ? inputNodes(doc, controls) : []), [doc, controls, check.ok])
|
|
24
50
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
51
|
+
// A LIST SCREEN has no fields of its own; what its columns mean — that
|
|
52
|
+
// `department` is a dropdown whose 2 reads "Finance" — lives on the screen it
|
|
53
|
+
// edits in. Those fields are borrowed for display, and their table options
|
|
54
|
+
// loaded alongside this screen's own.
|
|
55
|
+
const fieldsFor = useCallback((node) => {
|
|
56
|
+
if (inputs.length || !node || !node.props.editScreen) return inputs
|
|
57
|
+
const editDoc = screens && screens[node.props.editScreen]
|
|
58
|
+
return editDoc && validateDocument(editDoc, controls).ok ? inputNodes(editDoc, controls) : []
|
|
59
|
+
}, [inputs, screens, controls])
|
|
60
|
+
const optionNodes = useMemo(() => {
|
|
61
|
+
if (!check.ok) return []
|
|
62
|
+
const borrowed = doc.nodes.filter((n) => n.type === 'list').flatMap((n) => (inputs.length ? [] : fieldsFor(n)))
|
|
63
|
+
return [...inputs, ...borrowed]
|
|
64
|
+
}, [check.ok, doc, inputs, fieldsFor])
|
|
65
|
+
const source = check.ok ? doc.source || null : null
|
|
30
66
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
67
|
+
const startValues = useCallback(
|
|
68
|
+
(rec) => (rec ? { ...initialValues(doc, {}, controls), ...recordValues(doc, rec, controls) } : initialValues(doc, {}, controls)),
|
|
69
|
+
[doc, controls]
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const [values, setValues] = useState(() => (check.ok ? startValues(record) : {}))
|
|
73
|
+
const [recordId, setRecordId] = useState(() => (record ? record[recordKey] ?? null : null))
|
|
74
|
+
const [touched, setTouched] = useState(() => new Set())
|
|
75
|
+
const [status, setStatus] = useState('idle') // idle | pending | saving | saved | incomplete | invalid | error
|
|
76
|
+
const [saveError, setSaveError] = useState(null)
|
|
77
|
+
const [savedAt, setSavedAt] = useState(null)
|
|
78
|
+
const [listVersion, setListVersion] = useState(0)
|
|
79
|
+
|
|
80
|
+
// Latest of everything a delayed save needs, read at the moment it runs.
|
|
81
|
+
const live = useRef({})
|
|
82
|
+
live.current = { doc, values, recordId, touched, source, onSave, controls, recordKey }
|
|
42
83
|
|
|
43
84
|
// ── options for table dropdowns, fetched once per table ───────────────
|
|
44
85
|
const [options, setOptions] = useState({}) // node id → { loading, items, error }
|
|
45
|
-
const
|
|
86
|
+
const fetchOptionsRef = useRef(fetchOptions); fetchOptionsRef.current = fetchOptions
|
|
46
87
|
useEffect(() => {
|
|
47
88
|
let cancelled = false
|
|
48
|
-
const tableNodes =
|
|
89
|
+
const tableNodes = optionNodes.filter((n) => n.type === 'dropdown' && n.props.data?.source === 'table')
|
|
49
90
|
if (!tableNodes.length) { setOptions({}); return undefined }
|
|
50
91
|
setOptions(Object.fromEntries(tableNodes.map((n) => [n.id, { loading: true, items: [], error: null }])))
|
|
51
92
|
const byTable = new Map()
|
|
@@ -55,8 +96,8 @@ export function useFactoryScreen({ document: doc, fetchOptions, onSubmit, values
|
|
|
55
96
|
byTable.get(t).push(n)
|
|
56
97
|
})
|
|
57
98
|
byTable.forEach((nodes, table) => {
|
|
58
|
-
const run =
|
|
59
|
-
? Promise.resolve().then(() =>
|
|
99
|
+
const run = fetchOptionsRef.current
|
|
100
|
+
? Promise.resolve().then(() => fetchOptionsRef.current({ table, node: nodes[0] }))
|
|
60
101
|
: Promise.reject(new Error(`No fetchOptions was provided to read "${table}"`))
|
|
61
102
|
run
|
|
62
103
|
.then((rows) => {
|
|
@@ -68,9 +109,8 @@ export function useFactoryScreen({ document: doc, fetchOptions, onSubmit, values
|
|
|
68
109
|
})
|
|
69
110
|
})
|
|
70
111
|
return () => { cancelled = true }
|
|
71
|
-
}, [
|
|
112
|
+
}, [optionNodes])
|
|
72
113
|
|
|
73
|
-
/** The options a dropdown shows, whichever source they come from. */
|
|
74
114
|
const optionsFor = useCallback((node) => {
|
|
75
115
|
if (node.props.data?.source === 'static') return { loading: false, items: node.props.data.options, error: null }
|
|
76
116
|
return options[node.id] || { loading: false, items: [], error: null }
|
|
@@ -81,69 +121,216 @@ export function useFactoryScreen({ document: doc, fetchOptions, onSubmit, values
|
|
|
81
121
|
Object.entries(options).forEach(([id, o]) => { if (!o.loading && !o.error) map[id] = o.items })
|
|
82
122
|
return map
|
|
83
123
|
}, [options])
|
|
124
|
+
live.current.loadedItems = loadedItems
|
|
125
|
+
|
|
126
|
+
const state = useMemo(() => (check.ok ? saveState(doc, values, touched, loadedItems, controls) : { canSave: false, status: 'invalid', errors: {}, shown: {} }),
|
|
127
|
+
[check.ok, doc, values, touched, loadedItems, controls])
|
|
128
|
+
|
|
129
|
+
// ── saving ────────────────────────────────────────────────────────────
|
|
130
|
+
const timer = useRef(null)
|
|
131
|
+
const inFlight = useRef(null) // the promise of the save running now
|
|
132
|
+
const again = useRef(false) // a change arrived while it ran
|
|
133
|
+
|
|
134
|
+
const runSave = useCallback(async () => {
|
|
135
|
+
clearTimeout(timer.current)
|
|
136
|
+
timer.current = null
|
|
137
|
+
if (inFlight.current) { again.current = true; return inFlight.current }
|
|
138
|
+
const l = live.current
|
|
139
|
+
const st = saveState(l.doc, l.values, l.touched, l.loadedItems, l.controls)
|
|
140
|
+
if (!st.canSave) { setStatus(st.status); return { ok: false, errors: st.errors } }
|
|
141
|
+
if (!l.onSave) { setStatus('saved'); return { ok: true } }
|
|
142
|
+
|
|
143
|
+
setStatus('saving')
|
|
144
|
+
setSaveError(null)
|
|
145
|
+
const task = (async () => {
|
|
146
|
+
try {
|
|
147
|
+
const saved = await l.onSave(l.values, { id: l.recordId, source: l.source, screen: l.doc.id, document: l.doc })
|
|
148
|
+
// A new record's first save creates it; its id makes the next save an update.
|
|
149
|
+
if (saved && typeof saved === 'object' && saved[l.recordKey] !== undefined && saved[l.recordKey] !== null) {
|
|
150
|
+
setRecordId(saved[l.recordKey])
|
|
151
|
+
live.current.recordId = saved[l.recordKey]
|
|
152
|
+
}
|
|
153
|
+
setSavedAt(new Date())
|
|
154
|
+
setListVersion((v) => v + 1)
|
|
155
|
+
setStatus('saved')
|
|
156
|
+
return { ok: true, record: saved }
|
|
157
|
+
} catch (err) {
|
|
158
|
+
setSaveError(err.message || 'Could not save')
|
|
159
|
+
setStatus('error')
|
|
160
|
+
return { ok: false, error: err }
|
|
161
|
+
} finally {
|
|
162
|
+
inFlight.current = null
|
|
163
|
+
if (again.current) { again.current = false; schedule() } // eslint-disable-line no-use-before-define
|
|
164
|
+
}
|
|
165
|
+
})()
|
|
166
|
+
inFlight.current = task
|
|
167
|
+
return task
|
|
168
|
+
}, [])
|
|
169
|
+
|
|
170
|
+
const schedule = useCallback(() => {
|
|
171
|
+
clearTimeout(timer.current)
|
|
172
|
+
setStatus('pending')
|
|
173
|
+
timer.current = setTimeout(runSave, autosaveDelay)
|
|
174
|
+
}, [runSave, autosaveDelay])
|
|
175
|
+
|
|
176
|
+
/** Save any pending change right away — before opening another record, say. */
|
|
177
|
+
const flush = useCallback(async () => {
|
|
178
|
+
if (timer.current || again.current) return runSave()
|
|
179
|
+
if (inFlight.current) return inFlight.current
|
|
180
|
+
return { ok: true }
|
|
181
|
+
}, [runSave])
|
|
182
|
+
|
|
183
|
+
useEffect(() => () => clearTimeout(timer.current), [])
|
|
84
184
|
|
|
85
185
|
// ── editing ───────────────────────────────────────────────────────────
|
|
186
|
+
const onChangeRef = useRef(onChange); onChangeRef.current = onChange
|
|
86
187
|
const setValue = useCallback((node, raw) => {
|
|
87
188
|
let value = parseInput(node, raw)
|
|
88
189
|
if (node.type === 'dropdown' && value !== undefined) value = optionValue(optionsFor(node).items, value)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
190
|
+
const name = node.props.name
|
|
191
|
+
const next = { ...live.current.values }
|
|
192
|
+
if (value === undefined) delete next[name]
|
|
193
|
+
else next[name] = value
|
|
194
|
+
live.current.values = next
|
|
195
|
+
setValues(next)
|
|
196
|
+
if (!live.current.touched.has(name)) {
|
|
197
|
+
const t = new Set(live.current.touched); t.add(name)
|
|
198
|
+
live.current.touched = t
|
|
199
|
+
setTouched(t)
|
|
200
|
+
}
|
|
201
|
+
onChangeRef.current?.(next)
|
|
202
|
+
schedule()
|
|
203
|
+
}, [optionsFor, schedule])
|
|
204
|
+
|
|
205
|
+
/** Leaving a field shows its message, even if nothing was typed. */
|
|
206
|
+
const touch = useCallback((node) => {
|
|
207
|
+
const name = node.props.name
|
|
208
|
+
if (live.current.touched.has(name)) return
|
|
209
|
+
const t = new Set(live.current.touched); t.add(name)
|
|
210
|
+
live.current.touched = t
|
|
211
|
+
setTouched(t)
|
|
212
|
+
}, [])
|
|
213
|
+
|
|
214
|
+
/** Open a record: whatever was pending is saved first. */
|
|
215
|
+
const openRecord = useCallback(async (rec) => {
|
|
216
|
+
await flush()
|
|
217
|
+
const next = startValues(rec)
|
|
218
|
+
live.current.values = next
|
|
219
|
+
live.current.recordId = rec ? rec[recordKey] ?? null : null
|
|
220
|
+
live.current.touched = new Set()
|
|
221
|
+
setValues(next)
|
|
222
|
+
setRecordId(live.current.recordId)
|
|
223
|
+
setTouched(new Set())
|
|
224
|
+
setSaveError(null)
|
|
225
|
+
setStatus('idle')
|
|
226
|
+
}, [flush, startValues, recordKey])
|
|
227
|
+
|
|
228
|
+
const newRecord = useCallback(() => openRecord(null), [openRecord])
|
|
229
|
+
|
|
230
|
+
// A different document, or a different record handed in, starts over.
|
|
231
|
+
const firstRun = useRef(true)
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (firstRun.current) { firstRun.current = false; return }
|
|
234
|
+
if (!check.ok) return
|
|
235
|
+
openRecord(record || null)
|
|
236
|
+
}, [doc, record]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
237
|
+
|
|
238
|
+
// ── lists ─────────────────────────────────────────────────────────────
|
|
239
|
+
const [lists, setLists] = useState({}) // node id → { loading, rows, error }
|
|
240
|
+
const fetchRecordsRef = useRef(fetchRecords); fetchRecordsRef.current = fetchRecords
|
|
241
|
+
const listNodes = useMemo(() => (check.ok ? doc.nodes.filter((n) => n.type === 'list') : []), [doc, check.ok])
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
if (!listNodes.length) return undefined
|
|
244
|
+
let cancelled = false
|
|
245
|
+
listNodes.forEach((node) => {
|
|
246
|
+
const src = listSource(doc, node)
|
|
247
|
+
setLists((l) => ({ ...l, [node.id]: { rows: l[node.id]?.rows || [], loading: true, error: null } }))
|
|
248
|
+
const run = fetchRecordsRef.current
|
|
249
|
+
? Promise.resolve().then(() => fetchRecordsRef.current({ source: src, screen: doc.id, node }))
|
|
250
|
+
: Promise.reject(new Error(`No fetchRecords was provided to list "${src}"`))
|
|
251
|
+
run
|
|
252
|
+
.then((rows) => { if (!cancelled) setLists((l) => ({ ...l, [node.id]: { rows: Array.isArray(rows) ? rows : [], loading: false, error: null } })) })
|
|
253
|
+
.catch((err) => { if (!cancelled) setLists((l) => ({ ...l, [node.id]: { rows: l[node.id]?.rows || [], loading: false, error: err.message || 'Could not load records' } })) })
|
|
94
254
|
})
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
255
|
+
return () => { cancelled = true }
|
|
256
|
+
}, [listNodes, listVersion, doc])
|
|
257
|
+
|
|
258
|
+
const listFor = useCallback((node) => lists[node.id] || { rows: [], loading: false, error: null }, [lists])
|
|
259
|
+
|
|
260
|
+
const onDeleteRef = useRef(onDelete); onDeleteRef.current = onDelete
|
|
261
|
+
const deleteRecord = useCallback(async (node, rec) => {
|
|
262
|
+
const id = rec ? rec[recordKey] : undefined
|
|
263
|
+
if (!onDeleteRef.current) throw new Error('No onDelete was provided')
|
|
264
|
+
await onDeleteRef.current({ id, source: listSource(doc, node), screen: doc.id, record: rec })
|
|
265
|
+
// Deleting the record that is open leaves nothing to save into.
|
|
266
|
+
if (id !== undefined && id === live.current.recordId) {
|
|
267
|
+
clearTimeout(timer.current); timer.current = null
|
|
268
|
+
await openRecord(null)
|
|
105
269
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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)
|
|
270
|
+
setListVersion((v) => v + 1)
|
|
271
|
+
}, [doc, recordKey, openRecord])
|
|
272
|
+
|
|
273
|
+
// ── popup: a list's Edit / New in its edit screen ─────────────────────
|
|
274
|
+
const [popup, setPopup] = useState(null) // { node, screenId, record, document, loading, error }
|
|
275
|
+
const screensRef = useRef(screens); screensRef.current = screens
|
|
276
|
+
const loadScreenRef = useRef(loadScreen); loadScreenRef.current = loadScreen
|
|
277
|
+
|
|
278
|
+
/** Edit (a record) or New (null) in the list's edit screen. */
|
|
279
|
+
const openEditor = useCallback(async (node, rec) => {
|
|
280
|
+
const screenId = node.props.editScreen
|
|
281
|
+
const base = { node, screenId, record: rec || null, document: null, loading: true, error: null }
|
|
282
|
+
setPopup(base)
|
|
125
283
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
284
|
+
let found = screensRef.current && screensRef.current[screenId]
|
|
285
|
+
if (!found && loadScreenRef.current) found = await loadScreenRef.current(screenId)
|
|
286
|
+
if (!found) throw new Error(`Screen "${screenId}" was not provided — pass it in \`screens\` or supply loadScreen`)
|
|
287
|
+
const checked = validateDocument(found, live.current.controls)
|
|
288
|
+
if (!checked.ok) throw new Error(`Screen "${screenId}" is not valid: ${checked.errors[0].path} ${checked.errors[0].message}`)
|
|
289
|
+
setPopup((p) => (p && p.screenId === screenId ? { ...p, document: found, loading: false } : p))
|
|
128
290
|
} catch (err) {
|
|
129
|
-
|
|
130
|
-
return { ok: false, error: err }
|
|
131
|
-
} finally {
|
|
132
|
-
setSubmitting(false)
|
|
291
|
+
setPopup((p) => (p && p.screenId === screenId ? { ...p, loading: false, error: err.message } : p))
|
|
133
292
|
}
|
|
134
|
-
}, [
|
|
293
|
+
}, [])
|
|
294
|
+
|
|
295
|
+
const closeEditor = useCallback(() => {
|
|
296
|
+
setPopup(null)
|
|
297
|
+
setListVersion((v) => v + 1)
|
|
298
|
+
}, [])
|
|
299
|
+
|
|
300
|
+
/** The popup's saves go to the host's onSave, and refresh the list behind it. */
|
|
301
|
+
const onSaveRef = useRef(onSave); onSaveRef.current = onSave
|
|
302
|
+
const popupSave = useCallback(async (vals, meta) => {
|
|
303
|
+
const saved = onSaveRef.current ? await onSaveRef.current(vals, meta) : undefined
|
|
304
|
+
setListVersion((v) => v + 1)
|
|
305
|
+
return saved
|
|
306
|
+
}, [])
|
|
135
307
|
|
|
136
308
|
return {
|
|
137
309
|
document: doc,
|
|
138
310
|
documentErrors: check.errors,
|
|
311
|
+
popup,
|
|
312
|
+
fieldsFor,
|
|
313
|
+
openEditor,
|
|
314
|
+
closeEditor,
|
|
315
|
+
popupSave,
|
|
139
316
|
values,
|
|
140
|
-
|
|
317
|
+
recordId,
|
|
318
|
+
errors: state.shown,
|
|
319
|
+
allErrors: state.errors,
|
|
320
|
+
status,
|
|
321
|
+
saveError,
|
|
322
|
+
savedAt,
|
|
141
323
|
setValue,
|
|
324
|
+
touch,
|
|
142
325
|
optionsFor,
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
326
|
+
flush,
|
|
327
|
+
saveNow: runSave,
|
|
328
|
+
openRecord,
|
|
329
|
+
newRecord,
|
|
330
|
+
listFor,
|
|
331
|
+
deleteRecord,
|
|
332
|
+
recordKey,
|
|
333
|
+
canDelete: Boolean(onDelete)
|
|
147
334
|
}
|
|
148
335
|
}
|
|
149
336
|
|
package/src/validateDocument.js
CHANGED
|
@@ -10,10 +10,15 @@
|
|
|
10
10
|
// Loud by design: a screen that half-renders — a dropdown with no options, two
|
|
11
11
|
// fields writing the same key — looks like a data problem to whoever uses it.
|
|
12
12
|
|
|
13
|
-
import { CONTROLS, LABEL_VARIANTS,
|
|
13
|
+
import { CONTROLS, LABEL_VARIANTS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS } from './controls.js'
|
|
14
14
|
import { DOCUMENT_KIND, DOCUMENT_VERSION } from './document.js'
|
|
15
|
+
import { RESERVED_COLUMNS, MAX_IDENTIFIER } from './tableSchema.js'
|
|
15
16
|
|
|
16
17
|
const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
18
|
+
const TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_.$-]*$/
|
|
19
|
+
const SCREEN_ID = /^[A-Za-z0-9_-]+$/
|
|
20
|
+
const DOCUMENT_KEYS = ['kind', 'version', 'id', 'name', 'source', 'units', 'aspect', 'width', 'style', 'nodes']
|
|
21
|
+
const COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i
|
|
17
22
|
|
|
18
23
|
/**
|
|
19
24
|
* @returns {{ ok: boolean, errors: Array<{ path: string, message: string }> }}
|
|
@@ -29,9 +34,16 @@ export function validateDocument(doc, controls = CONTROLS) {
|
|
|
29
34
|
if (doc.kind !== DOCUMENT_KIND) err('kind', `must be "${DOCUMENT_KIND}"`)
|
|
30
35
|
if (doc.version !== DOCUMENT_VERSION) err('version', `must be ${DOCUMENT_VERSION}`)
|
|
31
36
|
if (!nonEmptyString(doc.id)) err('id', 'must be a non-empty string')
|
|
37
|
+
else if (!SCREEN_ID.test(doc.id)) err('id', `"${doc.id}" must contain only letters, digits, _ and -`)
|
|
32
38
|
if (!nonEmptyString(doc.name)) err('name', 'must be a non-empty string — the screen\'s title, e.g. "New employee"')
|
|
33
39
|
if (doc.units !== 'fraction') err('units', 'must be "fraction"')
|
|
34
40
|
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)')
|
|
41
|
+
if (!(Number.isInteger(doc.width) && doc.width >= 320 && doc.width <= 1920)) err('width', 'must be a whole number of pixels from 320 to 1920 — the width the screen is designed at')
|
|
42
|
+
if (doc.source !== undefined && !(nonEmptyString(doc.source) && TABLE_NAME.test(doc.source))) err('source', 'must name the table records are saved to, e.g. "employees"')
|
|
43
|
+
checkStyle(doc.style, SCREEN_STYLE_KEYS, 'style', err, 'the screen')
|
|
44
|
+
Object.keys(doc).forEach((k) => {
|
|
45
|
+
if (!DOCUMENT_KEYS.includes(k)) err(k, `is not a screen property — allowed: ${DOCUMENT_KEYS.join(', ')}`)
|
|
46
|
+
})
|
|
35
47
|
if (!Array.isArray(doc.nodes)) {
|
|
36
48
|
err('nodes', 'must be an array of controls')
|
|
37
49
|
return { ok: false, errors }
|
|
@@ -69,6 +81,8 @@ export function validateDocument(doc, controls = CONTROLS) {
|
|
|
69
81
|
if (!nonEmptyString(props.name)) err(`${at}.props.name`, 'is required — the key the value is saved under')
|
|
70
82
|
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
83
|
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`)
|
|
84
|
+
else if (RESERVED_COLUMNS.includes(props.name)) err(`${at}.props.name`, `"${props.name}" is a standard column of every table — choose another name`)
|
|
85
|
+
else if (props.name.length > MAX_IDENTIFIER) err(`${at}.props.name`, `is ${props.name.length} characters — a column name can be at most ${MAX_IDENTIFIER}`)
|
|
72
86
|
else names.set(props.name, i)
|
|
73
87
|
|
|
74
88
|
if (!nonEmptyString(props.label)) err(`${at}.props.label`, 'is required — the text shown above the field')
|
|
@@ -78,15 +92,13 @@ export function validateDocument(doc, controls = CONTROLS) {
|
|
|
78
92
|
checkValidation(node, def, at, err)
|
|
79
93
|
}
|
|
80
94
|
|
|
95
|
+
checkStyle(props.style, def.styles || [], `${at}.props.style`, err, `"${node.type}"`)
|
|
81
96
|
if (node.type === 'dropdown') checkDataSource(props.data, `${at}.props.data`, err)
|
|
97
|
+
if (node.type === 'list') checkList(props, doc, at, err)
|
|
82
98
|
if (node.type === 'label') {
|
|
83
99
|
if (typeof props.text !== 'string') err(`${at}.props.text`, 'must be a string')
|
|
84
100
|
if (props.variant !== undefined && !LABEL_VARIANTS.includes(props.variant)) err(`${at}.props.variant`, `must be one of: ${LABEL_VARIANTS.join(', ')}`)
|
|
85
101
|
}
|
|
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
102
|
})
|
|
91
103
|
|
|
92
104
|
return { ok: errors.length === 0, errors }
|
|
@@ -193,6 +205,62 @@ function checkDataSource(data, at, err) {
|
|
|
193
205
|
}
|
|
194
206
|
}
|
|
195
207
|
|
|
208
|
+
/** `props.style` / the screen's `style`: only known keys, each in range. */
|
|
209
|
+
function checkStyle(style, allowed, at, err, owner) {
|
|
210
|
+
if (style === undefined) return
|
|
211
|
+
if (!style || typeof style !== 'object' || Array.isArray(style)) { err(at, 'must be an object'); return }
|
|
212
|
+
Object.keys(style).forEach((key) => {
|
|
213
|
+
const path = `${at}.${key}`
|
|
214
|
+
const spec = STYLE_KEYS[key]
|
|
215
|
+
if (!spec || !allowed.includes(key)) {
|
|
216
|
+
err(path, `is not a style of ${owner} — allowed: ${allowed.join(', ')}`)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
const v = style[key]
|
|
220
|
+
let ok
|
|
221
|
+
switch (spec.type) {
|
|
222
|
+
case 'number': ok = typeof v === 'number' && Number.isFinite(v) && v >= spec.min && v <= spec.max; break
|
|
223
|
+
case 'select': ok = spec.options.some((o) => o.value === v); break
|
|
224
|
+
case 'toggleValue': ok = v === 'normal' || v === spec.on; break
|
|
225
|
+
case 'color': ok = typeof v === 'string' && (COLOR.test(v) || (key !== 'color' && key !== 'labelColor' && v === 'transparent')); break
|
|
226
|
+
case 'font': ok = typeof v === 'string' && v.trim() !== '' && v.length <= 200 && !/[;{}<>]/.test(v); break
|
|
227
|
+
default: ok = true
|
|
228
|
+
}
|
|
229
|
+
if (!ok) err(path, `must be ${spec.hint}`)
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A list of saved records: where they come from, which columns, which actions.
|
|
235
|
+
* With no `source` it reads the table the screen saves to, so one of the two
|
|
236
|
+
* must be set.
|
|
237
|
+
*/
|
|
238
|
+
function checkList(props, doc, at, err) {
|
|
239
|
+
if (props.title !== undefined && typeof props.title !== 'string') err(`${at}.props.title`, 'must be a string')
|
|
240
|
+
if (props.source !== undefined && !(nonEmptyString(props.source) && TABLE_NAME.test(props.source))) err(`${at}.props.source`, 'must name the table to list records from')
|
|
241
|
+
if (props.source === undefined && doc.source === undefined) err(`${at}.props.source`, 'is required when the screen has no "source" — which table should the list read?')
|
|
242
|
+
if (props.editScreen !== undefined && !(nonEmptyString(props.editScreen) && SCREEN_ID.test(props.editScreen))) err(`${at}.props.editScreen`, 'must be the id of the screen that edits a record, e.g. "employee_edit"')
|
|
243
|
+
if (props.pageSize !== undefined && !(Number.isInteger(props.pageSize) && props.pageSize >= 1 && props.pageSize <= 200)) err(`${at}.props.pageSize`, 'must be a whole number from 1 to 200')
|
|
244
|
+
if (props.actions !== undefined) {
|
|
245
|
+
if (!Array.isArray(props.actions)) err(`${at}.props.actions`, `must be an array of: ${LIST_ACTIONS.join(', ')}`)
|
|
246
|
+
else props.actions.forEach((a, j) => { if (!LIST_ACTIONS.includes(a)) err(`${at}.props.actions[${j}]`, `must be one of: ${LIST_ACTIONS.join(', ')}`) })
|
|
247
|
+
}
|
|
248
|
+
if (props.columns !== undefined) {
|
|
249
|
+
if (!Array.isArray(props.columns)) { err(`${at}.props.columns`, 'must be an array of { field, label }'); return }
|
|
250
|
+
const seen = new Set()
|
|
251
|
+
props.columns.forEach((c, j) => {
|
|
252
|
+
const p = `${at}.props.columns[${j}]`
|
|
253
|
+
if (!c || typeof c !== 'object') { err(p, 'must be { field, label }'); return }
|
|
254
|
+
if (!(nonEmptyString(c.field) && FIELD_NAME.test(c.field))) err(`${p}.field`, 'must be a field name — letters, digits and _')
|
|
255
|
+
else if (seen.has(c.field)) err(`${p}.field`, `"${c.field}" appears twice`)
|
|
256
|
+
else seen.add(c.field)
|
|
257
|
+
if (!nonEmptyString(c.label)) err(`${p}.label`, 'must be a non-empty string — the column heading')
|
|
258
|
+
const extra = Object.keys(c).filter((k) => k !== 'field' && k !== 'label')
|
|
259
|
+
if (extra.length) err(p, `only "field" and "label" are used — remove: ${extra.join(', ')}`)
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
196
264
|
function nonEmptyString(v) {
|
|
197
265
|
return typeof v === 'string' && v.trim() !== ''
|
|
198
266
|
}
|
package/src/values.js
CHANGED
|
@@ -146,3 +146,64 @@ export function optionValue(options, raw) {
|
|
|
146
146
|
function safeRegExp(pattern) {
|
|
147
147
|
try { return new RegExp(pattern) } catch (_) { return { test: () => true } }
|
|
148
148
|
}
|
|
149
|
+
|
|
150
|
+
// ── autosave ─────────────────────────────────────────────────────────────
|
|
151
|
+
// There is no submit. A screen saves itself a moment after a change, as long
|
|
152
|
+
// as what is entered is acceptable. This decides whether it can, and which
|
|
153
|
+
// messages to SHOW: a field nobody has touched is not shouted at for being
|
|
154
|
+
// empty — it only holds the save back.
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @param touched Set of field names the person has changed or left
|
|
158
|
+
* @returns {{ canSave, status, errors, shown }}
|
|
159
|
+
* status 'ready' | 'incomplete' (only untouched required fields are empty) | 'invalid'
|
|
160
|
+
* errors every failing field → message
|
|
161
|
+
* shown the ones to display now
|
|
162
|
+
*/
|
|
163
|
+
export function saveState(doc, values, touched, loadedOptions = {}, controls = CONTROLS) {
|
|
164
|
+
const errors = validateValues(doc, values, controls, loadedOptions)
|
|
165
|
+
const names = Object.keys(errors)
|
|
166
|
+
const shown = {}
|
|
167
|
+
names.forEach((n) => { if (touched && touched.has(n)) shown[n] = errors[n] })
|
|
168
|
+
if (!names.length) return { canSave: true, status: 'ready', errors, shown }
|
|
169
|
+
const onlyUntouched = names.every((n) => !(touched && touched.has(n)))
|
|
170
|
+
return { canSave: false, status: onlyUntouched ? 'incomplete' : 'invalid', errors, shown }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A saved record → the screen's values, for Edit. Fields the screen does not have are left out. */
|
|
174
|
+
export function recordValues(doc, record, controls = CONTROLS) {
|
|
175
|
+
const out = {}
|
|
176
|
+
inputNodes(doc, controls).forEach((node) => {
|
|
177
|
+
const name = node.props.name
|
|
178
|
+
const v = record ? record[name] : undefined
|
|
179
|
+
if (v !== undefined && v !== null) out[name] = node.type === 'date' && typeof v === 'string' ? v.slice(0, 10) : v
|
|
180
|
+
else if (node.type === 'checkbox') out[name] = false
|
|
181
|
+
})
|
|
182
|
+
return out
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── lists ────────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/** The table a list reads: its own, else the one the screen saves to. */
|
|
188
|
+
export function listSource(doc, node) {
|
|
189
|
+
return (node && node.props && node.props.source) || doc.source || null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A list's columns: the ones chosen, else the screen's fields in reading order. */
|
|
193
|
+
export function listColumns(doc, node, controls = CONTROLS) {
|
|
194
|
+
if (node && Array.isArray(node.props?.columns) && node.props.columns.length) return node.props.columns
|
|
195
|
+
return inputNodes(doc, controls).map((n) => ({ field: n.props.name, label: n.props.label || n.props.name }))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** How a stored value reads in a list: a dropdown's name, not its id; Yes/No for a checkbox. */
|
|
199
|
+
export function displayValue(node, value, options) {
|
|
200
|
+
if (value === undefined || value === null || value === '') return ''
|
|
201
|
+
if (!node) return typeof value === 'object' ? JSON.stringify(value) : value
|
|
202
|
+
if (node.type === 'checkbox') return value ? 'Yes' : 'No'
|
|
203
|
+
if (node.type === 'dropdown') {
|
|
204
|
+
const opts = node.props.data?.source === 'static' ? node.props.data.options : options
|
|
205
|
+
const hit = (opts || []).find((o) => sameId(o.id, value))
|
|
206
|
+
return hit ? hit.name : String(value)
|
|
207
|
+
}
|
|
208
|
+
return value
|
|
209
|
+
}
|