@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/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 |
|
|
@@ -235,6 +284,25 @@ class TaskHooks extends FactoryHooks {
|
|
|
235
284
|
}
|
|
236
285
|
```
|
|
237
286
|
|
|
287
|
+
A screen with branches can **rule steps out** from the same hook — they stay on
|
|
288
|
+
the bar, struck through, and Next and Back pass over them:
|
|
289
|
+
|
|
290
|
+
```js
|
|
291
|
+
step(ctx) {
|
|
292
|
+
if (ctx.from === 0 && ctx.values.kind === 'simple') {
|
|
293
|
+
ctx.disable(['pricing', 'approval']) // by key, label or number
|
|
294
|
+
return 'summary' // and go straight there
|
|
295
|
+
}
|
|
296
|
+
ctx.enable() // every step applies again
|
|
297
|
+
return super.step(ctx)
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
`ctx.go`, `ctx.disable` and `ctx.enable` act on that stepper; `ctx.steps` is the
|
|
302
|
+
same for a screen with more than one (`ctx.steps.disable('approval', stepperId)`).
|
|
303
|
+
`ctx.steps` is on `save` and `get` too, since a save is usually where an app
|
|
304
|
+
learns which branch it is on. "Step 2 of 4" counts only the steps that apply.
|
|
305
|
+
|
|
238
306
|
**A step is not a separate form.** Every field is a column, is checked, and is
|
|
239
307
|
saved, whichever step is showing — a screen still saves itself as it is filled
|
|
240
308
|
in. **Next** holds at a step whose own fields are not filled in, and says which,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/ui-factory",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.13",
|
|
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",
|
|
@@ -341,7 +341,8 @@ function StepperView({ node, p, design, stepper }) {
|
|
|
341
341
|
<div className="xeplr-factory-stepper" style={{ ...fieldStyle(p.style), ...text }}>
|
|
342
342
|
<ol className="xeplr-factory-steps">
|
|
343
343
|
{steps.map((s, i) => {
|
|
344
|
-
const
|
|
344
|
+
const off = Boolean(stepper && stepper.isDisabled && stepper.isDisabled(i))
|
|
345
|
+
const state = off ? 'is-off' : i === active ? 'is-active' : i < active ? 'is-done' : ''
|
|
345
346
|
return (
|
|
346
347
|
<li key={s.key} className={`xeplr-factory-step ${state}`} aria-current={i === active ? 'step' : undefined}>
|
|
347
348
|
<button
|
|
@@ -349,7 +350,9 @@ function StepperView({ node, p, design, stepper }) {
|
|
|
349
350
|
className="xeplr-factory-step-button"
|
|
350
351
|
// On the canvas a press must still be able to drag the control,
|
|
351
352
|
// so the step is taken on click, never on mousedown.
|
|
352
|
-
onClick={(e) => { if (go) { e.stopPropagation(); go(i) } }}
|
|
353
|
+
onClick={(e) => { if (go && !off) { e.stopPropagation(); go(i) } }}
|
|
354
|
+
disabled={off}
|
|
355
|
+
aria-disabled={off || undefined}
|
|
353
356
|
tabIndex={design ? -1 : undefined}
|
|
354
357
|
>
|
|
355
358
|
<span className="xeplr-factory-step-mark" aria-hidden="true">{i < active ? '✓' : (p.showNumbers === false ? '' : i + 1)}</span>
|
|
@@ -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
|
+
}
|
|
@@ -29,17 +29,26 @@ const STATUS = {
|
|
|
29
29
|
*/
|
|
30
30
|
function StepNav({ ctrl, node, children }) {
|
|
31
31
|
const at = ctrl.steps.active(node.id)
|
|
32
|
-
const
|
|
32
|
+
const total = ctrl.steps.count(node.id)
|
|
33
|
+
const off = ctrl.steps.disabled(node.id)
|
|
33
34
|
const unfinished = ctrl.steps.fieldsOn(node.id, at).filter((n) => ctrl.allErrors[n.props.name])
|
|
35
|
+
// Back and Next go to the nearest step that is switched on, passing over any
|
|
36
|
+
// the app has ruled out; null means there is none that way.
|
|
37
|
+
const back = ctrl.steps.nextOpen(node.id, 'back')
|
|
38
|
+
const forward = ctrl.steps.nextOpen(node.id, 'next')
|
|
34
39
|
const go = (to, direction) => {
|
|
35
|
-
if (to
|
|
36
|
-
|
|
40
|
+
if (to === null) return
|
|
41
|
+
if (direction === 'next' && unfinished.length) { unfinished.forEach((n) => ctrl.touch(n)); return }
|
|
42
|
+
ctrl.steps.go(node.id, to, direction)
|
|
37
43
|
}
|
|
44
|
+
// "Step 2 of 4" counts the steps that apply, not the ones ruled out.
|
|
45
|
+
const live = total - off.length
|
|
46
|
+
const place = at - off.filter((i) => i < at).length + 1
|
|
38
47
|
return (
|
|
39
48
|
<div className="xeplr-factory-stepnav">
|
|
40
|
-
<button type="button" className="xeplr-factory-secondary" onClick={() => go(
|
|
41
|
-
<span className="xeplr-factory-stepnav-where">Step {
|
|
42
|
-
<button type="button" className="xeplr-factory-primary" onClick={() => go(
|
|
49
|
+
<button type="button" className="xeplr-factory-secondary" onClick={() => go(back, 'back')} disabled={back === null}>Back</button>
|
|
50
|
+
<span className="xeplr-factory-stepnav-where">Step {place} of {live}</span>
|
|
51
|
+
<button type="button" className="xeplr-factory-primary" onClick={() => go(forward, 'next')} disabled={forward === null}>Next</button>
|
|
43
52
|
{children}
|
|
44
53
|
</div>
|
|
45
54
|
)
|
|
@@ -49,8 +58,13 @@ function StepNav({ ctrl, node, children }) {
|
|
|
49
58
|
function DoneButton({ ctrl }) {
|
|
50
59
|
const saving = ctrl.status === 'pending' || ctrl.status === 'saving'
|
|
51
60
|
return (
|
|
52
|
-
<button
|
|
53
|
-
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
className={ctrl.doneLabel ? 'xeplr-factory-primary' : 'xeplr-factory-secondary'}
|
|
64
|
+
disabled={!ctrl.canSave && ctrl.status === 'invalid'}
|
|
65
|
+
onClick={() => ctrl.flush().then(ctrl.done, ctrl.done)}
|
|
66
|
+
>
|
|
67
|
+
{saving ? 'Saving…' : (ctrl.doneLabel || 'Done')}
|
|
54
68
|
</button>
|
|
55
69
|
)
|
|
56
70
|
}
|
|
@@ -116,7 +130,11 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
|
|
|
116
130
|
error={node.props?.name ? ctrl.errors[node.props.name] : undefined}
|
|
117
131
|
options={CHOICE_TYPES.includes(node.type) ? ctrl.optionsFor(node) : undefined}
|
|
118
132
|
upload={node.type === 'file' ? ctrl.upload : undefined}
|
|
119
|
-
stepper={node.type === 'stepper' ? {
|
|
133
|
+
stepper={node.type === 'stepper' ? {
|
|
134
|
+
active: ctrl.steps.active(node.id),
|
|
135
|
+
isDisabled: (i) => ctrl.steps.isDisabled(node.id, i),
|
|
136
|
+
onStep: (i) => ctrl.steps.go(node.id, i, 'jump')
|
|
137
|
+
} : undefined}
|
|
120
138
|
onChange={(raw) => ctrl.setValue(node, raw)}
|
|
121
139
|
onBlur={() => ctrl.touch(node)}
|
|
122
140
|
list={node.type === 'list' ? {
|
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);
|
|
@@ -129,6 +130,10 @@
|
|
|
129
130
|
.xeplr-factory-step.is-active .xeplr-factory-step-mark,
|
|
130
131
|
.xeplr-factory-step.is-done .xeplr-factory-step-mark { background: var(--xf-accent); color: #fff; }
|
|
131
132
|
.xeplr-factory-step.is-active .xeplr-factory-step-button { color: var(--xf-accent); font-weight: 600; }
|
|
133
|
+
/* A step the app has ruled out: still shown, so the journey reads the same,
|
|
134
|
+
but plainly not one of the stops. */
|
|
135
|
+
.xeplr-factory-step.is-off .xeplr-factory-step-button { opacity: 0.4; cursor: default; }
|
|
136
|
+
.xeplr-factory-step.is-off .xeplr-factory-step-label { text-decoration: line-through; }
|
|
132
137
|
|
|
133
138
|
/* Back / Next, under the page. */
|
|
134
139
|
.xeplr-factory-stepnav { display: flex; align-items: center; gap: 12px; padding: 10px 0; }
|
|
@@ -314,3 +319,50 @@
|
|
|
314
319
|
.xeplr-factory-confirm-text { flex: 1; line-height: 1.5; }
|
|
315
320
|
.xeplr-factory-confirm-text ul { margin: 4px 0; padding-left: 18px; }
|
|
316
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/document.js
CHANGED
|
@@ -155,6 +155,46 @@ export function nodesForSteps(doc, active = {}, controls = CONTROLS) {
|
|
|
155
155
|
return (doc.nodes || []).filter((n) => shown(n, new Set()))
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
// Which step a move lands on. Pure, and the only place the rules live: a step
|
|
159
|
+
// may be named by its key, its label or its number; a step that is switched
|
|
160
|
+
// off is passed over, in the direction of travel, and only when there is
|
|
161
|
+
// nothing that way is the other direction tried.
|
|
162
|
+
|
|
163
|
+
/** A step named by key, label or number → its index, or -1. */
|
|
164
|
+
export function resolveStep(steps, which) {
|
|
165
|
+
if (Number.isInteger(which)) return which >= 0 && which < steps.length ? which : -1
|
|
166
|
+
return steps.findIndex((s) => s.key === which || s.label === which)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The step actually reached when aiming at `index`.
|
|
171
|
+
* @param disabled indexes switched off
|
|
172
|
+
* @param direction 'next' | 'back' | 'jump'
|
|
173
|
+
* @param from where we are now, for when nothing is open either way
|
|
174
|
+
*/
|
|
175
|
+
export function reachableStep(count, index, disabled = [], direction = 'next', from = 0) {
|
|
176
|
+
if (count <= 0) return 0
|
|
177
|
+
const off = (i) => disabled.includes(i)
|
|
178
|
+
const clamp = (i) => Math.min(Math.max(i, 0), count - 1)
|
|
179
|
+
const way = direction === 'back' ? -1 : 1
|
|
180
|
+
let i = clamp(index)
|
|
181
|
+
while (i >= 0 && i < count && off(i)) i += way
|
|
182
|
+
if (i < 0 || i >= count) {
|
|
183
|
+
i = clamp(index)
|
|
184
|
+
while (i >= 0 && i < count && off(i)) i -= way
|
|
185
|
+
}
|
|
186
|
+
return i >= 0 && i < count ? i : clamp(from)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The nearest open step in this direction, or null when there is none. */
|
|
190
|
+
export function nextOpenStep(count, at, disabled = [], direction = 'next') {
|
|
191
|
+
const step = direction === 'back' ? -1 : 1
|
|
192
|
+
const aim = at + step
|
|
193
|
+
if (aim < 0 || aim >= count) return null
|
|
194
|
+
const i = reachableStep(count, aim, disabled, direction, at)
|
|
195
|
+
return i === at || disabled.includes(i) ? null : i
|
|
196
|
+
}
|
|
197
|
+
|
|
158
198
|
/** Merges a geometry patch — what the canvas reports on drop. */
|
|
159
199
|
export function moveNode(doc, id, patch) {
|
|
160
200
|
const geometry = {}
|