@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/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
- return
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
- const x = MARGIN + col * (colW + GAP)
92
- ;({ document: doc } = addControl(doc, type, { at: { x: round(x), y: round(y) }, size: { w: colW, h }, props }, controls))
93
- rowH = Math.max(rowH, h)
94
- col++
95
- if (col === columns) { y += rowH + ROW_GAP; col = 0; rowH = 0 }
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 === 'dropdown') {
138
- props.data = dataFromField(field, at)
139
- // A dropdown reading another table stores that row's id: name the column
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 dropdown — give it options: [...] or table: "<name>"`)
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
  }
@@ -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.
@@ -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,
@@ -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 === 'dropdown' && n.props.data?.source === 'table')
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,
@@ -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') checkDataSource(props.data, `${at}.props.data`, err)
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
- date: () => typeof v === 'string' && ISO_DATE.test(v)
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 a ${def.valueType === 'date' ? 'date as "YYYY-MM-DD"' : def.valueType}`)
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
- if (!(typeof val === 'string' && ISO_DATE.test(val))) err(path, 'must be a date as "YYYY-MM-DD"')
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')