@xeplr/cli 0.3.3 → 0.3.5
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 +33 -3
- package/lib/generate/index.js +58 -1
- package/lib/plan.js +1 -0
- package/lib/questions.js +21 -2
- package/package.json +1 -1
- package/templates/CLAUDE.md +9 -7
- package/templates/README.md +14 -7
- package/templates/api/README.md +12 -4
- package/templates/api/app.js +2 -0
- package/templates/api/bin/www +13 -5
- package/templates/api/db/setup.js +1 -1
- package/templates/api/development.env.tpl +26 -12
- package/templates/api/env.required.js +7 -1
- package/templates/api/functions/index.js +47 -0
- package/templates/api/migrations-auth/0005_flows_menu.sql +14 -0
- package/templates/api/package.json +7 -4
- package/templates/api/production.env.example +6 -4
- package/templates/api/routes/flows.js +23 -0
- package/templates/api/routes/index.js +4 -0
- package/templates/api/screens/index.js +4 -6
- package/templates/api/screens/task/task-edit.screen.json +4 -1
- package/templates/api/screens/task/task.model.js +2 -2
- package/templates/api/workflow.js +32 -0
- package/templates/ui/package.json +2 -2
- package/templates/ui/src/App.jsx +20 -0
- package/templates/ui/src/api/flows.js +6 -0
- package/templates/ui/src/index.css +16 -0
- package/templates/ui/src/menu.js +1 -0
- package/templates/ui/src/pages/ConfigureUI.jsx +4 -0
- package/templates/ui/src/pages/EditTask.jsx +3 -3
- package/templates/ui/src/pages/FlowDesigner.jsx +30 -0
- package/templates/ui/src/pages/Flows.jsx +106 -0
- package/templates/ui/src/pages/FlowsHome.jsx +65 -0
- package/templates/ui/src/pages/Journey.jsx +33 -0
- package/templates/ui/src/pages/TasksHelp.jsx +3 -2
- package/templates/ui/vite.config.js +4 -1
- package/templates/api/screens/task/task.hooks.js +0 -37
|
@@ -17,15 +17,18 @@
|
|
|
17
17
|
"start-api": "node ./bin/www"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@xeplr/auth": "^1.
|
|
20
|
+
"@xeplr/auth": "^1.3.0",
|
|
21
21
|
"@xeplr/base-apis": "^2.0.0",
|
|
22
22
|
"@xeplr/db": "^1.0.6",
|
|
23
|
-
"@xeplr/factory": "^
|
|
24
|
-
"@xeplr/ui-factory": "^1.1
|
|
23
|
+
"@xeplr/factory": "^2.0.0",
|
|
24
|
+
"@xeplr/ui-factory": "^1.7.1",
|
|
25
25
|
"@xeplr/utils": "^1.0.11",
|
|
26
26
|
"dotenv": "^17.4.2",
|
|
27
27
|
"express": "^5.2.0",
|
|
28
|
-
"multer": "^2.0.2"
|
|
28
|
+
"multer": "^2.0.2",
|
|
29
|
+
"nodemailer": "^10.0.2",
|
|
30
|
+
"@xeplr/actions": "^1.4.0",
|
|
31
|
+
"@xeplr/workflow-api": "^1.1.0"
|
|
29
32
|
},
|
|
30
33
|
"devDependencies": {
|
|
31
34
|
"dotenv-cli": "^8.0.0",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# __NAME__ API — PRODUCTION settings. Copy to production.env on the server
|
|
2
|
-
# and fill in; start with NODE_ENV=production (bin/www reads <NODE_ENV>.env).
|
|
2
|
+
# and fill in; start with NODE_ENV=production (bin/www reads <NODE_ENV>.env).
|
|
3
|
+
# production.env is never committed (.gitignore).
|
|
3
4
|
#
|
|
4
5
|
# Every value that is a secret is blank here. Generate new ones for production
|
|
5
6
|
# — never reuse development's.
|
|
@@ -15,15 +16,16 @@ AUTH_PORT=__AUTH_PORT__
|
|
|
15
16
|
ENCRYPTION_KEY=
|
|
16
17
|
__PREFIX___CONNECTION=
|
|
17
18
|
AUTH_DB_CONNECTION_INFO_ENCRYPTED=
|
|
18
|
-
DB_API=
|
|
19
|
-
AUTH_DB_NAME=
|
|
19
|
+
DB_API=__DB_API__
|
|
20
|
+
AUTH_DB_NAME=__DB_AUTH__
|
|
21
|
+
DB_WORKFLOW=__DB_WORKFLOW__
|
|
20
22
|
|
|
21
23
|
# ── Sign-in ──
|
|
22
24
|
REDIS_PREFIX=__NAME__:
|
|
23
25
|
AUTH_JWT_SECRET=
|
|
24
26
|
# Where THIS server reaches the sign-in service (not the public address).
|
|
25
27
|
AUTH_URL=http://127.0.0.1:__AUTH_PORT__
|
|
26
|
-
XEPLR_AUTH_MIGRATIONS=./node_modules/@xeplr/factory/migrations-auth,./migrations-auth
|
|
28
|
+
XEPLR_AUTH_MIGRATIONS=./node_modules/@xeplr/factory/migrations-auth,./node_modules/@xeplr/workflow-api/migrations-auth,./migrations-auth
|
|
27
29
|
AUTH_SUPER_ADMIN_EMAIL=
|
|
28
30
|
AUTH_SUPER_ADMIN_PASSWORD=
|
|
29
31
|
# none for one set of roles; a multi-tenant app names its company header, e.g. x-company-id
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// FLOWS — screens one after another, run by @xeplr/workflow-api inside this
|
|
2
|
+
// API (bin/www starts it; workflow.js says where its data lives).
|
|
3
|
+
//
|
|
4
|
+
// Mounted by routes/index.js at /api/workflow, behind this app's auth gate:
|
|
5
|
+
// /api/workflow/flows the flows people walk through (Journey) and design
|
|
6
|
+
// /api/workflow/workflows the workflow document API underneath
|
|
7
|
+
var express = require('express');
|
|
8
|
+
|
|
9
|
+
var router = express.Router();
|
|
10
|
+
var workflow = null;
|
|
11
|
+
|
|
12
|
+
router.use(function(req, res, next) {
|
|
13
|
+
// Only before startup has finished — bin/www mounts it before listening.
|
|
14
|
+
if (!workflow) return res.status(503).json({ message: 'Flows are still starting' });
|
|
15
|
+
return workflow(req, res, next);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** bin/www hands over registerWorkflow's router, already gated. */
|
|
19
|
+
router.mount = function(workflowRouter) {
|
|
20
|
+
workflow = workflowRouter;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
module.exports = router;
|
|
@@ -34,4 +34,8 @@ 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
|
+
// Flows: @xeplr/workflow-api, at /api/workflow, running inside this API (see
|
|
38
|
+
// routes/flows.js and workflow.js).
|
|
39
|
+
router.use('/api/workflow', require('./flows'));
|
|
40
|
+
|
|
37
41
|
module.exports = router;
|
|
@@ -16,8 +16,11 @@
|
|
|
16
16
|
// screens/<form>/<form>.entity.json its name and fields
|
|
17
17
|
// screens/<form>/<form>-list.screen.json the list
|
|
18
18
|
// screens/<form>/<form>-edit.screen.json the add / edit form
|
|
19
|
-
// screens/<form>/<form>.hooks.js server hooks — save / get / delete
|
|
20
19
|
// screens/<form>/<form>.model.js server model — getters / setters
|
|
20
|
+
//
|
|
21
|
+
// Code around a form's records — checks on save, an email after — is not in
|
|
22
|
+
// these folders: it is a FUNCTION in functions/, named in the screen's
|
|
23
|
+
// `events` (task-edit.screen.json names tidyTaskTitle).
|
|
21
24
|
|
|
22
25
|
module.exports = {
|
|
23
26
|
// Publish order is worked out for you: a table before the dropdowns that
|
|
@@ -27,11 +30,6 @@ module.exports = {
|
|
|
27
30
|
require('./task/task-list.screen.json')
|
|
28
31
|
],
|
|
29
32
|
|
|
30
|
-
// Your own code around save / get / delete, per screen — see task/task.hooks.js.
|
|
31
|
-
hooks: {
|
|
32
|
-
task_edit: require('./task/task.hooks')
|
|
33
|
-
},
|
|
34
|
-
|
|
35
33
|
// How each table's values are shaped — see task/task.model.js.
|
|
36
34
|
models: [
|
|
37
35
|
require('./task/task.model')
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// The model for task records — how values are shaped between your code and
|
|
2
2
|
// the "tasks" table, like Sequelize's getters and setters.
|
|
3
3
|
//
|
|
4
|
-
// Register with @xeplr/factory: factory.init({ knex,
|
|
4
|
+
// Register with @xeplr/factory: factory.init({ knex, models: [require('./task.model')] })
|
|
5
5
|
//
|
|
6
6
|
// Used everywhere: the screens' routes and factory.table('tasks'). Models shape
|
|
7
|
-
// data; the
|
|
7
|
+
// data; the functions its events name decide behaviour (api/functions/).
|
|
8
8
|
|
|
9
9
|
var { FactoryModel } = require('@xeplr/factory');
|
|
10
10
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// FLOWS — how @xeplr/workflow-api starts, inside this API.
|
|
2
|
+
//
|
|
3
|
+
// ITS OWN DATABASE DB_WORKFLOW, beside yours rather than in it — a
|
|
4
|
+
// sub-product embedded in an app gets a store of its own.
|
|
5
|
+
// Created and migrated on first start, with the same
|
|
6
|
+
// server login as the app (__PREFIX___CONNECTION).
|
|
7
|
+
// THIS APP'S it registers no tenancy of its own: flows and runs are
|
|
8
|
+
// TENANCY filtered by the levels this app registered (or none),
|
|
9
|
+
// behind this app's membership gate.
|
|
10
|
+
// PERMISSIONS every route checks the caller's ("List flows", "Start
|
|
11
|
+
// flow run"…), granted by @xeplr/workflow-api's
|
|
12
|
+
// migrations-auth, which XEPLR_AUTH_MIGRATIONS loads.
|
|
13
|
+
//
|
|
14
|
+
// A flow's "let a function decide" picks one of this app's functions
|
|
15
|
+
// (functions/), the same registry the screens' events use.
|
|
16
|
+
var { registerWorkflow } = require('@xeplr/workflow-api');
|
|
17
|
+
|
|
18
|
+
/** Creates and migrates workflow's database, and returns its router — already gated. */
|
|
19
|
+
module.exports = async function startWorkflow(memberGate) {
|
|
20
|
+
var workflow = await registerWorkflow({
|
|
21
|
+
applicationId: '__NAME__',
|
|
22
|
+
db: {
|
|
23
|
+
name: process.env.DB_WORKFLOW,
|
|
24
|
+
connection: process.env.__PREFIX___CONNECTION,
|
|
25
|
+
connectionName: 'workflow'
|
|
26
|
+
},
|
|
27
|
+
tenantTables: false,
|
|
28
|
+
access: true,
|
|
29
|
+
mtMembershipGate: memberGate
|
|
30
|
+
});
|
|
31
|
+
return workflow.router;
|
|
32
|
+
};
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@tanstack/react-table": "^8.21.3",
|
|
13
|
-
"@xeplr/ui-account": "^1.0
|
|
13
|
+
"@xeplr/ui-account": "^1.4.0",
|
|
14
14
|
"@xeplr/ui-canvas": "^1.1.0",
|
|
15
|
-
"@xeplr/ui-factory": "^1.1
|
|
15
|
+
"@xeplr/ui-factory": "^1.7.1",
|
|
16
16
|
"@xeplr/ui-table": "^1.0.6",
|
|
17
17
|
"react": "^19.2.4",
|
|
18
18
|
"react-dom": "^19.2.4",
|
package/templates/ui/src/App.jsx
CHANGED
|
@@ -4,6 +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
|
+
import FlowDesigner from './pages/FlowDesigner.jsx'
|
|
8
|
+
import Journey from './pages/Journey.jsx'
|
|
9
|
+
import FlowsHome from './pages/FlowsHome.jsx'
|
|
7
10
|
import ConfigureUI from './pages/ConfigureUI.jsx'
|
|
8
11
|
import { FORM_MENU_PREFIX } from './menu.js'
|
|
9
12
|
import FormDesigner from './pages/FormDesigner.jsx'
|
|
@@ -21,6 +24,15 @@ const TasksIcon = (
|
|
|
21
24
|
</svg>
|
|
22
25
|
)
|
|
23
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
|
+
|
|
24
36
|
const FormsIcon = (
|
|
25
37
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
|
|
26
38
|
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
@@ -68,6 +80,8 @@ function Shell() {
|
|
|
68
80
|
const drawerItems = useMemo(() => [
|
|
69
81
|
{ key: 'Home', icon: HomeIcon, path: '/home', clickHandler: () => navigate('/home') },
|
|
70
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) => ({
|
|
@@ -115,6 +129,12 @@ __MT_SELECT_ROUTE__
|
|
|
115
129
|
<Route path="/forms/:form" element={<FormRecords />} />
|
|
116
130
|
<Route path="/configure" element={<ConfigureUI />} />
|
|
117
131
|
<Route path="/configure/forms/:form/design/:part" element={<FormDesigner />} />
|
|
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 />} />
|
|
118
138
|
</Route>
|
|
119
139
|
|
|
120
140
|
<Route path="*" element={<Navigate to="/auth/login" replace />} />
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Flows are at /api/workflow/flows — @xeplr/workflow-api, running inside this
|
|
2
|
+
// app's API. authFetch carries the same sign-in and company as every other call.
|
|
3
|
+
import { authFetch } from '@xeplr/ui-account'
|
|
4
|
+
import { createFlowsApi } from '@xeplr/ui-factory'
|
|
5
|
+
|
|
6
|
+
export const flows = createFlowsApi({ fetch: authFetch, base: '/api/workflow' })
|
|
@@ -358,3 +358,19 @@ body { margin: 0; }
|
|
|
358
358
|
.app-menu-shown { display: inline-flex; gap: 6px; align-items: center; }
|
|
359
359
|
|
|
360
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
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// countries. Ordinary forms with a flag, gathered in one place because
|
|
7
7
|
// that is what they are: reference data, not daily work
|
|
8
8
|
// Menu the side rail's items: rename, reorder, hide
|
|
9
|
+
// Flows forms one after another — what is filled in decides the next; run by
|
|
10
|
+
// Xeplr Workflow, and says how to connect it until it is
|
|
9
11
|
//
|
|
10
12
|
// Configuration lives here, not in the side rail — the rail is for the pages
|
|
11
13
|
// people use.
|
|
@@ -13,10 +15,12 @@ import { useSearchParams } from 'react-router-dom'
|
|
|
13
15
|
import Forms from './Forms.jsx'
|
|
14
16
|
import Masters from './Masters.jsx'
|
|
15
17
|
import MenuSettings from './MenuSettings.jsx'
|
|
18
|
+
import Flows from './Flows.jsx'
|
|
16
19
|
|
|
17
20
|
const TABS = [
|
|
18
21
|
{ id: 'forms', label: 'Forms', Page: Forms },
|
|
19
22
|
{ id: 'masters', label: 'Masters', Page: Masters },
|
|
23
|
+
{ id: 'flows', label: 'Flows', Page: Flows },
|
|
20
24
|
{ id: 'menu', label: 'Menu', Page: MenuSettings }
|
|
21
25
|
]
|
|
22
26
|
|
|
@@ -18,8 +18,8 @@ import { factory } from '../api/factory.js'
|
|
|
18
18
|
//
|
|
19
19
|
// ctx.screen says which screen is calling ("task_list" or "task_edit").
|
|
20
20
|
// Keep them as methods (save(values, ctx) { … }) — super does not work in
|
|
21
|
-
// arrow functions. Rules that must hold belong in
|
|
22
|
-
// (api/
|
|
21
|
+
// arrow functions. Rules that must hold belong in a server function
|
|
22
|
+
// (api/functions/), named in the screen's events.
|
|
23
23
|
export class TaskHooks extends FactoryHooks {
|
|
24
24
|
/** A list's rows (ctx.many), or the record Edit opens (ctx.id). */
|
|
25
25
|
get(ctx) {
|
|
@@ -82,7 +82,7 @@ export default function EditTask({ record }) {
|
|
|
82
82
|
factory.loadScreen('task_edit').then(setScreen, (e) => setError(e.message))
|
|
83
83
|
}, [])
|
|
84
84
|
|
|
85
|
-
// The record as it is NOW, through the server's
|
|
85
|
+
// The record as it is NOW, through the server's events — not a copy the
|
|
86
86
|
// list happened to have.
|
|
87
87
|
useEffect(() => {
|
|
88
88
|
if (record || !id || id === 'new') return
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// ONE FLOW, DESIGNED — /configure/flows/<key>.
|
|
2
|
+
//
|
|
3
|
+
// @xeplr/ui-factory's designer: the flow's screens down the left; for the one
|
|
4
|
+
// picked, its Form design (the screen's own builder) and its Events &
|
|
5
|
+
// navigation — this app's functions (api/functions/) picked by name for each
|
|
6
|
+
// event, and where the journey goes once the screen is saved. The selected
|
|
7
|
+
// step and tab live in the address (?flowSelected=…&flowTab=…), so a reload or
|
|
8
|
+
// a shared link opens the same place.
|
|
9
|
+
//
|
|
10
|
+
// Everything it needs comes from the two API clients — the wiring lives in
|
|
11
|
+
// the package, so an upgrade reaches this app.
|
|
12
|
+
import { useNavigate, useParams } from 'react-router-dom'
|
|
13
|
+
import { FlowDesignerPage } from '@xeplr/ui-factory'
|
|
14
|
+
import { factory } from '../api/factory.js'
|
|
15
|
+
import { flows } from '../api/flows.js'
|
|
16
|
+
|
|
17
|
+
export default function FlowDesigner() {
|
|
18
|
+
const { flow } = useParams()
|
|
19
|
+
const navigate = useNavigate()
|
|
20
|
+
return (
|
|
21
|
+
<div className="app-designer">
|
|
22
|
+
<div className="app-designer-bar">
|
|
23
|
+
<button type="button" className="app-btn app-btn-quiet" onClick={() => navigate('/configure?tab=flows')}>← Flows</button>
|
|
24
|
+
</div>
|
|
25
|
+
<div className="app-designer-canvas">
|
|
26
|
+
<FlowDesignerPage factory={factory} flows={flows} flowKey={flow} urlKey="flow" />
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
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. For
|
|
4
|
+
// each screen it says what runs around its records (its events — this app's
|
|
5
|
+
// functions, picked by name) and where the journey goes once it is saved: a
|
|
6
|
+
// screen, a function that decides, or the end. @xeplr/workflow-api runs it,
|
|
7
|
+
// inside this app's API, so a journey can be left and picked up later.
|
|
8
|
+
//
|
|
9
|
+
// A flow is addressed by a key made from its name — /configure/flows/<key> to
|
|
10
|
+
// design it, /journey/<key> to walk it.
|
|
11
|
+
import { useEffect, useState } from 'react'
|
|
12
|
+
import { Link, useNavigate } from 'react-router-dom'
|
|
13
|
+
import { flowKey } from '@xeplr/ui-factory'
|
|
14
|
+
import { flows } from '../api/flows.js'
|
|
15
|
+
|
|
16
|
+
export default function Flows() {
|
|
17
|
+
const [list, setList] = useState(null)
|
|
18
|
+
const [error, setError] = useState(null)
|
|
19
|
+
const [offline, setOffline] = useState(null) // API_NOT_RESTARTED
|
|
20
|
+
const [name, setName] = useState('')
|
|
21
|
+
const navigate = useNavigate()
|
|
22
|
+
|
|
23
|
+
const load = () => flows.listFlows().then(setList, (e) => {
|
|
24
|
+
const off = flowsOffline(e)
|
|
25
|
+
if (off) setOffline(off)
|
|
26
|
+
else setError(e.message)
|
|
27
|
+
})
|
|
28
|
+
useEffect(() => { load() }, [])
|
|
29
|
+
|
|
30
|
+
const create = async (e) => {
|
|
31
|
+
e.preventDefault()
|
|
32
|
+
const label = name.trim()
|
|
33
|
+
if (!label) return
|
|
34
|
+
try {
|
|
35
|
+
// No steps yet: the designer is where those come from, and it opens on
|
|
36
|
+
// the flow this creates. `key` is what every link to it uses from here on.
|
|
37
|
+
const made = await flows.createFlow({ key: flowKey(label), name: label })
|
|
38
|
+
navigate(`/configure/flows/${made.key}`)
|
|
39
|
+
} catch (err) {
|
|
40
|
+
setError(err.message)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (offline) return <NotConnected offline={offline} />
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="app-forms">
|
|
48
|
+
<p className="app-page-lead">
|
|
49
|
+
A flow is your forms, one after another. For each one, pick what runs around its records and
|
|
50
|
+
where the journey goes next — a screen, one of the app’s functions, or the end.
|
|
51
|
+
</p>
|
|
52
|
+
{error && <div className="app-error">{error}</div>}
|
|
53
|
+
|
|
54
|
+
<form className="app-forms-new" onSubmit={create}>
|
|
55
|
+
<label className="app-forms-field">
|
|
56
|
+
<span>Name</span>
|
|
57
|
+
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Employee registration" />
|
|
58
|
+
</label>
|
|
59
|
+
<button type="submit" className="app-btn" disabled={!name.trim()}>New flow</button>
|
|
60
|
+
</form>
|
|
61
|
+
|
|
62
|
+
{list && list.length === 0 && <p className="app-sub">No flows yet.</p>}
|
|
63
|
+
{list && list.length > 0 && (
|
|
64
|
+
<table className="app-table">
|
|
65
|
+
<thead><tr><th>Flow</th><th>Key</th><th>Status</th><th>Screens</th><th /></tr></thead>
|
|
66
|
+
<tbody>
|
|
67
|
+
{list.map((f) => (
|
|
68
|
+
<tr key={f.key}>
|
|
69
|
+
<td><strong>{f.name}</strong></td>
|
|
70
|
+
<td className="app-sub">{f.key}</td>
|
|
71
|
+
<td>{f.status}</td>
|
|
72
|
+
<td>{typeof f.steps === 'number' ? f.steps : (f.steps || []).length}</td>
|
|
73
|
+
<td className="app-row-actions">
|
|
74
|
+
<Link to={`/configure/flows/${f.key}`}>Design</Link>
|
|
75
|
+
{f.status === 'published' && <Link to={`/journey/${f.key}`}>Open</Link>}
|
|
76
|
+
</td>
|
|
77
|
+
</tr>
|
|
78
|
+
))}
|
|
79
|
+
</tbody>
|
|
80
|
+
</table>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Why flows cannot run right now, or null when the error is something else.
|
|
88
|
+
* Workflow runs inside this app's API, so there is one such case: an API
|
|
89
|
+
* started before flows existed (404 on /api/workflow) — restart it.
|
|
90
|
+
*/
|
|
91
|
+
export function flowsOffline(err) {
|
|
92
|
+
if (err && err.status === 404) return { code: 'API_NOT_RESTARTED', message: 'This app\'s API does not have the flows route yet.' }
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Flows before the API has been restarted with them. */
|
|
97
|
+
export function NotConnected({ offline }) {
|
|
98
|
+
return (
|
|
99
|
+
<div className="app-forms">
|
|
100
|
+
<div className="app-connect">
|
|
101
|
+
<h2>Restart the API to use flows</h2>
|
|
102
|
+
<p>{offline.message} It was started before flows were added — restart it, then reload this page.</p>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -63,7 +63,7 @@ export default function TasksHelp() {
|
|
|
63
63
|
<h3>Or without code</h3>
|
|
64
64
|
<p>
|
|
65
65
|
<strong>Configure UI → Forms → New form</strong>: label and key, design,
|
|
66
|
-
publish, add to menu — no code. The full steps for a form with its own page,
|
|
66
|
+
publish, add to menu — no code. The full steps for a form with its own page, functions and
|
|
67
67
|
model are in <code>CLAUDE.md</code>, under "Create a new UI".
|
|
68
68
|
</p>
|
|
69
69
|
|
|
@@ -75,7 +75,8 @@ export default function TasksHelp() {
|
|
|
75
75
|
</p>
|
|
76
76
|
<p>
|
|
77
77
|
On the server — a check against other records, an email afterwards, a
|
|
78
|
-
list only managers see: <code>api/
|
|
78
|
+
list only managers see: a function in <code>api/functions/</code>, registered by the event it
|
|
79
|
+
is for and named in the screen’s events — Tasks names <code>tidyTaskTitle</code>.
|
|
79
80
|
</p>
|
|
80
81
|
|
|
81
82
|
<h3>Two things that catch people</h3>
|
|
@@ -44,7 +44,10 @@ export default defineConfig(({ mode }) => {
|
|
|
44
44
|
// Force every import to THIS app's copy, so hook-using libraries don't
|
|
45
45
|
// load twice and break the rules of hooks in ways that read as impossible.
|
|
46
46
|
resolve: { dedupe: ['react', 'react-dom', 'react-router-dom', '@xeplr/ui-canvas', '@xeplr/ui-table'] },
|
|
47
|
-
|
|
47
|
+
// 127.0.0.1, not 'localhost': Node resolves 'localhost' to IPv6 (::1)
|
|
48
|
+
// first, so the dev server listened on ::1 alone and a browser going to
|
|
49
|
+
// 127.0.0.1 was refused.
|
|
50
|
+
server: { host: '127.0.0.1', port, strictPort: true, proxy },
|
|
48
51
|
preview: { port, strictPort: true }
|
|
49
52
|
}
|
|
50
53
|
})
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
// Server hooks for task records — screen "task_edit" (and "task_list", which edits in it).
|
|
2
|
-
//
|
|
3
|
-
// Register with @xeplr/factory:
|
|
4
|
-
// factory.init({ knex, hooks: { task_edit: require('./task.hooks') } })
|
|
5
|
-
//
|
|
6
|
-
// Each operation has four hooks. Delete the ones you do not need.
|
|
7
|
-
// before(ctx) first — save: return new values to replace them (they are then checked
|
|
8
|
-
// against the screen's rules); get: narrow ctx.query; any: ctx.reject(message, { field })
|
|
9
|
-
// after(ctx) once it succeeded — ctx.id, ctx.result; return a value to replace the result.
|
|
10
|
-
// If it throws, the operation still happened (and error is told).
|
|
11
|
-
// error(ctx, err) when the operation failed
|
|
12
|
-
// override(ctx) does the WHOLE operation instead — no rules, no before / after / error,
|
|
13
|
-
// no generic query. What it returns is the response.
|
|
14
|
-
//
|
|
15
|
-
// ctx: op, screenKey, screen, table, id, isNew (save), many (get), input (what the UI sent, read-only),
|
|
16
|
-
// values (save), previous (save / delete: the row before), result, user, tenant, knex, query (get), reject
|
|
17
|
-
|
|
18
|
-
module.exports = {
|
|
19
|
-
save: {
|
|
20
|
-
// before: async function(ctx) { return ctx.values },
|
|
21
|
-
// after: async function(ctx) {},
|
|
22
|
-
// error: async function(ctx, err) {},
|
|
23
|
-
// override: async function(ctx) { return savedRecord },
|
|
24
|
-
},
|
|
25
|
-
get: {
|
|
26
|
-
// before: async function(ctx) { /* ctx.query.where(...) */ },
|
|
27
|
-
// after: async function(ctx) { return ctx.result },
|
|
28
|
-
// error: async function(ctx, err) {},
|
|
29
|
-
// override: async function(ctx) { return ctx.id ? record : records },
|
|
30
|
-
},
|
|
31
|
-
delete: {
|
|
32
|
-
// before: async function(ctx) {},
|
|
33
|
-
// after: async function(ctx) {},
|
|
34
|
-
// error: async function(ctx, err) {},
|
|
35
|
-
// override: async function(ctx) { return { id: ctx.id } },
|
|
36
|
-
}
|
|
37
|
-
}
|