@xeplr/ui-factory 1.2.0 → 1.4.0

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.
@@ -29,6 +29,15 @@ import { useUrlSteps } from './useUrlPlaces.js'
29
29
  // A list whose `editScreen` names another screen opens that screen in a popup
30
30
  // for Edit and New. The screen comes from `screens` ({ id → document }) or the
31
31
  // host's `loadScreen(id)`. The list refreshes as the popup saves.
32
+ //
33
+ // ── A FORM INSIDE THIS ONE (subform) ─────────────────────────────────────
34
+ // A `subform` node draws another published form inline, saving into its own
35
+ // table. The view renders it as a second useFactoryScreen with `link` (the
36
+ // field on it pointing back here) and `onReady`, through which it hands this
37
+ // one a handle: validate / dirty / save. One Save writes THIS record first —
38
+ // so a new one has an id — then every subform with changes, with that id in
39
+ // its link field. An untouched subform is not saved: it is one optional
40
+ // record, and an empty one is not worth a row.
32
41
 
33
42
  /**
34
43
  * @param document a valid screen document
@@ -52,12 +61,33 @@ import { useUrlSteps } from './useUrlPlaces.js'
52
61
  * @param urlKey where the open step of each stepper lives in the URL (`<urlKey>Steps`),
53
62
  * so a refresh or a shared link lands on the same step. Omitted → in memory.
54
63
  */
64
+ /**
65
+ * @param params what this screen was CALLED WITH — { name: value } for the
66
+ * inputs it declares. A flow's Screen step supplies them; somebody opening
67
+ * the form from a list supplies nothing. A field whose default is
68
+ * `{params.x}` is filled from here, and left empty (for the person to fill
69
+ * in) when nobody said.
70
+ */
55
71
  export function useFactoryScreen({
56
72
  document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl,
57
- onOpenRecord, onDone, onDirtyChange, urlKey,
73
+ onOpenRecord, onDone, onDirtyChange, params, urlKey,
74
+ link, onReady,
58
75
  controls = CONTROLS
59
76
  } = {}) {
60
77
  const check = useMemo(() => validateDocument(doc, controls), [doc, controls])
78
+ // EMBEDDED AS A SUBFORM: `link` is the field pointing back at the record
79
+ // this sits in. It is never shown and never asked for — the host fills it
80
+ // in on save — so it is left out of what is checked here, and a form
81
+ // without that field is a mistake said out loud, not a record saved
82
+ // belonging to nothing.
83
+ const embedded = Boolean(link)
84
+ const checkDoc = useMemo(
85
+ () => (embedded && check.ok ? { ...doc, nodes: doc.nodes.filter((n) => !(n.props && n.props.name === link)) } : doc),
86
+ [doc, link, embedded, check.ok]
87
+ )
88
+ const linkError = embedded && check.ok && !inputNodes(doc, controls).some((n) => n.props.name === link)
89
+ ? { path: '', message: `"${doc.name || doc.id}" has no field "${link}" — add it, or change "Linked by" on the form this is placed in` }
90
+ : null
61
91
  // Read at call time, so a new hooks object never re-runs loads by itself.
62
92
  const hooksRef = useRef(hooks); hooksRef.current = hooks
63
93
  const hook = (name) => hookMethod(hooksRef.current, name)
@@ -100,9 +130,13 @@ export function useFactoryScreen({
100
130
  }, [check.ok, doc, inputs, fieldsFor])
101
131
  const source = check.ok ? doc.source || null : null
102
132
 
133
+ // WHAT IT WAS CALLED WITH, then what was actually saved: a record being
134
+ // reopened wins, because the row is the truth once there is one.
103
135
  const startValues = useCallback(
104
- (rec) => (rec ? { ...initialValues(doc, {}, controls), ...recordValues(doc, rec, controls) } : initialValues(doc, {}, controls)),
105
- [doc, controls]
136
+ (rec) => (rec
137
+ ? { ...initialValues(doc, params || {}, controls), ...recordValues(doc, rec, controls) }
138
+ : initialValues(doc, params || {}, controls)),
139
+ [doc, controls, params]
106
140
  )
107
141
 
108
142
  const [values, setValues] = useState(() => (check.ok ? startValues(record) : {}))
@@ -128,7 +162,7 @@ export function useFactoryScreen({
128
162
 
129
163
  // Latest of everything a save needs, read at the moment it runs.
130
164
  const live = useRef({})
131
- live.current = { doc, values, recordId, touched, source, onSave, controls, recordKey }
165
+ live.current = { doc, checkDoc, values, recordId, touched, source, onSave, controls, recordKey }
132
166
 
133
167
  // ── options for table dropdowns, fetched once per table ───────────────
134
168
  const [options, setOptions] = useState({}) // node id → { loading, items, error }
@@ -169,7 +203,10 @@ export function useFactoryScreen({
169
203
  // have branches; which of them apply is the app's to say, from its hooks.
170
204
  const [disabledSteps, setDisabledSteps] = useState({})
171
205
  const stepperNodes = useMemo(() => (check.ok ? steppers(doc, controls) : []), [check.ok, doc, controls])
172
- const visibleNodes = useMemo(() => (check.ok ? nodesForSteps(doc, openSteps, controls) : []), [check.ok, doc, openSteps, controls])
206
+ const visibleNodes = useMemo(
207
+ () => (check.ok ? nodesForSteps(doc, openSteps, controls).filter((n) => !(embedded && n.props && n.props.name === link)) : []),
208
+ [check.ok, doc, openSteps, controls, embedded, link]
209
+ )
173
210
 
174
211
  // A screen whose steps changed under it (a field moved to another step, a
175
212
  // step removed) must not stay open at a step that is gone. Steppers this
@@ -309,26 +346,50 @@ export function useFactoryScreen({
309
346
  }, [options])
310
347
  live.current.loadedItems = loadedItems
311
348
 
312
- const state = useMemo(() => (check.ok ? saveState(doc, values, touched, loadedItems, controls) : { canSave: false, status: 'invalid', errors: {}, shown: {} }),
313
- [check.ok, doc, values, touched, loadedItems, controls])
349
+ const state = useMemo(() => (check.ok ? saveState(checkDoc, values, touched, loadedItems, controls) : { canSave: false, status: 'invalid', errors: {}, shown: {} }),
350
+ [check.ok, checkDoc, values, touched, loadedItems, controls])
351
+
352
+ // ── subforms: the handles they hand over, and which hold changes ──────
353
+ const subApis = useRef({}) // node id → { validate, dirty, save, name }
354
+ const subDirty = useRef(new Set()) // node ids of subforms with unsaved changes
355
+ const [subVersion, setSubVersion] = useState(0)
356
+ const registerSubform = useCallback((id, api) => {
357
+ if (api) subApis.current[id] = api
358
+ else { delete subApis.current[id]; subDirty.current.delete(id) }
359
+ }, [])
314
360
 
315
361
  // ── saving ────────────────────────────────────────────────────────────
316
362
  const inFlight = useRef(null) // the promise of the save running now
317
363
 
318
- /** Save — what the Save button does. One AJAX call; never on its own. */
319
- const runSave = useCallback(async () => {
364
+ /**
365
+ * Is it fit to save? Says what is missing on every field — including the
366
+ * ones nobody has been in yet — because pressing Save is the moment to.
367
+ */
368
+ const checkNow = useCallback(() => {
369
+ const l = live.current
370
+ const st = saveState(l.checkDoc, l.values, l.touched, l.loadedItems, l.controls)
371
+ if (st.canSave) return { ok: true }
372
+ const t = new Set([...l.touched, ...Object.keys(st.errors)])
373
+ live.current.touched = t
374
+ setTouched(t)
375
+ setStatus('invalid')
376
+ return { ok: false, errors: st.errors }
377
+ }, [])
378
+
379
+ /**
380
+ * Save — what the Save button does. One AJAX call; never on its own.
381
+ * `extra` is merged into what is sent: a subform's link, from its host.
382
+ */
383
+ const runSave = useCallback(async (extra) => {
320
384
  if (inFlight.current) return inFlight.current
321
385
  const l = live.current
322
- const st = saveState(l.doc, l.values, l.touched, l.loadedItems, l.controls)
323
- if (!st.canSave) {
324
- // Pressing Save is the moment to say what is missing — on every field,
325
- // including the ones nobody has been in yet.
326
- const t = new Set([...l.touched, ...Object.keys(st.errors)])
327
- live.current.touched = t
328
- setTouched(t)
329
- setStatus('invalid')
330
- return { ok: false, errors: st.errors }
331
- }
386
+ // A subform with changes is checked BEFORE anything is written, so a bad
387
+ // bank account never leaves an employee saved without one.
388
+ const subs = Object.entries(subApis.current).filter(([id]) => subDirty.current.has(id))
389
+ const subsOk = subs.map(([, api]) => api.validate()).every(Boolean)
390
+ const own = checkNow()
391
+ if (!own.ok) return own
392
+ if (!subsOk) { setStatus('invalid'); return { ok: false } }
332
393
  const customSave = hooksRef.current && typeof hooksRef.current.save === 'function'
333
394
  const writing = changes.current
334
395
  if (!l.onSave && !customSave) { savedChanges.current = writing; setDirty(false); setStatus('saved'); return { ok: true } }
@@ -338,13 +399,14 @@ export function useFactoryScreen({
338
399
  const task = (async () => {
339
400
  try {
340
401
  const meta = { id: l.recordId, source: l.source, screen: l.doc.id, document: l.doc }
341
- const saved = await hook('save')(l.values, {
402
+ const vals = extra ? { ...l.values, ...extra } : l.values
403
+ const saved = await hook('save')(vals, {
342
404
  ...meta,
343
405
  isNew: l.recordId === null || l.recordId === undefined,
344
406
  // Save is where an app usually learns which branch it is on, so it
345
407
  // can switch steps off from here as well as from step().
346
408
  steps: live.current.steps,
347
- defaults: { save: (vals) => (l.onSave ? l.onSave(vals, meta) : undefined) }
409
+ defaults: { save: (v) => (l.onSave ? l.onSave(v, meta) : undefined) }
348
410
  })
349
411
  // A new record's first save creates it; its id makes the next save an update.
350
412
  if (saved && typeof saved === 'object' && saved[l.recordKey] !== undefined && saved[l.recordKey] !== null) {
@@ -352,7 +414,28 @@ export function useFactoryScreen({
352
414
  live.current.recordId = saved[l.recordKey]
353
415
  }
354
416
  savedChanges.current = writing
355
- const still = changes.current !== writing
417
+
418
+ // THEN THE SUBFORMS, each with this record's id in its link field.
419
+ // One that fails leaves this record saved and says which did not.
420
+ if (subs.length) {
421
+ const failed = []
422
+ for (const [id, api] of subs) {
423
+ const r = await api.save(live.current.recordId)
424
+ if (!r || !r.ok) failed.push(`${api.name()}${r && r.error && r.error.message ? ` (${r.error.message})` : ''}`)
425
+ // Read from the subform itself, now — its own report of being
426
+ // clean arrives after this function has decided what to say.
427
+ else if (!api.dirty()) subDirty.current.delete(id)
428
+ }
429
+ setSubVersion((v) => v + 1)
430
+ if (failed.length) {
431
+ setDirty(true)
432
+ setSaveError(`this record was saved, but ${failed.join(', ')} was not`)
433
+ setStatus('error')
434
+ return { ok: false, record: saved }
435
+ }
436
+ }
437
+
438
+ const still = changes.current !== writing || subDirty.current.size > 0
356
439
  setDirty(still)
357
440
  setSavedAt(new Date())
358
441
  setListVersion((v) => v + 1)
@@ -375,21 +458,45 @@ export function useFactoryScreen({
375
458
  })()
376
459
  inFlight.current = task
377
460
  return task
461
+ }, [setDirty, checkNow])
462
+
463
+ /** A subform's changes are this form's unsaved changes too. */
464
+ const subformDirty = useCallback((id, d) => {
465
+ if (d) subDirty.current.add(id)
466
+ else subDirty.current.delete(id)
467
+ setDirty(changes.current !== savedChanges.current || subDirty.current.size > 0)
468
+ if (d) setStatus('pending')
378
469
  }, [setDirty])
379
470
 
471
+ const unsaved = () => changes.current !== savedChanges.current || subDirty.current.size > 0
472
+
380
473
  /** Save if there is anything unsaved — what a flow's Next does before moving on. */
381
474
  const flush = useCallback(async () => {
382
475
  if (inFlight.current) await inFlight.current
383
- if (changes.current !== savedChanges.current) return runSave()
476
+ if (unsaved()) return runSave()
384
477
  return { ok: true }
385
- }, [runSave])
478
+ }, [runSave]) // eslint-disable-line react-hooks/exhaustive-deps
479
+
480
+ // EMBEDDED: hand the host what it needs to check and save this form as part
481
+ // of its own Save. Read through refs, so the handle never goes stale.
482
+ const onReadyRef = useRef(onReady); onReadyRef.current = onReady
483
+ useEffect(() => {
484
+ if (!onReadyRef.current) return undefined
485
+ onReadyRef.current({
486
+ validate: () => checkNow().ok,
487
+ dirty: () => changes.current !== savedChanges.current,
488
+ save: (ownerId) => runSave({ [link]: ownerId }),
489
+ name: () => live.current.doc.name || live.current.doc.id
490
+ })
491
+ return () => { if (onReadyRef.current) onReadyRef.current(null) }
492
+ }, [runSave, checkNow, link])
386
493
 
387
494
  /**
388
495
  * May the person leave? True when nothing is unsaved, or when they agree to
389
496
  * lose it. Cancel, the popup's × and opening another record all ask this.
390
497
  */
391
498
  const confirmDiscard = useCallback(() => {
392
- if (changes.current === savedChanges.current) return true
499
+ if (!unsaved()) return true
393
500
  // eslint-disable-next-line no-alert
394
501
  return typeof window === 'undefined' || window.confirm('Discard your unsaved changes?')
395
502
  }, [])
@@ -456,6 +563,8 @@ export function useFactoryScreen({
456
563
  setSaveError(null)
457
564
  setServerErrors({})
458
565
  setStatus('idle')
566
+ // Its subforms are this record's, so they are read again for it.
567
+ setSubVersion((v) => v + 1)
459
568
  }, [startValues, recordKey, setDirty])
460
569
 
461
570
  const newRecord = useCallback(() => openRecord(null), [openRecord])
@@ -484,7 +593,16 @@ export function useFactoryScreen({
484
593
  defaults: {
485
594
  get: () => {
486
595
  if (!fetchRecordsRef.current) throw new Error(`No fetchRecords was provided to list "${src}"`)
487
- return fetchRecordsRef.current({ source: src, screen: doc.id, node })
596
+ // A SUB-TABLE IS THIS RECORD'S. `belongsTo` names the column on
597
+ // those rows that points back here, and the id is this record's
598
+ // — which is nothing until it has been saved, and the server
599
+ // answers with no rows rather than with everybody's.
600
+ var link = node.props && node.props.belongsTo
601
+ return fetchRecordsRef.current({
602
+ source: src, screen: doc.id, node,
603
+ belongsTo: link || undefined,
604
+ belongsToId: link ? live.current.recordId : undefined
605
+ })
488
606
  }
489
607
  }
490
608
  }))
@@ -545,12 +663,20 @@ export function useFactoryScreen({
545
663
  const openRecordRef = useRef(onOpenRecord); openRecordRef.current = onOpenRecord
546
664
  const openEditor = useCallback(async (node, rec) => {
547
665
  const screenId = node.props.editScreen
666
+
667
+ // WHAT THE CHILD IS CALLED WITH. A sub-table's row belongs to THIS
668
+ // record, so the link column is handed to the screen that adds one — the
669
+ // same `params` a flow's Screen step passes. The person never sees it,
670
+ // and never has to know which material they are adding a line to.
671
+ const link = node.props.belongsTo
672
+ const given = link && live.current.recordId ? { [link]: live.current.recordId } : null
673
+
548
674
  if (node.props.openIn === 'page') {
549
675
  if (!openRecordRef.current) throw new Error('This list opens records on a page, but no onOpenRecord was provided to go there')
550
676
  const id = rec ? rec[live.current.recordKey] : null
551
- return openRecordRef.current({ screen: screenId, id, record: rec || null, source: listSource(doc, node), node })
677
+ return openRecordRef.current({ screen: screenId, id, record: rec || null, source: listSource(doc, node), node, params: given })
552
678
  }
553
- const base = { node, screenId, record: rec || null, document: null, loading: true, error: null }
679
+ const base = { node, screenId, record: rec || null, document: null, loading: true, error: null, params: given }
554
680
  setPopup(base)
555
681
  try {
556
682
  let found = screensRef.current && screensRef.current[screenId]
@@ -574,6 +700,49 @@ export function useFactoryScreen({
574
700
  }
575
701
  }, [])
576
702
 
703
+ // ── subforms: the form each draws, and this record's row of it ────────
704
+ //
705
+ // Read when the screen opens, when another record is opened, and after a
706
+ // Save — never just because the id changed, or a new record's first save
707
+ // would re-read an empty row over the subform it is in the middle of saving.
708
+ const [subforms, setSubforms] = useState({}) // node id → { loading, error, document, record }
709
+ const subformNodes = useMemo(() => (check.ok ? doc.nodes.filter((n) => n.type === 'subform') : []), [doc, check.ok])
710
+ useEffect(() => {
711
+ if (!subformNodes.length) return undefined
712
+ let cancelled = false
713
+ subformNodes.forEach((node) => {
714
+ const screenId = node.props.screen
715
+ setSubforms((m) => ({ ...m, [node.id]: { ...(m[node.id] || {}), loading: true, error: null } }))
716
+ ;(async () => {
717
+ let found = screensRef.current && screensRef.current[screenId]
718
+ if (!found && loadScreenRef.current) found = await loadScreenRef.current(screenId)
719
+ if (!found) throw new Error(`Form "${screenId}" was not provided — pass it in \`screens\` or supply loadScreen`)
720
+ const ownerId = live.current.recordId
721
+ // Nothing to read for a record that is not saved yet: it owns nothing.
722
+ if (ownerId === null || ownerId === undefined) return { document: found, record: null }
723
+ const rows = await hook('get')({
724
+ many: true, id: null, screen: screenId, source: found.source || null, node, document: found,
725
+ defaults: {
726
+ get: () => {
727
+ if (!fetchRecordsRef.current) throw new Error(`No fetchRecords was provided to read "${screenId}"`)
728
+ return fetchRecordsRef.current({
729
+ source: found.source || null, screen: screenId, node,
730
+ belongsTo: node.props.belongsTo, belongsToId: ownerId
731
+ })
732
+ }
733
+ }
734
+ })
735
+ return { document: found, record: Array.isArray(rows) && rows.length ? rows[0] : null }
736
+ })().then(
737
+ (got) => { if (!cancelled) setSubforms((m) => ({ ...m, [node.id]: { loading: false, error: null, ...got } })) },
738
+ (err) => { if (!cancelled) setSubforms((m) => ({ ...m, [node.id]: { ...(m[node.id] || {}), loading: false, error: err.message || 'Could not load the form' } })) }
739
+ )
740
+ })
741
+ return () => { cancelled = true }
742
+ }, [subformNodes, subVersion]) // eslint-disable-line react-hooks/exhaustive-deps
743
+
744
+ const subformFor = useCallback((node) => subforms[node.id] || { loading: true, error: null, document: null, record: null }, [subforms])
745
+
577
746
  const closeEditor = useCallback(() => {
578
747
  setPopup(null)
579
748
  setListVersion((v) => v + 1)
@@ -589,7 +758,11 @@ export function useFactoryScreen({
589
758
 
590
759
  return {
591
760
  document: doc,
592
- documentErrors: check.errors,
761
+ documentErrors: linkError ? [...check.errors, linkError] : check.errors,
762
+ embedded,
763
+ subformFor,
764
+ registerSubform,
765
+ subformDirty,
593
766
  popup,
594
767
  fieldsFor,
595
768
  openEditor,
package/src/useFlowRun.js CHANGED
@@ -15,14 +15,17 @@ import { useCallback, useEffect, useRef, useState } from 'react'
15
15
  * @param flowKey which flow to run (starting one, or picking up a run)
16
16
  * @param runId an existing run to continue — omit to start one
17
17
  * @param startRun async (flowKey) → { runId, status, stepKey, screen }
18
- * @param loadRun async (runId) → { runId, status, stepKey, screen, values }
18
+ * @param loadRun async (runId) → { runId, status, stepKey, screen, values, given }
19
19
  * @param submitRun async (runId, values, recordId) → { status, stepKey, screen }
20
20
  * @param loadScreen async (key) → the screen document
21
21
  * @param onStep (step) → void — told each time the run moves, for the address bar
22
22
  * @param onFinish (run) → void — the journey is over
23
23
  */
24
24
  export function useFlowRun({ flowKey, runId, startRun, loadRun, submitRun, loadScreen, onStep, onFinish } = {}) {
25
- const [state, setState] = useState({ loading: true, error: null, runId: runId || null, status: null, stepKey: null, screen: null, document: null, values: null })
25
+ // `given` is what the flow called this screen with — the step's own input,
26
+ // resolved by the engine (see @xeplr/workflow's screen-show). A field bound
27
+ // to {params.x} is filled from it.
28
+ const [state, setState] = useState({ loading: true, error: null, runId: runId || null, status: null, stepKey: null, screen: null, document: null, values: null, given: null })
26
29
  const [sending, setSending] = useState(false)
27
30
  const live = useRef({})
28
31
  live.current = { startRun, loadRun, submitRun, loadScreen, onStep, onFinish, flowKey }
@@ -40,7 +43,7 @@ export function useFlowRun({ flowKey, runId, startRun, loadRun, submitRun, loadS
40
43
  setState({
41
44
  loading: false, error: null,
42
45
  runId: run.runId, status: run.status || 'running', stepKey: run.stepKey, screen: run.screen,
43
- document: doc, values: run.values || null
46
+ document: doc, values: run.values || null, given: run.given || null
44
47
  })
45
48
  if (live.current.onStep) live.current.onStep(run)
46
49
  }, [])
@@ -21,7 +21,7 @@
21
21
  // createElement so the file stays .js.
22
22
 
23
23
  import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
24
- import { loadScreens } from './screenSource.js'
24
+ import { loadScreens, masterForms } from './screenSource.js'
25
25
  import { ScreenEditorSample } from './designs/ScreenEditorSample.jsx'
26
26
 
27
27
  /**
@@ -29,7 +29,7 @@ import { ScreenEditorSample } from './designs/ScreenEditorSample.jsx'
29
29
  * @param opts { Builder, labels } — the builder component to draw, normally
30
30
  * FactoryBuilder. Passed in rather than imported so this hook does
31
31
  * not pull the whole builder into a bundle that never opens it.
32
- * @returns { screens, screenEditor, reload, loading, error }
32
+ * @returns { screens, masters, screenEditor, reload, loading, error }
33
33
  */
34
34
  export function useScreenSource(api, opts) {
35
35
  opts = opts || {}
@@ -74,8 +74,12 @@ export function useScreenSource(api, opts) {
74
74
  })
75
75
  }, [api, Builder, labels, reload])
76
76
 
77
+ // The lists other forms point at, one entry per FORM — what the admin
78
+ // section shows. Derived here so no application groups screens by hand.
79
+ const masters = useMemo(() => masterForms(screens), [screens])
80
+
77
81
  return useMemo(
78
- () => ({ screens, screenEditor, reload, loading, error }),
79
- [screens, screenEditor, reload, loading, error]
82
+ () => ({ screens, masters, screenEditor, reload, loading, error }),
83
+ [screens, masters, screenEditor, reload, loading, error]
80
84
  )
81
85
  }
@@ -18,7 +18,7 @@ import { FIELD_PRESETS, INPUT_TYPES, presetFor } from './presets.js'
18
18
  const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
19
19
  const TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_.$-]*$/
20
20
  const SCREEN_ID = /^[A-Za-z0-9_-]+$/
21
- const DOCUMENT_KEYS = ['kind', 'version', 'id', 'name', 'source', 'units', 'aspect', 'width', 'style', 'nodes']
21
+ const DOCUMENT_KEYS = ['kind', 'version', 'id', 'name', 'source', 'units', 'aspect', 'width', 'style', 'description', 'master', 'labelField', 'params', 'nodes']
22
22
  const COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i
23
23
 
24
24
  /**
@@ -41,6 +41,49 @@ export function validateDocument(doc, controls = CONTROLS) {
41
41
  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)')
42
42
  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')
43
43
  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"')
44
+
45
+ // WHAT THIS FORM IS FOR, in the maker's own words. Three forms called
46
+ // "Inspection" are told apart by this and by who made them — never by their
47
+ // fields, which say what a form holds and not what it was for.
48
+ if (doc.description !== undefined && doc.description !== null && doc.description !== '') {
49
+ if (typeof doc.description !== 'string') err('description', 'must be a sentence about what this form is for')
50
+ else if (doc.description.length > 500) err('description', 'must be under 500 characters — a sentence, not the manual')
51
+ }
52
+
53
+ // A MASTER is a form whose records other forms point at — departments,
54
+ // statuses, countries. It is a FLAG on an ordinary form, not a kind of its
55
+ // own: a master has a table, a list, an edit screen and permissions like
56
+ // anything else, and a separate type would be two things that behave the
57
+ // same until they do not, with every later feature asking which it has.
58
+ if (doc.master !== undefined && typeof doc.master !== 'boolean') {
59
+ err('master', 'must be true or false — whether other forms pick from this one')
60
+ }
61
+ // What a picker SHOWS for a record. Said once here rather than on every
62
+ // dropdown that points at this form.
63
+ if (doc.labelField !== undefined) {
64
+ if (!nonEmptyString(doc.labelField)) err('labelField', 'must name a field of this screen, e.g. "name"')
65
+ else if (!(doc.nodes || []).some((n) => n.props && n.props.name === doc.labelField)) {
66
+ err('labelField', `"${doc.labelField}" is not a field on this screen`)
67
+ }
68
+ }
69
+
70
+ // WHAT THIS SCREEN IS CALLED WITH. Names only — a field binds to one with
71
+ // {params.<name>} as its default, so a name that is not a plain identifier
72
+ // could never be referenced, and a duplicate would make the reference mean
73
+ // two things.
74
+ if (doc.params !== undefined) {
75
+ if (!Array.isArray(doc.params)) err('params', 'must be a list of inputs this screen is called with')
76
+ else {
77
+ const seen = new Set()
78
+ doc.params.forEach((p, i) => {
79
+ const name = p && p.name
80
+ if (!nonEmptyString(name)) err(`params.${i}.name`, 'must be a name, e.g. "complaintId"')
81
+ else if (!/^[A-Za-z_][\w]*$/.test(name)) err(`params.${i}.name`, `"${name}" must be letters, digits and _, starting with a letter`)
82
+ else if (seen.has(name)) err(`params.${i}.name`, `"${name}" is declared twice — a field binding to it would mean two things`)
83
+ else seen.add(name)
84
+ })
85
+ }
86
+ }
44
87
  checkStyle(doc.style, SCREEN_STYLE_KEYS, 'style', err, 'the screen')
45
88
  Object.keys(doc).forEach((k) => {
46
89
  if (!DOCUMENT_KEYS.includes(k)) err(k, `is not a screen property — allowed: ${DOCUMENT_KEYS.join(', ')}`)
@@ -92,6 +135,18 @@ export function validateDocument(doc, controls = CONTROLS) {
92
135
  // Where the field came from (a ready-made field's key), or false: plain on purpose.
93
136
  if (props.preset !== undefined && props.preset !== false && !presetFor(props.preset)) err(`${at}.props.preset`, `"${props.preset}" is not a ready-made field — one of: ${FIELD_PRESETS.map((p) => p.key).join(', ')}, or false`)
94
137
  if (props.inputType !== undefined && !INPUT_TYPES.includes(props.inputType)) err(`${at}.props.inputType`, `must be one of: ${INPUT_TYPES.join(', ')}`)
138
+
139
+ // AN IDENTIFIER — the number or code this record is known by. How it is
140
+ // made decides the column, so it is checked here rather than discovered
141
+ // at publish time.
142
+ if (props.idKind !== undefined && props.idKind !== 'code' && props.idKind !== 'auto') {
143
+ err(`${at}.props.idKind`, 'must be "code" (CMP-7K3QF9, assigned on save) or "auto" (a number the database counts)')
144
+ }
145
+ if (props.codePrefix !== undefined && props.codePrefix !== '') {
146
+ if (typeof props.codePrefix !== 'string' || !/^[A-Za-z0-9]{1,8}$/.test(props.codePrefix)) {
147
+ err(`${at}.props.codePrefix`, 'must be up to 8 letters or digits, e.g. "CMP" — it is what the code starts with')
148
+ }
149
+ }
95
150
  checkDefault(node, def, at, err)
96
151
  checkValidation(node, def, at, err)
97
152
  }
@@ -107,6 +162,7 @@ export function validateDocument(doc, controls = CONTROLS) {
107
162
  if (node.type === 'stepper') checkSteps(props, at, err)
108
163
  checkStep(node, doc, at, err)
109
164
  if (node.type === 'list') checkList(props, doc, at, err)
165
+ if (node.type === 'subform') checkSubform(props, doc, at, err)
110
166
  if (node.type === 'label') {
111
167
  if (typeof props.text !== 'string') err(`${at}.props.text`, 'must be a string')
112
168
  if (props.variant !== undefined && !LABEL_VARIANTS.includes(props.variant)) err(`${at}.props.variant`, `must be one of: ${LABEL_VARIANTS.join(', ')}`)
@@ -330,6 +386,17 @@ function checkList(props, doc, at, err) {
330
386
  if (props.source !== undefined && !(nonEmptyString(props.source) && TABLE_NAME.test(props.source))) err(`${at}.props.source`, 'must name the table to list records from')
331
387
  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?')
332
388
  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"')
389
+
390
+ // THE LINK BACK, for a sub-table on a form: this material's
391
+ // characteristics. A column name, checked as one — a value that could never
392
+ // be a column is a list that is always empty and nobody knows why.
393
+ if (props.belongsTo !== undefined && props.belongsTo !== '') {
394
+ if (!(nonEmptyString(props.belongsTo) && /^[A-Za-z_][\w]*$/.test(props.belongsTo))) {
395
+ err(`${at}.props.belongsTo`, 'must be a column name, e.g. "materialId"')
396
+ } else if (!props.source) {
397
+ err(`${at}.props.belongsTo`, 'only means something with "Records from" set — a list of this screen\'s own table already belongs to this record')
398
+ }
399
+ }
333
400
  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')
334
401
  if (props.actions !== undefined) {
335
402
  if (!Array.isArray(props.actions)) err(`${at}.props.actions`, `must be an array of: ${LIST_ACTIONS.join(', ')}`)
@@ -351,6 +418,20 @@ function checkList(props, doc, at, err) {
351
418
  }
352
419
  }
353
420
 
421
+ /**
422
+ * A form inside this one. Both halves are required: a form with no link saves
423
+ * a record belonging to nothing, and a link with no form is nothing. Itself is
424
+ * refused — a form containing itself draws forever.
425
+ */
426
+ function checkSubform(props, doc, at, err) {
427
+ if (props.title !== undefined && typeof props.title !== 'string') err(`${at}.props.title`, 'must be a string')
428
+ if (!(nonEmptyString(props.screen) && SCREEN_ID.test(props.screen))) err(`${at}.props.screen`, 'is required — the id of the form drawn here, e.g. "bank_details_edit"')
429
+ else if (props.screen === doc.id) err(`${at}.props.screen`, 'cannot be this screen — a form cannot contain itself')
430
+ if (!(nonEmptyString(props.belongsTo) && /^[A-Za-z_][\w]*$/.test(props.belongsTo))) {
431
+ err(`${at}.props.belongsTo`, 'is required — the field on that form pointing back at this record, e.g. "employeeId"')
432
+ }
433
+ }
434
+
354
435
  function nonEmptyString(v) {
355
436
  return typeof v === 'string' && v.trim() !== ''
356
437
  }