@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/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
- if (node.type === 'dropdown' && p.data?.source === 'static') {
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 'dropdown': {
123
- const opts = p.data?.source === 'static' ? p.data.options : loadedOptions
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.type === 'date' && typeof v === 'string' ? v.slice(0, 10) : v
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 === '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)
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
  }