@xeplr/ui-factory 1.0.11 → 1.0.13
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 +68 -0
- package/package.json +1 -1
- package/src/designs/ControlView.jsx +5 -2
- package/src/designs/FlowBuilderSample.jsx +274 -0
- package/src/designs/FlowRunnerSample.jsx +69 -0
- package/src/designs/ScreenSample.jsx +27 -9
- package/src/designs/factory.css +53 -1
- package/src/designs/index.js +2 -0
- package/src/document.js +40 -0
- package/src/flow.js +318 -0
- package/src/flows.js +112 -0
- package/src/hooks.js +23 -3
- package/src/index.js +4 -2
- package/src/model.js +7 -1
- package/src/pages.jsx +66 -1
- package/src/useFactoryScreen.js +82 -13
- package/src/useFlowBuilder.js +186 -0
- package/src/useFlowRun.js +86 -0
package/src/useFactoryScreen.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { CONTROLS, CHOICE_TYPES } from './controls.js'
|
|
3
|
-
import { inputNodes, nodesForSteps, steppers, stepsOf, stepOf } from './document.js'
|
|
3
|
+
import { inputNodes, nodesForSteps, steppers, stepsOf, stepOf, resolveStep, reachableStep, nextOpenStep } 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'
|
|
@@ -152,6 +152,9 @@ export function useFactoryScreen({
|
|
|
152
152
|
// the fields of the steps not showing are still filled in, still checked and
|
|
153
153
|
// still saved — a step is what the person sees, not a separate form.
|
|
154
154
|
const [openSteps, setOpenSteps] = useState({})
|
|
155
|
+
// Steps the app has switched off — { stepper id: [index, …] }. A screen may
|
|
156
|
+
// have branches; which of them apply is the app's to say, from its hooks.
|
|
157
|
+
const [disabledSteps, setDisabledSteps] = useState({})
|
|
155
158
|
const stepperNodes = useMemo(() => (check.ok ? steppers(doc, controls) : []), [check.ok, doc, controls])
|
|
156
159
|
const visibleNodes = useMemo(() => (check.ok ? nodesForSteps(doc, openSteps, controls) : []), [check.ok, doc, openSteps, controls])
|
|
157
160
|
|
|
@@ -173,42 +176,104 @@ export function useFactoryScreen({
|
|
|
173
176
|
|
|
174
177
|
const stepControl = useMemo(() => {
|
|
175
178
|
const at = (id) => (Number.isInteger(openSteps[id]) ? openSteps[id] : 0)
|
|
176
|
-
|
|
179
|
+
const only = () => (stepperNodes.length === 1 ? stepperNodes[0].id : null)
|
|
180
|
+
const nodeFor = (id) => stepperNodes.find((n) => n.id === id) || stepperNodes[0] || null
|
|
181
|
+
const lastOf = (id) => stepsOf(nodeFor(id)).length - 1
|
|
182
|
+
/** A step named by its key ("planning"), its label, or its number from 0. */
|
|
183
|
+
const indexOf = (id, which) => resolveStep(stepsOf(nodeFor(id)), which)
|
|
184
|
+
const offList = (id) => disabledSteps[id] || []
|
|
185
|
+
const isOff = (id, index) => offList(id).includes(index)
|
|
186
|
+
const reachable = (id, index, direction) => reachableStep(lastOf(id) + 1, index, offList(id), direction, at(id))
|
|
187
|
+
const setOff = (id, list) => setDisabledSteps((d) => ({ ...d, [id || only()]: list }))
|
|
188
|
+
|
|
189
|
+
const control = {
|
|
177
190
|
nodes: stepperNodes,
|
|
178
191
|
active: at,
|
|
179
|
-
count: (id) => stepsOf(
|
|
192
|
+
count: (id) => stepsOf(nodeFor(id)).length,
|
|
193
|
+
labels: (id) => stepsOf(nodeFor(id)),
|
|
194
|
+
indexOf,
|
|
195
|
+
/** Is this step switched off — shown, but not one you can be on? */
|
|
196
|
+
isDisabled: (id, index) => isOff(id, index),
|
|
197
|
+
disabled: (id) => (disabledSteps[id || only()] || []).slice(),
|
|
198
|
+
/**
|
|
199
|
+
* Switch steps off (a key, a number, or a list of them). A step that is
|
|
200
|
+
* off cannot be reached by Next, Back or a click, and Next passes over
|
|
201
|
+
* it. Switching off the step someone is on moves them to the nearest one
|
|
202
|
+
* that is open.
|
|
203
|
+
*/
|
|
204
|
+
disable: (which, id) => {
|
|
205
|
+
const on = id || only()
|
|
206
|
+
if (!on) return []
|
|
207
|
+
const add = (Array.isArray(which) ? which : [which]).map((w) => indexOf(on, w)).filter((i) => i >= 0)
|
|
208
|
+
const next = [...new Set([...(disabledSteps[on] || []), ...add])]
|
|
209
|
+
setOff(on, next)
|
|
210
|
+
return next
|
|
211
|
+
},
|
|
212
|
+
/** Switch them back on. No arguments switches every step of it back on. */
|
|
213
|
+
enable: (which, id) => {
|
|
214
|
+
const on = id || only()
|
|
215
|
+
if (!on) return []
|
|
216
|
+
if (which === undefined) { setOff(on, []); return [] }
|
|
217
|
+
const drop = (Array.isArray(which) ? which : [which]).map((w) => indexOf(on, w))
|
|
218
|
+
const next = (disabledSteps[on] || []).filter((i) => !drop.includes(i))
|
|
219
|
+
setOff(on, next)
|
|
220
|
+
return next
|
|
221
|
+
},
|
|
180
222
|
/**
|
|
181
223
|
* Move, if the app's `step` hook lets it: false keeps the person where
|
|
182
224
|
* they are, a number sends them somewhere else. The hook runs for Next,
|
|
183
225
|
* Back and a click on the bar alike, so a rule cannot be walked around.
|
|
184
226
|
*/
|
|
185
227
|
go: async (id, index, direction) => {
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
const
|
|
228
|
+
const on = id || only()
|
|
229
|
+
const from = at(on)
|
|
230
|
+
const way = direction || (index > from ? 'next' : 'back')
|
|
231
|
+
const to = reachable(on, indexOf(on, index), way)
|
|
189
232
|
if (to === from) return from
|
|
190
233
|
const decide = hookMethod(hooksRef.current, 'step')
|
|
191
234
|
const answer = await decide({
|
|
192
235
|
from,
|
|
193
236
|
to,
|
|
194
|
-
direction:
|
|
237
|
+
direction: way,
|
|
195
238
|
values: live.current.values,
|
|
196
|
-
stepper:
|
|
239
|
+
stepper: on,
|
|
197
240
|
screen: doc.id,
|
|
198
|
-
document: doc
|
|
241
|
+
document: doc,
|
|
242
|
+
// What the hook can DO from here: send them somewhere, or say which
|
|
243
|
+
// steps this answer makes beside the point.
|
|
244
|
+
steps: control,
|
|
245
|
+
go: (where) => control.go(on, where, 'jump'),
|
|
246
|
+
disable: (which) => control.disable(which, on),
|
|
247
|
+
enable: (which) => control.enable(which, on)
|
|
199
248
|
})
|
|
200
249
|
if (answer === false) return from
|
|
201
|
-
const
|
|
202
|
-
|
|
250
|
+
const asked = Number.isInteger(answer) || typeof answer === 'string' ? indexOf(on, answer) : to
|
|
251
|
+
const where = reachable(on, asked >= 0 ? asked : to, way)
|
|
252
|
+
setOpenSteps((o) => ({ ...o, [on]: where }))
|
|
203
253
|
return where
|
|
204
254
|
},
|
|
255
|
+
/** The next step in this direction that is open, for Back and Next. */
|
|
256
|
+
nextOpen: (id, direction) => {
|
|
257
|
+
const on = id || only()
|
|
258
|
+
return nextOpenStep(lastOf(on) + 1, at(on), offList(on), direction)
|
|
259
|
+
},
|
|
205
260
|
/** The fields on this step, so Next can hold at a step that is not filled in. */
|
|
206
261
|
fieldsOn: (id, index) => inputNodes(doc, controls).filter((n) => {
|
|
207
262
|
const step = stepOf(n)
|
|
208
|
-
return step && step.of === id && step.index === index
|
|
263
|
+
return step && step.of === (id || only()) && step.index === index
|
|
209
264
|
})
|
|
210
265
|
}
|
|
211
|
-
|
|
266
|
+
return control
|
|
267
|
+
}, [stepperNodes, openSteps, disabledSteps, doc, controls])
|
|
268
|
+
|
|
269
|
+
live.current.steps = stepControl
|
|
270
|
+
|
|
271
|
+
// A step that is switched off cannot be the one showing.
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
stepperNodes.forEach((n) => {
|
|
274
|
+
if (stepControl.isDisabled(n.id, stepControl.active(n.id))) stepControl.go(n.id, stepControl.active(n.id) + 1, 'next')
|
|
275
|
+
})
|
|
276
|
+
}, [stepperNodes, stepControl])
|
|
212
277
|
|
|
213
278
|
// ── a file field's upload ─────────────────────────────────────────────
|
|
214
279
|
const uploadRef = useRef(uploadFile); uploadRef.current = uploadFile
|
|
@@ -257,6 +322,9 @@ export function useFactoryScreen({
|
|
|
257
322
|
const saved = await hook('save')(l.values, {
|
|
258
323
|
...meta,
|
|
259
324
|
isNew: l.recordId === null || l.recordId === undefined,
|
|
325
|
+
// Save is where an app usually learns which branch it is on, so it
|
|
326
|
+
// can switch steps off from here as well as from step().
|
|
327
|
+
steps: live.current.steps,
|
|
260
328
|
defaults: { save: (vals) => (l.onSave ? l.onSave(vals, meta) : undefined) }
|
|
261
329
|
})
|
|
262
330
|
// A new record's first save creates it; its id makes the next save an update.
|
|
@@ -376,6 +444,7 @@ export function useFactoryScreen({
|
|
|
376
444
|
setLists((l) => ({ ...l, [node.id]: { rows: l[node.id]?.rows || [], loading: true, error: null } }))
|
|
377
445
|
const run = Promise.resolve().then(() => hook('get')({
|
|
378
446
|
many: true, id: null, screen: doc.id, source: src, node, document: doc,
|
|
447
|
+
steps: live.current.steps,
|
|
379
448
|
defaults: {
|
|
380
449
|
get: () => {
|
|
381
450
|
if (!fetchRecordsRef.current) throw new Error(`No fetchRecords was provided to list "${src}"`)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
createFlow, addStep, moveStep, setStep, removeStep,
|
|
4
|
+
addTransition, setTransition, removeTransition, validateFlow, firstStep, layoutFlow, FLOW_END
|
|
5
|
+
} from './flow.js'
|
|
6
|
+
import { inputNodes } from './document.js'
|
|
7
|
+
|
|
8
|
+
// DESIGNING a flow: which screens, in what order, and what decides the order.
|
|
9
|
+
//
|
|
10
|
+
// The same shape as useFactoryBuilder, so the two designers feel like one
|
|
11
|
+
// product: the document, what is selected, and a save that happens by itself
|
|
12
|
+
// once what is drawn makes sense. Publishing is separate and deliberate — a
|
|
13
|
+
// flow that is published is what people actually walk through.
|
|
14
|
+
|
|
15
|
+
/** Quiet time after the last change before a save goes out. */
|
|
16
|
+
export const FLOW_AUTOSAVE_DELAY = 800
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param flow the flow to edit (omit for a new one)
|
|
20
|
+
* @param name name for a new flow
|
|
21
|
+
* @param screens [{ id, name }] — the published screens a step can show
|
|
22
|
+
* @param loadScreen async (id) → the screen document, so a condition can offer its fields
|
|
23
|
+
* @param onSave async (flow) → void — called after edits, only with a flow that makes sense
|
|
24
|
+
* @param onPublish async (key) → void — shows a Publish button
|
|
25
|
+
* @param onChange (flow) → void — every edit
|
|
26
|
+
*/
|
|
27
|
+
export function useFlowBuilder({ flow: given, name, screens = [], loadScreen, onSave, onPublish, onChange } = {}) {
|
|
28
|
+
const [flow, setFlow] = useState(() => given || createFlow({ name }))
|
|
29
|
+
const [selected, setSelected] = useState(null) // a step key
|
|
30
|
+
const [dirty, setDirty] = useState(false)
|
|
31
|
+
const [saving, setSaving] = useState(false)
|
|
32
|
+
const [saveError, setSaveError] = useState(null)
|
|
33
|
+
const [publishing, setPublishing] = useState(false)
|
|
34
|
+
const [publishResult, setPublishResult] = useState(null)
|
|
35
|
+
|
|
36
|
+
// A flow handed in from outside replaces what is being edited only when it
|
|
37
|
+
// is a different flow — otherwise a re-render would undo what was typed.
|
|
38
|
+
const givenKey = given && given.key
|
|
39
|
+
useEffect(() => { if (given) { setFlow(given); setDirty(false) } }, [givenKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
40
|
+
|
|
41
|
+
const live = useRef({})
|
|
42
|
+
live.current = { flow, onSave, onChange }
|
|
43
|
+
|
|
44
|
+
const check = useMemo(() => validateFlow(flow, { screens: screens.map((s) => s.id) }), [flow, screens])
|
|
45
|
+
const errorsByStep = useMemo(() => {
|
|
46
|
+
const out = {}
|
|
47
|
+
check.errors.forEach((e) => {
|
|
48
|
+
const m = /^steps\[(\d+)\]/.exec(e.path || '')
|
|
49
|
+
if (!m) return
|
|
50
|
+
const step = flow.steps[Number(m[1])]
|
|
51
|
+
if (!step) return
|
|
52
|
+
out[step.stepKey] = [...(out[step.stepKey] || []), e]
|
|
53
|
+
})
|
|
54
|
+
return out
|
|
55
|
+
}, [check, flow])
|
|
56
|
+
|
|
57
|
+
// ── the fields a condition can test ───────────────────────────────────
|
|
58
|
+
// A condition reads what the person filled in on THAT step's screen, so the
|
|
59
|
+
// designer offers exactly those fields, by name, with their labels.
|
|
60
|
+
const [fields, setFields] = useState({}) // screen id → [{ name, label }]
|
|
61
|
+
const loadRef = useRef(loadScreen); loadRef.current = loadScreen
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const wanted = [...new Set(flow.steps.map((s) => s.screen).filter(Boolean))]
|
|
64
|
+
const missing = wanted.filter((id) => !fields[id])
|
|
65
|
+
if (!missing.length || !loadRef.current) return undefined
|
|
66
|
+
let cancelled = false
|
|
67
|
+
Promise.all(missing.map((id) => Promise.resolve()
|
|
68
|
+
.then(() => loadRef.current(id))
|
|
69
|
+
.then((doc) => [id, inputNodes(doc).map((n) => ({ name: n.props.name, label: n.props.label || n.props.name }))])
|
|
70
|
+
.catch(() => [id, []])))
|
|
71
|
+
.then((pairs) => { if (!cancelled) setFields((f) => ({ ...f, ...Object.fromEntries(pairs) })) })
|
|
72
|
+
return () => { cancelled = true }
|
|
73
|
+
}, [flow.steps, fields])
|
|
74
|
+
|
|
75
|
+
/** The fields of the screen a step shows — what its arrows can test. */
|
|
76
|
+
const fieldsFor = useCallback((stepKey) => {
|
|
77
|
+
const step = flow.steps.find((s) => s.stepKey === stepKey)
|
|
78
|
+
return (step && fields[step.screen]) || []
|
|
79
|
+
}, [flow, fields])
|
|
80
|
+
|
|
81
|
+
// ── edits ─────────────────────────────────────────────────────────────
|
|
82
|
+
const update = useCallback((fn) => {
|
|
83
|
+
setFlow((f) => {
|
|
84
|
+
const next = fn(f)
|
|
85
|
+
if (next === f) return f
|
|
86
|
+
live.current.flow = next
|
|
87
|
+
setDirty(true)
|
|
88
|
+
if (live.current.onChange) live.current.onChange(next)
|
|
89
|
+
return next
|
|
90
|
+
})
|
|
91
|
+
}, [])
|
|
92
|
+
|
|
93
|
+
const rename = useCallback((value) => update((f) => ({ ...f, name: value })), [update])
|
|
94
|
+
const screensRef = useRef(screens); screensRef.current = screens
|
|
95
|
+
const add = useCallback((screen, at) => {
|
|
96
|
+
let added = null
|
|
97
|
+
const known = screensRef.current.find((x) => x.id === screen)
|
|
98
|
+
update((f) => {
|
|
99
|
+
const r = addStep(f, screen, { at, label: known && known.name ? known.name : undefined })
|
|
100
|
+
added = r.step
|
|
101
|
+
return r.flow
|
|
102
|
+
})
|
|
103
|
+
if (added) setSelected(added.stepKey)
|
|
104
|
+
return added
|
|
105
|
+
}, [update])
|
|
106
|
+
const move = useCallback((stepKey, at) => update((f) => moveStep(f, stepKey, at)), [update])
|
|
107
|
+
const change = useCallback((stepKey, patch) => update((f) => setStep(f, stepKey, patch)), [update])
|
|
108
|
+
const remove = useCallback((stepKey) => {
|
|
109
|
+
update((f) => removeStep(f, stepKey))
|
|
110
|
+
setSelected((s) => (s === stepKey ? null : s))
|
|
111
|
+
}, [update])
|
|
112
|
+
const connect = useCallback((fromKey, target, when = null) => update((f) => addTransition(f, fromKey, target, when)), [update])
|
|
113
|
+
const changeTransition = useCallback((fromKey, index, patch) => update((f) => setTransition(f, fromKey, index, patch)), [update])
|
|
114
|
+
const disconnect = useCallback((fromKey, index) => update((f) => removeTransition(f, fromKey, index)), [update])
|
|
115
|
+
/** Lays every step out by where it falls in the journey. */
|
|
116
|
+
const tidy = useCallback(() => update((f) => layoutFlow(f)), [update])
|
|
117
|
+
|
|
118
|
+
// ── saving ────────────────────────────────────────────────────────────
|
|
119
|
+
const timer = useRef(null)
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!dirty || !onSave) return undefined
|
|
122
|
+
clearTimeout(timer.current)
|
|
123
|
+
timer.current = setTimeout(() => {
|
|
124
|
+
const f = live.current.flow
|
|
125
|
+
if (!validateFlow(f, { screens: screens.map((s) => s.id) }).ok) return // saved once it makes sense
|
|
126
|
+
setSaving(true)
|
|
127
|
+
setSaveError(null)
|
|
128
|
+
Promise.resolve()
|
|
129
|
+
.then(() => live.current.onSave(f))
|
|
130
|
+
.then(() => { setDirty(false) })
|
|
131
|
+
.catch((err) => setSaveError(err.message || 'Could not save'))
|
|
132
|
+
.finally(() => setSaving(false))
|
|
133
|
+
}, FLOW_AUTOSAVE_DELAY)
|
|
134
|
+
return () => clearTimeout(timer.current)
|
|
135
|
+
}, [flow, dirty, onSave, screens])
|
|
136
|
+
|
|
137
|
+
const publish = useCallback(async () => {
|
|
138
|
+
if (!onPublish) return
|
|
139
|
+
setPublishing(true)
|
|
140
|
+
setPublishResult(null)
|
|
141
|
+
try {
|
|
142
|
+
clearTimeout(timer.current)
|
|
143
|
+
if (dirty && onSave) await onSave(live.current.flow)
|
|
144
|
+
await onPublish(live.current.flow.key)
|
|
145
|
+
setDirty(false)
|
|
146
|
+
setPublishResult({ ok: true, message: 'Published — people can walk through it now' })
|
|
147
|
+
} catch (err) {
|
|
148
|
+
setPublishResult({ ok: false, message: err.message || 'Could not publish' })
|
|
149
|
+
} finally {
|
|
150
|
+
setPublishing(false)
|
|
151
|
+
}
|
|
152
|
+
}, [onPublish, onSave, dirty])
|
|
153
|
+
|
|
154
|
+
const status = saveError ? 'error' : saving ? 'saving' : dirty ? (check.ok ? 'pending' : 'invalid') : 'saved'
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
flow,
|
|
158
|
+
screens,
|
|
159
|
+
selected,
|
|
160
|
+
setSelected,
|
|
161
|
+
selectedStep: flow.steps.find((s) => s.stepKey === selected) || null,
|
|
162
|
+
first: firstStep(flow),
|
|
163
|
+
validation: check,
|
|
164
|
+
errorsByStep,
|
|
165
|
+
fieldsFor,
|
|
166
|
+
rename,
|
|
167
|
+
addStep: add,
|
|
168
|
+
moveStep: move,
|
|
169
|
+
setStep: change,
|
|
170
|
+
removeStep: remove,
|
|
171
|
+
connect,
|
|
172
|
+
setTransition: changeTransition,
|
|
173
|
+
disconnect,
|
|
174
|
+
tidy,
|
|
175
|
+
end: FLOW_END,
|
|
176
|
+
status,
|
|
177
|
+
saving,
|
|
178
|
+
saveError,
|
|
179
|
+
dirty,
|
|
180
|
+
canPublish: Boolean(onPublish),
|
|
181
|
+
publish,
|
|
182
|
+
publishing,
|
|
183
|
+
publishResult,
|
|
184
|
+
clearPublishResult: () => setPublishResult(null)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
// RUNNING a flow: one screen at a time, and the run decides which.
|
|
4
|
+
//
|
|
5
|
+
// The browser holds no route through the journey. It asks the run what to
|
|
6
|
+
// show, renders that screen, and when the person is finished with it sends
|
|
7
|
+
// the values back — the engine tests the arrows leaving that step and answers
|
|
8
|
+
// with the next screen, or that the journey is done.
|
|
9
|
+
//
|
|
10
|
+
// The record itself is saved the ordinary way, by the screen, into its own
|
|
11
|
+
// table. What travels with the run is only what the arrows need to decide,
|
|
12
|
+
// plus the id of the row that was written.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param flowKey which flow to run (starting one, or picking up a run)
|
|
16
|
+
* @param runId an existing run to continue — omit to start one
|
|
17
|
+
* @param startRun async (flowKey) → { runId, status, stepKey, screen }
|
|
18
|
+
* @param loadRun async (runId) → { runId, status, stepKey, screen, values }
|
|
19
|
+
* @param submitRun async (runId, values, recordId) → { status, stepKey, screen }
|
|
20
|
+
* @param loadScreen async (key) → the screen document
|
|
21
|
+
* @param onStep (step) → void — told each time the run moves, for the address bar
|
|
22
|
+
* @param onFinish (run) → void — the journey is over
|
|
23
|
+
*/
|
|
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 })
|
|
26
|
+
const [sending, setSending] = useState(false)
|
|
27
|
+
const live = useRef({})
|
|
28
|
+
live.current = { startRun, loadRun, submitRun, loadScreen, onStep, onFinish, flowKey }
|
|
29
|
+
|
|
30
|
+
/** A step the run reports → the screen to draw. */
|
|
31
|
+
const open = useCallback(async (run) => {
|
|
32
|
+
if (!run) throw new Error('The run said nothing about what to show')
|
|
33
|
+
if (run.status === 'done' || !run.screen) {
|
|
34
|
+
setState((s) => ({ ...s, loading: false, error: null, runId: run.runId || s.runId, status: 'done', stepKey: null, screen: null, document: null }))
|
|
35
|
+
if (live.current.onFinish) live.current.onFinish(run)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
if (!live.current.loadScreen) throw new Error('No loadScreen was provided, so the flow cannot show "' + run.screen + '"')
|
|
39
|
+
const doc = await live.current.loadScreen(run.screen)
|
|
40
|
+
setState({
|
|
41
|
+
loading: false, error: null,
|
|
42
|
+
runId: run.runId, status: run.status || 'running', stepKey: run.stepKey, screen: run.screen,
|
|
43
|
+
document: doc, values: run.values || null
|
|
44
|
+
})
|
|
45
|
+
if (live.current.onStep) live.current.onStep(run)
|
|
46
|
+
}, [])
|
|
47
|
+
|
|
48
|
+
// Start, or pick up where a run got to.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
let cancelled = false
|
|
51
|
+
setState((s) => ({ ...s, loading: true, error: null }))
|
|
52
|
+
const begin = async () => {
|
|
53
|
+
if (runId) {
|
|
54
|
+
if (!live.current.loadRun) throw new Error('No loadRun was provided')
|
|
55
|
+
return live.current.loadRun(runId)
|
|
56
|
+
}
|
|
57
|
+
if (!live.current.startRun) throw new Error('No startRun was provided')
|
|
58
|
+
return live.current.startRun(live.current.flowKey)
|
|
59
|
+
}
|
|
60
|
+
begin()
|
|
61
|
+
.then((run) => { if (!cancelled) return open(run) })
|
|
62
|
+
.catch((err) => { if (!cancelled) setState((s) => ({ ...s, loading: false, error: err.message || 'The flow could not be opened' })) })
|
|
63
|
+
return () => { cancelled = true }
|
|
64
|
+
}, [flowKey, runId, open])
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Done with this screen. The values go to the run, which decides what comes
|
|
68
|
+
* next; `recordId` is the row the screen just saved, so later steps and
|
|
69
|
+
* their arrows can refer to it.
|
|
70
|
+
*/
|
|
71
|
+
const submit = useCallback(async (values, recordId) => {
|
|
72
|
+
if (!live.current.submitRun) throw new Error('No submitRun was provided')
|
|
73
|
+
setSending(true)
|
|
74
|
+
try {
|
|
75
|
+
const next = await live.current.submitRun(state.runId, values || {}, recordId)
|
|
76
|
+
await open({ runId: state.runId, ...next })
|
|
77
|
+
} catch (err) {
|
|
78
|
+
setState((s) => ({ ...s, error: err.message || 'That could not be sent' }))
|
|
79
|
+
throw err
|
|
80
|
+
} finally {
|
|
81
|
+
setSending(false)
|
|
82
|
+
}
|
|
83
|
+
}, [state.runId, open])
|
|
84
|
+
|
|
85
|
+
return { ...state, sending, submit }
|
|
86
|
+
}
|