@xeplr/cli 0.3.0 → 0.3.2
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 +1 -15
- package/lib/generate/index.js +1 -63
- package/lib/questions.js +0 -4
- package/package.json +1 -1
- package/templates/CLAUDE.md +0 -1
- package/templates/README.md +6 -25
- package/templates/api/README.md +1 -0
- package/templates/api/app.js +0 -4
- package/templates/api/bin/www +1 -15
- package/templates/api/development.env.tpl +7 -2
- package/templates/api/package.json +1 -3
- package/templates/api/production.env.example +4 -6
- package/templates/api/routes/index.js +0 -5
- package/templates/ui/dotenv.tpl +0 -1
- package/templates/ui/package.json +0 -1
- package/templates/ui/src/App.jsx +0 -20
- package/templates/ui/src/index.css +0 -16
- package/templates/ui/src/menu.js +0 -1
- package/templates/ui/src/pages/ConfigureUI.jsx +5 -4
- package/templates/ui/src/pages/Forms.jsx +120 -17
- package/templates/ui/src/pages/Masters.jsx +114 -0
- package/templates/ui/vite.config.js +1 -7
- package/templates/api/bin/workflow +0 -52
- package/templates/api/migrations-auth/0005_flows_menu.sql +0 -14
- package/templates/api/routes/flows.js +0 -47
- package/templates/api/workflow.js +0 -34
- package/templates/ui/src/api/flows.js +0 -8
- package/templates/ui/src/pages/FlowDesigner.jsx +0 -54
- package/templates/ui/src/pages/Flows.jsx +0 -125
- package/templates/ui/src/pages/FlowsHome.jsx +0 -65
- package/templates/ui/src/pages/Journey.jsx +0 -33
|
@@ -9,27 +9,56 @@
|
|
|
9
9
|
// Claude can make forms too — see "Create a new UI" in CLAUDE.md.
|
|
10
10
|
import { useCallback, useEffect, useState } from 'react'
|
|
11
11
|
import { useNavigate } from 'react-router-dom'
|
|
12
|
-
import { listMenuItems, addMenuItem, removeMenuItem, useAccess } from '@xeplr/ui-account'
|
|
12
|
+
import { listMenuItems, addMenuItem, removeMenuItem, useAccess, getUsers } from '@xeplr/ui-account'
|
|
13
|
+
import { deleteForm } from '@xeplr/ui-factory'
|
|
14
|
+
// THE APP'S OWN DIALOGS. window.confirm cannot say which button is the
|
|
15
|
+
// dangerous one and looks like a different piece of software appearing over
|
|
16
|
+
// this one; a red box pinned to the page stays there until something else
|
|
17
|
+
// replaces it, long after it has been read.
|
|
18
|
+
import { raiseConfirm, raiseSnackbar } from '@xeplr/ui-utils'
|
|
13
19
|
import { factory } from '../api/factory.js'
|
|
14
20
|
import { FORM_MENU_PREFIX } from '../menu.js'
|
|
15
21
|
|
|
16
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* listScreens rows → one entry per form.
|
|
24
|
+
*
|
|
25
|
+
* `description` and `createdBy` travel with it because of the thing that
|
|
26
|
+
* happens once more than a handful of people can make forms: three of them
|
|
27
|
+
* are called "Inspection", and the fields say what each HOLDS, never what it
|
|
28
|
+
* was for or whose it is. Deciding which to keep is otherwise guesswork.
|
|
29
|
+
*/
|
|
17
30
|
function groupForms(rows) {
|
|
18
31
|
const byKey = new Map()
|
|
19
32
|
rows.forEach((row) => {
|
|
20
33
|
const m = /^(.*)_(list|edit)$/.exec(row.screenKey)
|
|
21
34
|
const key = m ? m[1] : row.screenKey
|
|
22
|
-
const entry = byKey.get(key) || {
|
|
35
|
+
const entry = byKey.get(key) || {
|
|
36
|
+
key, name: null, source: null, list: null, edit: null,
|
|
37
|
+
description: null, createdBy: null, createdDate: null
|
|
38
|
+
}
|
|
23
39
|
if (m) entry[m[2]] = row
|
|
24
40
|
else entry.edit = row
|
|
25
41
|
if (m && m[2] === 'list') entry.name = row.name
|
|
26
42
|
entry.name = entry.name || row.name
|
|
27
43
|
entry.source = entry.source || row.source
|
|
44
|
+
entry.description = entry.description || row.description
|
|
45
|
+
// The form's own half is the one that speaks for it; a list screen is
|
|
46
|
+
// only consulted when there is no other answer.
|
|
47
|
+
if (m && m[2] === 'edit') { entry.createdBy = row.createdBy; entry.createdDate = row.createdDate }
|
|
48
|
+
entry.createdBy = entry.createdBy || row.createdBy
|
|
49
|
+
entry.createdDate = entry.createdDate || row.createdDate
|
|
28
50
|
byKey.set(key, entry)
|
|
29
51
|
})
|
|
30
52
|
return [...byKey.values()].sort((a, b) => String(a.name).localeCompare(String(b.name)))
|
|
31
53
|
}
|
|
32
54
|
|
|
55
|
+
/** "3 Feb 2026" — enough to tell two forms apart, no more. */
|
|
56
|
+
function madeOn(when) {
|
|
57
|
+
if (!when) return null
|
|
58
|
+
const d = new Date(when)
|
|
59
|
+
return isNaN(d) ? null : d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
|
|
60
|
+
}
|
|
61
|
+
|
|
33
62
|
function state(screen) {
|
|
34
63
|
if (!screen) return { label: '—', unpublished: false }
|
|
35
64
|
if (screen.version === null) return { label: 'Not published yet', unpublished: true }
|
|
@@ -50,16 +79,19 @@ export default function Forms() {
|
|
|
50
79
|
const navigate = useNavigate()
|
|
51
80
|
const { refreshAccess } = useAccess()
|
|
52
81
|
const [forms, setForms] = useState(null)
|
|
82
|
+
// WHO MADE EACH ONE. The screens table keeps the maker's id; the names live
|
|
83
|
+
// in the sign-in service, so they are fetched once here and matched up.
|
|
84
|
+
// Failing to get them is not an error worth showing — the date alone still
|
|
85
|
+
// tells two forms of the same name apart.
|
|
86
|
+
const [people, setPeople] = useState({})
|
|
53
87
|
const [inMenu, setInMenu] = useState(new Set())
|
|
54
|
-
const [error, setError] = useState(null)
|
|
55
|
-
const [notice, setNotice] = useState(null)
|
|
56
88
|
const [label, setLabel] = useState('')
|
|
57
89
|
const [key, setKey] = useState('')
|
|
58
90
|
const [keyEdited, setKeyEdited] = useState(false)
|
|
59
91
|
const [busy, setBusy] = useState(null)
|
|
60
92
|
|
|
61
93
|
const load = useCallback(() => {
|
|
62
|
-
factory.listScreens().then((rows) =>
|
|
94
|
+
factory.listScreens().then((rows) => setForms(groupForms(rows)), (e) => raiseSnackbar(e.message, { design: 'error' }))
|
|
63
95
|
listMenuItems().then(
|
|
64
96
|
(items) => setInMenu(new Set(items.filter((m) => m.name.startsWith(FORM_MENU_PREFIX) && !m.isHidden).map((m) => m.name.slice(FORM_MENU_PREFIX.length)))),
|
|
65
97
|
() => {}
|
|
@@ -67,6 +99,21 @@ export default function Forms() {
|
|
|
67
99
|
}, [])
|
|
68
100
|
useEffect(load, [load])
|
|
69
101
|
|
|
102
|
+
// Once. A name per row would be one request per form, and these change far
|
|
103
|
+
// more slowly than the page is opened.
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
let cancelled = false
|
|
106
|
+
getUsers()
|
|
107
|
+
.then((rows) => {
|
|
108
|
+
if (cancelled) return
|
|
109
|
+
const by = {}
|
|
110
|
+
;(rows || []).forEach((u) => { by[u.id] = u.name || u.email || u.id })
|
|
111
|
+
setPeople(by)
|
|
112
|
+
})
|
|
113
|
+
.catch(() => {})
|
|
114
|
+
return () => { cancelled = true }
|
|
115
|
+
}, [])
|
|
116
|
+
|
|
70
117
|
const onLabel = (value) => {
|
|
71
118
|
setLabel(value)
|
|
72
119
|
if (!keyEdited) setKey(suggestKey(value))
|
|
@@ -79,28 +126,63 @@ export default function Forms() {
|
|
|
79
126
|
const made = await factory.createEntity({ key: key.trim(), label: label.trim() })
|
|
80
127
|
navigate('/configure/forms/' + made.key + '/design/form')
|
|
81
128
|
} catch (err) {
|
|
82
|
-
|
|
129
|
+
raiseSnackbar(err.message, { design: 'error' })
|
|
83
130
|
setBusy(null)
|
|
84
131
|
}
|
|
85
132
|
}
|
|
86
133
|
|
|
87
134
|
// The form first — publishing it creates or changes the table — then the list.
|
|
135
|
+
/**
|
|
136
|
+
* Delete a form. Asked first, in the words of what will actually happen
|
|
137
|
+
* rather than "are you sure": the design goes, and the records do not
|
|
138
|
+
* unless there are none.
|
|
139
|
+
*/
|
|
140
|
+
const remove = async (form) => {
|
|
141
|
+
const ok = await raiseConfirm(
|
|
142
|
+
'Its design goes. Its table and any records in it are kept unless the table is empty.',
|
|
143
|
+
{
|
|
144
|
+
title: 'Delete "' + (form.name || form.key) + '"?',
|
|
145
|
+
confirmLabel: 'Delete',
|
|
146
|
+
// Red, because it cannot be undone from here.
|
|
147
|
+
danger: true
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
if (!ok) return
|
|
151
|
+
setBusy(form.key)
|
|
152
|
+
try {
|
|
153
|
+
const out = await deleteForm(factory, form)
|
|
154
|
+
// Said plainly: somebody deleting a form expects all of it to go, and a
|
|
155
|
+
// table left behind is worth knowing about rather than discovering.
|
|
156
|
+
if (out && out.records > 0) {
|
|
157
|
+
raiseSnackbar('"' + (form.name || form.key) + '" is gone. Its table "' + out.table + '" was kept — it still holds ' +
|
|
158
|
+
out.records + ' record' + (out.records === 1 ? '' : 's') + '.', { design: 'info' })
|
|
159
|
+
} else if (out && out.tableDropped) {
|
|
160
|
+
raiseSnackbar('"' + (form.name || form.key) + '" and its empty table are gone.', { design: 'success' })
|
|
161
|
+
} else {
|
|
162
|
+
raiseSnackbar('"' + (form.name || form.key) + '" is gone.', { design: 'success' })
|
|
163
|
+
}
|
|
164
|
+
load()
|
|
165
|
+
} catch (err) {
|
|
166
|
+
raiseSnackbar(err.message, { design: 'error' })
|
|
167
|
+
} finally {
|
|
168
|
+
setBusy(null)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
88
172
|
const publish = async (form) => {
|
|
89
173
|
setBusy(form.key)
|
|
90
|
-
setNotice(null)
|
|
91
174
|
try {
|
|
92
175
|
for (const screen of [form.edit, form.list]) {
|
|
93
176
|
if (!screen || (screen.version !== null && !screen.hasDraft)) continue
|
|
94
177
|
const row = await factory.loadDraft(screen.screenKey)
|
|
95
178
|
await factory.publish(row.document)
|
|
96
179
|
}
|
|
97
|
-
|
|
98
|
-
setError(null)
|
|
180
|
+
raiseSnackbar('Published "' + form.name + '"', { design: 'success' })
|
|
99
181
|
load()
|
|
100
182
|
} catch (err) {
|
|
101
|
-
|
|
183
|
+
raiseSnackbar(err.confirm
|
|
102
184
|
? 'Publishing "' + form.name + '" would remove columns and their data — open Design form and publish there to see what, and confirm.'
|
|
103
|
-
: err.message)
|
|
185
|
+
: err.message, { design: 'error' })
|
|
104
186
|
} finally {
|
|
105
187
|
setBusy(null)
|
|
106
188
|
}
|
|
@@ -112,10 +194,10 @@ export default function Forms() {
|
|
|
112
194
|
if (inMenu.has(form.key)) await removeMenuItem(FORM_MENU_PREFIX + form.key)
|
|
113
195
|
else await addMenuItem({ name: FORM_MENU_PREFIX + form.key, label: form.name })
|
|
114
196
|
await refreshAccess()
|
|
115
|
-
|
|
197
|
+
raiseSnackbar(inMenu.has(form.key) ? 'Removed "' + form.name + '" from the menu' : 'Added "' + form.name + '" to the menu — rename or move it in the Menu tab', { design: 'success' })
|
|
116
198
|
load()
|
|
117
199
|
} catch (err) {
|
|
118
|
-
|
|
200
|
+
raiseSnackbar(err.message, { design: 'error' })
|
|
119
201
|
} finally {
|
|
120
202
|
setBusy(null)
|
|
121
203
|
}
|
|
@@ -141,8 +223,6 @@ export default function Forms() {
|
|
|
141
223
|
</button>
|
|
142
224
|
</form>
|
|
143
225
|
|
|
144
|
-
{error && <div className="app-error">{error}</div>}
|
|
145
|
-
{notice && <div className="app-notice">{notice}</div>}
|
|
146
226
|
|
|
147
227
|
{forms && forms.length === 0 && <p className="app-sub">No forms yet.</p>}
|
|
148
228
|
{forms && forms.length > 0 && (
|
|
@@ -157,7 +237,19 @@ export default function Forms() {
|
|
|
157
237
|
const canOpen = form.list && form.list.version !== null
|
|
158
238
|
return (
|
|
159
239
|
<tr key={form.key}>
|
|
160
|
-
<td
|
|
240
|
+
<td>
|
|
241
|
+
<strong>{form.name || form.key}</strong>
|
|
242
|
+
<div className="app-sub">{form.key}</div>
|
|
243
|
+
{/* WHAT IT IS FOR, and whose it is — the two things that
|
|
244
|
+
tell three forms of the same name apart. The date
|
|
245
|
+
stands alone when the names cannot be fetched. */}
|
|
246
|
+
{form.description && <div className="app-sub">{form.description}</div>}
|
|
247
|
+
{(form.createdBy || form.createdDate) && (
|
|
248
|
+
<div className="app-sub">
|
|
249
|
+
{[people[form.createdBy], madeOn(form.createdDate)].filter(Boolean).join(' · ')}
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
</td>
|
|
161
253
|
<td><code>{form.source || '—'}</code></td>
|
|
162
254
|
<td className={edit.unpublished ? 'app-forms-pending' : undefined}>{edit.label}</td>
|
|
163
255
|
<td className={list.unpublished ? 'app-forms-pending' : undefined}>{list.label}</td>
|
|
@@ -173,6 +265,17 @@ export default function Forms() {
|
|
|
173
265
|
{busy === form.key ? 'Publishing…' : 'Publish'}
|
|
174
266
|
</button>
|
|
175
267
|
)}
|
|
268
|
+
{/* The design always goes; the TABLE only when it is empty
|
|
269
|
+
and nothing else describes it. Records are never lost
|
|
270
|
+
to a click — @xeplr/factory refuses to drop a table
|
|
271
|
+
with rows in it and says how many kept it. */}
|
|
272
|
+
<button
|
|
273
|
+
className="app-btn app-btn-quiet app-btn-danger"
|
|
274
|
+
disabled={busy === form.key}
|
|
275
|
+
onClick={() => remove(form)}
|
|
276
|
+
>
|
|
277
|
+
Delete
|
|
278
|
+
</button>
|
|
176
279
|
</td>
|
|
177
280
|
</tr>
|
|
178
281
|
)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// CONFIGURE UI → MASTERS — the lists the rest of the app points at.
|
|
2
|
+
//
|
|
3
|
+
// Departments, statuses, countries, categories: small tables that other forms
|
|
4
|
+
// choose from. They are ordinary forms with a flag (Design → This is a
|
|
5
|
+
// master), so they have a table, a list, an edit screen and permissions like
|
|
6
|
+
// anything else — a master is a fact about a form, not a kind of form.
|
|
7
|
+
//
|
|
8
|
+
// They are gathered HERE rather than scattered through the side rail because
|
|
9
|
+
// that is what they are: reference data somebody maintains occasionally, not
|
|
10
|
+
// pages people work in daily. The rail stays for the work.
|
|
11
|
+
//
|
|
12
|
+
// Which forms are masters is @xeplr/ui-factory's answer (`useScreenSource`),
|
|
13
|
+
// not this app's — grouping two screens into a form and reading the flag is
|
|
14
|
+
// the same code in every app, and a copy here could never be fixed centrally.
|
|
15
|
+
import { useState } from 'react'
|
|
16
|
+
import { Link } from 'react-router-dom'
|
|
17
|
+
import { useScreenSource, createMaster, possibleParents, FactoryBuilder } from '@xeplr/ui-factory'
|
|
18
|
+
import { factory } from '../api/factory.js'
|
|
19
|
+
|
|
20
|
+
export default function Masters() {
|
|
21
|
+
const { masters, loading, reload } = useScreenSource(factory, { Builder: FactoryBuilder })
|
|
22
|
+
const [name, setName] = useState('')
|
|
23
|
+
const [parent, setParent] = useState('')
|
|
24
|
+
const [busy, setBusy] = useState(false)
|
|
25
|
+
const [error, setError] = useState(null)
|
|
26
|
+
|
|
27
|
+
// MADE, NOT DESIGNED. A master is a name, an order and what it belongs to —
|
|
28
|
+
// the same three things every time — so it is created outright and the
|
|
29
|
+
// builder is for the day one needs a fourth field.
|
|
30
|
+
const create = async (e) => {
|
|
31
|
+
e.preventDefault()
|
|
32
|
+
if (!name.trim() || busy) return
|
|
33
|
+
setBusy(true)
|
|
34
|
+
try {
|
|
35
|
+
const owner = masters.find((m) => m.key === parent)
|
|
36
|
+
await createMaster(factory, {
|
|
37
|
+
name: name.trim(),
|
|
38
|
+
parent: owner ? { table: owner.source, label: owner.name } : null
|
|
39
|
+
})
|
|
40
|
+
setName('')
|
|
41
|
+
setParent('')
|
|
42
|
+
setError(null)
|
|
43
|
+
reload()
|
|
44
|
+
} catch (err) {
|
|
45
|
+
setError(err.message)
|
|
46
|
+
} finally {
|
|
47
|
+
setBusy(false)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (loading) return <p className="app-sub">Loading…</p>
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="app-forms">
|
|
55
|
+
<p className="app-page-lead">
|
|
56
|
+
The lists the rest of the app chooses from. A master is a name, an order of your own,
|
|
57
|
+
and — when it belongs to one — the master above it.
|
|
58
|
+
</p>
|
|
59
|
+
|
|
60
|
+
{error && <div className="app-error">{error}</div>}
|
|
61
|
+
|
|
62
|
+
<form className="app-forms-new" onSubmit={create}>
|
|
63
|
+
<label className="app-forms-field">
|
|
64
|
+
<span>New master</span>
|
|
65
|
+
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Departments" />
|
|
66
|
+
</label>
|
|
67
|
+
{/* Only worth asking once there is something to belong to. */}
|
|
68
|
+
{masters.length > 0 && (
|
|
69
|
+
<label className="app-forms-field">
|
|
70
|
+
<span>Belongs to</span>
|
|
71
|
+
<select value={parent} onChange={(e) => setParent(e.target.value)}>
|
|
72
|
+
<option value="">— nothing —</option>
|
|
73
|
+
{possibleParents(masters).map((m) => (
|
|
74
|
+
<option key={m.key} value={m.key}>{m.name}</option>
|
|
75
|
+
))}
|
|
76
|
+
</select>
|
|
77
|
+
</label>
|
|
78
|
+
)}
|
|
79
|
+
<button type="submit" className="app-btn" disabled={!name.trim() || busy}>
|
|
80
|
+
{busy ? 'Making it…' : 'Make the master'}
|
|
81
|
+
</button>
|
|
82
|
+
</form>
|
|
83
|
+
|
|
84
|
+
{masters.length === 0 && (
|
|
85
|
+
<p className="app-sub">
|
|
86
|
+
No masters yet. Departments, statuses, countries — anything other forms point at.
|
|
87
|
+
</p>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
{masters.length > 0 && (
|
|
91
|
+
<table className="app-table">
|
|
92
|
+
<thead><tr><th>Master</th><th>Shown as</th><th>Table</th><th /></tr></thead>
|
|
93
|
+
<tbody>
|
|
94
|
+
{masters.map((m) => (
|
|
95
|
+
<tr key={m.key}>
|
|
96
|
+
<td><strong>{m.name}</strong></td>
|
|
97
|
+
{/* What a picker shows for one of its records — said once on
|
|
98
|
+
the master, not again on every dropdown pointing at it. */}
|
|
99
|
+
<td className="app-sub">{m.labelField || '—'}</td>
|
|
100
|
+
<td className="app-sub">{m.source || '—'}</td>
|
|
101
|
+
<td className="app-row-actions">
|
|
102
|
+
{/* Its records: the list screen is what looking at a master
|
|
103
|
+
means. Its design is where the flag was set. */}
|
|
104
|
+
<Link to={`/forms/${m.key}`}>Open</Link>
|
|
105
|
+
<Link to={`/configure/forms/${m.key}/design/form`}>Design</Link>
|
|
106
|
+
</td>
|
|
107
|
+
</tr>
|
|
108
|
+
))}
|
|
109
|
+
</tbody>
|
|
110
|
+
</table>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
@@ -35,13 +35,7 @@ export default defineConfig(({ mode }) => {
|
|
|
35
35
|
// Only the real network calls (/auth/api/*), NOT bare /auth — that prefix
|
|
36
36
|
// is also used by client-side routes (/auth/login, /auth/profile), which
|
|
37
37
|
// must fall through to Vite's SPA index.html rather than the auth backend.
|
|
38
|
-
'/auth/api': authUrl
|
|
39
|
-
// A SERVICE THAT MAY RUN ON ITS OWN PORT, before the /api it sits under —
|
|
40
|
-
// the first matching prefix wins. Blank WORKFLOW_URL: it runs inside the
|
|
41
|
-
// API, so the API answers. The browser always calls /api/workflow/…; which
|
|
42
|
-
// process serves it is decided here (and by nginx in production), never in
|
|
43
|
-
// the UI's code.
|
|
44
|
-
'/api/workflow': env.WORKFLOW_URL || apiUrl
|
|
38
|
+
'/auth/api': authUrl
|
|
45
39
|
}
|
|
46
40
|
API_PATHS.forEach((p) => { proxy[p] = apiUrl })
|
|
47
41
|
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
// XEPLR WORKFLOW AS ITS OWN SERVICE — `npm run start-workflow`.
|
|
4
|
-
//
|
|
5
|
-
// Only when WORKFLOW_PORT is set (development.env / production.env). Blank, it
|
|
6
|
-
// runs inside the API instead and this refuses to start, saying so.
|
|
7
|
-
//
|
|
8
|
-
// The SAME workflow as the embedded one (../workflow.js): this app's database,
|
|
9
|
-
// this app's tenancy and membership gate, the same permission checks, and the
|
|
10
|
-
// same path — /api/workflow — so the browser cannot tell the two apart.
|
|
11
|
-
// What routes /api/workflow here: ui/.env WORKFLOW_URL for Vite's dev proxy,
|
|
12
|
-
// nginx in production (README, "Production").
|
|
13
|
-
require('dotenv').config({ path: require('path').join(__dirname, '..', (process.env.NODE_ENV || 'development') + '.env') });
|
|
14
|
-
|
|
15
|
-
if (!process.env.WORKFLOW_PORT) {
|
|
16
|
-
console.error('\n[workflow] WORKFLOW_PORT is blank — workflow runs INSIDE the API (npm run start-api).');
|
|
17
|
-
console.error(' To run it as its own service, set WORKFLOW_PORT in development.env,');
|
|
18
|
-
console.error(' and WORKFLOW_URL in ui/.env to http://localhost:<that port>.\n');
|
|
19
|
-
process.exit(1);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
require('@xeplr/base-apis').checkEnv(require('../env.required.js'), { appName: 'workflow' });
|
|
23
|
-
|
|
24
|
-
var express = require('express');
|
|
25
|
-
var createApp = require('@xeplr/base-apis/express');
|
|
26
|
-
|
|
27
|
-
async function start() {
|
|
28
|
-
// This app's tenancy levels, registered exactly as the API registers them.
|
|
29
|
-
await require('../db/setup');
|
|
30
|
-
var app = require('../app');
|
|
31
|
-
var workflowRouter = await require('../workflow')(app.memberGate);
|
|
32
|
-
|
|
33
|
-
var health = express.Router();
|
|
34
|
-
health.get('/', function(req, res) { res.json({ status: 'ok', service: 'workflow' }); });
|
|
35
|
-
|
|
36
|
-
createApp(process.env.WORKFLOW_PORT, '__NAME___workflow', {
|
|
37
|
-
// Every route needs a valid token, checked against the same sign-in
|
|
38
|
-
// service as the API — one login for both.
|
|
39
|
-
auth: { publicPaths: ['/health'] },
|
|
40
|
-
log: { logDir: process.env.LOG_DIR || './logs' },
|
|
41
|
-
middleware: app.middleware(),
|
|
42
|
-
routes: {
|
|
43
|
-
'/health': health,
|
|
44
|
-
'/api/workflow': workflowRouter
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
start().catch(function(err) {
|
|
50
|
-
console.error('[workflow] startup failed:', err.stack || err.message);
|
|
51
|
-
process.exit(1);
|
|
52
|
-
});
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
-- "Flows" — the side-rail item for journeys across the app's forms.
|
|
2
|
-
--
|
|
3
|
-
-- ALWAYS THERE: flows are run by @xeplr/workflow inside this app's own API,
|
|
4
|
-
-- so there is nothing to switch on.
|
|
5
|
-
--
|
|
6
|
-
-- The name is the KEY: it must match ui/src/App.jsx's drawerItems `key`
|
|
7
|
-
-- EXACTLY. What people read is the label, changed in Configure UI → Menu.
|
|
8
|
-
-- (0005, not 0004: a multi-tenant app's own 0004 is the switch menu.)
|
|
9
|
-
|
|
10
|
-
INSERT INTO "menus" (id, name, "menuGroup", "isPublic", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
|
|
11
|
-
SELECT encode(gen_random_bytes(12), 'hex'), 'Flows', '', true, true, '*', now(), now()
|
|
12
|
-
WHERE NOT EXISTS (SELECT 1 FROM "menus" WHERE name = 'Flows');
|
|
13
|
-
|
|
14
|
-
UPDATE "menus" SET "isPublic" = true WHERE name = 'Flows';
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
// FLOWS — screens one after another, run by Xeplr Workflow (@xeplr/workflow).
|
|
2
|
-
//
|
|
3
|
-
// WHERE IT RUNS. By default INSIDE THIS API, in this app's own database:
|
|
4
|
-
// bin/www starts it (registerWorkflow) after the screens and plugs its flows
|
|
5
|
-
// router in here. With WORKFLOW_PORT set in development.env / production.env it
|
|
6
|
-
// runs as its own service instead (npm run start-workflow → bin/workflow), at
|
|
7
|
-
// the SAME path — /api/workflow/flows — and this route answers 404 so a
|
|
8
|
-
// misrouted request says where it should have gone. The front door routes
|
|
9
|
-
// /api/workflow to it: ui/.env WORKFLOW_URL for Vite, nginx in production.
|
|
10
|
-
//
|
|
11
|
-
// ONE DATABASE workflow's tables (workflows, workflow_steps,
|
|
12
|
-
// workflow_runs…) sit beside the forms' tables, with their
|
|
13
|
-
// own migration record (workflow_migrations).
|
|
14
|
-
// THIS APP'S embedded workflow registers no tenancy of its own. Its
|
|
15
|
-
// TENANCY rows are filtered by the levels this app registered — or
|
|
16
|
-
// none, in an app without tenancy — and the membership gate
|
|
17
|
-
// below is the app's own. It never creates companies or
|
|
18
|
-
// workspaces tables.
|
|
19
|
-
// PERMISSIONS every route checks the caller's permissions ("List flows",
|
|
20
|
-
// "Start flow run"…), granted by @xeplr/workflow's
|
|
21
|
-
// migrations-auth — in XEPLR_AUTH_MIGRATIONS.
|
|
22
|
-
//
|
|
23
|
-
// Mounted by routes/index.js at /api/workflow, behind this app's auth gate.
|
|
24
|
-
// Everything Workflow serves lives under it:
|
|
25
|
-
// /api/workflow/flows the flows people walk through (Journey)
|
|
26
|
-
// /api/workflow/workflows the designer's document API
|
|
27
|
-
// /api/workflow/actions what a step can DO, for the palette
|
|
28
|
-
var express = require('express');
|
|
29
|
-
|
|
30
|
-
var router = express.Router();
|
|
31
|
-
var workflow = null;
|
|
32
|
-
|
|
33
|
-
router.use(function(req, res, next) {
|
|
34
|
-
if (process.env.WORKFLOW_PORT) {
|
|
35
|
-
return res.status(404).json({ message: 'Workflow runs on its own port (WORKFLOW_PORT) — route /api/workflow there (ui/.env WORKFLOW_URL, or nginx)' });
|
|
36
|
-
}
|
|
37
|
-
// Only before startup has finished — bin/www mounts it before listening.
|
|
38
|
-
if (!workflow) return res.status(503).json({ message: 'Flows are still starting' });
|
|
39
|
-
return workflow(req, res, next);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
/** bin/www hands over registerWorkflow's router, already gated. */
|
|
43
|
-
router.mount = function(workflowRouter) {
|
|
44
|
-
workflow = workflowRouter;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
module.exports = router;
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
// FLOWS — how @xeplr/workflow starts, in ONE place, for both ways it runs:
|
|
2
|
-
//
|
|
3
|
-
// inside the API bin/www calls this when WORKFLOW_PORT is blank
|
|
4
|
-
// its own service bin/workflow (npm run start-workflow) when it is set
|
|
5
|
-
//
|
|
6
|
-
// Either way: this app's own database (its tables beside the forms', with its
|
|
7
|
-
// own migration record), this app's tenancy (it creates no companies or
|
|
8
|
-
// workspaces of its own), and a permission check on every route — granted by
|
|
9
|
-
// @xeplr/workflow's migrations-auth, which XEPLR_AUTH_MIGRATIONS loads.
|
|
10
|
-
var { registerWorkflow } = require('@xeplr/workflow');
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Runs workflow's migrations and returns its ROUTER — already gated.
|
|
14
|
-
*
|
|
15
|
-
* The whole router, not just the flows facade: it carries `/flows` (the
|
|
16
|
-
* screens a person walks through) AND `/workflows` (the designer's own
|
|
17
|
-
* document API). One designer serves both — @xeplr/ui-workflow's canvas, the
|
|
18
|
-
* same one every xeplr product uses — so the app has to serve what it reads.
|
|
19
|
-
*/
|
|
20
|
-
module.exports = async function startWorkflow(memberGate) {
|
|
21
|
-
var workflow = await registerWorkflow({
|
|
22
|
-
applicationId: '__NAME__',
|
|
23
|
-
db: {
|
|
24
|
-
name: process.env.DB_API,
|
|
25
|
-
connection: process.env.__PREFIX___CONNECTION,
|
|
26
|
-
connectionName: 'workflow',
|
|
27
|
-
migrationsTable: 'workflow_migrations'
|
|
28
|
-
},
|
|
29
|
-
tenantTables: false,
|
|
30
|
-
access: true,
|
|
31
|
-
mtMembershipGate: memberGate
|
|
32
|
-
});
|
|
33
|
-
return workflow.router;
|
|
34
|
-
};
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// Flows are at /api/workflow/flows — served by @xeplr/workflow inside this
|
|
2
|
-
// app's API, or on its own port; the dev proxy (vite.config.js) and nginx
|
|
3
|
-
// decide which, never this file. authFetch carries the same sign-in and
|
|
4
|
-
// company as every other call.
|
|
5
|
-
import { authFetch } from '@xeplr/ui-account'
|
|
6
|
-
import { createFlowsApi } from '@xeplr/ui-factory'
|
|
7
|
-
|
|
8
|
-
export const flows = createFlowsApi({ fetch: authFetch, base: '/api/workflow' })
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
// ONE FLOW, ON WORKFLOW'S OWN CANVAS.
|
|
2
|
-
//
|
|
3
|
-
// The designer is a component from @xeplr/ui-workflow — the same one BI and
|
|
4
|
-
// every other xeplr product uses — dropped straight onto this page. It is not
|
|
5
|
-
// a section mounted at URLs of its own: this app already has a page for it,
|
|
6
|
-
// inside its own shell, and the designer renders where it is put.
|
|
7
|
-
//
|
|
8
|
-
// There used to be a second, screens-only builder here (@xeplr/ui-factory's
|
|
9
|
-
// FlowBuilder). One designer over one engine is the point: the trigger box,
|
|
10
|
-
// the condition boxes and the reference pickers are built once, and this app
|
|
11
|
-
// gets them by upgrading a package.
|
|
12
|
-
//
|
|
13
|
-
// THIS APP'S FORMS ARE THE FLOW'S SCREENS. A step can show one and wait for
|
|
14
|
-
// somebody to fill it in, and `useScreenSource` is @xeplr/ui-factory's answer
|
|
15
|
-
// to the two things the designer asks its host: which screens exist (with
|
|
16
|
-
// what each one hands back, so a later step can pick `title` by name) and how
|
|
17
|
-
// to design one without leaving the flow. Two props, because the alternative
|
|
18
|
-
// was the same twenty lines in every app — and a copy in your app could never
|
|
19
|
-
// be fixed once.
|
|
20
|
-
//
|
|
21
|
-
// WHERE THE API IS: /api/workflow, always — the dev proxy (vite.config.js) or
|
|
22
|
-
// nginx decides whether that is this API or Workflow on its own port.
|
|
23
|
-
import { useParams, useNavigate } from 'react-router-dom'
|
|
24
|
-
import { WorkflowDesigner } from '@xeplr/ui-workflow'
|
|
25
|
-
import { useScreenSource, FactoryBuilder } from '@xeplr/ui-factory'
|
|
26
|
-
import { factory } from '../api/factory.js'
|
|
27
|
-
|
|
28
|
-
export default function FlowDesigner() {
|
|
29
|
-
const { flow: key } = useParams()
|
|
30
|
-
const navigate = useNavigate()
|
|
31
|
-
const { screens, screenEditor } = useScreenSource(factory, { Builder: FactoryBuilder })
|
|
32
|
-
|
|
33
|
-
return (
|
|
34
|
-
<div className="app-designer">
|
|
35
|
-
<WorkflowDesigner
|
|
36
|
-
apiBase="/api/workflow"
|
|
37
|
-
screens={screens}
|
|
38
|
-
screenEditor={screenEditor}
|
|
39
|
-
// BY KEY, because that is what this app's URLs carry:
|
|
40
|
-
// /configure/flows/leave_request. A flow is created on the Flows page
|
|
41
|
-
// with a key made from its name, and that key is how it is addressed
|
|
42
|
-
// for the rest of its life — a readable link that survives a rebuild
|
|
43
|
-
// minting new ids.
|
|
44
|
-
workflowKey={key === 'new' ? undefined : key}
|
|
45
|
-
newKind={key === 'new' ? 'workflow' : undefined}
|
|
46
|
-
// Only reachable from /configure/flows/new, which this app does not
|
|
47
|
-
// link to — the Flows page creates the flow first, so the designer
|
|
48
|
-
// always opens on one that exists.
|
|
49
|
-
onOpened={(saved) => navigate('/configure/flows/' + saved, { replace: true })}
|
|
50
|
-
onBack={() => navigate('/configure?tab=flows')}
|
|
51
|
-
/>
|
|
52
|
-
</div>
|
|
53
|
-
)
|
|
54
|
-
}
|