@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.
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { CONTROLS, CHOICE_TYPES } from './controls.js'
3
- import { inputNodes, nodesForSteps, steppers, stepsOf, stepOf } from './document.js'
3
+ import { inputNodes, nodesForSteps, steppers, stepsOf, stepOf, resolveStep, reachableStep, nextOpenStep } from './document.js'
4
4
  import { validateDocument } from './validateDocument.js'
5
5
  import { initialValues, parseInput, saveState, fieldError, optionValue, recordValues, listSource } from './values.js'
6
6
  import { hookMethod } from './hooks.js'
@@ -152,6 +152,9 @@ export function useFactoryScreen({
152
152
  // the fields of the steps not showing are still filled in, still checked and
153
153
  // still saved — a step is what the person sees, not a separate form.
154
154
  const [openSteps, setOpenSteps] = useState({})
155
+ // Steps the app has switched off — { stepper id: [index, …] }. A screen may
156
+ // have branches; which of them apply is the app's to say, from its hooks.
157
+ const [disabledSteps, setDisabledSteps] = useState({})
155
158
  const stepperNodes = useMemo(() => (check.ok ? steppers(doc, controls) : []), [check.ok, doc, controls])
156
159
  const visibleNodes = useMemo(() => (check.ok ? nodesForSteps(doc, openSteps, controls) : []), [check.ok, doc, openSteps, controls])
157
160
 
@@ -173,42 +176,104 @@ export function useFactoryScreen({
173
176
 
174
177
  const stepControl = useMemo(() => {
175
178
  const at = (id) => (Number.isInteger(openSteps[id]) ? openSteps[id] : 0)
176
- return {
179
+ const only = () => (stepperNodes.length === 1 ? stepperNodes[0].id : null)
180
+ const nodeFor = (id) => stepperNodes.find((n) => n.id === id) || stepperNodes[0] || null
181
+ const lastOf = (id) => stepsOf(nodeFor(id)).length - 1
182
+ /** A step named by its key ("planning"), its label, or its number from 0. */
183
+ const indexOf = (id, which) => resolveStep(stepsOf(nodeFor(id)), which)
184
+ const offList = (id) => disabledSteps[id] || []
185
+ const isOff = (id, index) => offList(id).includes(index)
186
+ const reachable = (id, index, direction) => reachableStep(lastOf(id) + 1, index, offList(id), direction, at(id))
187
+ const setOff = (id, list) => setDisabledSteps((d) => ({ ...d, [id || only()]: list }))
188
+
189
+ const control = {
177
190
  nodes: stepperNodes,
178
191
  active: at,
179
- count: (id) => stepsOf(stepperNodes.find((n) => n.id === id)).length,
192
+ count: (id) => stepsOf(nodeFor(id)).length,
193
+ labels: (id) => stepsOf(nodeFor(id)),
194
+ indexOf,
195
+ /** Is this step switched off — shown, but not one you can be on? */
196
+ isDisabled: (id, index) => isOff(id, index),
197
+ disabled: (id) => (disabledSteps[id || only()] || []).slice(),
198
+ /**
199
+ * Switch steps off (a key, a number, or a list of them). A step that is
200
+ * off cannot be reached by Next, Back or a click, and Next passes over
201
+ * it. Switching off the step someone is on moves them to the nearest one
202
+ * that is open.
203
+ */
204
+ disable: (which, id) => {
205
+ const on = id || only()
206
+ if (!on) return []
207
+ const add = (Array.isArray(which) ? which : [which]).map((w) => indexOf(on, w)).filter((i) => i >= 0)
208
+ const next = [...new Set([...(disabledSteps[on] || []), ...add])]
209
+ setOff(on, next)
210
+ return next
211
+ },
212
+ /** Switch them back on. No arguments switches every step of it back on. */
213
+ enable: (which, id) => {
214
+ const on = id || only()
215
+ if (!on) return []
216
+ if (which === undefined) { setOff(on, []); return [] }
217
+ const drop = (Array.isArray(which) ? which : [which]).map((w) => indexOf(on, w))
218
+ const next = (disabledSteps[on] || []).filter((i) => !drop.includes(i))
219
+ setOff(on, next)
220
+ return next
221
+ },
180
222
  /**
181
223
  * Move, if the app's `step` hook lets it: false keeps the person where
182
224
  * they are, a number sends them somewhere else. The hook runs for Next,
183
225
  * Back and a click on the bar alike, so a rule cannot be walked around.
184
226
  */
185
227
  go: async (id, index, direction) => {
186
- const from = at(id)
187
- const last = stepsOf(stepperNodes.find((n) => n.id === id)).length - 1
188
- const to = Math.min(Math.max(index, 0), Math.max(last, 0))
228
+ const on = id || only()
229
+ const from = at(on)
230
+ const way = direction || (index > from ? 'next' : 'back')
231
+ const to = reachable(on, indexOf(on, index), way)
189
232
  if (to === from) return from
190
233
  const decide = hookMethod(hooksRef.current, 'step')
191
234
  const answer = await decide({
192
235
  from,
193
236
  to,
194
- direction: direction || (to > from ? 'next' : 'back'),
237
+ direction: way,
195
238
  values: live.current.values,
196
- stepper: id,
239
+ stepper: on,
197
240
  screen: doc.id,
198
- document: doc
241
+ document: doc,
242
+ // What the hook can DO from here: send them somewhere, or say which
243
+ // steps this answer makes beside the point.
244
+ steps: control,
245
+ go: (where) => control.go(on, where, 'jump'),
246
+ disable: (which) => control.disable(which, on),
247
+ enable: (which) => control.enable(which, on)
199
248
  })
200
249
  if (answer === false) return from
201
- const where = Number.isInteger(answer) ? Math.min(Math.max(answer, 0), Math.max(last, 0)) : to
202
- setOpenSteps((o) => ({ ...o, [id]: where }))
250
+ const asked = Number.isInteger(answer) || typeof answer === 'string' ? indexOf(on, answer) : to
251
+ const where = reachable(on, asked >= 0 ? asked : to, way)
252
+ setOpenSteps((o) => ({ ...o, [on]: where }))
203
253
  return where
204
254
  },
255
+ /** The next step in this direction that is open, for Back and Next. */
256
+ nextOpen: (id, direction) => {
257
+ const on = id || only()
258
+ return nextOpenStep(lastOf(on) + 1, at(on), offList(on), direction)
259
+ },
205
260
  /** The fields on this step, so Next can hold at a step that is not filled in. */
206
261
  fieldsOn: (id, index) => inputNodes(doc, controls).filter((n) => {
207
262
  const step = stepOf(n)
208
- return step && step.of === id && step.index === index
263
+ return step && step.of === (id || only()) && step.index === index
209
264
  })
210
265
  }
211
- }, [stepperNodes, openSteps, doc, controls])
266
+ return control
267
+ }, [stepperNodes, openSteps, disabledSteps, doc, controls])
268
+
269
+ live.current.steps = stepControl
270
+
271
+ // A step that is switched off cannot be the one showing.
272
+ useEffect(() => {
273
+ stepperNodes.forEach((n) => {
274
+ if (stepControl.isDisabled(n.id, stepControl.active(n.id))) stepControl.go(n.id, stepControl.active(n.id) + 1, 'next')
275
+ })
276
+ }, [stepperNodes, stepControl])
212
277
 
213
278
  // ── a file field's upload ─────────────────────────────────────────────
214
279
  const uploadRef = useRef(uploadFile); uploadRef.current = uploadFile
@@ -257,6 +322,9 @@ export function useFactoryScreen({
257
322
  const saved = await hook('save')(l.values, {
258
323
  ...meta,
259
324
  isNew: l.recordId === null || l.recordId === undefined,
325
+ // Save is where an app usually learns which branch it is on, so it
326
+ // can switch steps off from here as well as from step().
327
+ steps: live.current.steps,
260
328
  defaults: { save: (vals) => (l.onSave ? l.onSave(vals, meta) : undefined) }
261
329
  })
262
330
  // A new record's first save creates it; its id makes the next save an update.
@@ -376,6 +444,7 @@ export function useFactoryScreen({
376
444
  setLists((l) => ({ ...l, [node.id]: { rows: l[node.id]?.rows || [], loading: true, error: null } }))
377
445
  const run = Promise.resolve().then(() => hook('get')({
378
446
  many: true, id: null, screen: doc.id, source: src, node, document: doc,
447
+ steps: live.current.steps,
379
448
  defaults: {
380
449
  get: () => {
381
450
  if (!fetchRecordsRef.current) throw new Error(`No fetchRecords was provided to list "${src}"`)
@@ -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
+ }