@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/values.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// them. validateValues() below applies the same rules in the browser, with
|
|
12
12
|
// messages written for the person filling the form in rather than for a log.
|
|
13
13
|
|
|
14
|
-
import { CONTROLS } from './controls.js'
|
|
14
|
+
import { CONTROLS, MULTI_SEPARATOR } from './controls.js'
|
|
15
15
|
import { inputNodes } from './document.js'
|
|
16
16
|
|
|
17
17
|
/** schema-handler field schema for the screen's inputs, in reading order. */
|
|
@@ -24,7 +24,9 @@ export function formSchema(doc, controls = CONTROLS) {
|
|
|
24
24
|
if (p.label) field.description = p.label
|
|
25
25
|
if (p.required) field.required = true
|
|
26
26
|
if (p.default !== undefined) field.default = p.default
|
|
27
|
-
|
|
27
|
+
// A multi-select's value is an array, so schema-handler's `options` (which
|
|
28
|
+
// compares one value) cannot check it — chooseable(node) does, on both sides.
|
|
29
|
+
if ((node.type === 'dropdown' || node.type === 'radio') && p.data?.source === 'static') {
|
|
28
30
|
field.options = p.data.options.map((o) => o.id)
|
|
29
31
|
}
|
|
30
32
|
const v = p.validation && { ...p.validation }
|
|
@@ -44,6 +46,7 @@ export function initialValues(doc, provided = {}, controls = CONTROLS) {
|
|
|
44
46
|
if (provided[name] !== undefined) out[name] = provided[name]
|
|
45
47
|
else if (node.props.default !== undefined) out[name] = node.props.default
|
|
46
48
|
else if (node.type === 'checkbox') out[name] = false
|
|
49
|
+
else if (node.type === 'multiselect') out[name] = []
|
|
47
50
|
})
|
|
48
51
|
return out
|
|
49
52
|
}
|
|
@@ -55,12 +58,13 @@ export function initialValues(doc, provided = {}, controls = CONTROLS) {
|
|
|
55
58
|
*/
|
|
56
59
|
export function parseInput(node, raw) {
|
|
57
60
|
if (node.type === 'checkbox') return Boolean(raw)
|
|
61
|
+
if (node.type === 'multiselect') return Array.isArray(raw) ? raw : fromDbValue(node, raw) || []
|
|
58
62
|
if (raw === undefined || raw === null || raw === '') return undefined
|
|
59
63
|
if (node.type === 'number') {
|
|
60
64
|
const n = typeof raw === 'number' ? raw : Number(String(raw).trim())
|
|
61
65
|
return Number.isFinite(n) ? n : raw // left as typed; validation says why
|
|
62
66
|
}
|
|
63
|
-
if (node.type === 'dropdown') return raw
|
|
67
|
+
if (node.type === 'dropdown' || node.type === 'radio') return raw
|
|
64
68
|
return String(raw)
|
|
65
69
|
}
|
|
66
70
|
|
|
@@ -90,6 +94,15 @@ export function fieldError(node, value, loadedOptions) {
|
|
|
90
94
|
if (node.type === 'checkbox') {
|
|
91
95
|
return p.required && value !== true ? `${label} must be ticked` : null
|
|
92
96
|
}
|
|
97
|
+
if (node.type === 'multiselect') {
|
|
98
|
+
const chosen = Array.isArray(value) ? value : []
|
|
99
|
+
if (!chosen.length) return p.required ? `${label} is required` : null
|
|
100
|
+
if (v.minItems != null && chosen.length < v.minItems) return `${label}: choose at least ${v.minItems}`
|
|
101
|
+
if (v.maxItems != null && chosen.length > v.maxItems) return `${label}: choose at most ${v.maxItems}`
|
|
102
|
+
const allowed = chooseable(node, loadedOptions)
|
|
103
|
+
if (allowed && chosen.some((c) => !allowed.some((o) => sameId(o.id, c)))) return `${label} has a choice that is no longer offered`
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
93
106
|
if (value === undefined || value === null || value === '') {
|
|
94
107
|
return p.required ? `${label} is required` : null
|
|
95
108
|
}
|
|
@@ -119,8 +132,18 @@ export function fieldError(node, value, loadedOptions) {
|
|
|
119
132
|
if (v.max && day > v.max) return `${label} must be on or before ${v.max}`
|
|
120
133
|
return null
|
|
121
134
|
}
|
|
122
|
-
case '
|
|
123
|
-
|
|
135
|
+
case 'datetime': {
|
|
136
|
+
if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) return `${label} must be a date and time`
|
|
137
|
+
// ISO strings compare correctly as text, and comparing text keeps a
|
|
138
|
+
// timezone from shifting the moment that was typed in.
|
|
139
|
+
const at = value.slice(0, 16)
|
|
140
|
+
if (v.min && at < v.min.slice(0, 16)) return `${label} must be at or after ${v.min.replace('T', ' ')}`
|
|
141
|
+
if (v.max && at > v.max.slice(0, 16)) return `${label} must be at or before ${v.max.replace('T', ' ')}`
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
case 'dropdown':
|
|
145
|
+
case 'radio': {
|
|
146
|
+
const opts = chooseable(node, loadedOptions)
|
|
124
147
|
if (Array.isArray(opts) && !opts.some((o) => sameId(o.id, value))) return `${label} must be one of the options`
|
|
125
148
|
return null
|
|
126
149
|
}
|
|
@@ -129,6 +152,13 @@ export function fieldError(node, value, loadedOptions) {
|
|
|
129
152
|
}
|
|
130
153
|
}
|
|
131
154
|
|
|
155
|
+
/** The options a choice field offers: its own list, or the loaded rows. */
|
|
156
|
+
export function chooseable(node, loadedOptions) {
|
|
157
|
+
const data = node.props.data || {}
|
|
158
|
+
const opts = data.source === 'static' ? data.options : loadedOptions
|
|
159
|
+
return Array.isArray(opts) ? opts : null
|
|
160
|
+
}
|
|
161
|
+
|
|
132
162
|
/**
|
|
133
163
|
* <select> hands back a string, while a table's ids may be numbers. The id is
|
|
134
164
|
* matched loosely and then saved in the OPTION's own type — see optionValue.
|
|
@@ -176,8 +206,9 @@ export function recordValues(doc, record, controls = CONTROLS) {
|
|
|
176
206
|
inputNodes(doc, controls).forEach((node) => {
|
|
177
207
|
const name = node.props.name
|
|
178
208
|
const v = record ? record[name] : undefined
|
|
179
|
-
if (v !== undefined && v !== null) out[name] = node
|
|
209
|
+
if (v !== undefined && v !== null) out[name] = fromDbValue(node, v)
|
|
180
210
|
else if (node.type === 'checkbox') out[name] = false
|
|
211
|
+
else if (node.type === 'multiselect') out[name] = []
|
|
181
212
|
})
|
|
182
213
|
return out
|
|
183
214
|
}
|
|
@@ -200,10 +231,58 @@ export function displayValue(node, value, options) {
|
|
|
200
231
|
if (value === undefined || value === null || value === '') return ''
|
|
201
232
|
if (!node) return typeof value === 'object' ? JSON.stringify(value) : value
|
|
202
233
|
if (node.type === 'checkbox') return value ? 'Yes' : 'No'
|
|
203
|
-
if (node.type === '
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
234
|
+
if (node.type === 'file') return fileLabel(value)
|
|
235
|
+
if (node.type === 'datetime') return String(value).replace('T', ' ').slice(0, 16)
|
|
236
|
+
if (node.type === 'dropdown' || node.type === 'radio') return optionName(node, value, options)
|
|
237
|
+
if (node.type === 'multiselect') {
|
|
238
|
+
return (fromDbValue(node, value) || []).map((v) => optionName(node, v, options)).join(', ')
|
|
239
|
+
}
|
|
240
|
+
return value
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function optionName(node, value, options) {
|
|
244
|
+
const opts = node.props.data?.source === 'static' ? node.props.data.options : options
|
|
245
|
+
const hit = (opts || []).find((o) => sameId(o.id, value))
|
|
246
|
+
return hit ? hit.name : String(value)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The name at the end of a stored file path, without the id that keeps it apart. */
|
|
250
|
+
export function fileLabel(path) {
|
|
251
|
+
const s = String(path)
|
|
252
|
+
const cut = s.slice(s.lastIndexOf('/') + 1)
|
|
253
|
+
const sep = cut.indexOf('__')
|
|
254
|
+
return sep === -1 ? cut : cut.slice(sep + 2)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── the database boundary ────────────────────────────────────────────────
|
|
258
|
+
// Two types do not go into their column as they are read: a multi-select is
|
|
259
|
+
// several ids in one text column, and a date-and-time comes back as the
|
|
260
|
+
// database wrote it. Both sides of the wire use these, so the conversion is
|
|
261
|
+
// written once.
|
|
262
|
+
|
|
263
|
+
/** A screen value → what its column stores. */
|
|
264
|
+
export function toDbValue(node, value) {
|
|
265
|
+
if (value === undefined || value === null) return value
|
|
266
|
+
if (node.type === 'multiselect') {
|
|
267
|
+
const list = Array.isArray(value) ? value : [value]
|
|
268
|
+
return list.length ? list.map((v) => String(v)).join(MULTI_SEPARATOR) : null
|
|
269
|
+
}
|
|
270
|
+
return value
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** What a column holds → the screen's value. */
|
|
274
|
+
export function fromDbValue(node, value) {
|
|
275
|
+
if (value === undefined || value === null) return value
|
|
276
|
+
if (node.type === 'multiselect') {
|
|
277
|
+
if (Array.isArray(value)) return value
|
|
278
|
+
const s = String(value)
|
|
279
|
+
return s === '' ? [] : s.split(MULTI_SEPARATOR).filter((x) => x !== '')
|
|
280
|
+
}
|
|
281
|
+
if (node.type === 'date') return typeof value === 'string' ? value.slice(0, 10) : value
|
|
282
|
+
if (node.type === 'datetime') {
|
|
283
|
+
// "2026-09-16 14:30:00" or an ISO string → what <input type="datetime-local"> shows.
|
|
284
|
+
const s = value instanceof Date ? value.toISOString() : String(value)
|
|
285
|
+
return s.replace(' ', 'T').slice(0, 16)
|
|
207
286
|
}
|
|
208
287
|
return value
|
|
209
288
|
}
|