@xeplr/ui-factory 1.0.12 → 1.0.14
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 +49 -0
- package/package.json +1 -1
- package/src/designs/FlowBuilderSample.jsx +274 -0
- package/src/designs/FlowRunnerSample.jsx +69 -0
- package/src/designs/ScreenSample.jsx +17 -4
- package/src/designs/factory.css +49 -1
- package/src/designs/index.js +2 -0
- package/src/flow.js +318 -0
- package/src/flows.js +112 -0
- package/src/index.js +4 -2
- package/src/model.js +6 -0
- package/src/pages.jsx +66 -1
- package/src/useFlowBuilder.js +186 -0
- package/src/useFlowRun.js +86 -0
package/README.md
CHANGED
|
@@ -178,6 +178,55 @@ import schemaHandler from '@xeplr/schema-handler' // CommonJS: default
|
|
|
178
178
|
const clean = schemaHandler.applySchema(formSchema(screen), req.body) // throws ValidationError listing each field
|
|
179
179
|
```
|
|
180
180
|
|
|
181
|
+
## Flows — screens, one after another
|
|
182
|
+
|
|
183
|
+
A screen is a form; a **flow** is the journey across several of them. Design it
|
|
184
|
+
the same way you design a form — screens on a canvas, arrows between them:
|
|
185
|
+
|
|
186
|
+
```jsx
|
|
187
|
+
import { FlowBuilder, FlowRunner, createFlowsApi } from '@xeplr/ui-factory'
|
|
188
|
+
const flows = createFlowsApi({ fetch: authFetch, base: '/api' })
|
|
189
|
+
|
|
190
|
+
<FlowBuilder {...flows.builderProps} flow={flow} screens={published} loadScreen={factory.loadScreen} />
|
|
191
|
+
<FlowRunner {...flows.runnerProps} {...factory.screenProps} flowKey="employee_registration" runId={runId} />
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
An arrow is a question asked once its screen is filled in — the first one that
|
|
195
|
+
fits wins, and the one with no test ("otherwise") catches the rest:
|
|
196
|
+
|
|
197
|
+
```json
|
|
198
|
+
{ "stepKey": "details", "screen": "employee_edit",
|
|
199
|
+
"transitions": [
|
|
200
|
+
{ "when": { "field": "type", "op": "=", "value": "contractor" }, "target": "contract" },
|
|
201
|
+
{ "when": null, "target": "payroll" }
|
|
202
|
+
] }
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**The browser does not run the journey.** `createFlowsApi` talks to a service
|
|
206
|
+
that does — Xeplr Workflow — so a run remembers where it got to: a journey can
|
|
207
|
+
be left and picked up later, by someone else, on another day. Without such a
|
|
208
|
+
service there are no flows; the factory has no engine of its own, on purpose.
|
|
209
|
+
|
|
210
|
+
**Each screen still saves itself, into its own table.** What travels with the
|
|
211
|
+
run is only what the arrows need and the id of the row that was written
|
|
212
|
+
(`recordId`), so the data is exactly where it would be without a flow.
|
|
213
|
+
|
|
214
|
+
`validateFlow(flow, { screens })` is the same check on both sides: a screen
|
|
215
|
+
that exists, arrows that point somewhere, one "otherwise" at the end, and
|
|
216
|
+
nothing unreachable.
|
|
217
|
+
|
|
218
|
+
### A flow and a stepper
|
|
219
|
+
|
|
220
|
+
Both walk someone through something, and they answer different questions:
|
|
221
|
+
|
|
222
|
+
| | stepper | flow |
|
|
223
|
+
|---|---|---|
|
|
224
|
+
| where | steps of ONE screen | several screens |
|
|
225
|
+
| when | one sitting | over days, if need be |
|
|
226
|
+
| who | one person | can change hands |
|
|
227
|
+
| decides | a `step` hook, in the browser | the engine, on the server |
|
|
228
|
+
| needs | nothing | a workflow service |
|
|
229
|
+
|
|
181
230
|
## Controls
|
|
182
231
|
|
|
183
232
|
| type | value | validation |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/ui-factory",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.14",
|
|
4
4
|
"description": "Design data-entry screens on @xeplr/ui-canvas, save them as JSON, run them as auto-saving forms with lists of saved records — built for Claude to draft and people to refine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { XeplrCanvas } from '@xeplr/ui-canvas'
|
|
2
|
+
import { FLOW_END, FLOW_OPERATORS, VALUELESS_OPERATORS, describeWhen } from '../flow.js'
|
|
3
|
+
|
|
4
|
+
// THE FLOW DESIGNER — screens on a canvas, arrows between them.
|
|
5
|
+
//
|
|
6
|
+
// A box is a screen. An arrow leaving it is a question asked once the screen
|
|
7
|
+
// is filled in: the first arrow whose test passes wins, and the one with no
|
|
8
|
+
// test — "otherwise" — catches the rest. That is the whole language, and it is
|
|
9
|
+
// the same one the engine runs.
|
|
10
|
+
//
|
|
11
|
+
// Presentation only: everything comes from useFlowBuilder.
|
|
12
|
+
|
|
13
|
+
const STEP_W = 200
|
|
14
|
+
const STEP_H = 86
|
|
15
|
+
|
|
16
|
+
const STATUS = {
|
|
17
|
+
saved: 'All changes saved',
|
|
18
|
+
pending: 'Unsaved changes…',
|
|
19
|
+
saving: 'Saving…',
|
|
20
|
+
invalid: 'Not saved — finish the flow first',
|
|
21
|
+
error: 'Not saved'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function FlowBuilderSample({ ctrl, className, style }) {
|
|
25
|
+
const { flow } = ctrl
|
|
26
|
+
const items = flow.steps.map((s) => ({
|
|
27
|
+
id: s.stepKey,
|
|
28
|
+
x: (s.layout && s.layout.x) || 40,
|
|
29
|
+
y: (s.layout && s.layout.y) || 40,
|
|
30
|
+
w: STEP_W,
|
|
31
|
+
h: STEP_H
|
|
32
|
+
}))
|
|
33
|
+
// One arrow per transition. A step that ends the journey gets a stub to a
|
|
34
|
+
// "Finished" pill, the way the workflow editor draws its ends.
|
|
35
|
+
const edges = flow.steps.flatMap((s) => s.transitions.map((t, i) => ({
|
|
36
|
+
id: `${s.stepKey}-${i}`,
|
|
37
|
+
from: s.stepKey,
|
|
38
|
+
...(t.target === FLOW_END || !flow.steps.some((x) => x.stepKey === t.target)
|
|
39
|
+
? { toOffset: { x: 90, y: 0 }, done: true }
|
|
40
|
+
: { to: t.target }),
|
|
41
|
+
variant: t.when ? undefined : 'dashed',
|
|
42
|
+
label: describeWhen(t.when, labelOf(ctrl, s.stepKey, t.when))
|
|
43
|
+
})))
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className={'xeplr-factory-flow-builder' + (className ? ' ' + className : '')} style={style}>
|
|
47
|
+
<header className="xeplr-factory-bar">
|
|
48
|
+
<input
|
|
49
|
+
className="xeplr-factory-name"
|
|
50
|
+
value={flow.name}
|
|
51
|
+
onChange={(e) => ctrl.rename(e.target.value)}
|
|
52
|
+
placeholder="What is this journey called?"
|
|
53
|
+
aria-label="Flow name"
|
|
54
|
+
/>
|
|
55
|
+
<code className="xeplr-factory-flow-key" title="The key this flow is known by — it never changes">{flow.key}</code>
|
|
56
|
+
<div className={`xeplr-factory-bar-status xeplr-factory-bar-status--${ctrl.status}`} role="status" aria-live="polite">
|
|
57
|
+
{ctrl.status === 'error' ? `Not saved — ${ctrl.saveError}` : STATUS[ctrl.status]}
|
|
58
|
+
</div>
|
|
59
|
+
{ctrl.validation.errors.length > 0 && (
|
|
60
|
+
<span className="xeplr-factory-problems" title={ctrl.validation.errors.map((x) => `${x.path}: ${x.message}`).join('\n')}>
|
|
61
|
+
{ctrl.validation.errors.length} problem{ctrl.validation.errors.length === 1 ? '' : 's'}
|
|
62
|
+
</span>
|
|
63
|
+
)}
|
|
64
|
+
{flow.steps.length > 1 && (
|
|
65
|
+
<button type="button" className="xeplr-factory-secondary" onClick={ctrl.tidy} title="Lay the screens out as the journey reads">Tidy</button>
|
|
66
|
+
)}
|
|
67
|
+
{ctrl.canPublish && (
|
|
68
|
+
<button type="button" className="xeplr-factory-primary" onClick={ctrl.publish} disabled={ctrl.publishing || !ctrl.validation.ok}>
|
|
69
|
+
{ctrl.publishing ? 'Publishing…' : 'Publish'}
|
|
70
|
+
</button>
|
|
71
|
+
)}
|
|
72
|
+
</header>
|
|
73
|
+
|
|
74
|
+
{ctrl.publishResult && (
|
|
75
|
+
<div className={`xeplr-factory-publish${ctrl.publishResult.ok ? ' is-ok' : ' is-error'}`} role="status">
|
|
76
|
+
<span>{ctrl.publishResult.message}</span>
|
|
77
|
+
<button type="button" className="xeplr-factory-icon-button" aria-label="Dismiss" onClick={ctrl.clearPublishResult}>×</button>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
<div className="xeplr-factory-body">
|
|
82
|
+
<aside className="xeplr-factory-palette" aria-label="Screens">
|
|
83
|
+
<div className="xeplr-factory-palette-group">
|
|
84
|
+
<h3 className="xeplr-factory-palette-title">Screens</h3>
|
|
85
|
+
{ctrl.screens.length === 0 && <p className="xeplr-factory-hint">No published forms yet.</p>}
|
|
86
|
+
{ctrl.screens.map((s) => (
|
|
87
|
+
<button
|
|
88
|
+
key={s.id}
|
|
89
|
+
type="button"
|
|
90
|
+
className="xeplr-factory-palette-item"
|
|
91
|
+
onClick={() => ctrl.addStep(s.id, nextPlace(flow))}
|
|
92
|
+
title={`Add ${s.name || s.id} to the journey`}
|
|
93
|
+
>
|
|
94
|
+
{s.name || s.id}
|
|
95
|
+
</button>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
</aside>
|
|
99
|
+
|
|
100
|
+
<div className="xeplr-factory-stage">
|
|
101
|
+
<XeplrCanvas
|
|
102
|
+
className="xeplr-factory-flow-canvas"
|
|
103
|
+
items={items}
|
|
104
|
+
edges={edges}
|
|
105
|
+
features={{ drag: true, snap: true }}
|
|
106
|
+
selection={ctrl.selected ? [ctrl.selected] : []}
|
|
107
|
+
onSelectionChange={(ids) => ctrl.setSelected([...ids][0] || null)}
|
|
108
|
+
onItemChange={(id, patch) => ctrl.moveStep(id, patch)}
|
|
109
|
+
onItemClick={(id) => ctrl.setSelected(id)}
|
|
110
|
+
renderItem={(item) => {
|
|
111
|
+
const step = flow.steps.find((s) => s.stepKey === item.id)
|
|
112
|
+
if (!step) return null
|
|
113
|
+
const problems = ctrl.errorsByStep[step.stepKey] || []
|
|
114
|
+
return (
|
|
115
|
+
<div className={`xeplr-factory-step-card${ctrl.selected === step.stepKey ? ' is-selected' : ''}${problems.length ? ' has-error' : ''}`}>
|
|
116
|
+
<span className="xeplr-factory-step-card-name">{step.label || step.stepKey}</span>
|
|
117
|
+
<span className="xeplr-factory-step-card-screen">{screenName(ctrl, step.screen)}</span>
|
|
118
|
+
{ctrl.first && ctrl.first.stepKey === step.stepKey && <span className="xeplr-factory-step-card-first">starts here</span>}
|
|
119
|
+
</div>
|
|
120
|
+
)
|
|
121
|
+
}}
|
|
122
|
+
renderEdgeLabel={(edge, { mid }) => (
|
|
123
|
+
<span className="xeplr-factory-edge-label" style={{ position: 'absolute', left: mid.x, top: mid.y, transform: 'translate(-50%, -50%)' }}>
|
|
124
|
+
{edge.done ? 'Finished' : edge.label}
|
|
125
|
+
</span>
|
|
126
|
+
)}
|
|
127
|
+
underlay={flow.steps.length === 0 && (
|
|
128
|
+
<div className="xeplr-factory-empty">Add a screen from the left to start the journey</div>
|
|
129
|
+
)}
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<StepPanel ctrl={ctrl} />
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** What a step does, and where it goes next. */
|
|
140
|
+
function StepPanel({ ctrl }) {
|
|
141
|
+
const step = ctrl.selectedStep
|
|
142
|
+
if (!step) {
|
|
143
|
+
return (
|
|
144
|
+
<aside className="xeplr-factory-panel" aria-label="Step">
|
|
145
|
+
<p className="xeplr-factory-panel-empty">
|
|
146
|
+
Pick a screen on the canvas to say what happens after it.
|
|
147
|
+
<br /><br />
|
|
148
|
+
Each arrow is a question asked once that screen is filled in — the first one that fits wins, and “otherwise” catches the rest.
|
|
149
|
+
</p>
|
|
150
|
+
</aside>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
const fields = ctrl.fieldsFor(step.stepKey)
|
|
154
|
+
const others = ctrl.flow.steps.filter((s) => s.stepKey !== step.stepKey)
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<aside className="xeplr-factory-panel" aria-label="Step">
|
|
158
|
+
<div className="xeplr-factory-panel-head">
|
|
159
|
+
<span className="xeplr-factory-panel-type">{step.label || step.stepKey}</span>
|
|
160
|
+
<button type="button" className="xeplr-factory-danger-link" onClick={() => ctrl.removeStep(step.stepKey)}>Remove</button>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<fieldset className="xeplr-factory-group">
|
|
164
|
+
<legend>Step</legend>
|
|
165
|
+
<div className="xeplr-factory-prop">
|
|
166
|
+
<label className="xeplr-factory-prop-label" htmlFor="xf-step-label">Name</label>
|
|
167
|
+
<input id="xf-step-label" className="xeplr-factory-prop-input" value={step.label || ''} onChange={(e) => ctrl.setStep(step.stepKey, { label: e.target.value })} />
|
|
168
|
+
</div>
|
|
169
|
+
<div className="xeplr-factory-prop">
|
|
170
|
+
<label className="xeplr-factory-prop-label" htmlFor="xf-step-screen">Shows</label>
|
|
171
|
+
<select id="xf-step-screen" className="xeplr-factory-prop-input" value={step.screen} onChange={(e) => ctrl.setStep(step.stepKey, { screen: e.target.value })}>
|
|
172
|
+
{ctrl.screens.map((s) => <option key={s.id} value={s.id}>{s.name || s.id}</option>)}
|
|
173
|
+
</select>
|
|
174
|
+
</div>
|
|
175
|
+
</fieldset>
|
|
176
|
+
|
|
177
|
+
<fieldset className="xeplr-factory-group">
|
|
178
|
+
<legend>Then</legend>
|
|
179
|
+
{step.transitions.length === 0 && <p className="xeplr-factory-hint">The journey ends here.</p>}
|
|
180
|
+
{step.transitions.map((t, i) => (
|
|
181
|
+
<div key={i} className="xeplr-factory-arrow">
|
|
182
|
+
{t.when === null ? (
|
|
183
|
+
<span className="xeplr-factory-arrow-when">Otherwise</span>
|
|
184
|
+
) : (
|
|
185
|
+
<div className="xeplr-factory-arrow-test">
|
|
186
|
+
<select
|
|
187
|
+
className="xeplr-factory-prop-input"
|
|
188
|
+
value={t.when.field || ''}
|
|
189
|
+
onChange={(e) => ctrl.setTransition(step.stepKey, i, { when: { ...t.when, field: e.target.value } })}
|
|
190
|
+
aria-label="Field"
|
|
191
|
+
>
|
|
192
|
+
<option value="">Which field…</option>
|
|
193
|
+
{fields.map((f) => <option key={f.name} value={f.name}>{f.label}</option>)}
|
|
194
|
+
</select>
|
|
195
|
+
<select
|
|
196
|
+
className="xeplr-factory-prop-input"
|
|
197
|
+
value={t.when.op}
|
|
198
|
+
onChange={(e) => ctrl.setTransition(step.stepKey, i, { when: { ...t.when, op: e.target.value } })}
|
|
199
|
+
aria-label="Test"
|
|
200
|
+
>
|
|
201
|
+
{FLOW_OPERATORS.map((o) => <option key={o.op} value={o.op}>{o.label}</option>)}
|
|
202
|
+
</select>
|
|
203
|
+
{!VALUELESS_OPERATORS.includes(t.when.op) && (
|
|
204
|
+
<input
|
|
205
|
+
className="xeplr-factory-prop-input"
|
|
206
|
+
value={t.when.value ?? ''}
|
|
207
|
+
placeholder="value"
|
|
208
|
+
onChange={(e) => ctrl.setTransition(step.stepKey, i, { when: { ...t.when, value: e.target.value } })}
|
|
209
|
+
aria-label="Value"
|
|
210
|
+
/>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
<div className="xeplr-factory-arrow-target">
|
|
215
|
+
<span>go to</span>
|
|
216
|
+
<select
|
|
217
|
+
className="xeplr-factory-prop-input"
|
|
218
|
+
value={t.target}
|
|
219
|
+
onChange={(e) => ctrl.setTransition(step.stepKey, i, { target: e.target.value })}
|
|
220
|
+
aria-label="Goes to"
|
|
221
|
+
>
|
|
222
|
+
{others.map((s) => <option key={s.stepKey} value={s.stepKey}>{s.label || s.stepKey}</option>)}
|
|
223
|
+
<option value={FLOW_END}>Finish</option>
|
|
224
|
+
</select>
|
|
225
|
+
<button type="button" className="xeplr-factory-icon-button" aria-label="Remove this arrow" onClick={() => ctrl.disconnect(step.stepKey, i)}>×</button>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
))}
|
|
229
|
+
<div className="xeplr-factory-arrow-add">
|
|
230
|
+
<button
|
|
231
|
+
type="button"
|
|
232
|
+
className="xeplr-factory-add"
|
|
233
|
+
onClick={() => ctrl.connect(step.stepKey, others.length ? others[0].stepKey : FLOW_END, { field: fields[0] ? fields[0].name : '', op: '=', value: '' })}
|
|
234
|
+
>
|
|
235
|
+
+ If a field says…
|
|
236
|
+
</button>
|
|
237
|
+
{!step.transitions.some((t) => t.when === null) && (
|
|
238
|
+
<button
|
|
239
|
+
type="button"
|
|
240
|
+
className="xeplr-factory-add"
|
|
241
|
+
onClick={() => ctrl.connect(step.stepKey, others.length ? others[0].stepKey : FLOW_END, null)}
|
|
242
|
+
>
|
|
243
|
+
+ Otherwise
|
|
244
|
+
</button>
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
247
|
+
</fieldset>
|
|
248
|
+
|
|
249
|
+
{(ctrl.errorsByStep[step.stepKey] || []).length > 0 && (
|
|
250
|
+
<div className="xeplr-factory-panel-errors" role="alert">
|
|
251
|
+
{ctrl.errorsByStep[step.stepKey].map((e, i) => <div key={i}>{e.message}</div>)}
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
</aside>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Where the next screen goes: to the right of the last one, a row lower after four. */
|
|
259
|
+
function nextPlace(flow) {
|
|
260
|
+
const n = flow.steps.length
|
|
261
|
+
return { x: 60 + (n % 4) * (STEP_W + 90), y: 80 + Math.floor(n / 4) * (STEP_H + 110) }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function screenName(ctrl, id) {
|
|
265
|
+
const hit = ctrl.screens.find((s) => s.id === id)
|
|
266
|
+
return (hit && hit.name) || id
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** A field's label, for an arrow that tests it — the person never sees column names. */
|
|
270
|
+
function labelOf(ctrl, stepKey, when) {
|
|
271
|
+
if (!when) return null
|
|
272
|
+
const hit = ctrl.fieldsFor(stepKey).find((f) => f.name === when.field)
|
|
273
|
+
return hit ? hit.label : when.field
|
|
274
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useRef } from 'react'
|
|
2
|
+
import ScreenSample from './ScreenSample.jsx'
|
|
3
|
+
|
|
4
|
+
// A flow, running: the screen the run is parked on, and one button forward.
|
|
5
|
+
//
|
|
6
|
+
// Presentation only — everything comes from useFlowRun and useFactoryScreen.
|
|
7
|
+
// The screen saves itself into its own table exactly as it does anywhere else;
|
|
8
|
+
// "Next" hands the run what was filled in, and the run says which screen comes
|
|
9
|
+
// after it. Nothing here knows the journey.
|
|
10
|
+
|
|
11
|
+
export default function FlowRunnerSample({ run, screen, className, style, title, renderDone }) {
|
|
12
|
+
const values = useRef({})
|
|
13
|
+
const record = useRef(null)
|
|
14
|
+
|
|
15
|
+
if (run.loading) return <div className="xeplr-factory-flow-loading">Opening…</div>
|
|
16
|
+
if (run.error) {
|
|
17
|
+
return (
|
|
18
|
+
<div className="xeplr-factory-invalid" role="alert">
|
|
19
|
+
<strong>This journey cannot go on:</strong> {run.error}
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
if (run.status === 'done') {
|
|
24
|
+
return (
|
|
25
|
+
<div className={'xeplr-factory-flow' + (className ? ' ' + className : '')} style={style}>
|
|
26
|
+
{renderDone
|
|
27
|
+
? renderDone(run)
|
|
28
|
+
: <div className="xeplr-factory-flow-done" role="status"><strong>All done.</strong> Nothing more to fill in.</div>}
|
|
29
|
+
</div>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
if (!run.document) return null
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className={'xeplr-factory-flow' + (className ? ' ' + className : '')} style={style}>
|
|
36
|
+
{title && (
|
|
37
|
+
<div className="xeplr-factory-flow-head">
|
|
38
|
+
<span className="xeplr-factory-flow-where">{title}</span>
|
|
39
|
+
{run.sending && <span className="xeplr-factory-flow-sending">Working out what comes next…</span>}
|
|
40
|
+
</div>
|
|
41
|
+
)}
|
|
42
|
+
<ScreenSample
|
|
43
|
+
// A new step is a new form: the key stops the screen keeping the last
|
|
44
|
+
// one's values while the document changes under it.
|
|
45
|
+
key={run.stepKey}
|
|
46
|
+
ctrl={{
|
|
47
|
+
...screen,
|
|
48
|
+
// The flow's forward button IS the screen's own, renamed: one button,
|
|
49
|
+
// not two that both look like the way on.
|
|
50
|
+
done: () => run.submit(values.current, record.current),
|
|
51
|
+
doneLabel: run.sending ? 'Saving…' : 'Next'
|
|
52
|
+
}}
|
|
53
|
+
/>
|
|
54
|
+
<FlowValues screen={screen} values={values} record={record} />
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The bridge between the screen and the run: what is filled in, and the row
|
|
61
|
+
* the screen saved. Kept in refs — the run reads them when the person moves
|
|
62
|
+
* on, and a re-render on every keystroke would be the form's business, not
|
|
63
|
+
* the journey's.
|
|
64
|
+
*/
|
|
65
|
+
function FlowValues({ screen, values, record }) {
|
|
66
|
+
values.current = screen.values
|
|
67
|
+
record.current = screen.recordId
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
@@ -44,12 +44,20 @@ function StepNav({ ctrl, node, children }) {
|
|
|
44
44
|
// "Step 2 of 4" counts the steps that apply, not the ones ruled out.
|
|
45
45
|
const live = total - off.length
|
|
46
46
|
const place = at - off.filter((i) => i < at).length + 1
|
|
47
|
+
// A screen that moves on to something else when it is finished — the next
|
|
48
|
+
// screen of a flow — has ONE way forward: this form's own steps first, then
|
|
49
|
+
// that. Two buttons both called Next, one of which skips the steps still to
|
|
50
|
+
// do, is how a journey gets left half filled in.
|
|
51
|
+
const handsOn = Boolean(children) && Boolean(ctrl.doneLabel)
|
|
52
|
+
const lastStep = forward === null
|
|
47
53
|
return (
|
|
48
54
|
<div className="xeplr-factory-stepnav">
|
|
49
55
|
<button type="button" className="xeplr-factory-secondary" onClick={() => go(back, 'back')} disabled={back === null}>Back</button>
|
|
50
56
|
<span className="xeplr-factory-stepnav-where">Step {place} of {live}</span>
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
{!(handsOn && lastStep) && (
|
|
58
|
+
<button type="button" className="xeplr-factory-primary" onClick={() => go(forward, 'next')} disabled={lastStep}>Next</button>
|
|
59
|
+
)}
|
|
60
|
+
{(!handsOn || lastStep) && children}
|
|
53
61
|
</div>
|
|
54
62
|
)
|
|
55
63
|
}
|
|
@@ -58,8 +66,13 @@ function StepNav({ ctrl, node, children }) {
|
|
|
58
66
|
function DoneButton({ ctrl }) {
|
|
59
67
|
const saving = ctrl.status === 'pending' || ctrl.status === 'saving'
|
|
60
68
|
return (
|
|
61
|
-
<button
|
|
62
|
-
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
className={ctrl.doneLabel ? 'xeplr-factory-primary' : 'xeplr-factory-secondary'}
|
|
72
|
+
disabled={!ctrl.canSave && ctrl.status === 'invalid'}
|
|
73
|
+
onClick={() => ctrl.flush().then(ctrl.done, ctrl.done)}
|
|
74
|
+
>
|
|
75
|
+
{saving ? 'Saving…' : (ctrl.doneLabel || 'Done')}
|
|
63
76
|
</button>
|
|
64
77
|
)
|
|
65
78
|
}
|
package/src/designs/factory.css
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
it shrinks, so the layout keeps its proportions. */
|
|
9
9
|
|
|
10
10
|
.xeplr-factory-screen,
|
|
11
|
-
.xeplr-factory-canvas
|
|
11
|
+
.xeplr-factory-canvas,
|
|
12
|
+
.xeplr-factory-flow-builder {
|
|
12
13
|
container-type: inline-size;
|
|
13
14
|
--xf-u: min(1px, calc(100cqw / var(--xf-width, 800)));
|
|
14
15
|
--xf-accent: var(--xeplr-accent, #2563eb);
|
|
@@ -318,3 +319,50 @@
|
|
|
318
319
|
.xeplr-factory-confirm-text { flex: 1; line-height: 1.5; }
|
|
319
320
|
.xeplr-factory-confirm-text ul { margin: 4px 0; padding-left: 18px; }
|
|
320
321
|
.xeplr-factory-confirm-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
|
322
|
+
|
|
323
|
+
/* ── flows: screens joined by arrows ──────────────────────────────────────
|
|
324
|
+
The designer wears the builder's chrome (bar, palette, panel); what is new
|
|
325
|
+
is the step card on the canvas, the label on an arrow, and the rows that
|
|
326
|
+
say where a step goes next. */
|
|
327
|
+
.xeplr-factory-flow-builder { display: flex; flex-direction: column; height: 100%; min-height: 0; color: var(--xeplr-text-primary, #1a1a19); }
|
|
328
|
+
.xeplr-factory-flow-key { margin-right: auto; padding: 2px 6px; border-radius: 4px; background: var(--xf-border); font-size: 11px; }
|
|
329
|
+
.xeplr-factory-flow-canvas { height: 100%; }
|
|
330
|
+
|
|
331
|
+
.xeplr-factory-step-card {
|
|
332
|
+
display: flex; flex-direction: column; justify-content: center; gap: 2px;
|
|
333
|
+
width: 100%; height: 100%; box-sizing: border-box; padding: 10px 12px;
|
|
334
|
+
border: 1px solid var(--xf-border); border-radius: 10px; background: var(--xf-surface);
|
|
335
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
|
336
|
+
}
|
|
337
|
+
.xeplr-factory-step-card.is-selected { border-color: var(--xf-accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--xf-accent) 30%, transparent); }
|
|
338
|
+
.xeplr-factory-step-card.has-error { border-color: var(--xf-danger); }
|
|
339
|
+
.xeplr-factory-step-card-name { font-weight: 640; color: var(--xf-ink); }
|
|
340
|
+
.xeplr-factory-step-card-screen { color: var(--xf-muted); font-size: 12px; }
|
|
341
|
+
.xeplr-factory-step-card-first { color: var(--xf-accent); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
342
|
+
|
|
343
|
+
.xeplr-factory-edge-label {
|
|
344
|
+
/* Above the curve's midpoint rather than on it, so a label never sits on the
|
|
345
|
+
card an arrow is arriving at. */
|
|
346
|
+
margin-top: -12px;
|
|
347
|
+
padding: 1px 6px; border-radius: 999px; background: var(--xf-surface);
|
|
348
|
+
border: 1px solid var(--xf-border); color: var(--xf-muted); font-size: 11px; white-space: nowrap;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.xeplr-factory-arrow { padding: 8px 0; border-top: 1px dashed var(--xf-border); }
|
|
352
|
+
.xeplr-factory-arrow:first-of-type { border-top: none; }
|
|
353
|
+
.xeplr-factory-arrow-when { font-weight: 600; font-size: 12px; }
|
|
354
|
+
.xeplr-factory-arrow-test { display: grid; gap: 4px; }
|
|
355
|
+
.xeplr-factory-arrow-target { display: flex; align-items: center; gap: 6px; margin-top: 6px; color: var(--xf-muted); font-size: 12px; }
|
|
356
|
+
.xeplr-factory-arrow-target select { flex: 1; }
|
|
357
|
+
.xeplr-factory-arrow-add { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
|
358
|
+
.xeplr-factory-danger-link { border: none; background: none; color: var(--xf-danger); font: inherit; cursor: pointer; padding: 0; }
|
|
359
|
+
|
|
360
|
+
/* ── a flow, running ─────────────────────────────────────────────────────── */
|
|
361
|
+
.xeplr-factory-flow { display: flex; flex-direction: column; gap: 8px; }
|
|
362
|
+
.xeplr-factory-flow-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
|
363
|
+
.xeplr-factory-flow-where { font-weight: 640; }
|
|
364
|
+
.xeplr-factory-flow-sending, .xeplr-factory-flow-loading { color: var(--xf-muted); font-size: 12px; }
|
|
365
|
+
.xeplr-factory-flow-done {
|
|
366
|
+
padding: 16px; border-radius: 8px; border: 1px solid var(--xf-border);
|
|
367
|
+
background: var(--xf-surface); color: var(--xf-ink);
|
|
368
|
+
}
|
package/src/designs/index.js
CHANGED
|
@@ -4,6 +4,8 @@ export { default as BuilderSample } from './BuilderSample.jsx'
|
|
|
4
4
|
export { default as ScreenSample } from './ScreenSample.jsx'
|
|
5
5
|
export { default as ListView } from './ListView.jsx'
|
|
6
6
|
export { default as ScreenModal } from './ScreenModal.jsx'
|
|
7
|
+
export { default as FlowRunnerSample } from './FlowRunnerSample.jsx'
|
|
8
|
+
export { default as FlowBuilderSample } from './FlowBuilderSample.jsx'
|
|
7
9
|
export { default as ControlView, VIEWS } from './ControlView.jsx'
|
|
8
10
|
export { default as Palette, DRAG_TYPE } from './Palette.jsx'
|
|
9
11
|
export { default as PropertyPanel, EDITORS } from './PropertyPanel.jsx'
|
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/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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|