@xeplr/ui-factory 1.0.1 → 1.0.3
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 +101 -12
- package/README.md +73 -22
- package/bin/xeplr-factory.js +72 -1
- package/package.json +6 -2
- package/src/controls.js +142 -45
- package/src/designs/BuilderSample.jsx +80 -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 +72 -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 +12 -5
- package/src/pages.jsx +62 -10
- package/src/remote.js +66 -0
- package/src/scaffold.js +116 -0
- package/src/tableSchema.js +329 -0
- package/src/useFactoryBuilder.js +102 -17
- package/src/useFactoryScreen.js +290 -78
- package/src/validateDocument.js +73 -5
- package/src/values.js +61 -0
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
|
+
}
|