@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/flow.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// A FLOW — screens, one after another, and what decides which comes next.
|
|
2
|
+
//
|
|
3
|
+
// A screen is a form; a flow is the journey across several of them. Each step
|
|
4
|
+
// shows one screen, and the arrows leaving it say where to go once it is
|
|
5
|
+
// filled in: the first arrow whose test passes, and failing all of them, the
|
|
6
|
+
// one with no test — "otherwise". A step with no arrows ends the journey.
|
|
7
|
+
//
|
|
8
|
+
// Pure data, like controls.js: no React, no requests. The designer edits this
|
|
9
|
+
// shape, the server is handed the same shape, and both sides check it with
|
|
10
|
+
// validateFlow().
|
|
11
|
+
//
|
|
12
|
+
// { kind: 'xeplr-flow', version: 1, key: 'employee_registration',
|
|
13
|
+
// name: 'Employee registration',
|
|
14
|
+
// steps: [
|
|
15
|
+
// { stepKey: 'details', screen: 'employee_edit', label: 'Details',
|
|
16
|
+
// layout: { x: 40, y: 40 },
|
|
17
|
+
// transitions: [
|
|
18
|
+
// { when: { field: 'type', op: '=', value: 'contractor' }, target: 'contract' },
|
|
19
|
+
// { when: null, target: 'payroll' }
|
|
20
|
+
// ] },
|
|
21
|
+
// …
|
|
22
|
+
// ] }
|
|
23
|
+
//
|
|
24
|
+
// WHO RUNS IT: the workflow engine, not the browser. A run remembers where it
|
|
25
|
+
// got to, so a journey can be left and picked up later — by someone else, on
|
|
26
|
+
// another day. The browser only shows the screen it is told to and sends back
|
|
27
|
+
// what was typed.
|
|
28
|
+
|
|
29
|
+
export const FLOW_KIND = 'xeplr-flow'
|
|
30
|
+
export const FLOW_VERSION = 1
|
|
31
|
+
|
|
32
|
+
/** Where a journey can end, instead of another step — the engine's own word for a journey that finished. */
|
|
33
|
+
export const FLOW_END = 'end_success'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The tests an arrow can carry, spelled the way the workflow engine accepts
|
|
37
|
+
* them. One comparison per arrow; more than one is more steps.
|
|
38
|
+
*/
|
|
39
|
+
export const FLOW_OPERATORS = [
|
|
40
|
+
{ op: '=', label: 'is' },
|
|
41
|
+
{ op: '!=', label: 'is not' },
|
|
42
|
+
{ op: '>', label: 'is more than' },
|
|
43
|
+
{ op: '>=', label: 'is at least' },
|
|
44
|
+
{ op: '<', label: 'is less than' },
|
|
45
|
+
{ op: '<=', label: 'is at most' },
|
|
46
|
+
{ op: 'contains', label: 'contains' },
|
|
47
|
+
{ op: 'notContains', label: 'does not contain' },
|
|
48
|
+
{ op: 'startsWith', label: 'starts with' },
|
|
49
|
+
{ op: 'endsWith', label: 'ends with' },
|
|
50
|
+
{ op: 'isEmpty', label: 'is empty' },
|
|
51
|
+
{ op: 'isNotEmpty', label: 'is not empty' }
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
/** The comparisons that need no value typed beside them. */
|
|
55
|
+
export const VALUELESS_OPERATORS = ['isEmpty', 'isNotEmpty']
|
|
56
|
+
|
|
57
|
+
const KEY = /^[A-Za-z][A-Za-z0-9_]*$/
|
|
58
|
+
const MAX_KEY = 63
|
|
59
|
+
|
|
60
|
+
/** An empty flow, ready to have screens dropped on it. */
|
|
61
|
+
export function createFlow({ key, name } = {}) {
|
|
62
|
+
const flowName = name || 'New flow'
|
|
63
|
+
return {
|
|
64
|
+
kind: FLOW_KIND,
|
|
65
|
+
version: FLOW_VERSION,
|
|
66
|
+
key: key || flowKey(flowName),
|
|
67
|
+
name: flowName,
|
|
68
|
+
steps: []
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** "Employee registration" → "employee_registration". */
|
|
73
|
+
export function flowKey(name) {
|
|
74
|
+
const out = String(name || '')
|
|
75
|
+
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
|
|
76
|
+
.replace(/[^A-Za-z0-9]+/g, '_')
|
|
77
|
+
.replace(/^_+|_+$/g, '')
|
|
78
|
+
.toLowerCase()
|
|
79
|
+
.slice(0, MAX_KEY)
|
|
80
|
+
return KEY.test(out) ? out : 'flow_' + out.replace(/^[^A-Za-z]+/, '')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Adds a step showing `screen`, at `at` (canvas pixels). Returns { flow, step }. */
|
|
84
|
+
export function addStep(flow, screen, { at, label, stepKey } = {}) {
|
|
85
|
+
const base = stepKey || uniqueStepKey(flow, flowKey(screen))
|
|
86
|
+
const step = {
|
|
87
|
+
stepKey: base,
|
|
88
|
+
screen,
|
|
89
|
+
label: label || screen,
|
|
90
|
+
layout: { x: Math.round((at && at.x) || 40), y: Math.round((at && at.y) || 40) },
|
|
91
|
+
transitions: []
|
|
92
|
+
}
|
|
93
|
+
const steps = [...flow.steps, step]
|
|
94
|
+
// The step before it, if it ended the journey, now leads here: dropping a
|
|
95
|
+
// second screen on an empty canvas means "and then this one".
|
|
96
|
+
const previous = flow.steps[flow.steps.length - 1]
|
|
97
|
+
if (previous && !previous.transitions.length) {
|
|
98
|
+
return { flow: { ...flow, steps: steps.map((s) => (s.stepKey === previous.stepKey ? { ...s, transitions: [{ when: null, target: step.stepKey }] } : s)) }, step }
|
|
99
|
+
}
|
|
100
|
+
return { flow: { ...flow, steps }, step }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A step key nothing else in the flow is using. */
|
|
104
|
+
export function uniqueStepKey(flow, base) {
|
|
105
|
+
const taken = new Set(flow.steps.map((s) => s.stepKey))
|
|
106
|
+
const stem = KEY.test(base) ? base : 'step'
|
|
107
|
+
if (!taken.has(stem)) return stem
|
|
108
|
+
let n = 2
|
|
109
|
+
while (taken.has(`${stem}_${n}`)) n++
|
|
110
|
+
return `${stem}_${n}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Moves a step on the canvas. */
|
|
114
|
+
export function moveStep(flow, stepKey, at) {
|
|
115
|
+
return mapStep(flow, stepKey, (s) => ({ ...s, layout: { x: Math.round(at.x), y: Math.round(at.y) } }))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Changes one thing about a step — its screen, its label. */
|
|
119
|
+
export function setStep(flow, stepKey, patch) {
|
|
120
|
+
return mapStep(flow, stepKey, (s) => ({ ...s, ...patch }))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Removes a step, and every arrow pointing at it. */
|
|
124
|
+
export function removeStep(flow, stepKey) {
|
|
125
|
+
return {
|
|
126
|
+
...flow,
|
|
127
|
+
steps: flow.steps
|
|
128
|
+
.filter((s) => s.stepKey !== stepKey)
|
|
129
|
+
.map((s) => ({ ...s, transitions: s.transitions.filter((t) => t.target !== stepKey) }))
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Draws an arrow from one step to another (or to the end). A step's LAST
|
|
135
|
+
* arrow is the one with no test — "otherwise" — so a new arrow goes in front
|
|
136
|
+
* of it and the flow always has somewhere to go.
|
|
137
|
+
*/
|
|
138
|
+
export function addTransition(flow, fromKey, target, when = null) {
|
|
139
|
+
return mapStep(flow, fromKey, (s) => {
|
|
140
|
+
const rest = s.transitions.filter((t) => t.when !== null)
|
|
141
|
+
const otherwise = s.transitions.find((t) => t.when === null)
|
|
142
|
+
const next = when === null
|
|
143
|
+
? [...rest, { when: null, target }]
|
|
144
|
+
: [...rest, { when, target }, ...(otherwise ? [otherwise] : [])]
|
|
145
|
+
return { ...s, transitions: next }
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Changes one arrow of a step, by its position. */
|
|
150
|
+
export function setTransition(flow, fromKey, index, patch) {
|
|
151
|
+
return mapStep(flow, fromKey, (s) => ({
|
|
152
|
+
...s,
|
|
153
|
+
transitions: s.transitions.map((t, i) => (i === index ? { ...t, ...patch } : t))
|
|
154
|
+
}))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Removes one arrow of a step, by its position. */
|
|
158
|
+
export function removeTransition(flow, fromKey, index) {
|
|
159
|
+
return mapStep(flow, fromKey, (s) => ({ ...s, transitions: s.transitions.filter((_, i) => i !== index) }))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The step a journey starts at: the first one that nothing else points to, else the first. */
|
|
163
|
+
export function firstStep(flow) {
|
|
164
|
+
if (!flow.steps.length) return null
|
|
165
|
+
const pointedAt = new Set(flow.steps.flatMap((s) => s.transitions.map((t) => t.target)))
|
|
166
|
+
return flow.steps.find((s) => !pointedAt.has(s.stepKey)) || flow.steps[0]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Every step that can be reached from the first one. */
|
|
170
|
+
export function reachableSteps(flow) {
|
|
171
|
+
const start = firstStep(flow)
|
|
172
|
+
if (!start) return new Set()
|
|
173
|
+
const byKey = new Map(flow.steps.map((s) => [s.stepKey, s]))
|
|
174
|
+
const seen = new Set()
|
|
175
|
+
const walk = (key) => {
|
|
176
|
+
if (!key || key === FLOW_END || seen.has(key)) return
|
|
177
|
+
const step = byKey.get(key)
|
|
178
|
+
if (!step) return
|
|
179
|
+
seen.add(key)
|
|
180
|
+
step.transitions.forEach((t) => walk(t.target))
|
|
181
|
+
}
|
|
182
|
+
walk(start.stepKey)
|
|
183
|
+
return seen
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Lays the steps out as the journey reads: the first on the left, each step
|
|
188
|
+
* one column right of the step that leads to it, and the branches of one step
|
|
189
|
+
* stacked in the same column — so an arrow runs forward and never crosses a
|
|
190
|
+
* card to reach the one it means.
|
|
191
|
+
* @param size { w, h, gapX, gapY, left, top } in pixels
|
|
192
|
+
*/
|
|
193
|
+
export function layoutFlow(flow, size = {}) {
|
|
194
|
+
const { w = 200, h = 86, gapX = 110, gapY = 50, left = 60, top = 60 } = size
|
|
195
|
+
const start = firstStep(flow)
|
|
196
|
+
if (!start) return flow
|
|
197
|
+
const byKey = new Map(flow.steps.map((s) => [s.stepKey, s]))
|
|
198
|
+
const depth = new Map([[start.stepKey, 0]])
|
|
199
|
+
const queue = [start.stepKey]
|
|
200
|
+
while (queue.length) {
|
|
201
|
+
const key = queue.shift()
|
|
202
|
+
;(byKey.get(key).transitions || []).forEach((t) => {
|
|
203
|
+
if (!byKey.has(t.target) || depth.has(t.target)) return
|
|
204
|
+
depth.set(t.target, depth.get(key) + 1)
|
|
205
|
+
queue.push(t.target)
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
// Steps nothing reaches go in a column of their own at the end, where they
|
|
209
|
+
// are easy to see and to connect.
|
|
210
|
+
const deepest = Math.max(0, ...depth.values())
|
|
211
|
+
flow.steps.forEach((s) => { if (!depth.has(s.stepKey)) depth.set(s.stepKey, deepest + 1) })
|
|
212
|
+
const rows = new Map()
|
|
213
|
+
return {
|
|
214
|
+
...flow,
|
|
215
|
+
steps: flow.steps.map((s) => {
|
|
216
|
+
const d = depth.get(s.stepKey)
|
|
217
|
+
const row = rows.get(d) || 0
|
|
218
|
+
rows.set(d, row + 1)
|
|
219
|
+
return { ...s, layout: { x: left + d * (w + gapX), y: top + row * (h + gapY) } }
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* What is wrong with this flow, in the words of the person drawing it.
|
|
226
|
+
* @param options.screens the screen keys that exist, so a step cannot point at one that does not
|
|
227
|
+
* @returns {{ ok, errors: [{ path, message }] }}
|
|
228
|
+
*/
|
|
229
|
+
export function validateFlow(flow, options = {}) {
|
|
230
|
+
const errors = []
|
|
231
|
+
const err = (path, message) => errors.push({ path, message })
|
|
232
|
+
|
|
233
|
+
if (!flow || typeof flow !== 'object' || Array.isArray(flow)) {
|
|
234
|
+
err('', 'A flow must be an object')
|
|
235
|
+
return { ok: false, errors }
|
|
236
|
+
}
|
|
237
|
+
if (flow.kind !== FLOW_KIND) err('kind', `must be "${FLOW_KIND}"`)
|
|
238
|
+
if (flow.version !== FLOW_VERSION) err('version', `must be ${FLOW_VERSION}`)
|
|
239
|
+
if (!KEY.test(String(flow.key || ''))) err('key', 'must start with a letter and contain only letters, digits and _')
|
|
240
|
+
else if (String(flow.key).length > MAX_KEY) err('key', `is longer than ${MAX_KEY} characters`)
|
|
241
|
+
if (!String(flow.name || '').trim()) err('name', 'is required — what this journey is called')
|
|
242
|
+
if (!Array.isArray(flow.steps)) {
|
|
243
|
+
err('steps', 'must be an array of steps')
|
|
244
|
+
return { ok: false, errors }
|
|
245
|
+
}
|
|
246
|
+
if (!flow.steps.length) err('steps', 'a flow needs at least one screen')
|
|
247
|
+
|
|
248
|
+
const keys = new Map()
|
|
249
|
+
const screens = options.screens ? new Set(options.screens) : null
|
|
250
|
+
|
|
251
|
+
flow.steps.forEach((step, i) => {
|
|
252
|
+
const at = `steps[${i}]`
|
|
253
|
+
if (!step || typeof step !== 'object') { err(at, 'must be an object'); return }
|
|
254
|
+
if (!KEY.test(String(step.stepKey || ''))) err(`${at}.stepKey`, 'must start with a letter and contain only letters, digits and _')
|
|
255
|
+
else if (keys.has(step.stepKey)) err(`${at}.stepKey`, `"${step.stepKey}" is already used by steps[${keys.get(step.stepKey)}]`)
|
|
256
|
+
else keys.set(step.stepKey, i)
|
|
257
|
+
if (!String(step.screen || '').trim()) err(`${at}.screen`, 'must name the screen this step shows')
|
|
258
|
+
else if (screens && !screens.has(step.screen)) err(`${at}.screen`, `"${step.screen}" is not a published screen`)
|
|
259
|
+
if (step.layout && (!Number.isFinite(step.layout.x) || !Number.isFinite(step.layout.y))) {
|
|
260
|
+
err(`${at}.layout`, 'must be { x, y } in pixels')
|
|
261
|
+
}
|
|
262
|
+
if (!Array.isArray(step.transitions)) { err(`${at}.transitions`, 'must be an array'); return }
|
|
263
|
+
let otherwiseAt = -1
|
|
264
|
+
step.transitions.forEach((t, j) => {
|
|
265
|
+
const tp = `${at}.transitions[${j}]`
|
|
266
|
+
if (!t || typeof t !== 'object') { err(tp, 'must be { when, target }'); return }
|
|
267
|
+
if (!String(t.target || '').trim()) err(`${tp}.target`, `must name the step this arrow goes to, or "${FLOW_END}"`)
|
|
268
|
+
if (t.when === null || t.when === undefined) {
|
|
269
|
+
if (otherwiseAt !== -1) err(tp, 'a step can have only one "otherwise" — the others need a test')
|
|
270
|
+
otherwiseAt = j
|
|
271
|
+
} else {
|
|
272
|
+
checkWhen(t.when, tp, err)
|
|
273
|
+
if (otherwiseAt !== -1) err(tp, '"otherwise" must be the last arrow, or the ones after it are never tried')
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
// Targets are checked once every step key is known.
|
|
279
|
+
flow.steps.forEach((step, i) => {
|
|
280
|
+
;(step.transitions || []).forEach((t, j) => {
|
|
281
|
+
const target = t && t.target
|
|
282
|
+
if (!target || target === FLOW_END) return
|
|
283
|
+
if (!keys.has(target)) err(`steps[${i}].transitions[${j}].target`, `"${target}" is not a step of this flow`)
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
const reachable = reachableSteps(flow)
|
|
288
|
+
flow.steps.forEach((step, i) => {
|
|
289
|
+
if (step && step.stepKey && !reachable.has(step.stepKey)) {
|
|
290
|
+
err(`steps[${i}]`, `nothing leads to "${step.label || step.stepKey}" — draw an arrow to it, or remove it`)
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
return { ok: errors.length === 0, errors }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function checkWhen(when, at, err) {
|
|
298
|
+
if (typeof when !== 'object' || Array.isArray(when)) { err(at + '.when', 'must be { field, op, value }'); return }
|
|
299
|
+
if (!String(when.field || '').trim()) err(`${at}.when.field`, 'must name a field of the screen this step shows')
|
|
300
|
+
const known = FLOW_OPERATORS.some((o) => o.op === when.op)
|
|
301
|
+
if (!known) err(`${at}.when.op`, `must be one of: ${FLOW_OPERATORS.map((o) => o.op).join(', ')}`)
|
|
302
|
+
if (known && !VALUELESS_OPERATORS.includes(when.op) && (when.value === undefined || when.value === null || when.value === '')) {
|
|
303
|
+
err(`${at}.when.value`, 'needs a value to compare with')
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** A test in words: "Type is contractor". */
|
|
308
|
+
export function describeWhen(when, fieldLabel) {
|
|
309
|
+
if (!when) return 'otherwise'
|
|
310
|
+
const op = FLOW_OPERATORS.find((o) => o.op === when.op)
|
|
311
|
+
const name = fieldLabel || when.field
|
|
312
|
+
if (VALUELESS_OPERATORS.includes(when.op)) return `${name} ${op ? op.label : when.op}`
|
|
313
|
+
return `${name} ${op ? op.label : when.op} ${when.value}`
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function mapStep(flow, stepKey, fn) {
|
|
317
|
+
return { ...flow, steps: flow.steps.map((s) => (s.stepKey === stepKey ? fn(s) : s)) }
|
|
318
|
+
}
|
package/src/flows.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The browser side of FLOWS: designing a chain of screens, and running one.
|
|
2
|
+
//
|
|
3
|
+
// The factory does not run a flow — the app's workflow service does, which is
|
|
4
|
+
// why every call here goes to endpoints the app supplies. A run remembers
|
|
5
|
+
// where it got to, so a journey can be left and picked up later; the browser
|
|
6
|
+
// only asks "what should this person see now" and sends back what was typed.
|
|
7
|
+
//
|
|
8
|
+
// import { authFetch } from '@xeplr/ui-account'
|
|
9
|
+
// const flows = createFlowsApi({ fetch: authFetch, base: '/api' })
|
|
10
|
+
// <FlowRunner {...flows.runnerProps} flowKey="employee_registration" />
|
|
11
|
+
//
|
|
12
|
+
// Answers may come as xeplr's { code, message, dataArray } or as the plain
|
|
13
|
+
// object or list itself — the workflow service speaks the second. Both are read
|
|
14
|
+
// the same way (payload()), so a client never cares which service it is talking to.
|
|
15
|
+
|
|
16
|
+
import { FLOW_KIND, FLOW_VERSION } from './flow.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A flow as the server sends it → the document the designer edits. The server
|
|
20
|
+
* stores steps, not the document's envelope, so the envelope is put back here
|
|
21
|
+
* and a step with no label is named after its screen.
|
|
22
|
+
*/
|
|
23
|
+
export function asFlow(row) {
|
|
24
|
+
if (!row) return row
|
|
25
|
+
return {
|
|
26
|
+
kind: FLOW_KIND,
|
|
27
|
+
version: FLOW_VERSION,
|
|
28
|
+
key: row.key,
|
|
29
|
+
name: row.name,
|
|
30
|
+
status: row.status,
|
|
31
|
+
// A list or a publish answer counts the steps rather than listing them.
|
|
32
|
+
...(Array.isArray(row.steps) ? {} : { stepCount: Number(row.steps) || 0 }),
|
|
33
|
+
steps: (Array.isArray(row.steps) ? row.steps : []).map((s) => ({
|
|
34
|
+
stepKey: s.stepKey,
|
|
35
|
+
screen: s.screen,
|
|
36
|
+
label: s.label || s.screen,
|
|
37
|
+
layout: s.layout || { x: 40, y: 40 },
|
|
38
|
+
transitions: s.transitions || []
|
|
39
|
+
}))
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param options.fetch a fetch that adds auth and tenant headers (authFetch)
|
|
45
|
+
* @param options.base prefix before /flows (default '')
|
|
46
|
+
*/
|
|
47
|
+
export function createFlowsApi({ fetch: doFetch, base = '' } = {}) {
|
|
48
|
+
if (typeof doFetch !== 'function') throw new Error('createFlowsApi: pass { fetch } — usually authFetch from @xeplr/ui-account')
|
|
49
|
+
|
|
50
|
+
async function call(method, path, body) {
|
|
51
|
+
let res
|
|
52
|
+
try {
|
|
53
|
+
res = await doFetch(`${base}/flows${path}`, {
|
|
54
|
+
method,
|
|
55
|
+
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
|
|
56
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
57
|
+
})
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err && err.body !== undefined && err.status) throw failure(method, path, err.status, err.body, err.message)
|
|
60
|
+
throw err
|
|
61
|
+
}
|
|
62
|
+
if (res && typeof res.json === 'function' && typeof res.ok === 'boolean') {
|
|
63
|
+
let json = null
|
|
64
|
+
try { json = await res.json() } catch (_) { /* not JSON — reported below */ }
|
|
65
|
+
if (!res.ok) throw failure(method, path, res.status, json)
|
|
66
|
+
return payload(json)
|
|
67
|
+
}
|
|
68
|
+
return payload(res)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The payload, enveloped or not. */
|
|
72
|
+
function payload(json) {
|
|
73
|
+
if (json === null || json === undefined) return []
|
|
74
|
+
if (typeof json === 'object' && !Array.isArray(json) && Array.isArray(json.dataArray)) return json.dataArray
|
|
75
|
+
return json
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function failure(method, path, status, json, fallback) {
|
|
79
|
+
const err = new Error((json && json.message) || fallback || `${method} ${path} failed (${status})`)
|
|
80
|
+
err.status = status
|
|
81
|
+
err.fields = json && json.error && json.error.fields
|
|
82
|
+
err.code = json && json.code
|
|
83
|
+
return err
|
|
84
|
+
}
|
|
85
|
+
const one = (rows) => (Array.isArray(rows) ? rows[0] : rows)
|
|
86
|
+
const enc = encodeURIComponent
|
|
87
|
+
|
|
88
|
+
const api = {
|
|
89
|
+
// designing
|
|
90
|
+
listFlows: () => call('GET', ''),
|
|
91
|
+
createFlow: async (spec) => asFlow(one(await call('POST', '', spec))),
|
|
92
|
+
loadFlow: async (key) => asFlow(one(await call('GET', `/${enc(key)}`))),
|
|
93
|
+
saveFlow: async (flow) => asFlow(one(await call('PUT', `/${enc(flow.key)}`, {
|
|
94
|
+
name: flow.name,
|
|
95
|
+
steps: flow.steps.map((s) => ({ stepKey: s.stepKey, label: s.label, screen: s.screen, layout: s.layout, transitions: s.transitions }))
|
|
96
|
+
}))),
|
|
97
|
+
// Answers with the flow's status, not its steps — nothing to rebuild from it.
|
|
98
|
+
publishFlow: async (key) => one(await call('POST', `/${enc(key)}/publish`)),
|
|
99
|
+
|
|
100
|
+
// running
|
|
101
|
+
startRun: async (key) => one(await call('POST', `/${enc(key)}/runs`)),
|
|
102
|
+
loadRun: async (runId) => one(await call('GET', `/runs/${enc(runId)}`)),
|
|
103
|
+
/** What the person filled in. Answers with the next screen, or that it is done. */
|
|
104
|
+
submitRun: async (runId, values, recordId) => one(await call('POST', `/runs/${enc(runId)}/submit`, { values, recordId })),
|
|
105
|
+
/** Journeys of this flow that are still going — "in progress". */
|
|
106
|
+
myRuns: (key) => call('GET', `/${enc(key)}/runs?mine=1`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
api.builderProps = { listFlows: api.listFlows, loadFlow: api.loadFlow, onSave: api.saveFlow, onPublish: api.publishFlow, onCreate: api.createFlow }
|
|
110
|
+
api.runnerProps = { startRun: api.startRun, loadRun: api.loadRun, submitRun: api.submitRun, myRuns: api.myRuns }
|
|
111
|
+
return api
|
|
112
|
+
}
|
package/src/hooks.js
CHANGED
|
@@ -42,6 +42,8 @@ export class FactoryHooks {
|
|
|
42
42
|
/**
|
|
43
43
|
* Save — called on every autosave, so keep it quick and safe to repeat.
|
|
44
44
|
* @param values what the form holds
|
|
45
|
+
* @param ctx.steps switch steps off or on as the record decides them —
|
|
46
|
+
* ctx.steps.disable('approval'), ctx.steps.enable()
|
|
45
47
|
* @returns the saved record (its id makes the next save an update)
|
|
46
48
|
*/
|
|
47
49
|
save(values, ctx) {
|
|
@@ -61,14 +63,32 @@ export class FactoryHooks {
|
|
|
61
63
|
* return super.step(ctx)
|
|
62
64
|
* }
|
|
63
65
|
*
|
|
66
|
+
* A screen with branches decides which of them apply from here:
|
|
67
|
+
*
|
|
68
|
+
* step(ctx) {
|
|
69
|
+
* if (ctx.from === 0) {
|
|
70
|
+
* if (ctx.values.kind === 'simple') { ctx.disable(['pricing', 'approval']); return 'summary' }
|
|
71
|
+
* ctx.enable() // every step applies again
|
|
72
|
+
* }
|
|
73
|
+
* return super.step(ctx)
|
|
74
|
+
* }
|
|
75
|
+
*
|
|
64
76
|
* @param ctx.from the step being left, counting from 0
|
|
65
77
|
* @param ctx.to the step asked for
|
|
66
78
|
* @param ctx.direction 'next' | 'back' | 'jump' (a click on the bar)
|
|
67
79
|
* @param ctx.values what the form holds
|
|
68
80
|
* @param ctx.stepper the stepper's node id
|
|
69
|
-
* @
|
|
70
|
-
*
|
|
71
|
-
*
|
|
81
|
+
* @param ctx.go (step) → move there (a key or a number)
|
|
82
|
+
* @param ctx.disable (steps) → switch them off: shown, but passed over by
|
|
83
|
+
* Next and Back and not clickable. A key, a number, or
|
|
84
|
+
* a list of either.
|
|
85
|
+
* @param ctx.enable (steps) → switch them back on; no arguments, all of them
|
|
86
|
+
* @param ctx.steps the same, for a screen with more than one stepper:
|
|
87
|
+
* disable(which, stepperId), active(id), disabled(id)
|
|
88
|
+
* @returns false to stay where you are, a step (its key or its number) to go
|
|
89
|
+
* somewhere else, anything else to move as asked. The step's own
|
|
90
|
+
* required fields are checked before this runs, so this is for your
|
|
91
|
+
* rules, not theirs.
|
|
72
92
|
*/
|
|
73
93
|
step(ctx) {
|
|
74
94
|
return true
|
package/src/index.js
CHANGED
|
@@ -11,12 +11,14 @@ export * from './model.js'
|
|
|
11
11
|
// Controllers
|
|
12
12
|
export { useFactoryBuilder } from './useFactoryBuilder.js'
|
|
13
13
|
export { useFactoryScreen, normaliseOptions, AUTOSAVE_DELAY } from './useFactoryScreen.js'
|
|
14
|
+
export { useFlowRun } from './useFlowRun.js'
|
|
15
|
+
export { useFlowBuilder, FLOW_AUTOSAVE_DELAY } from './useFlowBuilder.js'
|
|
14
16
|
|
|
15
17
|
// Designs
|
|
16
|
-
export { BuilderSample, ScreenSample, ListView, ScreenModal, ControlView, VIEWS, Palette, DRAG_TYPE, PropertyPanel, EDITORS } from './designs/index.js'
|
|
18
|
+
export { BuilderSample, ScreenSample, ListView, ScreenModal, FlowRunnerSample, FlowBuilderSample, ControlView, VIEWS, Palette, DRAG_TYPE, PropertyPanel, EDITORS } from './designs/index.js'
|
|
17
19
|
|
|
18
20
|
// Front-end hooks — extend FactoryHooks, override what you need, call super
|
|
19
21
|
export { FactoryHooks } from './hooks.js'
|
|
20
22
|
|
|
21
23
|
// Ready-made
|
|
22
|
-
export { FactoryBuilder, FactoryScreen } from './pages.jsx'
|
|
24
|
+
export { FactoryBuilder, FactoryScreen, FlowRunner, FlowBuilder } from './pages.jsx'
|
package/src/model.js
CHANGED
|
@@ -8,7 +8,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
10
|
inputNodes, readingOrder, contentBottom, camelName, slugify, uniqueFieldName, nextPosition,
|
|
11
|
-
steppers, stepsOf, stepOf, setNodeStep, nodesForSteps
|
|
11
|
+
steppers, stepsOf, stepOf, setNodeStep, nodesForSteps, resolveStep, reachableStep, nextOpenStep
|
|
12
12
|
} from './document.js'
|
|
13
13
|
export { validateDocument, assertValidDocument } from './validateDocument.js'
|
|
14
14
|
export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, chooseable, saveState, recordValues, displayValue, fileLabel, toDbValue, fromDbValue, listColumns, listSource } from './values.js'
|
|
@@ -21,3 +21,9 @@ export {
|
|
|
21
21
|
columnForField, tableForScreen, columnSql, widening, diffTables, migrationFor, nextMigrationName,
|
|
22
22
|
createStatements, addStatements, alterStatement, dropStatement, columnFromDatabase, planTableChange
|
|
23
23
|
} from './tableSchema.js'
|
|
24
|
+
export { createFlowsApi } from './flows.js'
|
|
25
|
+
export {
|
|
26
|
+
FLOW_KIND, FLOW_VERSION, FLOW_END, FLOW_OPERATORS, VALUELESS_OPERATORS,
|
|
27
|
+
createFlow, flowKey, addStep, uniqueStepKey, moveStep, setStep, removeStep,
|
|
28
|
+
addTransition, setTransition, removeTransition, firstStep, reachableSteps, layoutFlow, validateFlow, describeWhen
|
|
29
|
+
} from './flow.js'
|
package/src/pages.jsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useFactoryBuilder } from './useFactoryBuilder.js'
|
|
2
2
|
import { useFactoryScreen } from './useFactoryScreen.js'
|
|
3
|
-
import {
|
|
3
|
+
import { useFlowRun } from './useFlowRun.js'
|
|
4
|
+
import { useFlowBuilder } from './useFlowBuilder.js'
|
|
5
|
+
import { BuilderSample, ScreenSample, ScreenModal, FlowRunnerSample, FlowBuilderSample } from './designs/index.js'
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* The builder, ready-made. Saves itself — there is no Save button.
|
|
@@ -94,3 +96,66 @@ export function FactoryScreen(props) {
|
|
|
94
96
|
/>
|
|
95
97
|
)
|
|
96
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A FLOW, running: one screen at a time, and the run decides which comes next.
|
|
102
|
+
*
|
|
103
|
+
* The journey is not in this page. It asks the run what to show, renders that
|
|
104
|
+
* screen — which saves itself into its own table, as anywhere else — and hands
|
|
105
|
+
* back what was filled in; the arrows leaving that step decide the rest.
|
|
106
|
+
*
|
|
107
|
+
* @prop flowKey the flow to start
|
|
108
|
+
* @prop runId a run to pick up instead of starting one
|
|
109
|
+
* @prop startRun async (flowKey) → { runId, status, stepKey, screen }
|
|
110
|
+
* @prop loadRun async (runId) → { runId, status, stepKey, screen, values }
|
|
111
|
+
* @prop submitRun async (runId, values, recordId) → { status, stepKey, screen }
|
|
112
|
+
* @prop onStep (step) → void — each move, for the address bar
|
|
113
|
+
* @prop onFinish (run) → void — the journey is over
|
|
114
|
+
* @prop renderDone what to show at the end (default: a plain "All done")
|
|
115
|
+
* Everything a screen needs — loadScreen, onSave, fetchOptions, uploadFile… —
|
|
116
|
+
* is passed straight through, so `{...factory.screenProps}` covers it.
|
|
117
|
+
*/
|
|
118
|
+
export function FlowRunner(props) {
|
|
119
|
+
const run = useFlowRun(props)
|
|
120
|
+
const screen = useFactoryScreen({
|
|
121
|
+
...props,
|
|
122
|
+
document: run.document || EMPTY_SCREEN,
|
|
123
|
+
record: run.values || undefined
|
|
124
|
+
})
|
|
125
|
+
return (
|
|
126
|
+
<FlowRunnerSample
|
|
127
|
+
run={run}
|
|
128
|
+
screen={screen}
|
|
129
|
+
title={props.title}
|
|
130
|
+
renderDone={props.renderDone}
|
|
131
|
+
className={props.className}
|
|
132
|
+
style={props.style}
|
|
133
|
+
/>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// A screen the runner can hold before the run has said what to show. Valid, so
|
|
138
|
+
// the controller has nothing to complain about, and never drawn.
|
|
139
|
+
const EMPTY_SCREEN = {
|
|
140
|
+
kind: 'xeplr-screen', version: 1, id: 'flow_waiting', name: 'Waiting',
|
|
141
|
+
units: 'fraction', aspect: 1, width: 800, nodes: []
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The FLOW designer, ready-made: screens on a canvas, arrows between them.
|
|
146
|
+
*
|
|
147
|
+
* Saves itself once what is drawn makes sense; Publish is deliberate, because
|
|
148
|
+
* a published flow is what people walk through.
|
|
149
|
+
*
|
|
150
|
+
* @prop flow the flow to edit (omit for a new one)
|
|
151
|
+
* @prop name name for a new flow
|
|
152
|
+
* @prop screens [{ id, name }] — the published screens a step can show
|
|
153
|
+
* @prop loadScreen async (id) → the screen document, so an arrow can offer its fields
|
|
154
|
+
* @prop onSave async (flow) → void
|
|
155
|
+
* @prop onPublish async (key) → void
|
|
156
|
+
* @prop onChange (flow) → void
|
|
157
|
+
*/
|
|
158
|
+
export function FlowBuilder(props) {
|
|
159
|
+
const ctrl = useFlowBuilder(props)
|
|
160
|
+
return <FlowBuilderSample ctrl={ctrl} className={props.className} style={props.style} />
|
|
161
|
+
}
|