@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 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.12",
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
- <button type="button" className="xeplr-factory-primary" onClick={() => go(forward, 'next')} disabled={forward === null}>Next</button>
52
- {children}
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 type="button" className="xeplr-factory-secondary" onClick={() => ctrl.flush().then(ctrl.done, ctrl.done)}>
62
- {saving ? 'Saving…' : 'Done'}
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
  }
@@ -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
+ }
@@ -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 { BuilderSample, ScreenSample, ScreenModal } from './designs/index.js'
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
+ }