@xeplr/ui-factory 1.0.5 → 1.0.7
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 +16 -5
- package/README.md +61 -9
- package/package.json +1 -1
- package/src/controls.js +191 -0
- package/src/designs/BuilderSample.jsx +38 -4
- package/src/designs/ControlView.jsx +202 -2
- package/src/designs/PropertyPanel.jsx +71 -1
- package/src/designs/ScreenSample.jsx +34 -3
- package/src/designs/factory.css +65 -0
- package/src/document.js +67 -3
- package/src/generate.js +83 -19
- package/src/model.js +4 -3
- package/src/remote.js +32 -2
- package/src/tableSchema.js +16 -2
- package/src/useFactoryBuilder.js +40 -3
- package/src/useFactoryScreen.js +61 -5
- package/src/validateDocument.js +89 -6
- package/src/values.js +89 -10
package/src/generate.js
CHANGED
|
@@ -23,14 +23,17 @@
|
|
|
23
23
|
// The result is an ordinary document, checked before it is returned; from
|
|
24
24
|
// there it is edited like any other.
|
|
25
25
|
|
|
26
|
-
import { CONTROLS } from './controls.js'
|
|
27
|
-
import { createScreen, addControl, MARGIN, GAP, slugify, camelName } from './document.js'
|
|
26
|
+
import { CONTROLS, CHOICE_TYPES } from './controls.js'
|
|
27
|
+
import { createScreen, addControl, MARGIN, GAP, slugify, camelName, stepsOf } from './document.js'
|
|
28
28
|
import { assertValidDocument } from './validateDocument.js'
|
|
29
29
|
|
|
30
30
|
/** Heights by control, as page fractions (a page is as tall as it is wide). */
|
|
31
31
|
export const SPEC_HEIGHTS = {
|
|
32
|
-
text: 0.08, number: 0.08, date: 0.08, dropdown: 0.08,
|
|
32
|
+
text: 0.08, number: 0.08, date: 0.08, datetime: 0.08, dropdown: 0.08,
|
|
33
|
+
file: 0.1, stepper: 0.09,
|
|
33
34
|
textarea: 0.18,
|
|
35
|
+
// A group is as tall as its options make it — see heightOf().
|
|
36
|
+
radio: 0.14, multiselect: 0.18,
|
|
34
37
|
checkbox: 0.05,
|
|
35
38
|
list: 0.42,
|
|
36
39
|
label: { heading: 0.07, subheading: 0.055, text: 0.045 }
|
|
@@ -42,7 +45,7 @@ export const SPEC_HEIGHTS = {
|
|
|
42
45
|
*/
|
|
43
46
|
export const ROW_GAP = 0.04
|
|
44
47
|
|
|
45
|
-
const FIELD_KEYS = ['type', 'label', 'name', 'required', 'placeholder', 'default', 'validation', 'data', 'options', 'table', 'width', 'text', 'variant', 'style']
|
|
48
|
+
const FIELD_KEYS = ['type', 'label', 'name', 'required', 'placeholder', 'default', 'validation', 'data', 'options', 'table', 'layout', 'accept', 'maxSize', 'step', 'steps', 'showNumbers', 'width', 'text', 'variant', 'style']
|
|
46
49
|
const SPEC_KEYS = ['name', 'id', 'source', 'columns', 'heading', 'fields', 'list', 'aspect', 'width', 'style']
|
|
47
50
|
const LIST_KEYS = ['title', 'source', 'columns', 'pageSize', 'actions', 'style']
|
|
48
51
|
|
|
@@ -79,20 +82,48 @@ export function screenFromSpec(spec, controls = CONTROLS) {
|
|
|
79
82
|
let col = 0
|
|
80
83
|
let rowH = 0
|
|
81
84
|
|
|
85
|
+
// A stepper on the screen, and where each of its steps starts: every step is
|
|
86
|
+
// laid out from just under the bar, so they sit on top of one another rather
|
|
87
|
+
// than running down a page as long as all of them put together.
|
|
88
|
+
let stepper = null
|
|
89
|
+
let stepTop = 0
|
|
90
|
+
let step = null
|
|
91
|
+
|
|
82
92
|
const place = (type, props, width) => {
|
|
83
93
|
const h = heightOf(type, props)
|
|
84
|
-
const isFull = width === 'full' || columns === 1 || type === 'textarea' || type === 'label' || type === 'list'
|
|
94
|
+
const isFull = width === 'full' || columns === 1 || type === 'textarea' || type === 'label' || type === 'list' || type === 'stepper'
|
|
95
|
+
let node
|
|
85
96
|
if (isFull) {
|
|
86
97
|
if (col > 0) { y += rowH + ROW_GAP; col = 0; rowH = 0 }
|
|
87
|
-
;({ document: doc } = addControl(doc, type, { at: { x: MARGIN, y: round(y) }, size: { w: round(full), h }, props }, controls))
|
|
98
|
+
;({ document: doc, node } = addControl(doc, type, { at: { x: MARGIN, y: round(y) }, size: { w: round(full), h }, props, step }, controls))
|
|
88
99
|
y += h + ROW_GAP
|
|
89
|
-
|
|
100
|
+
} else {
|
|
101
|
+
const x = MARGIN + col * (colW + GAP)
|
|
102
|
+
;({ document: doc, node } = addControl(doc, type, { at: { x: round(x), y: round(y) }, size: { w: colW, h }, props, step }, controls))
|
|
103
|
+
rowH = Math.max(rowH, h)
|
|
104
|
+
col++
|
|
105
|
+
if (col === columns) { y += rowH + ROW_GAP; col = 0; rowH = 0 }
|
|
106
|
+
}
|
|
107
|
+
if (type === 'stepper') {
|
|
108
|
+
stepper = node
|
|
109
|
+
stepTop = y
|
|
110
|
+
step = { of: node.id, index: 0 }
|
|
90
111
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
112
|
+
return node
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Move the layout to a step: back to the top, under the bar. */
|
|
116
|
+
const openStep = (which) => {
|
|
117
|
+
const steps = stepsOf(stepper)
|
|
118
|
+
const index = typeof which === 'number' ? which - 1 : steps.findIndex((s) => s.key === which || s.label === which)
|
|
119
|
+
if (index < 0 || index >= steps.length) {
|
|
120
|
+
throw new Error(`screenFromSpec: step ${JSON.stringify(which)} is not one of ${steps.map((s) => s.label).join(', ')}`)
|
|
121
|
+
}
|
|
122
|
+
if (step && step.index === index) return
|
|
123
|
+
step = { of: stepper.id, index }
|
|
124
|
+
y = stepTop
|
|
125
|
+
col = 0
|
|
126
|
+
rowH = 0
|
|
96
127
|
}
|
|
97
128
|
|
|
98
129
|
if (spec.heading !== false) {
|
|
@@ -106,6 +137,10 @@ export function screenFromSpec(spec, controls = CONTROLS) {
|
|
|
106
137
|
const type = field.type || 'text'
|
|
107
138
|
if (!controls[type]) throw new Error(`screenFromSpec: ${at}.type "${type}" is not a control — one of: ${Object.keys(controls).join(', ')}`)
|
|
108
139
|
if (field.width !== undefined && field.width !== 'half' && field.width !== 'full') throw new Error(`screenFromSpec: ${at}.width must be "half" or "full"`)
|
|
140
|
+
if (field.step !== undefined) {
|
|
141
|
+
if (!stepper) throw new Error(`screenFromSpec: ${at}.step needs a stepper before it — { "type": "stepper", "steps": [...] }`)
|
|
142
|
+
openStep(field.step)
|
|
143
|
+
}
|
|
109
144
|
place(type, propsFromField(type, field, at), field.width)
|
|
110
145
|
})
|
|
111
146
|
|
|
@@ -123,6 +158,18 @@ export function screenFromSpec(spec, controls = CONTROLS) {
|
|
|
123
158
|
}
|
|
124
159
|
|
|
125
160
|
function propsFromField(type, field, at) {
|
|
161
|
+
if (type === 'stepper') {
|
|
162
|
+
const steps = field.steps
|
|
163
|
+
if (!Array.isArray(steps) || steps.length < 2) throw new Error(`screenFromSpec: ${at} is a stepper — give it steps: ["Connect", "Transform", …]`)
|
|
164
|
+
const props = {
|
|
165
|
+
steps: steps.map((s, i) => (typeof s === 'string'
|
|
166
|
+
? { key: slugify(s, '_') || `step_${i + 1}`, label: s }
|
|
167
|
+
: s))
|
|
168
|
+
}
|
|
169
|
+
if (field.showNumbers !== undefined) props.showNumbers = field.showNumbers
|
|
170
|
+
if (field.style) props.style = field.style
|
|
171
|
+
return props
|
|
172
|
+
}
|
|
126
173
|
if (type === 'label') {
|
|
127
174
|
const props = { text: field.text ?? field.label ?? '', variant: field.variant || 'subheading' }
|
|
128
175
|
if (field.style) props.style = field.style
|
|
@@ -134,17 +181,23 @@ function propsFromField(type, field, at) {
|
|
|
134
181
|
if (field[k] !== undefined) props[k] = field[k]
|
|
135
182
|
})
|
|
136
183
|
if (type === 'dropdown' && props.placeholder === undefined) props.placeholder = 'Select…'
|
|
137
|
-
if (type
|
|
138
|
-
props.data = dataFromField(field, at)
|
|
139
|
-
|
|
184
|
+
if (CHOICE_TYPES.includes(type)) {
|
|
185
|
+
props.data = dataFromField(field, at, type)
|
|
186
|
+
if (field.layout !== undefined && type !== 'dropdown') props.layout = field.layout
|
|
187
|
+
// A field reading another table stores that row's id: name the column
|
|
140
188
|
// for what it holds — "Department" → departmentId, a foreign key.
|
|
141
|
-
if (props.data.source === 'table' && props.name === undefined) props.name = camelName(field.label) + 'Id'
|
|
189
|
+
if (props.data.source === 'table' && props.name === undefined && type !== 'multiselect') props.name = camelName(field.label) + 'Id'
|
|
190
|
+
}
|
|
191
|
+
else if (field.options || field.table || field.data) throw new Error(`screenFromSpec: ${at} — options/table/data only apply to ${CHOICE_TYPES.join(', ')}`)
|
|
192
|
+
if (type === 'file') {
|
|
193
|
+
;['accept', 'maxSize'].forEach((k) => { if (field[k] !== undefined) props[k] = field[k] })
|
|
194
|
+
} else if (field.accept !== undefined || field.maxSize !== undefined) {
|
|
195
|
+
throw new Error(`screenFromSpec: ${at} — accept/maxSize only apply to a file`)
|
|
142
196
|
}
|
|
143
|
-
else if (field.options || field.table || field.data) throw new Error(`screenFromSpec: ${at} — options/table/data only apply to a dropdown`)
|
|
144
197
|
return props
|
|
145
198
|
}
|
|
146
199
|
|
|
147
|
-
function dataFromField(field, at) {
|
|
200
|
+
function dataFromField(field, at, type) {
|
|
148
201
|
if (field.data) return field.data
|
|
149
202
|
if (field.table) return { source: 'table', table: field.table }
|
|
150
203
|
if (Array.isArray(field.options)) {
|
|
@@ -155,15 +208,26 @@ function dataFromField(field, at) {
|
|
|
155
208
|
: o))
|
|
156
209
|
}
|
|
157
210
|
}
|
|
158
|
-
throw new Error(`screenFromSpec: ${at} is a
|
|
211
|
+
throw new Error(`screenFromSpec: ${at} is a ${type} — give it options: [...] or table: "<name>"`)
|
|
159
212
|
}
|
|
160
213
|
|
|
161
214
|
function heightOf(type, props) {
|
|
162
215
|
const h = SPEC_HEIGHTS[type]
|
|
163
216
|
if (type === 'label') return h[props.variant] || h.text
|
|
217
|
+
// A group of options grows with them: the label, then a line per option
|
|
218
|
+
// (or one line for all of them, side by side).
|
|
219
|
+
if (type === 'radio' || type === 'multiselect') {
|
|
220
|
+
const n = props.data?.source === 'static' ? (props.data.options || []).length : 4
|
|
221
|
+
const lines = props.layout === 'horizontal' ? 1 : Math.max(n, 1)
|
|
222
|
+
return round(LABEL_H + lines * OPTION_H)
|
|
223
|
+
}
|
|
164
224
|
return h || 0.08
|
|
165
225
|
}
|
|
166
226
|
|
|
227
|
+
/** A field's label, and one option, as page fractions. */
|
|
228
|
+
const LABEL_H = 0.035
|
|
229
|
+
const OPTION_H = 0.03
|
|
230
|
+
|
|
167
231
|
function unknownKeys(obj, allowed, at) {
|
|
168
232
|
const extra = Object.keys(obj).filter((k) => !allowed.includes(k))
|
|
169
233
|
if (extra.length) throw new Error(`screenFromSpec: ${at} has unknown key(s) ${extra.join(', ')} — allowed: ${allowed.join(', ')}`)
|
package/src/model.js
CHANGED
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
//
|
|
4
4
|
// import { screenFromSpec, validateDocument, formSchema } from '@xeplr/ui-factory/model'
|
|
5
5
|
|
|
6
|
-
export { CONTROLS, OPTION_ID, OPTION_LABEL, LABEL_VARIANTS, LABEL_PRESETS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, FONT_FAMILIES, controlGroups, withControls } from './controls.js'
|
|
6
|
+
export { CONTROLS, OPTION_ID, OPTION_LABEL, LABEL_VARIANTS, LABEL_PRESETS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, FONT_FAMILIES, LAYOUTS, CHOICE_TYPES, STEP_LIMIT, MULTI_SEPARATOR, FILE_KINDS, MAX_FILE_MB, acceptList, controlGroups, withControls } from './controls.js'
|
|
7
7
|
export {
|
|
8
8
|
DOCUMENT_KIND, DOCUMENT_VERSION, DEFAULT_ASPECT, DEFAULT_WIDTH, MARGIN, GAP,
|
|
9
9
|
createScreen, renameScreen, setScreenProperty, addControl, moveNode, setNodeProperty, getNodeProperty, removeNodes,
|
|
10
|
-
inputNodes, readingOrder, contentBottom, camelName, slugify, uniqueFieldName, nextPosition
|
|
10
|
+
inputNodes, readingOrder, contentBottom, camelName, slugify, uniqueFieldName, nextPosition,
|
|
11
|
+
steppers, stepsOf, stepOf, setNodeStep, nodesForSteps
|
|
11
12
|
} from './document.js'
|
|
12
13
|
export { validateDocument, assertValidDocument } from './validateDocument.js'
|
|
13
|
-
export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, saveState, recordValues, displayValue, listColumns, listSource } from './values.js'
|
|
14
|
+
export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, chooseable, saveState, recordValues, displayValue, fileLabel, toDbValue, fromDbValue, listColumns, listSource } from './values.js'
|
|
14
15
|
export { screenFromSpec, screensFromSpec, entityNames, SPEC_HEIGHTS, ROW_GAP } from './generate.js'
|
|
15
16
|
export { scaffoldEntity } from './scaffold.js'
|
|
16
17
|
export { getAtPath, setAtPath } from './propertyPath.js'
|
package/src/remote.js
CHANGED
|
@@ -43,6 +43,27 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
43
43
|
return res && Array.isArray(res.dataArray) ? res.dataArray : []
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The same call, with a body the browser must describe itself (a FormData's
|
|
48
|
+
* multipart boundary). Nothing sets Content-Type here — see authFetch.
|
|
49
|
+
*/
|
|
50
|
+
async function send(method, path, body) {
|
|
51
|
+
let res
|
|
52
|
+
try {
|
|
53
|
+
res = await doFetch(`${base}/factory${path}`, { method, body })
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err && err.body !== undefined && err.status) throw failure(method, path, err.status, err.body, err.message)
|
|
56
|
+
throw err
|
|
57
|
+
}
|
|
58
|
+
if (res && typeof res.json === 'function' && typeof res.ok === 'boolean') {
|
|
59
|
+
let json = null
|
|
60
|
+
try { json = await res.json() } catch (_) { /* not JSON — reported below */ }
|
|
61
|
+
if (!res.ok) throw failure(method, path, res.status, json)
|
|
62
|
+
return json ? json.dataArray : []
|
|
63
|
+
}
|
|
64
|
+
return res && Array.isArray(res.dataArray) ? res.dataArray : []
|
|
65
|
+
}
|
|
66
|
+
|
|
46
67
|
function failure(method, path, status, json, fallback) {
|
|
47
68
|
const err = new Error((json && json.message) || fallback || `${method} ${path} failed (${status})`)
|
|
48
69
|
err.status = status
|
|
@@ -77,9 +98,18 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
77
98
|
fetchRecord: async ({ screen, id }) => one(await call('GET', `/records/${enc(screen)}/${enc(id)}`)),
|
|
78
99
|
onSave: async (values, { id, screen }) => one(await call('POST', `/records/${enc(screen)}/save`, { id, values })),
|
|
79
100
|
onDelete: ({ id, screen }) => call('POST', `/records/${enc(screen)}/delete`, { id }),
|
|
80
|
-
fetchOptions: ({ table }) => call('GET', `/options/${enc(table)}`)
|
|
101
|
+
fetchOptions: ({ table }) => call('GET', `/options/${enc(table)}`),
|
|
102
|
+
|
|
103
|
+
// A file field's upload. The answer's `path` is what the record stores;
|
|
104
|
+
// the file itself is read back through the same route, with auth.
|
|
105
|
+
uploadFile: async (file, { screen, field }) => {
|
|
106
|
+
const form = new FormData()
|
|
107
|
+
form.append('file', file)
|
|
108
|
+
return one(await send('POST', `/files/${enc(screen)}/${enc(field)}`, form))
|
|
109
|
+
},
|
|
110
|
+
fileUrl: (path) => `${base}/factory/files/${String(path).split('/').map(enc).join('/')}`
|
|
81
111
|
}
|
|
82
|
-
api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions }
|
|
112
|
+
api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions, uploadFile: api.uploadFile, fileUrl: api.fileUrl }
|
|
83
113
|
api.builderProps = { onSave: api.saveDraft, onPublish: api.publish, listTables: api.listTables, fetchOptions: api.fetchOptions, fetchRecords: api.fetchRecords }
|
|
84
114
|
return api
|
|
85
115
|
}
|
package/src/tableSchema.js
CHANGED
|
@@ -51,10 +51,11 @@ export const MAX_IDENTIFIER = 63
|
|
|
51
51
|
|
|
52
52
|
const TEXT_DEFAULT_LENGTH = 255
|
|
53
53
|
const OPTION_MIN_LENGTH = 50
|
|
54
|
+
const FILE_PATH_LENGTH = 255
|
|
54
55
|
|
|
55
56
|
/**
|
|
56
57
|
* The column a field is stored in.
|
|
57
|
-
* @returns {{ name, type: 'varchar'|'text'|'integer'|'numeric'|'date'|'boolean', length?, references?, notNull?, default? }}
|
|
58
|
+
* @returns {{ name, type: 'varchar'|'text'|'integer'|'numeric'|'date'|'timestamp'|'boolean', length?, references?, notNull?, default? }}
|
|
58
59
|
*/
|
|
59
60
|
export function columnForField(node) {
|
|
60
61
|
const p = node.props || {}
|
|
@@ -69,9 +70,18 @@ export function columnForField(node) {
|
|
|
69
70
|
return { ...base, type: v.integer ? 'integer' : 'numeric' }
|
|
70
71
|
case 'date':
|
|
71
72
|
return { ...base, type: 'date' }
|
|
73
|
+
case 'datetime':
|
|
74
|
+
return { ...base, type: 'timestamp' }
|
|
75
|
+
case 'file':
|
|
76
|
+
// The stored file's path, as the upload answered with it.
|
|
77
|
+
return { ...base, type: 'varchar', length: FILE_PATH_LENGTH }
|
|
78
|
+
case 'multiselect':
|
|
79
|
+
// Every chosen id in one text column, separated by commas.
|
|
80
|
+
return { ...base, type: 'text' }
|
|
72
81
|
case 'checkbox':
|
|
73
82
|
return { ...base, type: 'boolean', notNull: true, default: 'false' }
|
|
74
|
-
case 'dropdown':
|
|
83
|
+
case 'dropdown':
|
|
84
|
+
case 'radio': {
|
|
75
85
|
const data = p.data || {}
|
|
76
86
|
if (data.source === 'table') {
|
|
77
87
|
// A real foreign key to the other entity's id.
|
|
@@ -118,6 +128,8 @@ export function widening(from, to) {
|
|
|
118
128
|
}
|
|
119
129
|
if (from.type === 'varchar' && to.type === 'text') return { ok: true }
|
|
120
130
|
if (from.type === 'integer' && to.type === 'numeric') return { ok: true }
|
|
131
|
+
// A date becomes that day at midnight; the other way round would lose the time.
|
|
132
|
+
if (from.type === 'date' && to.type === 'timestamp') return { ok: true }
|
|
121
133
|
return { ok: false, reason: `${describe(from)} → ${describe(to)} is a different kind of value` }
|
|
122
134
|
}
|
|
123
135
|
|
|
@@ -250,6 +262,8 @@ export function columnFromDatabase(c) {
|
|
|
250
262
|
case 'int4': return { ...base, type: 'integer' }
|
|
251
263
|
case 'numeric': return { ...base, type: 'numeric' }
|
|
252
264
|
case 'date': return { ...base, type: 'date' }
|
|
265
|
+
case 'timestamp':
|
|
266
|
+
case 'timestamptz': return { ...base, type: 'timestamp' }
|
|
253
267
|
case 'bool': return { ...base, type: 'boolean' }
|
|
254
268
|
// Anything else (a timestamp, a bigint someone chose) is not a type a field
|
|
255
269
|
// makes — so any change to it is refused rather than guessed at.
|
package/src/useFactoryBuilder.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { CONTROLS, controlGroups } from './controls.js'
|
|
3
3
|
import {
|
|
4
|
-
createScreen, renameScreen, setScreenProperty, addControl as addControlTo, moveNode, setNodeProperty,
|
|
4
|
+
createScreen, renameScreen, setScreenProperty, addControl as addControlTo, moveNode, setNodeProperty, setNodeStep,
|
|
5
|
+
steppers, stepsOf, stepOf, nodesForSteps,
|
|
5
6
|
removeNodes
|
|
6
7
|
} from './document.js'
|
|
7
8
|
import { validateDocument } from './validateDocument.js'
|
|
@@ -98,17 +99,42 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
98
99
|
/** A screen property by path — `source`, `width`, `style.fontSize`. */
|
|
99
100
|
const setScreen = useCallback((path, value) => update((d) => setScreenProperty(d, path, value)), [update])
|
|
100
101
|
|
|
102
|
+
// ── steps ─────────────────────────────────────────────────────────────
|
|
103
|
+
// Which step of each stepper is open on the canvas. Everything dropped while
|
|
104
|
+
// a step is open belongs to that step, and the canvas shows only that step —
|
|
105
|
+
// so one screen holds as many little screens as it has steps.
|
|
106
|
+
const [openSteps, setOpenSteps] = useState({})
|
|
107
|
+
const stepperNodes = useMemo(() => steppers(doc, controls), [doc, controls])
|
|
108
|
+
const [building, setBuilding] = useState(null) // the stepper being built
|
|
109
|
+
const activeStepper = stepperNodes.find((n) => n.id === building) || stepperNodes[stepperNodes.length - 1] || null
|
|
110
|
+
const stepAt = useCallback((id) => (Number.isInteger(openSteps[id]) ? openSteps[id] : 0), [openSteps])
|
|
111
|
+
|
|
112
|
+
const goToStep = useCallback((id, index) => {
|
|
113
|
+
setBuilding(id)
|
|
114
|
+
setOpenSteps((o) => ({ ...o, [id]: index }))
|
|
115
|
+
}, [])
|
|
116
|
+
|
|
117
|
+
/** The step a new control lands on: the one open on the stepper being built. */
|
|
118
|
+
const dropStep = useCallback(() => (
|
|
119
|
+
activeStepper ? { of: activeStepper.id, index: stepAt(activeStepper.id) } : null
|
|
120
|
+
), [activeStepper, stepAt])
|
|
121
|
+
|
|
122
|
+
/** Moves a control to a step, or to none — shown whatever step is open. */
|
|
123
|
+
const setStep = useCallback((id, step) => update((d) => setNodeStep(d, id, step)), [update])
|
|
124
|
+
|
|
125
|
+
const visibleNodes = useMemo(() => nodesForSteps(doc, openSteps, controls), [doc, openSteps, controls])
|
|
126
|
+
|
|
101
127
|
/** Adds a control and selects it, so its properties are open straight away. */
|
|
102
128
|
const addControl = useCallback((type, at) => {
|
|
103
129
|
let added = null
|
|
104
130
|
update((d) => {
|
|
105
|
-
const r = addControlTo(d, type, { at }, controls)
|
|
131
|
+
const r = addControlTo(d, type, { at, step: dropStep() }, controls)
|
|
106
132
|
added = r.node
|
|
107
133
|
return r.document
|
|
108
134
|
})
|
|
109
135
|
if (added) setSelected(new Set([added.id]))
|
|
110
136
|
return added
|
|
111
|
-
}, [update, controls])
|
|
137
|
+
}, [update, controls, dropStep])
|
|
112
138
|
|
|
113
139
|
const moveControl = useCallback((id, patch) => update((d) => moveNode(d, id, patch)), [update])
|
|
114
140
|
|
|
@@ -271,6 +297,17 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
271
297
|
setScreen,
|
|
272
298
|
addControl,
|
|
273
299
|
moveControl,
|
|
300
|
+
steps: {
|
|
301
|
+
nodes: stepperNodes,
|
|
302
|
+
building: activeStepper ? activeStepper.id : null,
|
|
303
|
+
active: stepAt,
|
|
304
|
+
count: (id) => stepsOf(stepperNodes.find((n) => n.id === id)).length,
|
|
305
|
+
labels: (id) => stepsOf(stepperNodes.find((n) => n.id === id)),
|
|
306
|
+
go: goToStep,
|
|
307
|
+
of: stepOf,
|
|
308
|
+
set: setStep
|
|
309
|
+
},
|
|
310
|
+
visibleNodes,
|
|
274
311
|
setProperty,
|
|
275
312
|
removeSelected,
|
|
276
313
|
duplicateSelected,
|
package/src/useFactoryScreen.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
-
import { CONTROLS } from './controls.js'
|
|
3
|
-
import { inputNodes } from './document.js'
|
|
2
|
+
import { CONTROLS, CHOICE_TYPES } from './controls.js'
|
|
3
|
+
import { inputNodes, nodesForSteps, steppers, stepsOf, stepOf } from './document.js'
|
|
4
4
|
import { validateDocument } from './validateDocument.js'
|
|
5
5
|
import { initialValues, parseInput, saveState, fieldError, optionValue, recordValues, listSource } from './values.js'
|
|
6
6
|
import { hookMethod } from './hooks.js'
|
|
@@ -19,6 +19,7 @@ import { hookMethod } from './hooks.js'
|
|
|
19
19
|
// fetchRecords({ source, screen, node }) → rows, for a list
|
|
20
20
|
// onDelete({ id, source, screen, record })
|
|
21
21
|
// fetchOptions({ table, node }) → [{ id, name }], for a table dropdown
|
|
22
|
+
// uploadFile(file, { screen, field, node }) → { path } — a file field's upload
|
|
22
23
|
//
|
|
23
24
|
// ── LIST → EDIT IN A POPUP ───────────────────────────────────────────────
|
|
24
25
|
// A list whose `editScreen` names another screen opens that screen in a popup
|
|
@@ -34,6 +35,8 @@ export const AUTOSAVE_DELAY = 700
|
|
|
34
35
|
* @param recordKey the id field on a record (default 'id')
|
|
35
36
|
* @param onSave async (values, { id, source, document }) → saved record | void
|
|
36
37
|
* @param fetchOptions async ({ table, node }) → [{ id, name }]
|
|
38
|
+
* @param uploadFile async (file, { screen, field, node }) → { path } — what a file field saves
|
|
39
|
+
* @param fileUrl (path) → the address an attached file is read back from
|
|
37
40
|
* @param fetchRecords async ({ source, node }) → records, for lists
|
|
38
41
|
* @param onDelete async ({ id, source, record }) → void
|
|
39
42
|
* @param onChange (values) → void — every change, before any save
|
|
@@ -45,7 +48,7 @@ export const AUTOSAVE_DELAY = 700
|
|
|
45
48
|
* @param hooks a FactoryHooks (or an object with some of its methods) — get / save / delete / actions
|
|
46
49
|
*/
|
|
47
50
|
export function useFactoryScreen({
|
|
48
|
-
document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks,
|
|
51
|
+
document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl,
|
|
49
52
|
autosaveDelay = AUTOSAVE_DELAY, controls = CONTROLS
|
|
50
53
|
} = {}) {
|
|
51
54
|
const check = useMemo(() => validateDocument(doc, controls), [doc, controls])
|
|
@@ -116,7 +119,7 @@ export function useFactoryScreen({
|
|
|
116
119
|
const fetchOptionsRef = useRef(fetchOptions); fetchOptionsRef.current = fetchOptions
|
|
117
120
|
useEffect(() => {
|
|
118
121
|
let cancelled = false
|
|
119
|
-
const tableNodes = optionNodes.filter((n) => n.type
|
|
122
|
+
const tableNodes = optionNodes.filter((n) => CHOICE_TYPES.includes(n.type) && n.props.data?.source === 'table')
|
|
120
123
|
if (!tableNodes.length) { setOptions({}); return undefined }
|
|
121
124
|
setOptions(Object.fromEntries(tableNodes.map((n) => [n.id, { loading: true, items: [], error: null }])))
|
|
122
125
|
const byTable = new Map()
|
|
@@ -141,6 +144,54 @@ export function useFactoryScreen({
|
|
|
141
144
|
return () => { cancelled = true }
|
|
142
145
|
}, [optionNodes])
|
|
143
146
|
|
|
147
|
+
// ── steps ─────────────────────────────────────────────────────────────
|
|
148
|
+
// A stepper shows one step at a time. Which step each is open at lives here;
|
|
149
|
+
// the fields of the steps not showing are still filled in, still checked and
|
|
150
|
+
// still saved — a step is what the person sees, not a separate form.
|
|
151
|
+
const [openSteps, setOpenSteps] = useState({})
|
|
152
|
+
const stepperNodes = useMemo(() => (check.ok ? steppers(doc, controls) : []), [check.ok, doc, controls])
|
|
153
|
+
const visibleNodes = useMemo(() => (check.ok ? nodesForSteps(doc, openSteps, controls) : []), [check.ok, doc, openSteps, controls])
|
|
154
|
+
|
|
155
|
+
// A screen whose steps changed under it (a field moved to another step, a
|
|
156
|
+
// step removed) must not stay open at a step that is gone.
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
setOpenSteps((open) => {
|
|
159
|
+
let changed = false
|
|
160
|
+
const next = {}
|
|
161
|
+
stepperNodes.forEach((n) => {
|
|
162
|
+
const count = stepsOf(n).length
|
|
163
|
+
const at = Number.isInteger(open[n.id]) ? open[n.id] : 0
|
|
164
|
+
next[n.id] = Math.min(Math.max(at, 0), Math.max(count - 1, 0))
|
|
165
|
+
if (next[n.id] !== open[n.id]) changed = true
|
|
166
|
+
})
|
|
167
|
+
return changed || Object.keys(next).length !== Object.keys(open).length ? next : open
|
|
168
|
+
})
|
|
169
|
+
}, [stepperNodes])
|
|
170
|
+
|
|
171
|
+
const stepControl = useMemo(() => {
|
|
172
|
+
const at = (id) => (Number.isInteger(openSteps[id]) ? openSteps[id] : 0)
|
|
173
|
+
return {
|
|
174
|
+
nodes: stepperNodes,
|
|
175
|
+
active: at,
|
|
176
|
+
count: (id) => stepsOf(stepperNodes.find((n) => n.id === id)).length,
|
|
177
|
+
go: (id, index) => setOpenSteps((o) => ({ ...o, [id]: index })),
|
|
178
|
+
/** The fields on this step, so Next can hold at a step that is not filled in. */
|
|
179
|
+
fieldsOn: (id, index) => inputNodes(doc, controls).filter((n) => {
|
|
180
|
+
const step = stepOf(n)
|
|
181
|
+
return step && step.of === id && step.index === index
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
}, [stepperNodes, openSteps, doc, controls])
|
|
185
|
+
|
|
186
|
+
// ── a file field's upload ─────────────────────────────────────────────
|
|
187
|
+
const uploadRef = useRef(uploadFile); uploadRef.current = uploadFile
|
|
188
|
+
const upload = useCallback(async (file, node) => {
|
|
189
|
+
if (!uploadRef.current) throw new Error('No uploadFile was provided, so a file cannot be attached')
|
|
190
|
+
const out = await uploadRef.current(file, { screen: doc.id, field: node.props.name, node })
|
|
191
|
+
if (!out || !out.path) throw new Error('The upload did not answer with the stored file')
|
|
192
|
+
return out
|
|
193
|
+
}, [doc.id])
|
|
194
|
+
|
|
144
195
|
const optionsFor = useCallback((node) => {
|
|
145
196
|
if (node.props.data?.source === 'static') return { loading: false, items: node.props.data.options, error: null }
|
|
146
197
|
return options[node.id] || { loading: false, items: [], error: null }
|
|
@@ -229,7 +280,8 @@ export function useFactoryScreen({
|
|
|
229
280
|
const onChangeRef = useRef(onChange); onChangeRef.current = onChange
|
|
230
281
|
const setValue = useCallback((node, raw) => {
|
|
231
282
|
let value = parseInput(node, raw)
|
|
232
|
-
if (node.type === 'dropdown' && value !== undefined) value = optionValue(optionsFor(node).items, value)
|
|
283
|
+
if ((node.type === 'dropdown' || node.type === 'radio') && value !== undefined) value = optionValue(optionsFor(node).items, value)
|
|
284
|
+
if (node.type === 'multiselect' && Array.isArray(value)) value = value.map((v) => optionValue(optionsFor(node).items, v))
|
|
233
285
|
const name = node.props.name
|
|
234
286
|
const next = { ...live.current.values }
|
|
235
287
|
if (value === undefined) delete next[name]
|
|
@@ -412,6 +464,10 @@ export function useFactoryScreen({
|
|
|
412
464
|
setValue,
|
|
413
465
|
touch,
|
|
414
466
|
optionsFor,
|
|
467
|
+
steps: stepControl,
|
|
468
|
+
visibleNodes,
|
|
469
|
+
upload,
|
|
470
|
+
fileUrl,
|
|
415
471
|
flush,
|
|
416
472
|
saveNow: runSave,
|
|
417
473
|
openRecord,
|
package/src/validateDocument.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
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, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS } from './controls.js'
|
|
13
|
+
import { CONTROLS, LABEL_VARIANTS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, LAYOUTS, MULTI_SEPARATOR, MAX_FILE_MB, STEP_LIMIT, acceptList } from './controls.js'
|
|
14
14
|
import { DOCUMENT_KIND, DOCUMENT_VERSION } from './document.js'
|
|
15
15
|
import { RESERVED_COLUMNS, MAX_IDENTIFIER } from './tableSchema.js'
|
|
16
16
|
|
|
@@ -93,7 +93,15 @@ export function validateDocument(doc, controls = CONTROLS) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
checkStyle(props.style, def.styles || [], `${at}.props.style`, err, `"${node.type}"`)
|
|
96
|
-
if (node.type === 'dropdown'
|
|
96
|
+
if (node.type === 'dropdown' || node.type === 'radio' || node.type === 'multiselect') {
|
|
97
|
+
checkDataSource(props.data, `${at}.props.data`, err, node.type === 'multiselect')
|
|
98
|
+
}
|
|
99
|
+
if (node.type === 'radio' || node.type === 'multiselect') {
|
|
100
|
+
if (props.layout !== undefined && !LAYOUTS.includes(props.layout)) err(`${at}.props.layout`, `must be one of: ${LAYOUTS.join(', ')}`)
|
|
101
|
+
}
|
|
102
|
+
if (node.type === 'file') checkFile(props, at, err)
|
|
103
|
+
if (node.type === 'stepper') checkSteps(props, at, err)
|
|
104
|
+
checkStep(node, doc, at, err)
|
|
97
105
|
if (node.type === 'list') checkList(props, doc, at, err)
|
|
98
106
|
if (node.type === 'label') {
|
|
99
107
|
if (typeof props.text !== 'string') err(`${at}.props.text`, 'must be a string')
|
|
@@ -130,16 +138,86 @@ function checkGeometry(node, at, err) {
|
|
|
130
138
|
function checkDefault(node, def, at, err) {
|
|
131
139
|
const v = node.props.default
|
|
132
140
|
if (v === undefined) return
|
|
141
|
+
const isDatetime = node.type === 'datetime'
|
|
133
142
|
const ok = {
|
|
134
143
|
string: () => typeof v === 'string',
|
|
135
144
|
number: () => typeof v === 'number' && Number.isFinite(v),
|
|
136
145
|
boolean: () => typeof v === 'boolean',
|
|
137
|
-
|
|
146
|
+
array: () => Array.isArray(v),
|
|
147
|
+
date: () => typeof v === 'string' && (isDatetime ? ISO_DATETIME.test(v) : ISO_DATE.test(v))
|
|
138
148
|
}[def.valueType]
|
|
139
|
-
if (ok && !ok()) err(`${at}.props.default`, `must be
|
|
149
|
+
if (ok && !ok()) err(`${at}.props.default`, `must be ${dateWord(def.valueType, isDatetime)}`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function dateWord(valueType, isDatetime) {
|
|
153
|
+
if (valueType === 'date') return isDatetime ? 'a date and time as "YYYY-MM-DDTHH:MM"' : 'a date as "YYYY-MM-DD"'
|
|
154
|
+
if (valueType === 'array') return 'an array'
|
|
155
|
+
return `a ${valueType}`
|
|
140
156
|
}
|
|
141
157
|
|
|
142
158
|
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
|
159
|
+
const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A stepper's steps: at least two, each with a key of its own, so a control can
|
|
163
|
+
* be tied to one and stay tied to it when the others are renamed.
|
|
164
|
+
*/
|
|
165
|
+
function checkSteps(props, at, err) {
|
|
166
|
+
const steps = props.steps
|
|
167
|
+
if (!Array.isArray(steps)) { err(`${at}.props.steps`, 'must be an array of { key, label }'); return }
|
|
168
|
+
if (steps.length < 2) err(`${at}.props.steps`, 'needs at least two steps — one step is not a journey')
|
|
169
|
+
if (steps.length > STEP_LIMIT) err(`${at}.props.steps`, `has ${steps.length} steps — at most ${STEP_LIMIT}`)
|
|
170
|
+
const seen = new Set()
|
|
171
|
+
steps.forEach((s, j) => {
|
|
172
|
+
const p = `${at}.props.steps[${j}]`
|
|
173
|
+
if (!s || typeof s !== 'object') { err(p, 'must be { key, label }'); return }
|
|
174
|
+
if (!nonEmptyString(s.key)) err(`${p}.key`, 'must be a non-empty string — what the controls on this step point at')
|
|
175
|
+
else if (seen.has(s.key)) err(`${p}.key`, `"${s.key}" appears twice`)
|
|
176
|
+
else seen.add(s.key)
|
|
177
|
+
if (!nonEmptyString(s.label)) err(`${p}.label`, 'must be a non-empty string — what the person reads')
|
|
178
|
+
const extra = Object.keys(s).filter((k) => k !== 'key' && k !== 'label')
|
|
179
|
+
if (extra.length) err(p, `only "key" and "label" are used — remove: ${extra.join(', ')}`)
|
|
180
|
+
})
|
|
181
|
+
if (props.showNumbers !== undefined && typeof props.showNumbers !== 'boolean') err(`${at}.props.showNumbers`, 'must be true or false')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A node's `step`: a stepper that exists, and one of its steps. */
|
|
185
|
+
function checkStep(node, doc, at, err) {
|
|
186
|
+
if (node.step === undefined) return
|
|
187
|
+
const step = node.step
|
|
188
|
+
if (!step || typeof step !== 'object' || Array.isArray(step)) { err(`${at}.step`, 'must be { of, index } — the stepper, and which step'); return }
|
|
189
|
+
const extra = Object.keys(step).filter((k) => k !== 'of' && k !== 'index')
|
|
190
|
+
if (extra.length) err(`${at}.step`, `only "of" and "index" are used — remove: ${extra.join(', ')}`)
|
|
191
|
+
if (!nonEmptyString(step.of)) { err(`${at}.step.of`, 'must be the id of a stepper on this screen'); return }
|
|
192
|
+
if (step.of === node.id) { err(`${at}.step.of`, 'a stepper cannot be a step of itself'); return }
|
|
193
|
+
const owner = (doc.nodes || []).find((n) => n && n.id === step.of)
|
|
194
|
+
if (!owner || owner.type !== 'stepper') { err(`${at}.step.of`, `"${step.of}" is not a stepper on this screen`); return }
|
|
195
|
+
const count = Array.isArray(owner.props?.steps) ? owner.props.steps.length : 0
|
|
196
|
+
if (!Number.isInteger(step.index) || step.index < 0 || step.index >= count) {
|
|
197
|
+
err(`${at}.step.index`, `must be a step of "${step.of}" — it has ${count}`)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** A file field: which extensions it takes, and how big a file may be. */
|
|
202
|
+
function checkFile(props, at, err) {
|
|
203
|
+
if (props.accept !== undefined) {
|
|
204
|
+
if (typeof props.accept !== 'string') err(`${at}.props.accept`, 'must be a string of extensions, e.g. ".pdf,.docx"')
|
|
205
|
+
else {
|
|
206
|
+
const list = acceptList(props.accept)
|
|
207
|
+
if (!list.length) err(`${at}.props.accept`, 'names no extension — ".pdf,.docx", or leave it out for any file')
|
|
208
|
+
list.forEach((x) => {
|
|
209
|
+
if (!EXTENSION.test(x)) err(`${at}.props.accept`, `"${x}" is not an extension — letters and digits after a dot, e.g. ".xlsx"`)
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (props.maxSize !== undefined) {
|
|
214
|
+
if (!(Number.isFinite(props.maxSize) && props.maxSize > 0 && props.maxSize <= MAX_FILE_MB)) {
|
|
215
|
+
err(`${at}.props.maxSize`, `must be a number of megabytes from 1 to ${MAX_FILE_MB}`)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const EXTENSION = /^\.[a-z0-9]{1,12}$/
|
|
143
221
|
|
|
144
222
|
function checkValidation(node, def, at, err) {
|
|
145
223
|
const v = node.props.validation
|
|
@@ -161,14 +239,18 @@ function checkValidation(node, def, at, err) {
|
|
|
161
239
|
} else if (key === 'pattern') {
|
|
162
240
|
try { new RegExp(val) } catch (_) { err(path, `"${val}" is not a valid regular expression`) }
|
|
163
241
|
if (typeof val !== 'string') err(path, 'must be a string')
|
|
242
|
+
} else if (key === 'minItems' || key === 'maxItems') {
|
|
243
|
+
if (!(Number.isInteger(val) && val >= 0)) err(path, 'must be a whole number, 0 or more')
|
|
164
244
|
} else if (key === 'patternMessage') {
|
|
165
245
|
if (typeof val !== 'string') err(path, 'must be a string')
|
|
166
246
|
} else if (def.valueType === 'number') {
|
|
167
247
|
if (!(typeof val === 'number' && Number.isFinite(val))) err(path, 'must be a number')
|
|
168
248
|
} else if (def.valueType === 'date') {
|
|
169
|
-
|
|
249
|
+
const re = node.type === 'datetime' ? ISO_DATETIME : ISO_DATE
|
|
250
|
+
if (!(typeof val === 'string' && re.test(val))) err(path, `must be ${dateWord('date', node.type === 'datetime')}`)
|
|
170
251
|
}
|
|
171
252
|
})
|
|
253
|
+
if (v.minItems != null && v.maxItems != null && v.minItems > v.maxItems) err(`${at}.props.validation`, 'minItems is greater than maxItems — nothing could pass')
|
|
172
254
|
if (v.minLength != null && v.maxLength != null && v.minLength > v.maxLength) err(`${at}.props.validation`, 'minLength is greater than maxLength — nothing could pass')
|
|
173
255
|
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')
|
|
174
256
|
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')
|
|
@@ -178,7 +260,7 @@ function checkValidation(node, def, at, err) {
|
|
|
178
260
|
* A dropdown's options: typed in, or read from a table. Either way each option
|
|
179
261
|
* is { id, name } — the id is what is saved, the name is what is shown.
|
|
180
262
|
*/
|
|
181
|
-
function checkDataSource(data, at, err) {
|
|
263
|
+
function checkDataSource(data, at, err, multi) {
|
|
182
264
|
if (!data || typeof data !== 'object') {
|
|
183
265
|
err(at, 'is required — { "source": "static", "options": [{ "id", "name" }] } or { "source": "table", "table": "<name>" }')
|
|
184
266
|
return
|
|
@@ -190,6 +272,7 @@ function checkDataSource(data, at, err) {
|
|
|
190
272
|
const p = `${at}.options[${j}]`
|
|
191
273
|
if (!o || typeof o !== 'object') { err(p, 'must be { id, name }'); return }
|
|
192
274
|
if (!(typeof o.id === 'string' || typeof o.id === 'number') || o.id === '') err(`${p}.id`, 'must be a non-empty string or a number')
|
|
275
|
+
else if (multi && String(o.id).includes(MULTI_SEPARATOR)) err(`${p}.id`, `"${o.id}" contains "${MULTI_SEPARATOR}" — several choices are stored in one column, separated by it`)
|
|
193
276
|
else if (seen.has(o.id)) err(`${p}.id`, `"${o.id}" appears twice`)
|
|
194
277
|
else seen.add(o.id)
|
|
195
278
|
if (!nonEmptyString(o.name)) err(`${p}.name`, 'must be a non-empty string — what the user sees')
|