@xeplr/cli 0.1.11 → 0.3.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 +14 -9
- package/lib/generate/index.js +46 -75
- package/lib/questions.js +3 -42
- package/package.json +1 -1
- package/templates/CLAUDE.md +4 -4
- package/templates/README.md +50 -0
- package/templates/api/app.js +8 -2
- package/templates/api/bin/workflow +52 -0
- package/templates/api/bin/www +15 -1
- package/templates/api/env.required.js +0 -1
- package/templates/api/gitignore.tpl +5 -0
- package/templates/api/migrations-auth/0005_flows_menu.sql +14 -0
- package/templates/api/package.json +6 -4
- package/templates/api/production.env.example +39 -0
- package/templates/api/routes/flows.js +47 -0
- package/templates/api/routes/index.js +4 -1
- package/templates/api/screens/task/task-edit.screen.json +2 -2
- package/templates/api/workflow.js +34 -0
- package/templates/ui/README.md +2 -1
- package/templates/ui/dotenv.tpl +2 -1
- package/templates/ui/package.json +5 -3
- package/templates/ui/src/App.jsx +27 -6
- package/templates/ui/src/api/flows.js +8 -0
- package/templates/ui/src/index.css +19 -2
- package/templates/ui/src/menu.js +1 -0
- package/templates/ui/src/pages/ConfigureUI.jsx +4 -3
- package/templates/ui/src/pages/EditTask.jsx +5 -5
- package/templates/ui/src/pages/FlowDesigner.jsx +54 -0
- package/templates/ui/src/pages/Flows.jsx +125 -0
- package/templates/ui/src/pages/FlowsHome.jsx +65 -0
- package/templates/ui/src/pages/Journey.jsx +33 -0
- package/templates/ui/src/pages/Tasks.jsx +2 -2
- package/templates/ui/src/pages/TasksHelp.jsx +2 -1
- package/templates/ui/vite.config.js +7 -1
|
@@ -0,0 +1,47 @@
|
|
|
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;
|
|
@@ -34,6 +34,9 @@ router.get('/whoami', function(req, res) {
|
|
|
34
34
|
// var { genericRoute } = require('@xeplr/base-apis');
|
|
35
35
|
// router.use('/api/things', genericRoute({ key: 'thing', model: require('../models').Thing }));
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// Flows: Xeplr Workflow, at /api/workflow — inside this API, or on its
|
|
38
|
+
// own port with WORKFLOW_PORT (see routes/flows.js). The path is the same
|
|
39
|
+
// either way: the browser never knows which.
|
|
40
|
+
router.use('/api/workflow', require('./flows'));
|
|
38
41
|
|
|
39
42
|
module.exports = router;
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"z": 4,
|
|
78
78
|
"props": {
|
|
79
79
|
"label": "Status",
|
|
80
|
-
"placeholder": "Select
|
|
80
|
+
"placeholder": "Select\u2026",
|
|
81
81
|
"data": {
|
|
82
82
|
"source": "static",
|
|
83
83
|
"options": [
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"x": 0.5125,
|
|
110
110
|
"y": 0.4,
|
|
111
111
|
"w": 0.4475,
|
|
112
|
-
"h": 0.
|
|
112
|
+
"h": 0.08,
|
|
113
113
|
"z": 5,
|
|
114
114
|
"props": {
|
|
115
115
|
"label": "Priority",
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
};
|
package/templates/ui/README.md
CHANGED
|
@@ -24,5 +24,6 @@ npm run build # → dist/
|
|
|
24
24
|
## Rules
|
|
25
25
|
|
|
26
26
|
- `authFetch` returns the **parsed body**, not a Response — no `.json()`.
|
|
27
|
-
- A side-rail item needs an **icon** (a collapsed rail shows icons only) and its key must exist as a menu row, or it is dropped silently.
|
|
27
|
+
- A side-rail item needs an **icon** (a collapsed rail shows icons only) and its key must exist as a menu row, or it is dropped silently. Give it its **`path`** too: that is how the rail marks the page you are on.
|
|
28
|
+
- The rail is 48px when collapsed; `--app-rail-width` in `src/index.css` reserves the same space and must match it.
|
|
28
29
|
- Set page backgrounds on a container inside the theme, never on `body`.
|
package/templates/ui/dotenv.tpl
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# __NAME__ UI — dev port and the
|
|
1
|
+
# __NAME__ UI — dev port and the services it talks to.
|
|
2
2
|
#
|
|
3
3
|
# Read by vite.config.js, so no port is written in code. Change a number here
|
|
4
4
|
# and the dev server and every proxy target follow.
|
|
@@ -6,3 +6,4 @@
|
|
|
6
6
|
UI_PORT=__UI_PORT__
|
|
7
7
|
AUTH_URL=http://localhost:__AUTH_PORT__
|
|
8
8
|
API_URL=http://localhost:__API_PORT__
|
|
9
|
+
__WF_UI_ENV__
|
|
@@ -11,12 +11,14 @@
|
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@tanstack/react-table": "^8.21.3",
|
|
13
13
|
"@xeplr/ui-account": "^1.0.9",
|
|
14
|
-
"@xeplr/ui-canvas": "^1.0
|
|
15
|
-
"@xeplr/ui-factory": "^1.0
|
|
14
|
+
"@xeplr/ui-canvas": "^1.1.0",
|
|
15
|
+
"@xeplr/ui-factory": "^1.1.0",
|
|
16
16
|
"@xeplr/ui-table": "^1.0.6",
|
|
17
17
|
"react": "^19.2.4",
|
|
18
18
|
"react-dom": "^19.2.4",
|
|
19
|
-
"react-router-dom": "^7.13.2"
|
|
19
|
+
"react-router-dom": "^7.13.2",
|
|
20
|
+
"@xeplr/ui-workflow": "^1.2.0",
|
|
21
|
+
"@xeplr/ui-utils": "^1.0.2"
|
|
20
22
|
},
|
|
21
23
|
"devDependencies": {
|
|
22
24
|
"@vitejs/plugin-react": "^5.2.0",
|
package/templates/ui/src/App.jsx
CHANGED
|
@@ -4,7 +4,9 @@ import { authRoutes, authPath, NavPage, ProtectedRoute, useAccess } from '@xeplr
|
|
|
4
4
|
import Home from './pages/Home.jsx'
|
|
5
5
|
import Tasks from './pages/Tasks.jsx'
|
|
6
6
|
import EditTask from './pages/EditTask.jsx'
|
|
7
|
-
|
|
7
|
+
import FlowDesigner from './pages/FlowDesigner.jsx'
|
|
8
|
+
import Journey from './pages/Journey.jsx'
|
|
9
|
+
import FlowsHome from './pages/FlowsHome.jsx'
|
|
8
10
|
import ConfigureUI from './pages/ConfigureUI.jsx'
|
|
9
11
|
import { FORM_MENU_PREFIX } from './menu.js'
|
|
10
12
|
import FormDesigner from './pages/FormDesigner.jsx'
|
|
@@ -22,6 +24,15 @@ const TasksIcon = (
|
|
|
22
24
|
</svg>
|
|
23
25
|
)
|
|
24
26
|
|
|
27
|
+
const FlowsIcon = (
|
|
28
|
+
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
|
|
29
|
+
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
30
|
+
<rect x="3" y="3" width="6" height="6" rx="1" />
|
|
31
|
+
<rect x="15" y="15" width="6" height="6" rx="1" />
|
|
32
|
+
<path d="M9 6h4a2 2 0 0 1 2 2v7" />
|
|
33
|
+
</svg>
|
|
34
|
+
)
|
|
35
|
+
|
|
25
36
|
const FormsIcon = (
|
|
26
37
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
|
|
27
38
|
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
@@ -64,14 +75,19 @@ function Shell() {
|
|
|
64
75
|
// The drawer's catalog, by KEY. A key must match a `menus` row's name —
|
|
65
76
|
// @xeplr/ui-account drops unknown keys SILENTLY, so a typo removes the item
|
|
66
77
|
// with no error anywhere. The text shown is the row's label (Configure UI →
|
|
67
|
-
// Menu), in the order set there — never written here.
|
|
78
|
+
// Menu), in the order set there — never written here. `path` is how the rail
|
|
79
|
+
// marks the page you are on (and /tasks/12 still counts as Tasks).
|
|
68
80
|
const drawerItems = useMemo(() => [
|
|
69
|
-
{ key: 'Home', icon: HomeIcon, clickHandler: () => navigate('/home') },
|
|
70
|
-
{ key: 'Tasks', icon: TasksIcon, clickHandler: () => navigate('/tasks') },
|
|
81
|
+
{ key: 'Home', icon: HomeIcon, path: '/home', clickHandler: () => navigate('/home') },
|
|
82
|
+
{ key: 'Tasks', icon: TasksIcon, path: '/tasks', clickHandler: () => navigate('/tasks') },
|
|
83
|
+
// Always in the rail — the page says when Xeplr Workflow is not connected.
|
|
84
|
+
{ key: 'Flows', icon: FlowsIcon, path: '/flows', clickHandler: () => navigate('/flows') },
|
|
71
85
|
__MT_DRAWER_ITEM__
|
|
72
86
|
// Every form added to the menu, with no code: "form:crop" opens /forms/crop.
|
|
73
87
|
...menus.filter((key) => key.startsWith(FORM_MENU_PREFIX)).map((key) => ({
|
|
74
|
-
key, icon: FormsIcon,
|
|
88
|
+
key, icon: FormsIcon,
|
|
89
|
+
path: '/forms/' + key.slice(FORM_MENU_PREFIX.length),
|
|
90
|
+
clickHandler: () => navigate('/forms/' + key.slice(FORM_MENU_PREFIX.length))
|
|
75
91
|
}))
|
|
76
92
|
], [navigate, menus])
|
|
77
93
|
|
|
@@ -113,7 +129,12 @@ __MT_SELECT_ROUTE__
|
|
|
113
129
|
<Route path="/forms/:form" element={<FormRecords />} />
|
|
114
130
|
<Route path="/configure" element={<ConfigureUI />} />
|
|
115
131
|
<Route path="/configure/forms/:form/design/:part" element={<FormDesigner />} />
|
|
116
|
-
|
|
132
|
+
{/* Flows — run by @xeplr/workflow inside this app's own API. */}
|
|
133
|
+
<Route path="/flows" element={<FlowsHome />} />
|
|
134
|
+
<Route path="/configure/flows/:flow" element={<FlowDesigner />} />
|
|
135
|
+
{/* A journey: its run id joins the address once it starts, so it can be resumed. */}
|
|
136
|
+
<Route path="/journey/:flow" element={<Journey />} />
|
|
137
|
+
<Route path="/journey/:flow/:run" element={<Journey />} />
|
|
117
138
|
</Route>
|
|
118
139
|
|
|
119
140
|
<Route path="*" element={<Navigate to="/auth/login" replace />} />
|
|
@@ -0,0 +1,8 @@
|
|
|
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' })
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
of the page rather than pushing it along — the app has to leave the room
|
|
6
6
|
itself. Without this the rail covers the first 60px of every screen, which
|
|
7
7
|
reads as text mysteriously cut off down the left edge rather than as a
|
|
8
|
-
layout problem.
|
|
9
|
-
|
|
8
|
+
layout problem. 48px: compact, like VS Code's activity bar — it must
|
|
9
|
+
match @xeplr/ui-account's collapsed rail. */
|
|
10
|
+
--app-rail-width: 48px;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
body { margin: 0; }
|
|
@@ -357,3 +358,19 @@ body { margin: 0; }
|
|
|
357
358
|
.app-menu-shown { display: inline-flex; gap: 6px; align-items: center; }
|
|
358
359
|
|
|
359
360
|
.app-menu-settings .app-table { margin-bottom: 14px; }
|
|
361
|
+
|
|
362
|
+
/* ── Flows, before Workflow is connected (src/pages/Flows.jsx) ─────────────── */
|
|
363
|
+
.app-connect {
|
|
364
|
+
max-width: 720px;
|
|
365
|
+
padding: 22px 24px;
|
|
366
|
+
border: 1px solid var(--xeplr-border-primary);
|
|
367
|
+
border-radius: 10px;
|
|
368
|
+
background: var(--xeplr-bg-tertiary);
|
|
369
|
+
line-height: 1.55;
|
|
370
|
+
}
|
|
371
|
+
.app-connect h2 { margin: 0 0 8px; font-size: 18px; }
|
|
372
|
+
.app-connect p { margin: 0 0 14px; }
|
|
373
|
+
.app-connect-steps { margin: 0 0 14px; padding-left: 20px; }
|
|
374
|
+
.app-connect-steps li { margin-bottom: 14px; }
|
|
375
|
+
.app-connect code { font-size: 12.5px; padding: 1px 5px; border-radius: 4px; background: var(--xeplr-bg-secondary); border: 1px solid var(--xeplr-border-primary); }
|
|
376
|
+
.app-connect pre { margin: 6px 0 0; font-size: 12.5px; }
|
package/templates/ui/src/menu.js
CHANGED
|
@@ -3,18 +3,19 @@
|
|
|
3
3
|
// Forms every UI in the app: make one, design it, publish it, open it, add it
|
|
4
4
|
// to the side rail
|
|
5
5
|
// Menu the side rail's items: rename, reorder, hide
|
|
6
|
-
|
|
6
|
+
// Flows forms one after another — what is filled in decides the next; run by
|
|
7
|
+
// Xeplr Workflow, and says how to connect it until it is
|
|
7
8
|
//
|
|
8
9
|
// Configuration lives here, not in the side rail — the rail is for the pages
|
|
9
10
|
// people use.
|
|
10
11
|
import { useSearchParams } from 'react-router-dom'
|
|
11
12
|
import Forms from './Forms.jsx'
|
|
12
13
|
import MenuSettings from './MenuSettings.jsx'
|
|
13
|
-
|
|
14
|
+
import Flows from './Flows.jsx'
|
|
14
15
|
|
|
15
16
|
const TABS = [
|
|
16
17
|
{ id: 'forms', label: 'Forms', Page: Forms },
|
|
17
|
-
|
|
18
|
+
{ id: 'flows', label: 'Flows', Page: Flows },
|
|
18
19
|
{ id: 'menu', label: 'Menu', Page: MenuSettings }
|
|
19
20
|
]
|
|
20
21
|
|
|
@@ -26,7 +26,7 @@ export class TaskHooks extends FactoryHooks {
|
|
|
26
26
|
return super.get(ctx)
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/**
|
|
29
|
+
/** The Save button — one AJAX call. Returns the saved record. */
|
|
30
30
|
save(values, ctx) {
|
|
31
31
|
return super.save(values, ctx)
|
|
32
32
|
}
|
|
@@ -65,11 +65,11 @@ export const taskHooks = new TaskHooks()
|
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
67
|
* The task form on a page of its own — /tasks/new, or /tasks/:id for a task
|
|
68
|
-
* that exists. The list sends people here (its "Opens in" is a page)
|
|
69
|
-
* Done brings them back.
|
|
68
|
+
* that exists. The list sends people here (its "Opens in" is a page).
|
|
70
69
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
70
|
+
* Nothing is written while typing. Save sends the task (one AJAX call, never
|
|
71
|
+
* a form submit) and comes back to the list; Cancel comes back without
|
|
72
|
+
* saving, asking first if anything is unsaved. Both go through onDone.
|
|
73
73
|
*/
|
|
74
74
|
export default function EditTask({ record }) {
|
|
75
75
|
const { id } = useParams()
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// CONFIGURE UI → FLOWS — every journey across the app's forms.
|
|
2
|
+
//
|
|
3
|
+
// A flow is what someone is walked through: screens one after another, and
|
|
4
|
+
// whatever has to happen between them. What they fill in on one decides which
|
|
5
|
+
// comes next. Xeplr Workflow runs it, so a journey can be left and picked up
|
|
6
|
+
// later — by someone else, on another day. Design it here; open it at
|
|
7
|
+
// /journey/<key>.
|
|
8
|
+
//
|
|
9
|
+
// A FLOW IS AN ORDINARY WORKFLOW, addressed by a key this page makes from its
|
|
10
|
+
// name. It used to be created as kind 'screens', which is a workflow whose
|
|
11
|
+
// steps are GENERATED from a design held in another app — and that app was
|
|
12
|
+
// the screens-only builder this one replaced. The designer then had to refuse
|
|
13
|
+
// to edit its own flows ("designed in Configure UI"), which is how a flow
|
|
14
|
+
// became a page you could open and not change.
|
|
15
|
+
//
|
|
16
|
+
// So a flow is a workflow now, and every kind of step is available inside it:
|
|
17
|
+
// a Screen for the part a person fills in, an Action for the email that goes
|
|
18
|
+
// out after it, a Condition for where it goes next. Flows created the old way
|
|
19
|
+
// still appear here and still run; the designer shows them read-only.
|
|
20
|
+
import { useEffect, useState } from 'react'
|
|
21
|
+
import { Link, useNavigate } from 'react-router-dom'
|
|
22
|
+
import { listWorkflows, saveWorkflow, configureWorkflowApi } from '@xeplr/ui-workflow'
|
|
23
|
+
|
|
24
|
+
// The same mount the designer uses. Said here as well because this page can
|
|
25
|
+
// be the first one to ask, and a base set after the first request is too late.
|
|
26
|
+
configureWorkflowApi('/api/workflow')
|
|
27
|
+
|
|
28
|
+
export default function Flows() {
|
|
29
|
+
const [list, setList] = useState(null)
|
|
30
|
+
const [error, setError] = useState(null)
|
|
31
|
+
const [offline, setOffline] = useState(null) // API_NOT_RESTARTED
|
|
32
|
+
const [name, setName] = useState('')
|
|
33
|
+
const navigate = useNavigate()
|
|
34
|
+
|
|
35
|
+
const load = () => listWorkflows().then(setList, (e) => {
|
|
36
|
+
const off = flowsOffline(e)
|
|
37
|
+
if (off) setOffline(off)
|
|
38
|
+
else setError(e.message)
|
|
39
|
+
})
|
|
40
|
+
useEffect(() => { load() }, [])
|
|
41
|
+
|
|
42
|
+
const create = async (e) => {
|
|
43
|
+
e.preventDefault()
|
|
44
|
+
const label = name.trim()
|
|
45
|
+
if (!label) return
|
|
46
|
+
const key = label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
|
47
|
+
try {
|
|
48
|
+
// No steps: the designer is where those come from, and it opens on the
|
|
49
|
+
// flow this creates. `key` is what every link to it uses from here on.
|
|
50
|
+
await saveWorkflow({ name: label, key, kind: 'workflow', status: 'draft' })
|
|
51
|
+
navigate(`/configure/flows/${key}`)
|
|
52
|
+
} catch (err) {
|
|
53
|
+
setError(err.message)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (offline) return <NotConnected offline={offline} />
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="app-forms">
|
|
61
|
+
<p className="app-page-lead">
|
|
62
|
+
A flow is your forms, one after another — what someone fills in decides the next one.
|
|
63
|
+
Xeplr Workflow runs it, so a journey can be left and picked up later.
|
|
64
|
+
</p>
|
|
65
|
+
{error && <div className="app-error">{error}</div>}
|
|
66
|
+
|
|
67
|
+
<form className="app-forms-new" onSubmit={create}>
|
|
68
|
+
<label className="app-forms-field">
|
|
69
|
+
<span>Name</span>
|
|
70
|
+
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Employee registration" />
|
|
71
|
+
</label>
|
|
72
|
+
<button type="submit" className="app-btn" disabled={!name.trim()}>New flow</button>
|
|
73
|
+
</form>
|
|
74
|
+
|
|
75
|
+
{list && list.length === 0 && <p className="app-sub">No flows yet.</p>}
|
|
76
|
+
{list && list.length > 0 && (
|
|
77
|
+
<table className="app-table">
|
|
78
|
+
<thead><tr><th>Flow</th><th>Key</th><th>Status</th><th>Screens</th><th /></tr></thead>
|
|
79
|
+
<tbody>
|
|
80
|
+
{list.map((f) => (
|
|
81
|
+
<tr key={f.id || f.key}>
|
|
82
|
+
<td><strong>{f.name}</strong></td>
|
|
83
|
+
<td className="app-sub">{f.key || '—'}</td>
|
|
84
|
+
<td>{f.kind === 'screens' ? f.status + ' · read-only' : f.status}</td>
|
|
85
|
+
<td>{typeof f.steps === 'number' ? f.steps : (f.steps || []).length}</td>
|
|
86
|
+
<td className="app-row-actions">
|
|
87
|
+
{f.key && <Link to={`/configure/flows/${f.key}`}>Design</Link>}
|
|
88
|
+
{/* PUBLISHING IS A SCREENS-FLOW IDEA: there, publish is what
|
|
89
|
+
checks the design before somebody is walked through it.
|
|
90
|
+
A flow drawn in the designer has no such step, so it can
|
|
91
|
+
be opened as soon as it has one. */}
|
|
92
|
+
{(f.kind === 'screens' ? f.status === 'published' : (f.steps || []).length > 0) && f.key && (
|
|
93
|
+
<Link to={`/journey/${f.key}`}>Open</Link>
|
|
94
|
+
)}
|
|
95
|
+
</td>
|
|
96
|
+
</tr>
|
|
97
|
+
))}
|
|
98
|
+
</tbody>
|
|
99
|
+
</table>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Why flows cannot run right now, or null when the error is something else.
|
|
107
|
+
* Workflow runs inside this app's API, so there is one such case left: an API
|
|
108
|
+
* started before flows existed (404 on /api/flows) — restart it.
|
|
109
|
+
*/
|
|
110
|
+
export function flowsOffline(err) {
|
|
111
|
+
if (err && err.status === 404) return { code: 'API_NOT_RESTARTED', message: 'This app\'s API does not have the flows route yet.' }
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Flows before the API has been restarted with them. */
|
|
116
|
+
export function NotConnected({ offline }) {
|
|
117
|
+
return (
|
|
118
|
+
<div className="app-forms">
|
|
119
|
+
<div className="app-connect">
|
|
120
|
+
<h2>Restart the API to use flows</h2>
|
|
121
|
+
<p>{offline.message} It was started before flows were added — restart it, then reload this page.</p>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// FLOWS — the side-rail page: the journeys people can start, one click each.
|
|
2
|
+
//
|
|
3
|
+
// Always in the rail. Flows are run by @xeplr/workflow inside this app's own
|
|
4
|
+
// API, so they are simply there. Designing them is Configure UI → Flows.
|
|
5
|
+
import { useEffect, useState } from 'react'
|
|
6
|
+
import { Link, useNavigate } from 'react-router-dom'
|
|
7
|
+
import { useAccess } from '@xeplr/ui-account'
|
|
8
|
+
import { flows } from '../api/flows.js'
|
|
9
|
+
import { NotConnected, flowsOffline } from './Flows.jsx'
|
|
10
|
+
|
|
11
|
+
export default function FlowsHome() {
|
|
12
|
+
const [list, setList] = useState(null)
|
|
13
|
+
const [error, setError] = useState(null)
|
|
14
|
+
const [offline, setOffline] = useState(null)
|
|
15
|
+
const navigate = useNavigate()
|
|
16
|
+
const { access } = useAccess()
|
|
17
|
+
// Whoever may open Configure UI may design flows and connect Workflow.
|
|
18
|
+
const admin = ((access && access.menus) || []).includes('Configure UI')
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
flows.listFlows().then(setList, (e) => {
|
|
22
|
+
const off = flowsOffline(e)
|
|
23
|
+
if (off) setOffline(off)
|
|
24
|
+
else setError(e.message)
|
|
25
|
+
})
|
|
26
|
+
}, [])
|
|
27
|
+
|
|
28
|
+
const published = (list || []).filter((f) => f.status === 'published')
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="app-page">
|
|
32
|
+
<h1>Flows</h1>
|
|
33
|
+
<p className="app-page-lead">
|
|
34
|
+
Forms one after another — what you fill in on one decides which comes next. A journey you
|
|
35
|
+
leave is kept, and picks up where it stopped.
|
|
36
|
+
</p>
|
|
37
|
+
{offline && <NotConnected offline={offline} />}
|
|
38
|
+
{error && <div className="app-error">{error}</div>}
|
|
39
|
+
{list && published.length === 0 && (
|
|
40
|
+
<p className="app-sub">
|
|
41
|
+
No flows to start yet.{admin && <> <Link to="/configure?tab=flows">Design one</Link> in Configure UI.</>}
|
|
42
|
+
</p>
|
|
43
|
+
)}
|
|
44
|
+
{published.length > 0 && (
|
|
45
|
+
<table className="app-table">
|
|
46
|
+
<thead><tr><th>Flow</th><th>Screens</th><th /></tr></thead>
|
|
47
|
+
<tbody>
|
|
48
|
+
{published.map((f) => (
|
|
49
|
+
<tr key={f.key}>
|
|
50
|
+
<td><strong>{f.name}</strong></td>
|
|
51
|
+
<td>{typeof f.steps === 'number' ? f.steps : (f.steps || []).length}</td>
|
|
52
|
+
<td className="app-row-actions">
|
|
53
|
+
<button type="button" className="app-btn" onClick={() => navigate(`/journey/${f.key}`)}>Start</button>
|
|
54
|
+
</td>
|
|
55
|
+
</tr>
|
|
56
|
+
))}
|
|
57
|
+
</tbody>
|
|
58
|
+
</table>
|
|
59
|
+
)}
|
|
60
|
+
{admin && list && published.length > 0 && (
|
|
61
|
+
<p className="app-sub" style={{ marginTop: 16 }}><Link to="/configure?tab=flows">Design flows</Link> in Configure UI.</p>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// A FLOW, being walked through — /journey/<key>, and /journey/<key>/<run> once
|
|
2
|
+
// it has started. The run id in the address is what lets a journey be
|
|
3
|
+
// reloaded, left, or handed to someone else and opened on the step it is on.
|
|
4
|
+
//
|
|
5
|
+
// This page holds no route through the journey: Workflow says which screen
|
|
6
|
+
// comes next, from what was filled in.
|
|
7
|
+
import { useNavigate, useParams } from 'react-router-dom'
|
|
8
|
+
import { FlowRunner } from '@xeplr/ui-factory'
|
|
9
|
+
import { flows } from '../api/flows.js'
|
|
10
|
+
import { factory } from '../api/factory.js'
|
|
11
|
+
|
|
12
|
+
export default function Journey() {
|
|
13
|
+
const { flow, run } = useParams()
|
|
14
|
+
const navigate = useNavigate()
|
|
15
|
+
return (
|
|
16
|
+
<div className="app-page">
|
|
17
|
+
<FlowRunner
|
|
18
|
+
{...flows.runnerProps}
|
|
19
|
+
{...factory.screenProps}
|
|
20
|
+
flowKey={flow}
|
|
21
|
+
runId={run}
|
|
22
|
+
onStep={(step) => { if (step.runId && step.runId !== run) navigate(`/journey/${flow}/${step.runId}`, { replace: true }) }}
|
|
23
|
+
renderDone={() => (
|
|
24
|
+
<div className="app-help">
|
|
25
|
+
<h2>All done</h2>
|
|
26
|
+
<p>Everything in this journey is filled in.</p>
|
|
27
|
+
<button type="button" className="app-btn" onClick={() => navigate(`/journey/${flow}`)}>Start another</button>
|
|
28
|
+
</div>
|
|
29
|
+
)}
|
|
30
|
+
/>
|
|
31
|
+
</div>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// THE SAMPLE — a list of tasks. New and Edit open the task form on a PAGE of
|
|
2
|
-
// its own
|
|
3
|
-
//
|
|
2
|
+
// its own. Its Save button saves over AJAX (never a form submit) and comes
|
|
3
|
+
// back here; Cancel comes back without saving. (A list can open its form in a popup instead: Configure
|
|
4
4
|
// UI → Forms → the list → Opens in.)
|
|
5
5
|
//
|
|
6
6
|
// Nothing about tasks is written in this file. The list and the form are
|
|
@@ -47,7 +47,8 @@ export default function TasksHelp() {
|
|
|
47
47
|
</p>
|
|
48
48
|
<p>
|
|
49
49
|
<strong>New</strong> and <strong>Edit</strong> open the form on a page
|
|
50
|
-
of its own
|
|
50
|
+
of its own. Nothing is saved while you type: <strong>Save</strong> saves
|
|
51
|
+
and comes back here, <strong>Cancel</strong> comes back without saving. A list can open
|
|
51
52
|
it in a popup instead — that is its <strong>Opens in</strong> setting.
|
|
52
53
|
</p>
|
|
53
54
|
|
|
@@ -35,7 +35,13 @@ 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
|
|
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
|
|
39
45
|
}
|
|
40
46
|
API_PATHS.forEach((p) => { proxy[p] = apiUrl })
|
|
41
47
|
|