@xeplr/ui-factory 1.5.0 → 1.7.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 +42 -0
- package/package.json +1 -1
- package/src/designs/FlowDesignerSample.jsx +371 -0
- package/src/designs/factory.css +115 -2
- package/src/designs/index.js +2 -0
- package/src/events.js +16 -0
- package/src/flow.js +78 -7
- package/src/index.js +6 -2
- package/src/model.js +3 -2
- package/src/pages.jsx +60 -1
- package/src/remote.js +3 -0
- package/src/urlState.js +2 -2
- package/src/useFlowBuilder.js +6 -1
- package/src/useFunctionCatalog.js +41 -0
- package/src/useScreenEvents.js +110 -0
- package/src/useUrlPlaces.js +12 -0
package/README.md
CHANGED
|
@@ -346,6 +346,48 @@ to one of these); the screen only names it:
|
|
|
346
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
347
|
- `SCREEN_EVENTS` carries each event's contract (`when`, `gets`, `returns`) — what the designer shows beside each picker.
|
|
348
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
|
+
|
|
349
391
|
## Masters — the lists the rest of the app points at
|
|
350
392
|
|
|
351
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.
|
|
3
|
+
"version": "1.7.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
|
+
}
|
package/src/designs/factory.css
CHANGED
|
@@ -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; }
|
package/src/designs/index.js
CHANGED
|
@@ -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
CHANGED
|
@@ -82,3 +82,19 @@ export function missingFunctions(doc, registered) {
|
|
|
82
82
|
return []
|
|
83
83
|
})
|
|
84
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) => ({
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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 (
|
|
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
|
@@ -13,7 +13,7 @@ export {
|
|
|
13
13
|
} from './document.js'
|
|
14
14
|
export { validateDocument, assertValidDocument } from './validateDocument.js'
|
|
15
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 } from './events.js'
|
|
16
|
+
export { SCREEN_EVENTS, SCREEN_EVENT_KEYS, DECIDE_EVENT, FUNCTION_NAME, screenFunctions, missingFunctions, setEvent, functionsFor } from './events.js'
|
|
17
17
|
export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, chooseable, saveState, recordValues, displayValue, fileLabel, toDbValue, fromDbValue, listColumns, listSource } from './values.js'
|
|
18
18
|
export { screenFromSpec, screensFromSpec, entityNames, SPEC_HEIGHTS, ROW_GAP } from './generate.js'
|
|
19
19
|
export { scaffoldEntity } from './scaffold.js'
|
|
@@ -33,5 +33,6 @@ export { createFlowsApi } from './flows.js'
|
|
|
33
33
|
export {
|
|
34
34
|
FLOW_KIND, FLOW_VERSION, FLOW_END, FLOW_OPERATORS, VALUELESS_OPERATORS,
|
|
35
35
|
createFlow, flowKey, addStep, uniqueStepKey, moveStep, setStep, removeStep,
|
|
36
|
-
addTransition, setTransition, removeTransition, firstStep, reachableSteps, layoutFlow, validateFlow, describeWhen
|
|
36
|
+
addTransition, setTransition, removeTransition, firstStep, reachableSteps, layoutFlow, validateFlow, describeWhen,
|
|
37
|
+
arrowTargets, navigationOf, setNavigation
|
|
37
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 {
|
|
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
|
/**
|
package/src/useFlowBuilder.js
CHANGED
|
@@ -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
|
+
}
|
package/src/useUrlPlaces.js
CHANGED
|
@@ -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
|
+
}
|