@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.
Files changed (37) hide show
  1. package/README.md +33 -3
  2. package/lib/generate/index.js +58 -1
  3. package/lib/plan.js +1 -0
  4. package/lib/questions.js +21 -2
  5. package/package.json +1 -1
  6. package/templates/CLAUDE.md +9 -7
  7. package/templates/README.md +14 -7
  8. package/templates/api/README.md +12 -4
  9. package/templates/api/app.js +2 -0
  10. package/templates/api/bin/www +13 -5
  11. package/templates/api/db/setup.js +1 -1
  12. package/templates/api/development.env.tpl +26 -12
  13. package/templates/api/env.required.js +7 -1
  14. package/templates/api/functions/index.js +47 -0
  15. package/templates/api/migrations-auth/0005_flows_menu.sql +14 -0
  16. package/templates/api/package.json +7 -4
  17. package/templates/api/production.env.example +6 -4
  18. package/templates/api/routes/flows.js +23 -0
  19. package/templates/api/routes/index.js +4 -0
  20. package/templates/api/screens/index.js +4 -6
  21. package/templates/api/screens/task/task-edit.screen.json +4 -1
  22. package/templates/api/screens/task/task.model.js +2 -2
  23. package/templates/api/workflow.js +32 -0
  24. package/templates/ui/package.json +2 -2
  25. package/templates/ui/src/App.jsx +20 -0
  26. package/templates/ui/src/api/flows.js +6 -0
  27. package/templates/ui/src/index.css +16 -0
  28. package/templates/ui/src/menu.js +1 -0
  29. package/templates/ui/src/pages/ConfigureUI.jsx +4 -0
  30. package/templates/ui/src/pages/EditTask.jsx +3 -3
  31. package/templates/ui/src/pages/FlowDesigner.jsx +30 -0
  32. package/templates/ui/src/pages/Flows.jsx +106 -0
  33. package/templates/ui/src/pages/FlowsHome.jsx +65 -0
  34. package/templates/ui/src/pages/Journey.jsx +33 -0
  35. package/templates/ui/src/pages/TasksHelp.jsx +3 -2
  36. package/templates/ui/vite.config.js +4 -1
  37. 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.1.4",
20
+ "@xeplr/auth": "^1.3.0",
21
21
  "@xeplr/base-apis": "^2.0.0",
22
22
  "@xeplr/db": "^1.0.6",
23
- "@xeplr/factory": "^1.1.0",
24
- "@xeplr/ui-factory": "^1.1.0",
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). production.env is never committed (.gitignore).
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=__NAME___api
19
- AUTH_DB_NAME=__NAME___auth
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')
@@ -269,5 +269,8 @@
269
269
  }
270
270
  }
271
271
  ],
272
- "source": "tasks"
272
+ "source": "tasks",
273
+ "events": {
274
+ "beforeSave": "tidyTaskTitle"
275
+ }
273
276
  }
@@ -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, hooks, models: [require('./task.model')] })
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 hooks file decides behaviour.
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.9",
13
+ "@xeplr/ui-account": "^1.4.0",
14
14
  "@xeplr/ui-canvas": "^1.1.0",
15
- "@xeplr/ui-factory": "^1.1.0",
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",
@@ -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; }
@@ -11,6 +11,7 @@ export const FORM_MENU_PREFIX = 'form:'
11
11
  export const APP_MENU_KEYS = [
12
12
  'Home',
13
13
  'Tasks',
14
+ 'Flows',
14
15
  __MT_MENU_KEY__
15
16
  'Admin',
16
17
  'Configure UI'
@@ -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 the server's hooks
22
- // (api/screens/task/task.hooks.js).
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 get hooks — not a copy the
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, hooks and
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/screens/task/task.hooks.js</code>.
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
- server: { port, strictPort: true, proxy },
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
- }