@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.
- package/README.md +201 -0
- package/bin/xeplr-factory.js +2 -1
- package/package.json +1 -1
- package/src/controls.js +97 -2
- package/src/designs/BuilderSample.jsx +47 -1
- package/src/designs/ControlView.jsx +73 -2
- package/src/designs/ListView.jsx +3 -1
- package/src/designs/PropertyPanel.jsx +237 -7
- package/src/designs/ScreenSample.jsx +15 -9
- package/src/designs/factory.css +64 -0
- package/src/designs/styles.js +1 -1
- package/src/document.js +51 -0
- package/src/index.js +3 -1
- package/src/masters.js +193 -0
- package/src/model.js +5 -0
- package/src/pages.jsx +37 -1
- package/src/remote.js +22 -2
- package/src/screenSource.js +85 -2
- package/src/tableSchema.js +16 -0
- package/src/useFactoryBuilder.js +103 -2
- package/src/useFactoryScreen.js +202 -29
- package/src/useFlowRun.js +6 -3
- package/src/useScreenSource.js +8 -4
- package/src/validateDocument.js +82 -1
- package/src/values.js +118 -2
package/src/useFactoryScreen.js
CHANGED
|
@@ -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
|
|
105
|
-
|
|
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(
|
|
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(
|
|
313
|
-
[check.ok,
|
|
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
|
-
/**
|
|
319
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
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: (
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}, [])
|
package/src/useScreenSource.js
CHANGED
|
@@ -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
|
}
|
package/src/validateDocument.js
CHANGED
|
@@ -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
|
}
|