@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.
@@ -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, BUTTON_ACTIONS } from './controls.js'
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
+ }