@xeplr/ui-factory 1.4.0 → 1.6.0

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
@@ -323,6 +323,71 @@ Controller: the embedded screen is a second `useFactoryScreen` with `link`
323
323
  save, name }`). `FactoryScreen` wires both; a custom design gets `subformFor(node)`,
324
324
  `registerSubform(id, api)` and `subformDirty(id, dirty)` from the host's controller.
325
325
 
326
+ ## Events — the app's code, picked by name
327
+
328
+ A screen says which of the app's functions run around its records, one per
329
+ event. The code is registered in the app (with `@xeplr/actions`, `event` set
330
+ to one of these); the screen only names it:
331
+
332
+ ```json
333
+ "events": { "beforeSave": "checkSalaryBand", "afterSave": "notifyHR" }
334
+ ```
335
+
336
+ | event | runs | gets | returns |
337
+ |---|---|---|---|
338
+ | `beforeLoad` | Before records are read — the list, and one record for Edit | ctx.query (narrow it), ctx.id (one record) or ctx.many (the list), ctx.user, ctx.tenant | nothing, or a new query |
339
+ | `beforeSave` | Before a record is written — the screen's rules run on what it returns | ctx.values, ctx.isNew, ctx.previous (on update), ctx.user | the values to save (or nothing to keep them); ctx.reject(message, { field }) refuses |
340
+ | `afterSave` | Once the record is saved | ctx.result (the saved row), ctx.isNew, ctx.values | nothing — what it does is the point (an email, another record) |
341
+ | `beforeDelete` | Before a record is deleted | ctx.previous (the row), ctx.id | nothing; ctx.reject(message) refuses |
342
+ | `onError` | When loading, saving or deleting failed | ctx.error, ctx.op (get, save or delete) | nothing — the error is still the answer; this is for telling someone |
343
+
344
+ - **This is the only way to attach code to a screen's records.** One function per event, named in the screen — the screen is the one place that says what runs.
345
+ - **Every event runs on the server** (`@xeplr/factory` 2+). A rule that runs in the browser is one a person can skip.
346
+ - **Checked twice.** Here, that each name could be a function and each event exists. On the server, at publish and when it runs, that the function is registered *for that event* — a missing one refuses with its name (`missingFunctions(doc, registry)`).
347
+ - `SCREEN_EVENTS` carries each event's contract (`when`, `gets`, `returns`) — what the designer shows beside each picker.
348
+
349
+ ## The flow designer, as a list — `<FlowDesigner>`
350
+
351
+ Your screens as a journey: the steps down the left; for the one picked, its
352
+ **Form design** (the screen's builder), the **events** the app's functions run
353
+ on, and **where it goes next**. The selected step and tab live in the URL
354
+ (`<urlKey>Selected`, `<urlKey>Tab`).
355
+
356
+ ```jsx
357
+ const factory = createFactoryApi({ fetch: authFetch, base: '/api' })
358
+ const flows = createFlowsApi({ fetch: authFetch, base: '/api/workflow' })
359
+
360
+ <FlowDesigner
361
+ flow={flow} screens={screens} loadScreen={factory.loadScreen}
362
+ onSave={flows.saveFlow} onPublish={flows.publishFlow}
363
+ listFunctions={factory.listFunctions}
364
+ loadDraft={factory.loadDraft} saveDraft={factory.saveDraft} publishScreen={factory.publish}
365
+ builderProps={factory.builderProps}
366
+ urlKey="flow"
367
+ />
368
+ ```
369
+
370
+ - **Events are the screen's.** Picking a function saves it to that screen's
371
+ draft; it runs once the screen is **published** (a button on the card), and
372
+ in every journey that shows the screen. Each picker offers only the functions
373
+ the app registered for that event, with what each is handed and returns —
374
+ the code itself is never in the designer.
375
+ - **Navigation is the flow's:** go to a screen, **let a function decide** (a
376
+ registered `decide` function, and the steps it may choose), or finish the
377
+ journey. A step whose arrows test its fields shows them, and can be replaced
378
+ with one of the three.
379
+ - **Nothing runs in the browser.** Before load, Before save, After save,
380
+ Before delete and On error run on the server; On load / On change / On exit
381
+ are not offered, because nothing would run them.
382
+ - Approvals (another person's turn, with an assignee and a timeout) are not
383
+ part of this designer.
384
+
385
+ Controllers, for a design of your own: `useFlowBuilder` (the flow, plus
386
+ `navigationOf` / `setNavigation`), `useScreenEvents` (one screen's events,
387
+ `adopt` to share its draft with a builder), `useFunctionCatalog` (the app's
388
+ functions, `forEvent`). Model: `navigationOf`, `setNavigation`, `arrowTargets`,
389
+ `setEvent`, `functionsFor`.
390
+
326
391
  ## Masters — the lists the rest of the app points at
327
392
 
328
393
  Departments, statuses, countries, categories. Mark a form as a master in its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-factory",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
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,371 @@
1
+ import { FLOW_END, describeWhen } from '../flow.js'
2
+ import { DECIDE_EVENT } from '../events.js'
3
+
4
+ // THE FLOW DESIGNER, AS A LIST — the screens of a journey down the left; for
5
+ // the one picked, its form, its events and where it goes next.
6
+ //
7
+ // left the steps, in order, and "Add a screen"
8
+ // top the step's name, and the flow's own save state and Publish
9
+ // tabs Form design — the screen's builder (renderFormDesign)
10
+ // Events & navigation — what runs around its records, and what
11
+ // comes after it
12
+ //
13
+ // EVENTS ARE THE SCREEN'S, NAVIGATION IS THE FLOW'S. A screen's records behave
14
+ // the same in every flow that shows it, so its events are saved to the
15
+ // screen's draft and take effect when the screen is published; where a step
16
+ // goes next belongs to this flow alone and saves with it.
17
+ //
18
+ // The code is never here. Each picker offers the functions the app registered
19
+ // for that event (useFunctionCatalog), with what each is for and what it is
20
+ // handed and returns — the developer writes the code in the app.
21
+ //
22
+ // Presentation only: everything comes from useFlowBuilder (ctrl),
23
+ // useScreenEvents (events) and useFunctionCatalog (catalog).
24
+
25
+ const FLOW_STATUS = {
26
+ saved: 'All changes saved',
27
+ pending: 'Unsaved changes…',
28
+ saving: 'Saving…',
29
+ invalid: 'Not saved — finish the flow first',
30
+ error: 'Not saved'
31
+ }
32
+
33
+ export const FLOW_DESIGNER_TABS = [
34
+ { key: 'settings', label: 'Events & navigation' },
35
+ { key: 'design', label: 'Form design' }
36
+ ]
37
+
38
+ export default function FlowDesignerSample({ ctrl, step, events, catalog, tab, onTab, renderFormDesign, className, style }) {
39
+ const { flow } = ctrl
40
+ return (
41
+ <div className={'xeplr-factory-fd' + (className ? ' ' + className : '')} style={style}>
42
+ <header className="xeplr-factory-bar">
43
+ <input
44
+ className="xeplr-factory-name"
45
+ value={flow.name}
46
+ onChange={(e) => ctrl.rename(e.target.value)}
47
+ placeholder="What is this journey called?"
48
+ aria-label="Flow name"
49
+ />
50
+ <div className={`xeplr-factory-bar-status xeplr-factory-bar-status--${ctrl.status}`} role="status" aria-live="polite">
51
+ {ctrl.status === 'error' ? `Not saved — ${ctrl.saveError}` : FLOW_STATUS[ctrl.status]}
52
+ </div>
53
+ {ctrl.validation.errors.length > 0 && (
54
+ <span className="xeplr-factory-problems" title={ctrl.validation.errors.map((x) => x.message).join('\n')}>
55
+ {ctrl.validation.errors.length} problem{ctrl.validation.errors.length === 1 ? '' : 's'}
56
+ </span>
57
+ )}
58
+ {ctrl.canPublish && (
59
+ <button type="button" className="xeplr-factory-primary" onClick={ctrl.publish} disabled={ctrl.publishing || !ctrl.validation.ok}>
60
+ {ctrl.publishing ? 'Publishing…' : 'Publish flow'}
61
+ </button>
62
+ )}
63
+ </header>
64
+
65
+ {ctrl.publishResult && (
66
+ <div className={`xeplr-factory-publish${ctrl.publishResult.ok ? ' is-ok' : ' is-error'}`} role="status">
67
+ <span>{ctrl.publishResult.message}</span>
68
+ <button type="button" className="xeplr-factory-icon-button" aria-label="Dismiss" onClick={ctrl.clearPublishResult}>×</button>
69
+ </div>
70
+ )}
71
+
72
+ <div className="xeplr-factory-fd-body">
73
+ <Steps ctrl={ctrl} step={step} />
74
+ <main className="xeplr-factory-fd-main">
75
+ {!step ? (
76
+ <div className="xeplr-factory-fd-empty">Add a screen on the left to start the journey.</div>
77
+ ) : (
78
+ <>
79
+ <div className="xeplr-factory-fd-head">
80
+ <input
81
+ className="xeplr-factory-fd-step-name"
82
+ value={step.label || ''}
83
+ placeholder={step.stepKey}
84
+ onChange={(e) => ctrl.setStep(step.stepKey, { label: e.target.value })}
85
+ aria-label="Step name"
86
+ />
87
+ <span className="xeplr-factory-fd-step-screen">Shows <code>{screenName(ctrl, step.screen)}</code></span>
88
+ <button type="button" className="xeplr-factory-fd-remove" onClick={() => ctrl.removeStep(step.stepKey)}>Remove step</button>
89
+ </div>
90
+ <div className="xeplr-factory-fd-tabs" role="tablist">
91
+ {FLOW_DESIGNER_TABS.map((t) => (
92
+ <button
93
+ key={t.key}
94
+ type="button"
95
+ role="tab"
96
+ aria-selected={tab === t.key}
97
+ className={'xeplr-factory-fd-tab' + (tab === t.key ? ' is-active' : '')}
98
+ onClick={() => onTab(t.key)}
99
+ >
100
+ {t.label}
101
+ </button>
102
+ ))}
103
+ </div>
104
+ {tab === 'design' ? (
105
+ <div className="xeplr-factory-fd-design">{renderFormDesign ? renderFormDesign(step, events) : null}</div>
106
+ ) : (
107
+ <div className="xeplr-factory-fd-settings">
108
+ <EventsPanel step={step} events={events} catalog={catalog} ctrl={ctrl} />
109
+ <NavigationPanel step={step} ctrl={ctrl} catalog={catalog} />
110
+ </div>
111
+ )}
112
+ </>
113
+ )}
114
+ </main>
115
+ </div>
116
+ </div>
117
+ )
118
+ }
119
+
120
+ /** The journey's screens, in order, with "Add a screen" under them. */
121
+ function Steps({ ctrl, step }) {
122
+ const { flow } = ctrl
123
+ const unused = ctrl.screens.filter((s) => !flow.steps.some((x) => x.screen === s.id))
124
+ return (
125
+ <nav className="xeplr-factory-fd-steps" aria-label="Screens of this journey">
126
+ <ol className="xeplr-factory-fd-step-list">
127
+ {flow.steps.map((s, i) => {
128
+ const problems = ctrl.errorsByStep[s.stepKey] || []
129
+ const on = step && step.stepKey === s.stepKey
130
+ return (
131
+ <li key={s.stepKey}>
132
+ <button
133
+ type="button"
134
+ className={'xeplr-factory-fd-step' + (on ? ' is-selected' : '') + (problems.length ? ' has-error' : '')}
135
+ aria-current={on ? 'step' : undefined}
136
+ onClick={() => ctrl.setSelected(s.stepKey)}
137
+ title={problems.map((p) => p.message).join('\n') || undefined}
138
+ >
139
+ <span className="xeplr-factory-fd-step-number">{i + 1}</span>
140
+ <span className="xeplr-factory-fd-step-text">
141
+ <span className="xeplr-factory-fd-step-label">{s.label || s.stepKey}</span>
142
+ <span className="xeplr-factory-fd-step-next">{nextInWords(ctrl, s)}</span>
143
+ </span>
144
+ </button>
145
+ </li>
146
+ )
147
+ })}
148
+ </ol>
149
+ <label className="xeplr-factory-fd-add">
150
+ <span className="xeplr-factory-fd-add-label">Add a screen</span>
151
+ <select
152
+ value=""
153
+ onChange={(e) => { if (e.target.value) ctrl.addStep(e.target.value) }}
154
+ aria-label="Add a screen to the journey"
155
+ >
156
+ <option value="">{ctrl.screens.length ? 'Choose a form…' : 'No published forms yet'}</option>
157
+ {unused.map((s) => <option key={s.id} value={s.id}>{s.name || s.id}</option>)}
158
+ {unused.length < ctrl.screens.length && unused.length > 0 && <option disabled>──────────</option>}
159
+ {ctrl.screens.filter((s) => !unused.includes(s)).map((s) => <option key={s.id} value={s.id}>{s.name || s.id} (again)</option>)}
160
+ </select>
161
+ </label>
162
+ </nav>
163
+ )
164
+ }
165
+
166
+ /** The screen's events: one function per event, from those the app registered for it. */
167
+ function EventsPanel({ step, events, catalog }) {
168
+ return (
169
+ <section className="xeplr-factory-fd-card" aria-labelledby="xf-fd-events">
170
+ <div className="xeplr-factory-fd-card-head">
171
+ <h3 id="xf-fd-events">Events of this screen</h3>
172
+ <p>
173
+ The app’s own functions, run on the server around <strong>{step.label || step.stepKey}</strong>’s records.
174
+ They belong to the screen, so they run in every journey that shows it.
175
+ </p>
176
+ </div>
177
+ {events.loading && <p className="xeplr-factory-hint">Opening the screen…</p>}
178
+ {events.error && <div className="xeplr-factory-invalid" role="alert">{events.error}</div>}
179
+ {catalog.error && <div className="xeplr-factory-invalid" role="alert">{catalog.error}</div>}
180
+ {events.document && (
181
+ <>
182
+ <ul className="xeplr-factory-fd-events">
183
+ {events.rows.map((row) => {
184
+ const options = catalog.forEvent(row.key)
185
+ const picked = row.picked ? catalog.byName(row.picked) : null
186
+ const gone = row.picked && !catalog.loading && (!picked || picked.event !== row.key)
187
+ const id = `xf-fd-event-${row.key}`
188
+ return (
189
+ <li key={row.key} className={'xeplr-factory-fd-event' + (row.picked ? ' is-on' : '') + (gone ? ' has-error' : '')}>
190
+ <div className="xeplr-factory-fd-event-what">
191
+ <label htmlFor={id} className="xeplr-factory-fd-event-name">{row.label}</label>
192
+ <span className="xeplr-factory-fd-event-when">{row.when}</span>
193
+ </div>
194
+ <select
195
+ id={id}
196
+ value={row.picked}
197
+ onChange={(e) => events.setEvent(row.key, e.target.value)}
198
+ >
199
+ <option value="">{options.length ? 'Nothing runs' : 'No functions for this yet'}</option>
200
+ {options.map((f) => <option key={f.name} value={f.name}>{f.name}</option>)}
201
+ {gone && <option value={row.picked}>{row.picked} (not registered)</option>}
202
+ </select>
203
+ {row.picked && (
204
+ <div className="xeplr-factory-fd-contract">
205
+ {gone
206
+ ? <p className="xeplr-factory-fd-contract-gone">“{row.picked}” is not registered for {row.label.toLowerCase()} in the app. Publishing this screen will be refused until it is — or pick another.</p>
207
+ : picked && picked.description && <p className="xeplr-factory-fd-contract-desc">{picked.description}</p>}
208
+ <dl>
209
+ <dt>Gets</dt><dd>{row.gets}</dd>
210
+ <dt>Returns</dt><dd>{row.returns}</dd>
211
+ </dl>
212
+ </div>
213
+ )}
214
+ </li>
215
+ )
216
+ })}
217
+ </ul>
218
+ <div className="xeplr-factory-fd-card-foot">
219
+ <span className={`xeplr-factory-fd-save xeplr-factory-fd-save--${events.status}`} role="status" aria-live="polite">
220
+ {events.status === 'saving' ? 'Saving…'
221
+ : events.status === 'error' ? `Not saved — ${events.saveError}`
222
+ : events.status === 'saved' ? 'Saved to the screen’s draft — publish the screen to use it'
223
+ : 'Changes take effect when the screen is published'}
224
+ </span>
225
+ {events.canPublish && (
226
+ <button type="button" className="xeplr-factory-secondary" onClick={events.publish} disabled={events.publishing}>
227
+ {events.publishing ? 'Publishing…' : 'Publish screen'}
228
+ </button>
229
+ )}
230
+ </div>
231
+ {events.publishResult && (
232
+ <div className={'xeplr-factory-fd-result' + (events.publishResult.ok ? ' is-ok' : ' is-error')} role="status">{events.publishResult.message}</div>
233
+ )}
234
+ </>
235
+ )}
236
+ </section>
237
+ )
238
+ }
239
+
240
+ /** Where this step goes once its screen is saved: a screen, a function's choice, or the end. */
241
+ function NavigationPanel({ step, ctrl, catalog }) {
242
+ const nav = navigationOfStep(ctrl, step)
243
+ const others = ctrl.flow.steps.filter((s) => s.stepKey !== step.stepKey)
244
+ const deciders = catalog.forEvent(DECIDE_EVENT)
245
+ const set = (patch) => ctrl.setNavigation(step.stepKey, { ...nav, ...patch })
246
+ const name = `xf-fd-nav-${step.stepKey}`
247
+ const picked = nav.mode === 'decide' && nav.decide ? catalog.byName(nav.decide) : null
248
+ const problems = ctrl.errorsByStep[step.stepKey] || []
249
+
250
+ return (
251
+ <section className="xeplr-factory-fd-card" aria-labelledby="xf-fd-nav">
252
+ <div className="xeplr-factory-fd-card-head">
253
+ <h3 id="xf-fd-nav">After this screen is saved</h3>
254
+ <p>Where the journey goes next. This belongs to this flow only.</p>
255
+ </div>
256
+
257
+ {nav.mode === 'branches' ? (
258
+ <div className="xeplr-factory-fd-branches">
259
+ <p>This step chooses by what was filled in:</p>
260
+ <ul>
261
+ {step.transitions.map((t, i) => (
262
+ <li key={i}>{describeWhen(t.when, fieldLabel(ctrl, step, t.when))} → {targetName(ctrl, t.target)}</li>
263
+ ))}
264
+ </ul>
265
+ <button type="button" className="xeplr-factory-secondary" onClick={() => ctrl.setNavigation(step.stepKey, { mode: 'screen', target: others[0] ? others[0].stepKey : '' })}>
266
+ Replace with one choice below
267
+ </button>
268
+ </div>
269
+ ) : (
270
+ <div className="xeplr-factory-fd-choices" role="radiogroup" aria-labelledby="xf-fd-nav">
271
+ <div className={'xeplr-factory-fd-choice' + (nav.mode === 'screen' ? ' is-on' : '')}>
272
+ <label className="xeplr-factory-fd-choice-pick">
273
+ <input type="radio" name={name} checked={nav.mode === 'screen'} onChange={() => ctrl.setNavigation(step.stepKey, { mode: 'screen', target: others[0] ? others[0].stepKey : '' })} />
274
+ <span>Go to a screen</span>
275
+ </label>
276
+ {nav.mode === 'screen' && (
277
+ <select value={nav.target || ''} onChange={(e) => set({ target: e.target.value })} aria-label="Go to">
278
+ {!nav.target && <option value="">Choose a screen…</option>}
279
+ {others.map((s) => <option key={s.stepKey} value={s.stepKey}>{s.label || s.stepKey}</option>)}
280
+ </select>
281
+ )}
282
+ </div>
283
+
284
+ <div className={'xeplr-factory-fd-choice' + (nav.mode === 'decide' ? ' is-on' : '')}>
285
+ <label className="xeplr-factory-fd-choice-pick">
286
+ <input type="radio" name={name} checked={nav.mode === 'decide'} onChange={() => ctrl.setNavigation(step.stepKey, { mode: 'decide', decide: deciders[0] ? deciders[0].name : '', targets: others.map((s) => s.stepKey) })} />
287
+ <span>Let a function decide</span>
288
+ </label>
289
+ {nav.mode === 'decide' && (
290
+ <div className="xeplr-factory-fd-decide">
291
+ <select value={nav.decide || ''} onChange={(e) => set({ decide: e.target.value })} aria-label="Function that decides">
292
+ {!nav.decide && <option value="">{deciders.length ? 'Choose a function…' : 'No decide functions registered'}</option>}
293
+ {deciders.map((f) => <option key={f.name} value={f.name}>{f.name}</option>)}
294
+ {nav.decide && !picked && !catalog.loading && <option value={nav.decide}>{nav.decide} (not registered)</option>}
295
+ </select>
296
+ {picked && picked.description && <p className="xeplr-factory-fd-contract-desc">{picked.description}</p>}
297
+ <fieldset className="xeplr-factory-fd-targets">
298
+ <legend>It may choose</legend>
299
+ {others.map((s) => (
300
+ <label key={s.stepKey} className="xeplr-factory-fd-target">
301
+ <input
302
+ type="checkbox"
303
+ checked={(nav.targets || []).includes(s.stepKey)}
304
+ onChange={(e) => set({ targets: e.target.checked ? [...(nav.targets || []), s.stepKey] : (nav.targets || []).filter((k) => k !== s.stepKey) })}
305
+ />
306
+ <span>{s.label || s.stepKey}</span>
307
+ </label>
308
+ ))}
309
+ <label className="xeplr-factory-fd-target">
310
+ <input
311
+ type="checkbox"
312
+ checked={(nav.targets || []).includes(FLOW_END)}
313
+ onChange={(e) => set({ targets: e.target.checked ? [...(nav.targets || []), FLOW_END] : (nav.targets || []).filter((k) => k !== FLOW_END) })}
314
+ />
315
+ <span>Finish the journey</span>
316
+ </label>
317
+ </fieldset>
318
+ <p className="xeplr-factory-hint">It is handed what was filled in and returns one of these — anything else stops the journey and says so.</p>
319
+ </div>
320
+ )}
321
+ </div>
322
+
323
+ <div className={'xeplr-factory-fd-choice' + (nav.mode === 'end' ? ' is-on' : '')}>
324
+ <label className="xeplr-factory-fd-choice-pick">
325
+ <input type="radio" name={name} checked={nav.mode === 'end'} onChange={() => ctrl.setNavigation(step.stepKey, { mode: 'end' })} />
326
+ <span>Finish the journey</span>
327
+ </label>
328
+ </div>
329
+ </div>
330
+ )}
331
+
332
+ {problems.length > 0 && (
333
+ <div className="xeplr-factory-invalid" role="alert">
334
+ {problems.map((p, i) => <div key={i}>{p.message}</div>)}
335
+ </div>
336
+ )}
337
+ </section>
338
+ )
339
+ }
340
+
341
+ function navigationOfStep(ctrl, step) {
342
+ return ctrl.navigationOf(step)
343
+ }
344
+
345
+ /** "→ Contract", "→ routeByAmount decides", "Finishes" — under each step in the list. */
346
+ function nextInWords(ctrl, step) {
347
+ const nav = ctrl.navigationOf(step)
348
+ if (nav.mode === 'screen') return '→ ' + targetName(ctrl, nav.target)
349
+ if (nav.mode === 'decide') return nav.decide ? `→ ${nav.decide} decides` : '→ a function decides'
350
+ if (nav.mode === 'branches') return `→ ${step.transitions.length} branches`
351
+ if (nav.mode === 'end') return 'Finishes the journey'
352
+ return 'Where next?'
353
+ }
354
+
355
+ function targetName(ctrl, key) {
356
+ if (!key) return '…'
357
+ if (key === FLOW_END) return 'Finish'
358
+ const s = ctrl.flow.steps.find((x) => x.stepKey === key)
359
+ return s ? (s.label || s.stepKey) : key
360
+ }
361
+
362
+ function screenName(ctrl, id) {
363
+ const hit = ctrl.screens.find((s) => s.id === id)
364
+ return (hit && hit.name) || id
365
+ }
366
+
367
+ function fieldLabel(ctrl, step, when) {
368
+ if (!when) return null
369
+ const hit = ctrl.fieldsFor(step.stepKey).find((f) => f.name === when.field)
370
+ return hit ? hit.label : when.field
371
+ }
@@ -26,6 +26,7 @@
26
26
  .xeplr-factory-screen,
27
27
  .xeplr-factory-canvas,
28
28
  .xeplr-factory-flow-builder,
29
+ .xeplr-factory-fd,
29
30
  .xeplr-factory-builder,
30
31
  .xeplr-factory-modal-backdrop {
31
32
  --xf-accent: var(--xeplr-accent, #2563eb);
@@ -107,7 +108,7 @@
107
108
  the theme's muted ink and follows a dark theme. Only background-image and
108
109
  its longhands are set: each box keeps its own colour, and a control's
109
110
  Background (boxStyle) is a background-COLOR so it cannot wipe the arrow. */
110
- :is(.xeplr-factory-screen, .xeplr-factory-canvas, .xeplr-factory-flow-builder, .xeplr-factory-builder, .xeplr-factory-modal-backdrop) select:not([multiple]) {
111
+ :is(.xeplr-factory-screen, .xeplr-factory-canvas, .xeplr-factory-flow-builder, .xeplr-factory-fd, .xeplr-factory-builder, .xeplr-factory-modal-backdrop) select:not([multiple]) {
111
112
  -webkit-appearance: none;
112
113
  appearance: none;
113
114
  /* Two thin diagonals side by side — each corner to corner of its own small
@@ -122,7 +123,7 @@
122
123
  text-overflow: ellipsis;
123
124
  cursor: pointer;
124
125
  }
125
- :is(.xeplr-factory-screen, .xeplr-factory-canvas, .xeplr-factory-flow-builder, .xeplr-factory-builder, .xeplr-factory-modal-backdrop) select:not([multiple]):disabled { cursor: default; opacity: .6; }
126
+ :is(.xeplr-factory-screen, .xeplr-factory-canvas, .xeplr-factory-flow-builder, .xeplr-factory-fd, .xeplr-factory-builder, .xeplr-factory-modal-backdrop) select:not([multiple]):disabled { cursor: default; opacity: .6; }
126
127
  .xeplr-factory-field.has-error .xeplr-factory-input { border-color: var(--xf-danger); }
127
128
  .xeplr-factory-error {
128
129
  /* In the gap BELOW the field, out of flow: a message appearing must not
@@ -524,3 +525,115 @@
524
525
  .xeplr-factory-modal-body { flex: 1; min-height: 0; overflow: hidden; }
525
526
  .xeplr-factory-modal-body > * { height: 100%; }
526
527
  .xeplr-factory-builder--nested { min-height: 0; }
528
+
529
+ /* ── the flow designer, as a list (FlowDesignerSample) ────────────────────
530
+ The journey's screens down the left; for the one picked, two cards side by
531
+ side — the screen's events, and where the journey goes after it. Wears the
532
+ builder's bar; everything else is its own. */
533
+ .xeplr-factory-fd {
534
+ display: flex; flex-direction: column; height: 100%; min-height: 560px;
535
+ color: var(--xf-ink); background: var(--xf-plane); font-size: 13px;
536
+ }
537
+ .xeplr-factory-fd select, .xeplr-factory-fd-step-name {
538
+ font: inherit; color: var(--xf-ink); background-color: var(--xf-field-bg);
539
+ border: 1px solid var(--xf-field-border); border-radius: 6px; padding: 6px 8px; min-width: 0;
540
+ }
541
+ .xeplr-factory-fd select:focus-visible, .xeplr-factory-fd-step-name:focus-visible {
542
+ outline: none; border-color: var(--xf-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--xf-accent) 22%, transparent);
543
+ }
544
+ .xeplr-factory-fd-body { flex: 1; min-height: 0; display: flex; }
545
+
546
+ /* the steps */
547
+ .xeplr-factory-fd-steps {
548
+ width: 260px; flex: 0 0 auto; display: flex; flex-direction: column; gap: 12px;
549
+ padding: 14px 12px; border-right: 1px solid var(--xf-border); background: var(--xf-surface); overflow-y: auto;
550
+ }
551
+ .xeplr-factory-fd-step-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
552
+ .xeplr-factory-fd-step {
553
+ display: flex; align-items: flex-start; gap: 10px; width: 100%; padding: 9px 10px; text-align: left;
554
+ font: inherit; color: inherit; background: transparent; cursor: pointer;
555
+ border: 1px solid transparent; border-radius: 8px;
556
+ }
557
+ .xeplr-factory-fd-step:hover { background: var(--xf-hover); }
558
+ .xeplr-factory-fd-step.is-selected { border-color: color-mix(in srgb, var(--xf-accent) 55%, var(--xf-border)); background: color-mix(in srgb, var(--xf-accent) 9%, transparent); }
559
+ .xeplr-factory-fd-step.has-error .xeplr-factory-fd-step-number { background: var(--xf-danger); color: #fff; }
560
+ .xeplr-factory-fd-step-number {
561
+ flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
562
+ font-size: 11px; font-weight: 700; background: var(--xf-hover); color: var(--xf-muted);
563
+ }
564
+ .xeplr-factory-fd-step.is-selected .xeplr-factory-fd-step-number { background: var(--xf-accent); color: var(--xf-accent-ink); }
565
+ .xeplr-factory-fd-step-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
566
+ .xeplr-factory-fd-step-label { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
567
+ .xeplr-factory-fd-step-next { color: var(--xf-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
568
+ .xeplr-factory-fd-add { display: flex; flex-direction: column; gap: 4px; padding-top: 10px; border-top: 1px solid var(--xf-border); }
569
+ .xeplr-factory-fd-add-label { font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--xf-muted); }
570
+
571
+ /* the step that is open */
572
+ .xeplr-factory-fd-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow-y: auto; }
573
+ .xeplr-factory-fd-empty { margin: auto; color: var(--xf-muted); }
574
+ .xeplr-factory-fd-head { display: flex; align-items: center; gap: 12px; padding: 14px 20px 0; flex-wrap: wrap; }
575
+ .xeplr-factory-fd-step-name { font-size: 18px; font-weight: 650; background: transparent; border-color: transparent; padding: 4px 8px; margin-left: -8px; min-width: 220px; }
576
+ .xeplr-factory-fd-step-name:hover { border-color: var(--xf-border); }
577
+ .xeplr-factory-fd-step-screen { color: var(--xf-muted); }
578
+ .xeplr-factory-fd-step-screen code { color: var(--xf-ink); }
579
+ .xeplr-factory-fd-remove { margin-left: auto; font: inherit; font-size: 12px; color: var(--xf-danger); background: none; border: none; cursor: pointer; padding: 4px 6px; border-radius: 6px; }
580
+ .xeplr-factory-fd-remove:hover { background: color-mix(in srgb, var(--xf-danger) 10%, transparent); }
581
+ .xeplr-factory-fd-tabs { display: flex; gap: 4px; padding: 10px 20px 0; border-bottom: 1px solid var(--xf-border); }
582
+ .xeplr-factory-fd-tab {
583
+ font: inherit; font-weight: 500; color: var(--xf-muted); background: none; cursor: pointer;
584
+ border: none; border-bottom: 2px solid transparent; padding: 8px 12px; margin-bottom: -1px;
585
+ }
586
+ .xeplr-factory-fd-tab:hover { color: var(--xf-ink); }
587
+ .xeplr-factory-fd-tab.is-active { color: var(--xf-accent); border-bottom-color: var(--xf-accent); font-weight: 600; }
588
+ .xeplr-factory-fd-design { flex: 1; min-height: 560px; display: flex; }
589
+ .xeplr-factory-fd-design > * { flex: 1; min-width: 0; }
590
+ .xeplr-factory-fd-settings { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 16px; padding: 16px 20px 24px; align-items: start; }
591
+ @container (max-width: 900px) { .xeplr-factory-fd-settings { grid-template-columns: minmax(0, 1fr); } }
592
+ @media (max-width: 1100px) { .xeplr-factory-fd-settings { grid-template-columns: minmax(0, 1fr); } }
593
+
594
+ /* a card: the events, or the navigation */
595
+ .xeplr-factory-fd-card { display: flex; flex-direction: column; gap: 12px; padding: 16px; border: 1px solid var(--xf-border); border-radius: 12px; background: var(--xf-surface); }
596
+ .xeplr-factory-fd-card-head h3 { margin: 0 0 4px; font-size: 15px; }
597
+ .xeplr-factory-fd-card-head p { margin: 0; color: var(--xf-muted); line-height: 1.45; }
598
+ .xeplr-factory-fd-card-foot { display: flex; align-items: center; gap: 12px; justify-content: space-between; padding-top: 4px; }
599
+ .xeplr-factory-fd-save { color: var(--xf-muted); font-size: 12px; }
600
+ .xeplr-factory-fd-save--saved { color: var(--xf-good); }
601
+ .xeplr-factory-fd-save--error { color: var(--xf-danger); }
602
+ .xeplr-factory-fd-result { font-size: 12px; padding: 8px 10px; border-radius: 8px; }
603
+ .xeplr-factory-fd-result.is-ok { color: var(--xf-good); background: color-mix(in srgb, var(--xf-good) 10%, transparent); }
604
+ .xeplr-factory-fd-result.is-error { color: var(--xf-danger); background: color-mix(in srgb, var(--xf-danger) 10%, transparent); }
605
+
606
+ /* one event */
607
+ .xeplr-factory-fd-events { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
608
+ .xeplr-factory-fd-event {
609
+ display: grid; grid-template-columns: minmax(0, 1fr) minmax(180px, 240px); gap: 6px 14px; align-items: center;
610
+ padding: 10px 12px; border: 1px solid var(--xf-border); border-radius: 10px;
611
+ }
612
+ .xeplr-factory-fd-event.is-on { border-color: color-mix(in srgb, var(--xf-accent) 40%, var(--xf-border)); }
613
+ .xeplr-factory-fd-event.has-error { border-color: var(--xf-danger); }
614
+ .xeplr-factory-fd-event-what { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
615
+ .xeplr-factory-fd-event-name { font-weight: 600; }
616
+ .xeplr-factory-fd-event-when { color: var(--xf-muted); font-size: 12px; line-height: 1.4; }
617
+ .xeplr-factory-fd-contract { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 6px; padding: 8px 10px; border-radius: 8px; background: var(--xf-plane); font-size: 12px; }
618
+ .xeplr-factory-fd-contract dl { display: grid; grid-template-columns: auto 1fr; gap: 3px 10px; margin: 0; }
619
+ .xeplr-factory-fd-contract dt { color: var(--xf-muted); font-weight: 600; }
620
+ .xeplr-factory-fd-contract dd { margin: 0; }
621
+ .xeplr-factory-fd-contract-desc { margin: 0; }
622
+ .xeplr-factory-fd-contract-gone { margin: 0; color: var(--xf-danger); }
623
+
624
+ /* navigation */
625
+ .xeplr-factory-fd-choices { display: flex; flex-direction: column; gap: 8px; }
626
+ .xeplr-factory-fd-choice { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border: 1px solid var(--xf-border); border-radius: 10px; }
627
+ .xeplr-factory-fd-choice.is-on { border-color: color-mix(in srgb, var(--xf-accent) 45%, var(--xf-border)); background: color-mix(in srgb, var(--xf-accent) 6%, transparent); }
628
+ .xeplr-factory-fd-choice-pick { display: flex; align-items: center; gap: 8px; font-weight: 600; cursor: pointer; }
629
+ .xeplr-factory-fd-choice-pick input { accent-color: var(--xf-accent); margin: 0; }
630
+ .xeplr-factory-fd-choice > select { margin-left: 24px; }
631
+ .xeplr-factory-fd-decide { display: flex; flex-direction: column; gap: 8px; margin-left: 24px; }
632
+ .xeplr-factory-fd-targets { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 8px 10px; border: 1px solid var(--xf-border); border-radius: 8px; }
633
+ .xeplr-factory-fd-targets legend { padding: 0 4px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--xf-muted); }
634
+ .xeplr-factory-fd-target { display: flex; align-items: center; gap: 8px; cursor: pointer; }
635
+ .xeplr-factory-fd-target input { accent-color: var(--xf-accent); margin: 0; }
636
+ .xeplr-factory-fd-branches { display: flex; flex-direction: column; gap: 8px; }
637
+ .xeplr-factory-fd-branches p { margin: 0; color: var(--xf-muted); }
638
+ .xeplr-factory-fd-branches ul { margin: 0; padding-left: 18px; }
639
+ .xeplr-factory-fd-branches button { align-self: flex-start; }
@@ -6,6 +6,8 @@ export { default as ListView } from './ListView.jsx'
6
6
  export { default as ScreenModal } from './ScreenModal.jsx'
7
7
  export { default as FlowRunnerSample } from './FlowRunnerSample.jsx'
8
8
  export { default as FlowBuilderSample } from './FlowBuilderSample.jsx'
9
+ // The flow designer as a list of screens, with each one's events and navigation.
10
+ export { default as FlowDesignerSample, FLOW_DESIGNER_TABS } from './FlowDesignerSample.jsx'
9
11
  export { default as ControlView, VIEWS } from './ControlView.jsx'
10
12
  export { default as Palette, DRAG_TYPE } from './Palette.jsx'
11
13
  export { default as PropertyPanel, EDITORS } from './PropertyPanel.jsx'
package/src/events.js ADDED
@@ -0,0 +1,100 @@
1
+ // A SCREEN'S EVENTS — the moments a developer's code may run around its
2
+ // records, each picked BY NAME from the functions the app registered.
3
+ //
4
+ // The code lives in the app (registered with @xeplr/actions, `event` set to
5
+ // one of the keys below); the screen only names it:
6
+ //
7
+ // doc.events = { beforeSave: 'checkSalaryBand', afterSave: 'notifyHR' }
8
+ //
9
+ // One function per event, and this is the ONLY way to attach one — the screen
10
+ // is the one place that says what runs. Every event runs on the SERVER: a rule
11
+ // that runs in the browser is one a person can skip.
12
+ //
13
+ // What each is called with, and what it may return, is the contract a
14
+ // developer writes to. `@xeplr/factory` runs them; the designer lists them.
15
+
16
+ export const SCREEN_EVENTS = [
17
+ {
18
+ key: 'beforeLoad',
19
+ label: 'Before load',
20
+ when: 'Before records are read — the list, and one record for Edit',
21
+ gets: 'ctx.query (narrow it), ctx.id (one record) or ctx.many (the list), ctx.user, ctx.tenant',
22
+ returns: 'nothing, or a new query'
23
+ },
24
+ {
25
+ key: 'beforeSave',
26
+ label: 'Before save',
27
+ when: 'Before a record is written — the screen\'s rules run on what it returns',
28
+ gets: 'ctx.values, ctx.isNew, ctx.previous (on update), ctx.user',
29
+ returns: 'the values to save (or nothing to keep them); ctx.reject(message, { field }) refuses'
30
+ },
31
+ {
32
+ key: 'afterSave',
33
+ label: 'After save',
34
+ when: 'Once the record is saved',
35
+ gets: 'ctx.result (the saved row), ctx.isNew, ctx.values',
36
+ returns: 'nothing — what it does is the point (an email, another record)'
37
+ },
38
+ {
39
+ key: 'beforeDelete',
40
+ label: 'Before delete',
41
+ when: 'Before a record is deleted',
42
+ gets: 'ctx.previous (the row), ctx.id',
43
+ returns: 'nothing; ctx.reject(message) refuses'
44
+ },
45
+ {
46
+ key: 'onError',
47
+ label: 'On error',
48
+ when: 'When loading, saving or deleting failed',
49
+ gets: 'ctx.error, ctx.op (get, save or delete)',
50
+ returns: 'nothing — the error is still the answer; this is for telling someone'
51
+ }
52
+ ]
53
+
54
+ export const SCREEN_EVENT_KEYS = SCREEN_EVENTS.map((e) => e.key)
55
+
56
+ /**
57
+ * A FLOW's "let a function decide": the function an arrow asks for the next
58
+ * step. Not a screen event — the workflow engine runs it, between screens.
59
+ */
60
+ export const DECIDE_EVENT = 'decide'
61
+
62
+ /** A function's name: what it was registered under. */
63
+ export const FUNCTION_NAME = /^[A-Za-z_][\w.-]{0,99}$/
64
+
65
+ /** The functions a screen names, as [{ event, name }] — what has to be registered for it to run. */
66
+ export function screenFunctions(doc) {
67
+ const ev = (doc && doc.events) || {}
68
+ return SCREEN_EVENT_KEYS.filter((k) => typeof ev[k] === 'string' && ev[k]).map((k) => ({ event: k, name: ev[k] }))
69
+ }
70
+
71
+ /**
72
+ * Which of a screen's functions are not registered, or are registered for a
73
+ * different event — given the registry's list ([{ name, event }]). Empty when
74
+ * the screen can run. Said by name, so the fix is obvious.
75
+ */
76
+ export function missingFunctions(doc, registered) {
77
+ const byName = new Map((registered || []).map((f) => [f.name, f]))
78
+ return screenFunctions(doc).flatMap(({ event, name }) => {
79
+ const f = byName.get(name)
80
+ if (!f) return [{ event, name, problem: `"${name}" is not a registered function` }]
81
+ if (f.event !== event) return [{ event, name, problem: `"${name}" is written for ${f.event ? `"${f.event}"` : 'a workflow step'}, not "${event}"` }]
82
+ return []
83
+ })
84
+ }
85
+
86
+ /** The screen with one event set to a function — or cleared, given nothing. */
87
+ export function setEvent(doc, key, name) {
88
+ if (!SCREEN_EVENT_KEYS.includes(key)) throw new Error(`"${key}" is not an event — one of: ${SCREEN_EVENT_KEYS.join(', ')}`)
89
+ const events = { ...((doc && doc.events) || {}) }
90
+ if (name) events[key] = name
91
+ else delete events[key]
92
+ const next = { ...doc, events }
93
+ if (!Object.keys(events).length) delete next.events
94
+ return next
95
+ }
96
+
97
+ /** The functions a picker offers one event: those registered for it, by name. */
98
+ export function functionsFor(functions, event) {
99
+ return (functions || []).filter((f) => f && f.event === event).slice().sort((a, b) => a.name.localeCompare(b.name))
100
+ }
package/src/flow.js CHANGED
@@ -21,6 +21,12 @@
21
21
  // …
22
22
  // ] }
23
23
  //
24
+ // LET A FUNCTION DECIDE: an arrow may name one of the app's registered
25
+ // functions (@xeplr/actions, event 'decide') instead of a step. The engine
26
+ // asks it where to go; `targets` are the steps it may choose (none: any):
27
+ //
28
+ // { when: null, decide: 'routeByAmount', targets: ['review', 'thanks'] }
29
+ //
24
30
  // WHO RUNS IT: the workflow engine, not the browser. A run remembers where it
25
31
  // got to, so a journey can be left and picked up later — by someone else, on
26
32
  // another day. The browser only shows the screen it is told to and sends back
@@ -55,6 +61,18 @@ export const FLOW_OPERATORS = [
55
61
  export const VALUELESS_OPERATORS = ['isEmpty', 'isNotEmpty']
56
62
 
57
63
  const KEY = /^[A-Za-z][A-Za-z0-9_]*$/
64
+ // A registered function's name — the same rule as events.js.
65
+ const FUNCTION = /^[A-Za-z_][\w.-]{0,99}$/
66
+
67
+ /**
68
+ * Where an arrow can lead: its target, or — when a function decides — the
69
+ * steps it may choose, and every step when it lists none.
70
+ */
71
+ export function arrowTargets(flow, t) {
72
+ if (!t) return []
73
+ if (t.decide) return Array.isArray(t.targets) && t.targets.length ? t.targets : flow.steps.map((s) => s.stepKey)
74
+ return t.target ? [t.target] : []
75
+ }
58
76
  const MAX_KEY = 63
59
77
 
60
78
  /** An empty flow, ready to have screens dropped on it. */
@@ -126,7 +144,12 @@ export function removeStep(flow, stepKey) {
126
144
  ...flow,
127
145
  steps: flow.steps
128
146
  .filter((s) => s.stepKey !== stepKey)
129
- .map((s) => ({ ...s, transitions: s.transitions.filter((t) => t.target !== stepKey) }))
147
+ .map((s) => ({
148
+ ...s,
149
+ transitions: s.transitions
150
+ .filter((t) => t.decide || t.target !== stepKey)
151
+ .map((t) => (t.decide && Array.isArray(t.targets) ? { ...t, targets: t.targets.filter((k) => k !== stepKey) } : t))
152
+ }))
130
153
  }
131
154
  }
132
155
 
@@ -162,7 +185,9 @@ export function removeTransition(flow, fromKey, index) {
162
185
  /** The step a journey starts at: the first one that nothing else points to, else the first. */
163
186
  export function firstStep(flow) {
164
187
  if (!flow.steps.length) return null
165
- const pointedAt = new Set(flow.steps.flatMap((s) => s.transitions.map((t) => t.target)))
188
+ // A function that may go anywhere points at nothing in particular — it does
189
+ // not make every step look like the middle of the journey.
190
+ const pointedAt = new Set(flow.steps.flatMap((s) => s.transitions.flatMap((t) => (t.decide && !(t.targets && t.targets.length) ? [] : arrowTargets(flow, t)))))
166
191
  return flow.steps.find((s) => !pointedAt.has(s.stepKey)) || flow.steps[0]
167
192
  }
168
193
 
@@ -177,7 +202,7 @@ export function reachableSteps(flow) {
177
202
  const step = byKey.get(key)
178
203
  if (!step) return
179
204
  seen.add(key)
180
- step.transitions.forEach((t) => walk(t.target))
205
+ step.transitions.forEach((t) => arrowTargets(flow, t).forEach(walk))
181
206
  }
182
207
  walk(start.stepKey)
183
208
  return seen
@@ -200,9 +225,11 @@ export function layoutFlow(flow, size = {}) {
200
225
  while (queue.length) {
201
226
  const key = queue.shift()
202
227
  ;(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)
228
+ arrowTargets(flow, t).forEach((target) => {
229
+ if (!byKey.has(target) || depth.has(target)) return
230
+ depth.set(target, depth.get(key) + 1)
231
+ queue.push(target)
232
+ })
206
233
  })
207
234
  }
208
235
  // Steps nothing reaches go in a column of their own at the end, where they
@@ -264,7 +291,10 @@ export function validateFlow(flow, options = {}) {
264
291
  step.transitions.forEach((t, j) => {
265
292
  const tp = `${at}.transitions[${j}]`
266
293
  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}"`)
294
+ if (t.decide !== undefined && t.decide !== null && t.decide !== '') {
295
+ if (!FUNCTION.test(String(t.decide))) err(`${tp}.decide`, 'must be the name of a registered function, e.g. "routeByAmount"')
296
+ if (t.targets !== undefined && !(Array.isArray(t.targets) && t.targets.every((k) => typeof k === 'string' && k))) err(`${tp}.targets`, 'must be a list of the steps the function may choose')
297
+ } else if (!String(t.target || '').trim()) err(`${tp}.target`, `must name the step this arrow goes to, or "${FLOW_END}" — or a function to decide`)
268
298
  if (t.when === null || t.when === undefined) {
269
299
  if (otherwiseAt !== -1) err(tp, 'a step can have only one "otherwise" — the others need a test')
270
300
  otherwiseAt = j
@@ -278,6 +308,12 @@ export function validateFlow(flow, options = {}) {
278
308
  // Targets are checked once every step key is known.
279
309
  flow.steps.forEach((step, i) => {
280
310
  ;(step.transitions || []).forEach((t, j) => {
311
+ if (t && t.decide) {
312
+ ;(t.targets || []).forEach((k) => {
313
+ if (k !== FLOW_END && !keys.has(k)) err(`steps[${i}].transitions[${j}].targets`, `"${t.decide}" may choose "${k}", which is not a step of this flow`)
314
+ })
315
+ return
316
+ }
281
317
  const target = t && t.target
282
318
  if (!target || target === FLOW_END) return
283
319
  if (!keys.has(target)) err(`steps[${i}].transitions[${j}].target`, `"${target}" is not a step of this flow`)
@@ -304,6 +340,41 @@ function checkWhen(when, at, err) {
304
340
  }
305
341
  }
306
342
 
343
+ // ── navigation: where a step goes once its screen is saved ──────────────
344
+ //
345
+ // What the designer's Navigation panel says, in three plain choices — the
346
+ // common case of a step's arrows. A step whose arrows test its fields
347
+ // ("branches") is drawn with those; the panel says so rather than flattening
348
+ // them.
349
+
350
+ /**
351
+ * How a step moves on:
352
+ * { mode: 'screen', target } — always to this step
353
+ * { mode: 'decide', decide, targets } — a registered function chooses
354
+ * { mode: 'end' } — the journey finishes here
355
+ * { mode: 'branches' } — arrows that test the screen's fields
356
+ * { mode: 'none' } — nothing chosen yet
357
+ */
358
+ export function navigationOf(step) {
359
+ const ts = (step && step.transitions) || []
360
+ if (!ts.length) return { mode: 'none' }
361
+ if (ts.some((t) => t.when)) return { mode: 'branches' }
362
+ const t = ts[0]
363
+ if (t.decide) return { mode: 'decide', decide: t.decide, targets: t.targets || [] }
364
+ if (t.target === FLOW_END) return { mode: 'end' }
365
+ return { mode: 'screen', target: t.target }
366
+ }
367
+
368
+ /** Sets how a step moves on — one of navigationOf's shapes (not 'branches'). */
369
+ export function setNavigation(flow, stepKey, nav) {
370
+ const n = nav || {}
371
+ const transitions = n.mode === 'decide' ? [{ when: null, decide: n.decide || '', targets: n.targets || [] }]
372
+ : n.mode === 'end' ? [{ when: null, target: FLOW_END }]
373
+ : n.mode === 'screen' ? [{ when: null, target: n.target || '' }]
374
+ : []
375
+ return mapStep(flow, stepKey, (s) => ({ ...s, transitions }))
376
+ }
377
+
307
378
  /** A test in words: "Type is contractor". */
308
379
  export function describeWhen(when, fieldLabel) {
309
380
  if (!when) return 'otherwise'
package/src/index.js CHANGED
@@ -19,12 +19,16 @@ export { describeScreen, screenStub, loadScreens, keyFromName, editScreenOf, mas
19
19
  export { masterSpec, createMaster, possibleParents, singularOf, MASTER_LABEL, MASTER_ORDER, childSpec, createChildTable } from './masters.js'
20
20
  export { useFlowRun } from './useFlowRun.js'
21
21
  export { useFlowBuilder, FLOW_AUTOSAVE_DELAY } from './useFlowBuilder.js'
22
+ // A screen's events, set from outside its builder, and the app's functions to pick from.
23
+ export { useScreenEvents } from './useScreenEvents.js'
24
+ export { useFunctionCatalog } from './useFunctionCatalog.js'
25
+ export { useUrlTab } from './useUrlPlaces.js'
22
26
 
23
27
  // Designs
24
- export { BuilderSample, ScreenSample, ListView, ScreenModal, FlowRunnerSample, FlowBuilderSample, ScreenEditorSample, ControlView, VIEWS, Palette, DRAG_TYPE, PropertyPanel, EDITORS } from './designs/index.js'
28
+ export { BuilderSample, ScreenSample, ListView, ScreenModal, FlowRunnerSample, FlowBuilderSample, FlowDesignerSample, FLOW_DESIGNER_TABS, ScreenEditorSample, ControlView, VIEWS, Palette, DRAG_TYPE, PropertyPanel, EDITORS } from './designs/index.js'
25
29
 
26
30
  // Front-end hooks — extend FactoryHooks, override what you need, call super
27
31
  export { FactoryHooks } from './hooks.js'
28
32
 
29
33
  // Ready-made
30
- export { FactoryBuilder, FactoryScreen, FlowRunner, FlowBuilder } from './pages.jsx'
34
+ export { FactoryBuilder, FactoryScreen, FlowRunner, FlowBuilder, FlowDesigner } from './pages.jsx'
package/src/model.js CHANGED
@@ -12,6 +12,8 @@ export {
12
12
  steppers, stepsOf, stepOf, setNodeStep, nodesForSteps, resolveStep, reachableStep, nextOpenStep
13
13
  } from './document.js'
14
14
  export { validateDocument, assertValidDocument } from './validateDocument.js'
15
+ // A screen's events: functions picked by name, run on the server.
16
+ export { SCREEN_EVENTS, SCREEN_EVENT_KEYS, DECIDE_EVENT, FUNCTION_NAME, screenFunctions, missingFunctions, setEvent, functionsFor } from './events.js'
15
17
  export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, chooseable, saveState, recordValues, displayValue, fileLabel, toDbValue, fromDbValue, listColumns, listSource } from './values.js'
16
18
  export { screenFromSpec, screensFromSpec, entityNames, SPEC_HEIGHTS, ROW_GAP } from './generate.js'
17
19
  export { scaffoldEntity } from './scaffold.js'
@@ -31,5 +33,6 @@ export { createFlowsApi } from './flows.js'
31
33
  export {
32
34
  FLOW_KIND, FLOW_VERSION, FLOW_END, FLOW_OPERATORS, VALUELESS_OPERATORS,
33
35
  createFlow, flowKey, addStep, uniqueStepKey, moveStep, setStep, removeStep,
34
- addTransition, setTransition, removeTransition, firstStep, reachableSteps, layoutFlow, validateFlow, describeWhen
36
+ addTransition, setTransition, removeTransition, firstStep, reachableSteps, layoutFlow, validateFlow, describeWhen,
37
+ arrowTargets, navigationOf, setNavigation
35
38
  } from './flow.js'
package/src/pages.jsx CHANGED
@@ -3,7 +3,10 @@ import { useFactoryBuilder } from './useFactoryBuilder.js'
3
3
  import { useFactoryScreen } from './useFactoryScreen.js'
4
4
  import { useFlowRun } from './useFlowRun.js'
5
5
  import { useFlowBuilder } from './useFlowBuilder.js'
6
- import { BuilderSample, ScreenSample, ScreenModal, FlowRunnerSample, FlowBuilderSample } from './designs/index.js'
6
+ import { useScreenEvents } from './useScreenEvents.js'
7
+ import { useFunctionCatalog } from './useFunctionCatalog.js'
8
+ import { useUrlTab } from './useUrlPlaces.js'
9
+ import { BuilderSample, ScreenSample, ScreenModal, FlowRunnerSample, FlowBuilderSample, FlowDesignerSample } from './designs/index.js'
7
10
 
8
11
  /**
9
12
  * The builder, ready-made. Saves itself — there is no Save button.
@@ -210,3 +213,59 @@ export function FlowBuilder(props) {
210
213
  const ctrl = useFlowBuilder(props)
211
214
  return <FlowBuilderSample ctrl={ctrl} className={props.className} style={props.style} />
212
215
  }
216
+
217
+ /**
218
+ * The FLOW designer as a list — the screens of a journey down the left; for
219
+ * the one picked, its form (Form design), the app's functions that run around
220
+ * its records (Events) and where it goes next (Navigation). The design is
221
+ * FlowDesignerSample; its controllers are useFlowBuilder, useScreenEvents and
222
+ * useFunctionCatalog, for a custom design to use directly.
223
+ *
224
+ * @prop flow, name, screens, loadScreen, onSave, onPublish, onChange as FlowBuilder
225
+ * @prop listFunctions async () → [{ name, event, description }] — the app's registered functions
226
+ * @prop loadDraft async (screenId) → { document } — a step's screen, to set its events
227
+ * @prop saveDraft async (document) → void
228
+ * @prop publishScreen async (document) → { version } — makes a screen's events take effect
229
+ * @prop builderProps what FactoryBuilder needs for the Form design tab (createFactoryApi's builderProps)
230
+ * @prop urlKey keep the selected step and tab in the URL (<urlKey>Selected, <urlKey>Tab)
231
+ */
232
+ export function FlowDesigner(props) {
233
+ const ctrl = useFlowBuilder(props)
234
+ const catalog = useFunctionCatalog({ listFunctions: props.listFunctions })
235
+ // The step open on the right: the one picked, else the first — a journey
236
+ // opened with nothing in the URL shows its start, not a blank page.
237
+ const step = ctrl.selectedStep || ctrl.flow.steps[0] || null
238
+ const events = useScreenEvents({
239
+ screen: step ? step.screen : null,
240
+ loadDraft: props.loadDraft,
241
+ saveDraft: props.saveDraft,
242
+ publish: props.publishScreen,
243
+ functions: catalog.functions
244
+ })
245
+ const [tab, setTab] = useUrlTab(props.urlKey, 'settings')
246
+ return (
247
+ <FlowDesignerSample
248
+ ctrl={ctrl}
249
+ step={step}
250
+ events={events}
251
+ catalog={catalog}
252
+ tab={tab}
253
+ onTab={setTab}
254
+ className={props.className}
255
+ style={props.style}
256
+ renderFormDesign={() => (events.document ? (
257
+ <FactoryBuilder
258
+ key={events.document.id}
259
+ {...(props.builderProps || {})}
260
+ document={events.document}
261
+ // One draft, two editors: the builder's changes become the
262
+ // document Events sets functions on (useScreenEvents.adopt).
263
+ onChange={events.adopt}
264
+ screens={(props.screens || []).map((s) => ({ id: s.id, name: s.name }))}
265
+ />
266
+ ) : (
267
+ <div className="xeplr-factory-hint">{events.error || 'Opening the screen…'}</div>
268
+ ))}
269
+ />
270
+ )
271
+ }
package/src/remote.js CHANGED
@@ -97,6 +97,9 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
97
97
  confirmConvert: (options && options.confirmConvert) || []
98
98
  })),
99
99
  listTables: () => call('GET', '/tables'),
100
+ // The app's registered functions a screen's events — and a flow's "let a
101
+ // function decide" — can pick: [{ name, event, description }], never code.
102
+ listFunctions: () => call('GET', '/functions'),
100
103
  // DELETE A FORM'S SCREEN. The design always goes; the table only when it
101
104
  // is empty and nothing else describes it — the answer says which
102
105
  // happened, and how many records kept it. Refused while another
package/src/urlState.js CHANGED
@@ -12,11 +12,11 @@
12
12
 
13
13
  /**
14
14
  * The query keys under a host's `urlKey`, or null (in memory).
15
- * factoryUrlKeys('form') → { steps: 'formSteps', selected: 'formSelected' }
15
+ * factoryUrlKeys('form') → { steps: 'formSteps', selected: 'formSelected', tab: 'formTab' }
16
16
  */
17
17
  export function factoryUrlKeys(urlKey) {
18
18
  if (!urlKey) return null
19
- return { steps: `${urlKey}Steps`, selected: `${urlKey}Selected` }
19
+ return { steps: `${urlKey}Steps`, selected: `${urlKey}Selected`, tab: `${urlKey}Tab` }
20
20
  }
21
21
 
22
22
  /**
@@ -1,7 +1,8 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import {
3
3
  createFlow, addStep, moveStep, setStep, removeStep,
4
- addTransition, setTransition, removeTransition, validateFlow, firstStep, layoutFlow, FLOW_END
4
+ addTransition, setTransition, removeTransition, validateFlow, firstStep, layoutFlow, FLOW_END,
5
+ navigationOf, setNavigation
5
6
  } from './flow.js'
6
7
  import { inputNodes } from './document.js'
7
8
  import { useUrlSelection } from './useUrlPlaces.js'
@@ -115,6 +116,8 @@ export function useFlowBuilder({ flow: given, name, screens = [], loadScreen, on
115
116
  const connect = useCallback((fromKey, target, when = null) => update((f) => addTransition(f, fromKey, target, when)), [update])
116
117
  const changeTransition = useCallback((fromKey, index, patch) => update((f) => setTransition(f, fromKey, index, patch)), [update])
117
118
  const disconnect = useCallback((fromKey, index) => update((f) => removeTransition(f, fromKey, index)), [update])
119
+ /** Where a step goes once its screen is saved — a screen, a function's choice, or the end (flow.js). */
120
+ const changeNavigation = useCallback((stepKey, nav) => update((f) => setNavigation(f, stepKey, nav)), [update])
118
121
  /** Lays every step out by where it falls in the journey. */
119
122
  const tidy = useCallback(() => update((f) => layoutFlow(f)), [update])
120
123
 
@@ -174,6 +177,8 @@ export function useFlowBuilder({ flow: given, name, screens = [], loadScreen, on
174
177
  connect,
175
178
  setTransition: changeTransition,
176
179
  disconnect,
180
+ navigationOf,
181
+ setNavigation: changeNavigation,
177
182
  tidy,
178
183
  end: FLOW_END,
179
184
  status,
@@ -0,0 +1,41 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { functionsFor } from './events.js'
3
+
4
+ // THE APP'S FUNCTIONS — what a screen's events and a flow's "let a function
5
+ // decide" can pick. Registered in the app's code (@xeplr/actions, with an
6
+ // `event`); the server lists names, events and descriptions, never code.
7
+ // No JSX — a design draws the pickers.
8
+
9
+ /**
10
+ * @param listFunctions async () → [{ name, event, description }] (createFactoryApi's listFunctions)
11
+ */
12
+ export function useFunctionCatalog({ listFunctions } = {}) {
13
+ const [functions, setFunctions] = useState([])
14
+ const [loading, setLoading] = useState(Boolean(listFunctions))
15
+ const [error, setError] = useState(null)
16
+ const listRef = useRef(listFunctions); listRef.current = listFunctions
17
+
18
+ const reload = useCallback(() => {
19
+ if (!listRef.current) { setLoading(false); return Promise.resolve([]) }
20
+ setLoading(true)
21
+ setError(null)
22
+ return Promise.resolve()
23
+ .then(() => listRef.current())
24
+ .then((rows) => { const list = Array.isArray(rows) ? rows : []; setFunctions(list); return list })
25
+ .catch((err) => { setError(err.message || 'Could not list the app\'s functions'); return [] })
26
+ .finally(() => setLoading(false))
27
+ }, [])
28
+
29
+ useEffect(() => { reload() }, [reload])
30
+
31
+ return {
32
+ functions,
33
+ loading,
34
+ error,
35
+ reload,
36
+ /** The functions one event may pick, by name. */
37
+ forEvent: useCallback((event) => functionsFor(functions, event), [functions]),
38
+ /** One function by name — or null: picked once, no longer registered. */
39
+ byName: useCallback((name) => functions.find((f) => f.name === name) || null, [functions])
40
+ }
41
+ }
@@ -0,0 +1,110 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { SCREEN_EVENTS, setEvent as withEvent, missingFunctions } from './events.js'
3
+
4
+ // ONE SCREEN'S EVENTS, edited outside its builder — the flow designer's
5
+ // "Events" panel for the screen a step shows. No JSX.
6
+ //
7
+ // Events belong to the SCREEN, not to the flow: a screen's records behave the
8
+ // same in every flow that shows it. So a change here is a change to that
9
+ // screen's draft — saved at once, like any edit in the builder — and takes
10
+ // effect when the screen is PUBLISHED. Publishing is deliberate and separate,
11
+ // because the draft may hold other changes (a field added in Form design).
12
+
13
+ /**
14
+ * @param screen the screen's id (a step's `screen`), or null
15
+ * @param loadDraft async (id) → { document } — the screen's draft (createFactoryApi)
16
+ * @param saveDraft async (document) → void
17
+ * @param publish async (document) → { version } — refuses with the functions that are missing
18
+ * @param functions [{ name, event }] — the app's registry, to say which picked ones are gone
19
+ */
20
+ export function useScreenEvents({ screen, loadDraft, saveDraft, publish, functions } = {}) {
21
+ const [doc, setDoc] = useState(null)
22
+ const [loading, setLoading] = useState(false)
23
+ const [error, setError] = useState(null)
24
+ const [status, setStatus] = useState('idle') // idle | saving | saved | error
25
+ const [saveError, setSaveError] = useState(null)
26
+ const [publishing, setPublishing] = useState(false)
27
+ const [publishResult, setPublishResult] = useState(null)
28
+ const calls = useRef({}); calls.current = { loadDraft, saveDraft, publish }
29
+ const docRef = useRef(null); docRef.current = doc
30
+
31
+ useEffect(() => {
32
+ setDoc(null); setError(null); setStatus('idle'); setPublishResult(null)
33
+ if (!screen || !calls.current.loadDraft) return undefined
34
+ let cancelled = false
35
+ setLoading(true)
36
+ Promise.resolve()
37
+ .then(() => calls.current.loadDraft(screen))
38
+ .then((row) => { if (!cancelled) setDoc((row && row.document) || row || null) })
39
+ .catch((err) => { if (!cancelled) setError(err.message || `Could not open "${screen}"`) })
40
+ .finally(() => { if (!cancelled) setLoading(false) })
41
+ return () => { cancelled = true }
42
+ }, [screen])
43
+
44
+ /** Pick a function for an event (or clear it with nothing). Saved to the draft at once. */
45
+ const setEvent = useCallback(async (key, name) => {
46
+ const current = docRef.current
47
+ if (!current) return
48
+ const next = withEvent(current, key, name)
49
+ setDoc(next)
50
+ docRef.current = next
51
+ setPublishResult(null)
52
+ if (!calls.current.saveDraft) return
53
+ setStatus('saving')
54
+ setSaveError(null)
55
+ try {
56
+ await calls.current.saveDraft(next)
57
+ setStatus('saved')
58
+ } catch (err) {
59
+ setSaveError(err.message || 'Could not save')
60
+ setStatus('error')
61
+ }
62
+ }, [])
63
+
64
+ const publishNow = useCallback(async () => {
65
+ const current = docRef.current
66
+ if (!current || !calls.current.publish) return
67
+ setPublishing(true)
68
+ setPublishResult(null)
69
+ try {
70
+ const out = await calls.current.publish(current)
71
+ setPublishResult({ ok: true, message: `Published${out && out.version ? ` — version ${out.version}` : ''}. These events run from the next save.` })
72
+ } catch (err) {
73
+ setPublishResult({ ok: false, message: err.message || 'Could not publish' })
74
+ } finally {
75
+ setPublishing(false)
76
+ }
77
+ }, [])
78
+
79
+ /**
80
+ * The screen's builder changed the same draft (Form design) — take its
81
+ * document as the one events are set on, so neither editor writes back a
82
+ * copy older than the other's.
83
+ */
84
+ const adopt = useCallback((next) => {
85
+ if (!next) return
86
+ setDoc(next)
87
+ docRef.current = next
88
+ }, [])
89
+
90
+ const events = (doc && doc.events) || {}
91
+ return {
92
+ screen,
93
+ document: doc,
94
+ loading,
95
+ error,
96
+ events,
97
+ /** The events a screen has, each with its contract and the function picked for it. */
98
+ rows: SCREEN_EVENTS.map((e) => ({ ...e, picked: events[e.key] || '' })),
99
+ /** Picked functions the app no longer registers, or registered for another event. */
100
+ missing: doc && functions ? missingFunctions(doc, functions) : [],
101
+ setEvent,
102
+ adopt,
103
+ status,
104
+ saveError,
105
+ canPublish: Boolean(publish) && Boolean(doc),
106
+ publish: publishNow,
107
+ publishing,
108
+ publishResult
109
+ }
110
+ }
@@ -46,3 +46,15 @@ export function useUrlSelection(urlKey) {
46
46
  }, [setValue])
47
47
  return [value, select]
48
48
  }
49
+
50
+ /**
51
+ * [tab, setTab] — which tab of a panel is open (the flow designer's Form
52
+ * design / Events & navigation). Switching tabs is a switch, so it replaces
53
+ * the history entry. Absent from the URL is the first tab.
54
+ */
55
+ export function useUrlTab(urlKey, first) {
56
+ const keys = factoryUrlKeys(urlKey)
57
+ const [value, setValue] = useUrlState(keys ? keys.tab : 'tab', { inUrl: Boolean(keys), replace: true })
58
+ const setTab = useCallback((next) => setValue(next === first ? null : next), [setValue, first])
59
+ return [value || first, setTab]
60
+ }
@@ -10,6 +10,7 @@
10
10
  // Loud by design: a screen that half-renders — a dropdown with no options, two
11
11
  // fields writing the same key — looks like a data problem to whoever uses it.
12
12
 
13
+ import { SCREEN_EVENT_KEYS, FUNCTION_NAME } from './events.js'
13
14
  import { CONTROLS, LABEL_VARIANTS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, LAYOUTS, MULTI_SEPARATOR, MAX_FILE_MB, STEP_LIMIT, acceptList } from './controls.js'
14
15
  import { DOCUMENT_KIND, DOCUMENT_VERSION } from './document.js'
15
16
  import { RESERVED_COLUMNS, MAX_IDENTIFIER } from './tableSchema.js'
@@ -18,7 +19,7 @@ import { FIELD_PRESETS, INPUT_TYPES, presetFor } from './presets.js'
18
19
  const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
19
20
  const TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_.$-]*$/
20
21
  const SCREEN_ID = /^[A-Za-z0-9_-]+$/
21
- const DOCUMENT_KEYS = ['kind', 'version', 'id', 'name', 'source', 'units', 'aspect', 'width', 'style', 'description', 'master', 'labelField', 'params', 'nodes']
22
+ const DOCUMENT_KEYS = ['kind', 'version', 'id', 'name', 'source', 'units', 'aspect', 'width', 'style', 'description', 'master', 'labelField', 'params', 'events', 'nodes']
22
23
  const COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i
23
24
 
24
25
  /**
@@ -71,6 +72,17 @@ export function validateDocument(doc, controls = CONTROLS) {
71
72
  // {params.<name>} as its default, so a name that is not a plain identifier
72
73
  // could never be referenced, and a duplicate would make the reference mean
73
74
  // two things.
75
+ // EVENTS — functions picked by name, one per event (see events.js). Whether
76
+ // each is REGISTERED is the server's to say, at publish and at run time:
77
+ // this checks only that the screen asks for something that could exist.
78
+ if (doc.events !== undefined) {
79
+ if (!doc.events || typeof doc.events !== 'object' || Array.isArray(doc.events)) err('events', `must be { ${SCREEN_EVENT_KEYS.join(', ')} } — each the name of a registered function`)
80
+ else Object.keys(doc.events).forEach((k) => {
81
+ const v = doc.events[k]
82
+ if (!SCREEN_EVENT_KEYS.includes(k)) err(`events.${k}`, `is not an event — one of: ${SCREEN_EVENT_KEYS.join(', ')}`)
83
+ else if (v !== null && v !== '' && !(typeof v === 'string' && FUNCTION_NAME.test(v))) err(`events.${k}`, 'must be the name of a registered function, e.g. "checkSalaryBand"')
84
+ })
85
+ }
74
86
  if (doc.params !== undefined) {
75
87
  if (!Array.isArray(doc.params)) err('params', 'must be a list of inputs this screen is called with')
76
88
  else {