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