@xeplr/ui-factory 1.0.1 → 1.0.2

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.
@@ -2,50 +2,91 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { CONTROLS } from './controls.js'
3
3
  import { inputNodes } from './document.js'
4
4
  import { validateDocument } from './validateDocument.js'
5
- import { initialValues, parseInput, validateValues, fieldError, optionValue } from './values.js'
5
+ import { initialValues, parseInput, saveState, fieldError, optionValue, recordValues, listSource } from './values.js'
6
6
 
7
- // A saved screen, running: its values, its dropdown options, validation and
8
- // submit. No JSX — designs/ScreenSample.jsx draws it.
7
+ // A saved screen, running. No JSX — designs/ScreenSample.jsx draws it.
9
8
  //
10
- // The DATABASE IS THE HOST'S. A dropdown reading a table asks the host for its
11
- // rows — `fetchOptions({ table })` → [{ id, name }] — and a submit hands the
12
- // values to `onSubmit`. Nothing here knows where either goes.
9
+ // ── NO SUBMIT ────────────────────────────────────────────────────────────
10
+ // The screen saves ITSELF, in the background, a moment after the person stops
11
+ // changing something — once what is entered is acceptable. There is no button
12
+ // to press and no page to post. The first save of a new record creates it
13
+ // (the host hands back its id); every save after that updates the same record.
14
+ //
15
+ // ── THE DATABASE IS THE HOST'S ───────────────────────────────────────────
16
+ // Everything that reads or writes data is a call the host supplies:
17
+ // onSave(values, { id, source, screen, document }) → the saved record (with its id)
18
+ // fetchRecords({ source, screen, node }) → rows, for a list
19
+ // onDelete({ id, source, screen, record })
20
+ // fetchOptions({ table, node }) → [{ id, name }], for a table dropdown
21
+ //
22
+ // ── LIST → EDIT IN A POPUP ───────────────────────────────────────────────
23
+ // A list whose `editScreen` names another screen opens that screen in a popup
24
+ // for Edit and New. The screen comes from `screens` ({ id → document }) or the
25
+ // host's `loadScreen(id)`. The list refreshes as the popup saves.
26
+
27
+ /** Quiet time after the last change before a save goes out. */
28
+ export const AUTOSAVE_DELAY = 700
13
29
 
14
30
  /**
15
31
  * @param document a valid screen document
16
- * @param fetchOptions async ({ table, node }) → [{ id, name }] — for table dropdowns
17
- * @param onSubmit async (values, { document }) → void
18
- * @param values initial values, e.g. the record being edited
32
+ * @param record the record to open, e.g. { id: 7, firstName: 'Ada' } — omit for a new one
33
+ * @param recordKey the id field on a record (default 'id')
34
+ * @param onSave async (values, { id, source, document }) → saved record | void
35
+ * @param fetchOptions async ({ table, node }) → [{ id, name }]
36
+ * @param fetchRecords async ({ source, node }) → records, for lists
37
+ * @param onDelete async ({ id, source, record }) → void
38
+ * @param onChange (values) → void — every change, before any save
39
+ * @param screens { id → document } — screens a list's Edit / New can open
40
+ * @param loadScreen async (id) → document — for screens not in `screens`
41
+ * @param autosaveDelay ms (default 700)
19
42
  * @param controls the control registry (default: the built-ins)
20
43
  */
21
- export function useFactoryScreen({ document: doc, fetchOptions, onSubmit, values: provided, controls = CONTROLS } = {}) {
44
+ export function useFactoryScreen({
45
+ document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, onDelete, onChange, screens, loadScreen,
46
+ autosaveDelay = AUTOSAVE_DELAY, controls = CONTROLS
47
+ } = {}) {
22
48
  const check = useMemo(() => validateDocument(doc, controls), [doc, controls])
23
49
  const inputs = useMemo(() => (check.ok ? inputNodes(doc, controls) : []), [doc, controls, check.ok])
24
50
 
25
- const [values, setValues] = useState(() => (check.ok ? initialValues(doc, provided, controls) : {}))
26
- const [errors, setErrors] = useState({})
27
- const [submitted, setSubmitted] = useState(false)
28
- const [submitting, setSubmitting] = useState(false)
29
- const [submitError, setSubmitError] = useState(null)
51
+ // A LIST SCREEN has no fields of its own; what its columns mean — that
52
+ // `department` is a dropdown whose 2 reads "Finance" — lives on the screen it
53
+ // edits in. Those fields are borrowed for display, and their table options
54
+ // loaded alongside this screen's own.
55
+ const fieldsFor = useCallback((node) => {
56
+ if (inputs.length || !node || !node.props.editScreen) return inputs
57
+ const editDoc = screens && screens[node.props.editScreen]
58
+ return editDoc && validateDocument(editDoc, controls).ok ? inputNodes(editDoc, controls) : []
59
+ }, [inputs, screens, controls])
60
+ const optionNodes = useMemo(() => {
61
+ if (!check.ok) return []
62
+ const borrowed = doc.nodes.filter((n) => n.type === 'list').flatMap((n) => (inputs.length ? [] : fieldsFor(n)))
63
+ return [...inputs, ...borrowed]
64
+ }, [check.ok, doc, inputs, fieldsFor])
65
+ const source = check.ok ? doc.source || null : null
30
66
 
31
- // A different document or record starts the form over.
32
- const providedRef = useRef(provided); providedRef.current = provided
33
- const firstRun = useRef(true)
34
- useEffect(() => {
35
- if (firstRun.current) { firstRun.current = false; return }
36
- if (!check.ok) return
37
- setValues(initialValues(doc, providedRef.current, controls))
38
- setErrors({})
39
- setSubmitted(false)
40
- setSubmitError(null)
41
- }, [doc, provided, controls, check.ok])
67
+ const startValues = useCallback(
68
+ (rec) => (rec ? { ...initialValues(doc, {}, controls), ...recordValues(doc, rec, controls) } : initialValues(doc, {}, controls)),
69
+ [doc, controls]
70
+ )
71
+
72
+ const [values, setValues] = useState(() => (check.ok ? startValues(record) : {}))
73
+ const [recordId, setRecordId] = useState(() => (record ? record[recordKey] ?? null : null))
74
+ const [touched, setTouched] = useState(() => new Set())
75
+ const [status, setStatus] = useState('idle') // idle | pending | saving | saved | incomplete | invalid | error
76
+ const [saveError, setSaveError] = useState(null)
77
+ const [savedAt, setSavedAt] = useState(null)
78
+ const [listVersion, setListVersion] = useState(0)
79
+
80
+ // Latest of everything a delayed save needs, read at the moment it runs.
81
+ const live = useRef({})
82
+ live.current = { doc, values, recordId, touched, source, onSave, controls, recordKey }
42
83
 
43
84
  // ── options for table dropdowns, fetched once per table ───────────────
44
85
  const [options, setOptions] = useState({}) // node id → { loading, items, error }
45
- const fetchRef = useRef(fetchOptions); fetchRef.current = fetchOptions
86
+ const fetchOptionsRef = useRef(fetchOptions); fetchOptionsRef.current = fetchOptions
46
87
  useEffect(() => {
47
88
  let cancelled = false
48
- const tableNodes = inputs.filter((n) => n.type === 'dropdown' && n.props.data?.source === 'table')
89
+ const tableNodes = optionNodes.filter((n) => n.type === 'dropdown' && n.props.data?.source === 'table')
49
90
  if (!tableNodes.length) { setOptions({}); return undefined }
50
91
  setOptions(Object.fromEntries(tableNodes.map((n) => [n.id, { loading: true, items: [], error: null }])))
51
92
  const byTable = new Map()
@@ -55,8 +96,8 @@ export function useFactoryScreen({ document: doc, fetchOptions, onSubmit, values
55
96
  byTable.get(t).push(n)
56
97
  })
57
98
  byTable.forEach((nodes, table) => {
58
- const run = fetchRef.current
59
- ? Promise.resolve().then(() => fetchRef.current({ table, node: nodes[0] }))
99
+ const run = fetchOptionsRef.current
100
+ ? Promise.resolve().then(() => fetchOptionsRef.current({ table, node: nodes[0] }))
60
101
  : Promise.reject(new Error(`No fetchOptions was provided to read "${table}"`))
61
102
  run
62
103
  .then((rows) => {
@@ -68,9 +109,8 @@ export function useFactoryScreen({ document: doc, fetchOptions, onSubmit, values
68
109
  })
69
110
  })
70
111
  return () => { cancelled = true }
71
- }, [inputs])
112
+ }, [optionNodes])
72
113
 
73
- /** The options a dropdown shows, whichever source they come from. */
74
114
  const optionsFor = useCallback((node) => {
75
115
  if (node.props.data?.source === 'static') return { loading: false, items: node.props.data.options, error: null }
76
116
  return options[node.id] || { loading: false, items: [], error: null }
@@ -81,69 +121,216 @@ export function useFactoryScreen({ document: doc, fetchOptions, onSubmit, values
81
121
  Object.entries(options).forEach(([id, o]) => { if (!o.loading && !o.error) map[id] = o.items })
82
122
  return map
83
123
  }, [options])
124
+ live.current.loadedItems = loadedItems
125
+
126
+ const state = useMemo(() => (check.ok ? saveState(doc, values, touched, loadedItems, controls) : { canSave: false, status: 'invalid', errors: {}, shown: {} }),
127
+ [check.ok, doc, values, touched, loadedItems, controls])
128
+
129
+ // ── saving ────────────────────────────────────────────────────────────
130
+ const timer = useRef(null)
131
+ const inFlight = useRef(null) // the promise of the save running now
132
+ const again = useRef(false) // a change arrived while it ran
133
+
134
+ const runSave = useCallback(async () => {
135
+ clearTimeout(timer.current)
136
+ timer.current = null
137
+ if (inFlight.current) { again.current = true; return inFlight.current }
138
+ const l = live.current
139
+ const st = saveState(l.doc, l.values, l.touched, l.loadedItems, l.controls)
140
+ if (!st.canSave) { setStatus(st.status); return { ok: false, errors: st.errors } }
141
+ if (!l.onSave) { setStatus('saved'); return { ok: true } }
142
+
143
+ setStatus('saving')
144
+ setSaveError(null)
145
+ const task = (async () => {
146
+ try {
147
+ const saved = await l.onSave(l.values, { id: l.recordId, source: l.source, screen: l.doc.id, document: l.doc })
148
+ // A new record's first save creates it; its id makes the next save an update.
149
+ if (saved && typeof saved === 'object' && saved[l.recordKey] !== undefined && saved[l.recordKey] !== null) {
150
+ setRecordId(saved[l.recordKey])
151
+ live.current.recordId = saved[l.recordKey]
152
+ }
153
+ setSavedAt(new Date())
154
+ setListVersion((v) => v + 1)
155
+ setStatus('saved')
156
+ return { ok: true, record: saved }
157
+ } catch (err) {
158
+ setSaveError(err.message || 'Could not save')
159
+ setStatus('error')
160
+ return { ok: false, error: err }
161
+ } finally {
162
+ inFlight.current = null
163
+ if (again.current) { again.current = false; schedule() } // eslint-disable-line no-use-before-define
164
+ }
165
+ })()
166
+ inFlight.current = task
167
+ return task
168
+ }, [])
169
+
170
+ const schedule = useCallback(() => {
171
+ clearTimeout(timer.current)
172
+ setStatus('pending')
173
+ timer.current = setTimeout(runSave, autosaveDelay)
174
+ }, [runSave, autosaveDelay])
175
+
176
+ /** Save any pending change right away — before opening another record, say. */
177
+ const flush = useCallback(async () => {
178
+ if (timer.current || again.current) return runSave()
179
+ if (inFlight.current) return inFlight.current
180
+ return { ok: true }
181
+ }, [runSave])
182
+
183
+ useEffect(() => () => clearTimeout(timer.current), [])
84
184
 
85
185
  // ── editing ───────────────────────────────────────────────────────────
186
+ const onChangeRef = useRef(onChange); onChangeRef.current = onChange
86
187
  const setValue = useCallback((node, raw) => {
87
188
  let value = parseInput(node, raw)
88
189
  if (node.type === 'dropdown' && value !== undefined) value = optionValue(optionsFor(node).items, value)
89
- setValues((v) => {
90
- const next = { ...v }
91
- if (value === undefined) delete next[node.props.name]
92
- else next[node.props.name] = value
93
- return next
190
+ const name = node.props.name
191
+ const next = { ...live.current.values }
192
+ if (value === undefined) delete next[name]
193
+ else next[name] = value
194
+ live.current.values = next
195
+ setValues(next)
196
+ if (!live.current.touched.has(name)) {
197
+ const t = new Set(live.current.touched); t.add(name)
198
+ live.current.touched = t
199
+ setTouched(t)
200
+ }
201
+ onChangeRef.current?.(next)
202
+ schedule()
203
+ }, [optionsFor, schedule])
204
+
205
+ /** Leaving a field shows its message, even if nothing was typed. */
206
+ const touch = useCallback((node) => {
207
+ const name = node.props.name
208
+ if (live.current.touched.has(name)) return
209
+ const t = new Set(live.current.touched); t.add(name)
210
+ live.current.touched = t
211
+ setTouched(t)
212
+ }, [])
213
+
214
+ /** Open a record: whatever was pending is saved first. */
215
+ const openRecord = useCallback(async (rec) => {
216
+ await flush()
217
+ const next = startValues(rec)
218
+ live.current.values = next
219
+ live.current.recordId = rec ? rec[recordKey] ?? null : null
220
+ live.current.touched = new Set()
221
+ setValues(next)
222
+ setRecordId(live.current.recordId)
223
+ setTouched(new Set())
224
+ setSaveError(null)
225
+ setStatus('idle')
226
+ }, [flush, startValues, recordKey])
227
+
228
+ const newRecord = useCallback(() => openRecord(null), [openRecord])
229
+
230
+ // A different document, or a different record handed in, starts over.
231
+ const firstRun = useRef(true)
232
+ useEffect(() => {
233
+ if (firstRun.current) { firstRun.current = false; return }
234
+ if (!check.ok) return
235
+ openRecord(record || null)
236
+ }, [doc, record]) // eslint-disable-line react-hooks/exhaustive-deps
237
+
238
+ // ── lists ─────────────────────────────────────────────────────────────
239
+ const [lists, setLists] = useState({}) // node id → { loading, rows, error }
240
+ const fetchRecordsRef = useRef(fetchRecords); fetchRecordsRef.current = fetchRecords
241
+ const listNodes = useMemo(() => (check.ok ? doc.nodes.filter((n) => n.type === 'list') : []), [doc, check.ok])
242
+ useEffect(() => {
243
+ if (!listNodes.length) return undefined
244
+ let cancelled = false
245
+ listNodes.forEach((node) => {
246
+ const src = listSource(doc, node)
247
+ setLists((l) => ({ ...l, [node.id]: { rows: l[node.id]?.rows || [], loading: true, error: null } }))
248
+ const run = fetchRecordsRef.current
249
+ ? Promise.resolve().then(() => fetchRecordsRef.current({ source: src, screen: doc.id, node }))
250
+ : Promise.reject(new Error(`No fetchRecords was provided to list "${src}"`))
251
+ run
252
+ .then((rows) => { if (!cancelled) setLists((l) => ({ ...l, [node.id]: { rows: Array.isArray(rows) ? rows : [], loading: false, error: null } })) })
253
+ .catch((err) => { if (!cancelled) setLists((l) => ({ ...l, [node.id]: { rows: l[node.id]?.rows || [], loading: false, error: err.message || 'Could not load records' } })) })
94
254
  })
95
- // Once a submit has failed, errors follow the typing — the message goes
96
- // away the moment the field is fixed, not on the next click of Save.
97
- if (submitted) {
98
- setErrors((e) => {
99
- const msg = fieldError(node, value, loadedItems[node.id])
100
- const next = { ...e }
101
- if (msg) next[node.props.name] = msg
102
- else delete next[node.props.name]
103
- return next
104
- })
255
+ return () => { cancelled = true }
256
+ }, [listNodes, listVersion, doc])
257
+
258
+ const listFor = useCallback((node) => lists[node.id] || { rows: [], loading: false, error: null }, [lists])
259
+
260
+ const onDeleteRef = useRef(onDelete); onDeleteRef.current = onDelete
261
+ const deleteRecord = useCallback(async (node, rec) => {
262
+ const id = rec ? rec[recordKey] : undefined
263
+ if (!onDeleteRef.current) throw new Error('No onDelete was provided')
264
+ await onDeleteRef.current({ id, source: listSource(doc, node), screen: doc.id, record: rec })
265
+ // Deleting the record that is open leaves nothing to save into.
266
+ if (id !== undefined && id === live.current.recordId) {
267
+ clearTimeout(timer.current); timer.current = null
268
+ await openRecord(null)
105
269
  }
106
- }, [submitted, optionsFor, loadedItems])
107
-
108
- const reset = useCallback(() => {
109
- setValues(initialValues(doc, providedRef.current, controls))
110
- setErrors({})
111
- setSubmitted(false)
112
- setSubmitError(null)
113
- }, [doc, controls])
114
-
115
- const onSubmitRef = useRef(onSubmit); onSubmitRef.current = onSubmit
116
- const submit = useCallback(async (e) => {
117
- e?.preventDefault?.()
118
- setSubmitted(true)
119
- setSubmitError(null)
120
- const found = validateValues(doc, values, controls, loadedItems)
121
- setErrors(found)
122
- if (Object.keys(found).length) return { ok: false, errors: found }
123
- if (!onSubmitRef.current) return { ok: true, values }
124
- setSubmitting(true)
270
+ setListVersion((v) => v + 1)
271
+ }, [doc, recordKey, openRecord])
272
+
273
+ // ── popup: a list's Edit / New in its edit screen ─────────────────────
274
+ const [popup, setPopup] = useState(null) // { node, screenId, record, document, loading, error }
275
+ const screensRef = useRef(screens); screensRef.current = screens
276
+ const loadScreenRef = useRef(loadScreen); loadScreenRef.current = loadScreen
277
+
278
+ /** Edit (a record) or New (null) in the list's edit screen. */
279
+ const openEditor = useCallback(async (node, rec) => {
280
+ const screenId = node.props.editScreen
281
+ const base = { node, screenId, record: rec || null, document: null, loading: true, error: null }
282
+ setPopup(base)
125
283
  try {
126
- await onSubmitRef.current(values, { document: doc })
127
- return { ok: true, values }
284
+ let found = screensRef.current && screensRef.current[screenId]
285
+ if (!found && loadScreenRef.current) found = await loadScreenRef.current(screenId)
286
+ if (!found) throw new Error(`Screen "${screenId}" was not provided — pass it in \`screens\` or supply loadScreen`)
287
+ const checked = validateDocument(found, live.current.controls)
288
+ if (!checked.ok) throw new Error(`Screen "${screenId}" is not valid: ${checked.errors[0].path} ${checked.errors[0].message}`)
289
+ setPopup((p) => (p && p.screenId === screenId ? { ...p, document: found, loading: false } : p))
128
290
  } catch (err) {
129
- setSubmitError(err.message || 'Could not submit')
130
- return { ok: false, error: err }
131
- } finally {
132
- setSubmitting(false)
291
+ setPopup((p) => (p && p.screenId === screenId ? { ...p, loading: false, error: err.message } : p))
133
292
  }
134
- }, [doc, values, controls, loadedItems])
293
+ }, [])
294
+
295
+ const closeEditor = useCallback(() => {
296
+ setPopup(null)
297
+ setListVersion((v) => v + 1)
298
+ }, [])
299
+
300
+ /** The popup's saves go to the host's onSave, and refresh the list behind it. */
301
+ const onSaveRef = useRef(onSave); onSaveRef.current = onSave
302
+ const popupSave = useCallback(async (vals, meta) => {
303
+ const saved = onSaveRef.current ? await onSaveRef.current(vals, meta) : undefined
304
+ setListVersion((v) => v + 1)
305
+ return saved
306
+ }, [])
135
307
 
136
308
  return {
137
309
  document: doc,
138
310
  documentErrors: check.errors,
311
+ popup,
312
+ fieldsFor,
313
+ openEditor,
314
+ closeEditor,
315
+ popupSave,
139
316
  values,
140
- errors,
317
+ recordId,
318
+ errors: state.shown,
319
+ allErrors: state.errors,
320
+ status,
321
+ saveError,
322
+ savedAt,
141
323
  setValue,
324
+ touch,
142
325
  optionsFor,
143
- submit,
144
- reset,
145
- submitting,
146
- submitError
326
+ flush,
327
+ saveNow: runSave,
328
+ openRecord,
329
+ newRecord,
330
+ listFor,
331
+ deleteRecord,
332
+ recordKey,
333
+ canDelete: Boolean(onDelete)
147
334
  }
148
335
  }
149
336
 
@@ -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
+ }