@xeplr/cli 0.3.1 → 0.3.3

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 CHANGED
@@ -41,12 +41,6 @@ Already working, with nothing to wire up:
41
41
  - **Menu**: rename, reorder and hide the side rail's items.
42
42
 
43
43
  Nobody else sees it, and the API refuses everyone else.
44
- - **Flows, in every app** — your forms one after another, where what someone
45
- fills in decides the next screen. Designed in Configure UI → Flows, walked at
46
- `/journey/<key>`, and resumable. Run by `@xeplr/workflow` **inside the API**,
47
- in the app's own database — nothing extra to start, no port, no setting. It
48
- follows the app's tenancy (none, or the levels you chose) and never creates
49
- companies or workspaces of its own.
50
44
  - **Multi-tenancy, if you ask for it** — companies (or companies and
51
45
  workspaces, up to four levels). Every row is stamped with the one it was made
52
46
  in, every read sees only its rows, membership is checked on every request,
@@ -77,7 +71,7 @@ These are architecture decisions, not defaults to tune — each one exists becau
77
71
  | **`REDIS_PREFIX` is required and unique per app** (`api/development.env`, set to `<name>:`). | `@xeplr/auth`, `@xeplr/utils` | Two apps on one Redis under the shared default serve each other's sessions, menus and API permissions. Sign-in refuses to start without it, or with `xeplr:`. |
78
72
  | **Designing, new forms and publishing are Super Admin only.** | `api/routes/access.js`, `@xeplr/auth` menu routes | Publishing changes database tables. Every other screen route checks the permission catalog. |
79
73
  | **The browser is not a security boundary.** Front-end hooks (`ui/src/pages/Edit<Form>.jsx`) shape what people see and send; anything that must hold goes in server hooks (`api/screens/<form>/<form>.hooks.js`); data shape in the model (`<form>.model.js`). | `@xeplr/ui-factory`, `@xeplr/factory` | |
80
- | **Anything every app would write identically lives in a package, not in the generated app.** A generated app is a starting point that then lives its own life — glue copied into it can never be fixed centrally, so an app made last month keeps that copy forever. When the flow designer needed this app's forms as its Screen steps, the wiring went into `@xeplr/ui-factory` (`useScreenSource`) and the generated page passes two props. | `@xeplr/ui-factory`, `@xeplr/ui-workflow` | A fix or a new control type has to reach apps that already exist, and it cannot reach code that was copied into them. `test/cli.test.js` fails if the glue reappears in the generated app. |
74
+ | **Anything every app would write identically lives in a package, not in the generated app.** A generated app is a starting point that then lives its own life — glue copied into it can never be fixed centrally, so an app made last month keeps that copy forever. When Configure UI → Masters needed to know which forms are masters, that answer went into `@xeplr/ui-factory` (`useScreenSource`) and the generated page only reads `masters`. | `@xeplr/ui-factory` | A fix or a new control type has to reach apps that already exist, and it cannot reach code that was copied into them. `test/cli.test.js` fails if the glue reappears in the generated app. |
81
75
  | **Every data address is under `/api`.** | `api/routes`, `ui/vite.config.js` | A page path and a data path must never collide. |
82
76
  | **No guessed database names or connections.** | `api/development.env` | A wrong-but-present value starts cleanly and reads as empty data. |
83
77
  | **Email is checked at start, never fatal.** The sign-in banner shows `email ✓ …` or `✗ NOT WORKING — reason`. | `@xeplr/auth` | Sign-in works without email; activation, invite and reset links cannot be sent until `EMAIL_PROVIDER` is set. |
@@ -206,14 +200,6 @@ no defaults, ever.
206
200
 
207
201
  Encryption keys and signing secrets are **generated, never asked**.
208
202
 
209
- **Flows, inside the API.** `@xeplr/workflow` is a dependency of `api/`, and
210
- `bin/www` starts it with `registerWorkflow` after the screens — in the app's
211
- own database (`DB_API`, its own migration record `workflow_migrations`), with
212
- `tenantTables: false` so it follows the app's tenancy rather than creating
213
- `companies` / `workspaces`, and `access: true` so each flows route checks the
214
- caller's permissions, which its `migrations-auth` grants (loaded through
215
- `XEPLR_AUTH_MIGRATIONS`).
216
-
217
203
  ## Licence
218
204
 
219
205
  MIT
@@ -28,52 +28,13 @@ function substitutions(answers) {
28
28
  __UI_PORT__: String(answers.ports.ui),
29
29
  __AUTH_PORT__: String(answers.ports.auth),
30
30
  __API_PORT__: String(answers.ports.api),
31
- // The port after the API's — suggested for a service run on its own (workflow).
32
- __NEXT_PORT__: String(answers.ports.api + 1),
33
31
  __ENCRYPTION_KEY__: answers.secrets.encryptionKey,
34
32
  __JWT_SECRET__: answers.secrets.jwtSecret,
35
33
  __ADMIN_EMAIL__: answers.admin.email,
36
34
  __ADMIN_PASSWORD__: answers.admin.password,
37
35
  __DB_CONNECTION__: answers.connection || '',
38
36
  __CONNECTION_NOTE__: connectionNote(answers)
39
- }, tenancySubstitutions(answers.tenancy || []), flowsSubstitutions(answers));
40
- }
41
-
42
- /**
43
- * FLOWS ARE IN EVERY APP, and so is what runs them: @xeplr/workflow, started
44
- * INSIDE the API (bin/www → registerWorkflow) in the app's own database. There
45
- * is no Workflow server to run, no port, no setting and nothing to connect, so
46
- * nothing here depends on an answer — these tokens only carry text.
47
- */
48
- function flowsSubstitutions(answers) {
49
- return {
50
- // Where workflow runs. Its database is DB_API with the app's login — no
51
- // setting; only the port, and only to run it as its own service.
52
- __WF_ENV__: [
53
- '# ── Flows (@xeplr/workflow) ──',
54
- '# BLANK: workflow runs INSIDE this API (npm run start-api) — nothing else to',
55
- '# start. Set a port to run it as its OWN SERVICE (npm run start-workflow);',
56
- '# then point /api/workflow at it: WORKFLOW_URL in ui/.env for local, nginx',
57
- '# in production (README, "Production"). This app\'s next free port: ' + (answers.ports.api + 1) + '.',
58
- 'WORKFLOW_PORT=',
59
- ''
60
- ].join('\n'),
61
- __WF_UI_ENV__: [
62
- '',
63
- '# Where /api/workflow goes. BLANK: the API above (workflow runs inside it).',
64
- '# Set it when api/development.env sets WORKFLOW_PORT — e.g.',
65
- '# http://localhost:' + (answers.ports.api + 1),
66
- 'WORKFLOW_URL='
67
- ].join('\n'),
68
- // Workflow's permissions — who may design a flow, who may run one — load
69
- // into this app's sign-in service after the app's own.
70
- __WF_AUTH_MIGRATIONS__: ',./node_modules/@xeplr/workflow/migrations-auth',
71
- __WF_README__: flowsReadme(answers),
72
- __WF_CLAUDE__: [
73
- '**Flows** — forms one after another, run by `@xeplr/workflow` INSIDE this API (bin/www starts it) and stored in this app\'s own database. Design them in Configure UI → Flows, never in code: a flow is data, reached through `/api/flows` (routes/flows.js), and walked at `/journey/<key>`. An arrow tests one field of the screen it leaves; a form that is a step must be published first. Workflow follows this app\'s tenancy — it never creates companies or workspaces of its own.',
74
- ''
75
- ].join('\n')
76
- };
37
+ }, tenancySubstitutions(answers.tenancy || []));
77
38
  }
78
39
 
79
40
  /**
@@ -117,8 +78,6 @@ function tenancySubstitutions(levels) {
117
78
  " '/api/tenants': tenants.router,"
118
79
  ].join('\n') : '',
119
80
  __MT_MEMBER_GATE__: on ? 'tenants.memberGate' : 'access.anyone',
120
- // api/app.js — the request middleware bin/workflow runs as well.
121
- __MT_MIDDLEWARE_LIST__: on ? '[mtMiddleware()]' : '[]',
122
81
  __MT_MEMBER_NOUN__: on ? levels.map(function (l) { return l.label.toLowerCase(); }).join(' and ') : 'data (no tenancy: nothing to check)',
123
82
 
124
83
  // ui/src/main.jsx
@@ -159,27 +118,6 @@ function tenancySubstitutions(levels) {
159
118
  };
160
119
  }
161
120
 
162
- /** The README's Flows section. */
163
- function flowsReadme(answers) {
164
- var tenancy = answers.tenancy || [];
165
- return [
166
- '## Flows',
167
- '',
168
- 'Your forms, one after another: what someone fills in on one decides which comes next. Design one in **Configure UI → Flows**, publish it, and people start it from **Flows** in the side rail (or at `/journey/<key>`). The run id joins the address once it starts, so a journey can be reloaded, left, or handed to someone else and opened on the step it is on.',
169
- '',
170
- 'Flows are run by **`@xeplr/workflow`**, which runs **inside this API** — `bin/www` starts it after the screens. Nothing else to start, no port, no setting:',
171
- '',
172
- '| | |',
173
- '|---|---|',
174
- '| **Database** | this app\'s own (`DB_API`). Workflow\'s tables (`workflows`, `workflow_steps`, `workflow_runs`…) sit beside the forms\' tables, and it keeps its own record of which of its migrations ran (`workflow_migrations`). Each screen still saves into its own table. |',
175
- '| **Tenancy** | ' + (tenancy.length
176
- ? 'this app\'s — flows and runs belong to the ' + tenancy.map(function (l) { return l.label.toLowerCase(); }).join(' and ') + ' chosen after signing in, checked by the same membership gate as everything else.'
177
- : 'none, like the rest of this app — flows belong to the whole app.') + ' Workflow never creates companies or workspaces of its own. |',
178
- '| **Permissions** | each route checks the caller\'s (`List flows`, `Start flow run`, `Publish flow`…), granted to Super Admin, CompanyAdmin, Creator and — read only — Viewer by `@xeplr/workflow`\'s `migrations-auth`, which `XEPLR_AUTH_MIGRATIONS` loads. |',
179
- ''
180
- ].join('\n');
181
- }
182
-
183
121
  /** One level's own table: what the picker lists. Its parents' ids in mtIdN. */
184
122
  function tenantTableSql(level, i) {
185
123
  var parents = i === 0 ? 'none — the outermost level' : i === 1 ? 'mtId1 holds the one above' : 'mtId1–mtId' + i + ' hold the ones above';
package/lib/questions.js CHANGED
@@ -134,10 +134,6 @@ async function collect(session, argvName) {
134
134
  tenancy = tenancyLevels(levelsText);
135
135
  }
136
136
 
137
- // ── flows ─────────────────────────────────────────────────────────────
138
- // Nothing to ask: every app has flows, run by @xeplr/workflow inside its own
139
- // API and database (see lib/generate's flowsSubstitutions).
140
-
141
137
  // ── the database ──────────────────────────────────────────────────────
142
138
  //
143
139
  // ASKED, not defaulted, and not left for the reader to do by hand either.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Creates a working xeplr application — API, sign-in and UI — from a few answers.",
5
5
  "license": "MIT",
6
6
  "author": "xeppie <admin@xeplr.com>",
@@ -49,7 +49,6 @@ Then change only what the request asks for:
49
49
 
50
50
  **A list opens its form on a page or in a popup** — `"openIn": "page"` in the entity spec, or Configure UI → Forms → the list. A page needs `onOpenRecord` on the list's page (where to navigate) and `onDone` on the form's page (where Save and Cancel go back to): `Tasks.jsx` and `EditTask.jsx` show both, with routes `/tasks/new` and `/tasks/:id` in `App.jsx`.
51
51
 
52
- __WF_CLAUDE__
53
52
  **Keys and labels.** A form's key names its screens and table and never changes once published; its label is the screens' name. A menu item's key (`menus.name`) is what `drawerItems` matches; its label is what the rail shows, renamed in Configure UI → Menu. Code only ever uses keys.
54
53
 
55
54
  ### The prompt
@@ -21,9 +21,6 @@ cd ui && npm run dev # the app http://localhost:__UI_PORT_
21
21
 
22
22
  Open http://localhost:__UI_PORT__ and sign in as `__ADMIN_EMAIL__`.
23
23
 
24
- Flows (Xeplr Workflow) run **inside the API** — nothing else to start. To run
25
- them as their own service instead, see "Where each address goes" below.
26
-
27
24
  ## Where each address goes
28
25
 
29
26
  The browser only ever calls **paths** on the app's own address — never a port.
@@ -33,39 +30,24 @@ locally (`ui/vite.config.js`, from `ui/.env`), nginx in production.
33
30
  | path | served by | local (`ui/.env`) | production (nginx) |
34
31
  |---|---|---|---|
35
32
  | `/auth/api/…` | the sign-in service | `AUTH_URL` | `127.0.0.1:<AUTH_PORT>` |
36
- | `/api/workflow/…` | flows — `@xeplr/workflow` | `WORKFLOW_URL`, or `API_URL` when blank | the workflow process, or the API when it runs inside |
37
33
  | `/api/…`, `/health`, `/whoami` | the API | `API_URL` | `127.0.0.1:<__PREFIX___PORT>` |
38
34
 
39
- **Workflow on its own port.** Set `WORKFLOW_PORT` in `api/development.env`
40
- (this app's next free port is __NEXT_PORT__), set `WORKFLOW_URL` in `ui/.env` to
41
- `http://localhost:__NEXT_PORT__`, and start it beside the API:
42
-
43
- ```bash
44
- cd api && npm run start-workflow # flows http://localhost:__NEXT_PORT__
45
- ```
46
-
47
- Same database, same sign-in, same permissions and the same `/api/workflow/…`
48
- paths — only the process differs. Leave `WORKFLOW_PORT` blank and it runs inside
49
- the API again.
50
-
51
35
  ### Production
52
36
 
53
37
  `api/production.env.example` lists every setting (copy it to
54
38
  `api/production.env`, fill in, start with `NODE_ENV=production`). Build the UI
55
- (`cd ui && npm run build`) and let nginx serve it and route the paths — the
56
- more specific `/api/workflow/` first:
39
+ (`cd ui && npm run build`) and let nginx serve it and route the paths:
57
40
 
58
41
  ```nginx
59
42
  server {
60
43
  server_name app.example.com;
61
44
  root /srv/__NAME__/ui/dist;
62
45
 
63
- location /auth/api/ { proxy_pass http://127.0.0.1:__AUTH_PORT__; }
64
- location /api/workflow/ { proxy_pass http://127.0.0.1:__API_PORT__; } # or WORKFLOW_PORT when it runs on its own
65
- location /api/ { proxy_pass http://127.0.0.1:__API_PORT__; }
66
- location = /health { proxy_pass http://127.0.0.1:__API_PORT__; }
67
- location = /whoami { proxy_pass http://127.0.0.1:__API_PORT__; }
68
- location / { try_files $uri /index.html; } # the React app's own pages
46
+ location /auth/api/ { proxy_pass http://127.0.0.1:__AUTH_PORT__; }
47
+ location /api/ { proxy_pass http://127.0.0.1:__API_PORT__; }
48
+ location = /health { proxy_pass http://127.0.0.1:__API_PORT__; }
49
+ location = /whoami { proxy_pass http://127.0.0.1:__API_PORT__; }
50
+ location / { try_files $uri /index.html; } # the React app's own pages
69
51
  }
70
52
  ```
71
53
 
@@ -84,7 +66,6 @@ Every one of these checks the same sign-in token, so one login covers them all.
84
66
 
85
67
  > Create a new UI for farming departments. One record is a farming department with: Name (required), Region (dropdown: North, South, East, West), Area in acres (number, at least 0), Started on (date), Notes (long text). Show Name, Region and Area in the list. Follow "Create a new UI" in CLAUDE.md.
86
68
 
87
- __WF_README__
88
69
  ## Rules this app follows
89
70
 
90
71
  | Rule | Why |
@@ -13,10 +13,6 @@ var memberGate = __MT_MEMBER_GATE__;
13
13
  var port = process.env.__PREFIX___PORT;
14
14
 
15
15
  module.exports = buildApp;
16
- // bin/www and bin/workflow hand the same gate to the flows router they start,
17
- // and bin/workflow runs the same request middleware (tenancy) as this app.
18
- module.exports.memberGate = memberGate;
19
- module.exports.middleware = function() { return __MT_MIDDLEWARE_LIST__; };
20
16
 
21
17
  function buildApp(extraRoutes) {
22
18
  return createApp(port, '__NAME___api', {
@@ -76,21 +76,7 @@ async function start() {
76
76
  var published = await factory.publishScreens(screens.documents);
77
77
  if (published.published.length) console.log('[api] published screens: ' + published.published.join(', '));
78
78
 
79
- // FLOWS — Xeplr Workflow, inside this API and in this app's own database
80
- // (routes/flows.js says why). It creates its tables on first start, keeps its
81
- // own migration record, and follows this app's tenancy instead of creating
82
- // companies / workspaces of its own.
83
- var buildApp = require('../app');
84
- // WORKFLOW_PORT set: it runs as its own service (npm run start-workflow),
85
- // and this API leaves it alone.
86
- if (process.env.WORKFLOW_PORT) {
87
- console.log('[api] flows: workflow runs on its own port ' + process.env.WORKFLOW_PORT + ' (npm run start-workflow)');
88
- buildApp();
89
- return;
90
- }
91
- require('../routes/flows').mount(await require('../workflow')(buildApp.memberGate));
92
-
93
- buildApp();
79
+ require('../app')();
94
80
  }
95
81
 
96
82
  start().catch(function(err) {
@@ -42,7 +42,7 @@ AUTH_URL=http://localhost:__AUTH_PORT__
42
42
 
43
43
  # Sign-in setup files: the screens' permissions (@xeplr/factory's), then this
44
44
  # app's own — menu entries and anything else it adds. Comma-separated, in order.
45
- XEPLR_AUTH_MIGRATIONS=./node_modules/@xeplr/factory/migrations-auth,./migrations-auth__WF_AUTH_MIGRATIONS__
45
+ XEPLR_AUTH_MIGRATIONS=./node_modules/@xeplr/factory/migrations-auth,./migrations-auth
46
46
 
47
47
  # ── The first account you sign in with ──
48
48
  # Created for you when you set up the database. Change the password here and
@@ -63,7 +63,6 @@ AUTH_TENANT_HEADER=none
63
63
  AUTH_ACTIVATION_URL=http://localhost:__UI_PORT__/auth/activate
64
64
  AUTH_INVITE_URL=http://localhost:__UI_PORT__/auth/accept-invite
65
65
 
66
- __WF_ENV__
67
66
  # ── Uploaded files ──
68
67
  # Where a form's FILE field puts what people upload. The path is what the
69
68
  # record stores; the file itself is served only through the API's own route,
@@ -14,8 +14,7 @@
14
14
  "migrate:auth": "dotenv -e development.env -- xeplr-auth-migrate up --connection-env AUTH_DB_CONNECTION_INFO_ENCRYPTED",
15
15
  "migrate:up": "dotenv -e development.env -- xeplr-migrate up --dir ./migrations --connectionName api --connection-env __PREFIX___CONNECTION",
16
16
  "start-auth": "dotenv -e ./development.env -- xeplr-auth-server",
17
- "start-api": "node ./bin/www",
18
- "start-workflow": "node ./bin/workflow"
17
+ "start-api": "node ./bin/www"
19
18
  },
20
19
  "dependencies": {
21
20
  "@xeplr/auth": "^1.1.4",
@@ -24,7 +23,6 @@
24
23
  "@xeplr/factory": "^1.1.0",
25
24
  "@xeplr/ui-factory": "^1.1.0",
26
25
  "@xeplr/utils": "^1.0.11",
27
- "@xeplr/workflow": "^1.2.0",
28
26
  "dotenv": "^17.4.2",
29
27
  "express": "^5.2.0",
30
28
  "multer": "^2.0.2"
@@ -1,6 +1,5 @@
1
1
  # __NAME__ API — PRODUCTION settings. Copy to production.env on the server
2
- # and fill in; start with NODE_ENV=production (bin/www and bin/workflow read
3
- # <NODE_ENV>.env). production.env is never committed (.gitignore).
2
+ # and fill in; start with NODE_ENV=production (bin/www reads <NODE_ENV>.env). production.env is never committed (.gitignore).
4
3
  #
5
4
  # Every value that is a secret is blank here. Generate new ones for production
6
5
  # — never reuse development's.
@@ -11,9 +10,6 @@ LOG_DIR=./logs
11
10
  # ── Ports: one per process on this server ──
12
11
  __PREFIX___PORT=__API_PORT__
13
12
  AUTH_PORT=__AUTH_PORT__
14
- # Blank: workflow runs inside the API. Set: it runs as its own process
15
- # (npm run start-workflow) and nginx sends /api/workflow to it.
16
- WORKFLOW_PORT=
17
13
 
18
14
  # ── Databases (same server, one login) ──
19
15
  ENCRYPTION_KEY=
@@ -27,7 +23,7 @@ REDIS_PREFIX=__NAME__:
27
23
  AUTH_JWT_SECRET=
28
24
  # Where THIS server reaches the sign-in service (not the public address).
29
25
  AUTH_URL=http://127.0.0.1:__AUTH_PORT__
30
- XEPLR_AUTH_MIGRATIONS=./node_modules/@xeplr/factory/migrations-auth,./migrations-auth,./node_modules/@xeplr/workflow/migrations-auth
26
+ XEPLR_AUTH_MIGRATIONS=./node_modules/@xeplr/factory/migrations-auth,./migrations-auth
31
27
  AUTH_SUPER_ADMIN_EMAIL=
32
28
  AUTH_SUPER_ADMIN_PASSWORD=
33
29
  # none for one set of roles; a multi-tenant app names its company header, e.g. x-company-id
@@ -34,9 +34,4 @@ 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, 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'));
41
-
42
37
  module.exports = router;
@@ -6,4 +6,3 @@
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__
@@ -17,7 +17,6 @@
17
17
  "react": "^19.2.4",
18
18
  "react-dom": "^19.2.4",
19
19
  "react-router-dom": "^7.13.2",
20
- "@xeplr/ui-workflow": "^1.2.0",
21
20
  "@xeplr/ui-utils": "^1.0.2"
22
21
  },
23
22
  "devDependencies": {
@@ -4,9 +4,6 @@ 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'
10
7
  import ConfigureUI from './pages/ConfigureUI.jsx'
11
8
  import { FORM_MENU_PREFIX } from './menu.js'
12
9
  import FormDesigner from './pages/FormDesigner.jsx'
@@ -24,15 +21,6 @@ const TasksIcon = (
24
21
  </svg>
25
22
  )
26
23
 
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
-
36
24
  const FormsIcon = (
37
25
  <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
38
26
  strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -80,8 +68,6 @@ function Shell() {
80
68
  const drawerItems = useMemo(() => [
81
69
  { key: 'Home', icon: HomeIcon, path: '/home', clickHandler: () => navigate('/home') },
82
70
  { 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') },
85
71
  __MT_DRAWER_ITEM__
86
72
  // Every form added to the menu, with no code: "form:crop" opens /forms/crop.
87
73
  ...menus.filter((key) => key.startsWith(FORM_MENU_PREFIX)).map((key) => ({
@@ -129,12 +115,6 @@ __MT_SELECT_ROUTE__
129
115
  <Route path="/forms/:form" element={<FormRecords />} />
130
116
  <Route path="/configure" element={<ConfigureUI />} />
131
117
  <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 />} />
138
118
  </Route>
139
119
 
140
120
  <Route path="*" element={<Navigate to="/auth/login" replace />} />
@@ -358,19 +358,3 @@ 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,7 +11,6 @@ export const FORM_MENU_PREFIX = 'form:'
11
11
  export const APP_MENU_KEYS = [
12
12
  'Home',
13
13
  'Tasks',
14
- 'Flows',
15
14
  __MT_MENU_KEY__
16
15
  'Admin',
17
16
  'Configure UI'
@@ -6,8 +6,6 @@
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
11
9
  //
12
10
  // Configuration lives here, not in the side rail — the rail is for the pages
13
11
  // people use.
@@ -15,12 +13,10 @@ import { useSearchParams } from 'react-router-dom'
15
13
  import Forms from './Forms.jsx'
16
14
  import Masters from './Masters.jsx'
17
15
  import MenuSettings from './MenuSettings.jsx'
18
- import Flows from './Flows.jsx'
19
16
 
20
17
  const TABS = [
21
18
  { id: 'forms', label: 'Forms', Page: Forms },
22
19
  { id: 'masters', label: 'Masters', Page: Masters },
23
- { id: 'flows', label: 'Flows', Page: Flows },
24
20
  { id: 'menu', label: 'Menu', Page: MenuSettings }
25
21
  ]
26
22
 
@@ -35,13 +35,7 @@ export default defineConfig(({ mode }) => {
35
35
  // Only the real network calls (/auth/api/*), NOT bare /auth — that prefix
36
36
  // is also used by client-side routes (/auth/login, /auth/profile), which
37
37
  // must fall through to Vite's SPA index.html rather than the auth backend.
38
- '/auth/api': authUrl,
39
- // A SERVICE THAT MAY RUN ON ITS OWN PORT, before the /api it sits under —
40
- // the first matching prefix wins. Blank WORKFLOW_URL: it runs inside the
41
- // API, so the API answers. The browser always calls /api/workflow/…; which
42
- // process serves it is decided here (and by nginx in production), never in
43
- // the UI's code.
44
- '/api/workflow': env.WORKFLOW_URL || apiUrl
38
+ '/auth/api': authUrl
45
39
  }
46
40
  API_PATHS.forEach((p) => { proxy[p] = apiUrl })
47
41
 
@@ -1,52 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // XEPLR WORKFLOW AS ITS OWN SERVICE — `npm run start-workflow`.
4
- //
5
- // Only when WORKFLOW_PORT is set (development.env / production.env). Blank, it
6
- // runs inside the API instead and this refuses to start, saying so.
7
- //
8
- // The SAME workflow as the embedded one (../workflow.js): this app's database,
9
- // this app's tenancy and membership gate, the same permission checks, and the
10
- // same path — /api/workflow — so the browser cannot tell the two apart.
11
- // What routes /api/workflow here: ui/.env WORKFLOW_URL for Vite's dev proxy,
12
- // nginx in production (README, "Production").
13
- require('dotenv').config({ path: require('path').join(__dirname, '..', (process.env.NODE_ENV || 'development') + '.env') });
14
-
15
- if (!process.env.WORKFLOW_PORT) {
16
- console.error('\n[workflow] WORKFLOW_PORT is blank — workflow runs INSIDE the API (npm run start-api).');
17
- console.error(' To run it as its own service, set WORKFLOW_PORT in development.env,');
18
- console.error(' and WORKFLOW_URL in ui/.env to http://localhost:<that port>.\n');
19
- process.exit(1);
20
- }
21
-
22
- require('@xeplr/base-apis').checkEnv(require('../env.required.js'), { appName: 'workflow' });
23
-
24
- var express = require('express');
25
- var createApp = require('@xeplr/base-apis/express');
26
-
27
- async function start() {
28
- // This app's tenancy levels, registered exactly as the API registers them.
29
- await require('../db/setup');
30
- var app = require('../app');
31
- var workflowRouter = await require('../workflow')(app.memberGate);
32
-
33
- var health = express.Router();
34
- health.get('/', function(req, res) { res.json({ status: 'ok', service: 'workflow' }); });
35
-
36
- createApp(process.env.WORKFLOW_PORT, '__NAME___workflow', {
37
- // Every route needs a valid token, checked against the same sign-in
38
- // service as the API — one login for both.
39
- auth: { publicPaths: ['/health'] },
40
- log: { logDir: process.env.LOG_DIR || './logs' },
41
- middleware: app.middleware(),
42
- routes: {
43
- '/health': health,
44
- '/api/workflow': workflowRouter
45
- }
46
- });
47
- }
48
-
49
- start().catch(function(err) {
50
- console.error('[workflow] startup failed:', err.stack || err.message);
51
- process.exit(1);
52
- });
@@ -1,14 +0,0 @@
1
- -- "Flows" — the side-rail item for journeys across the app's forms.
2
- --
3
- -- ALWAYS THERE: flows are run by @xeplr/workflow inside this app's own API,
4
- -- so there is nothing to switch on.
5
- --
6
- -- The name is the KEY: it must match ui/src/App.jsx's drawerItems `key`
7
- -- EXACTLY. What people read is the label, changed in Configure UI → Menu.
8
- -- (0005, not 0004: a multi-tenant app's own 0004 is the switch menu.)
9
-
10
- INSERT INTO "menus" (id, name, "menuGroup", "isPublic", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
11
- SELECT encode(gen_random_bytes(12), 'hex'), 'Flows', '', true, true, '*', now(), now()
12
- WHERE NOT EXISTS (SELECT 1 FROM "menus" WHERE name = 'Flows');
13
-
14
- UPDATE "menus" SET "isPublic" = true WHERE name = 'Flows';
@@ -1,47 +0,0 @@
1
- // FLOWS — screens one after another, run by Xeplr Workflow (@xeplr/workflow).
2
- //
3
- // WHERE IT RUNS. By default INSIDE THIS API, in this app's own database:
4
- // bin/www starts it (registerWorkflow) after the screens and plugs its flows
5
- // router in here. With WORKFLOW_PORT set in development.env / production.env it
6
- // runs as its own service instead (npm run start-workflow → bin/workflow), at
7
- // the SAME path — /api/workflow/flows — and this route answers 404 so a
8
- // misrouted request says where it should have gone. The front door routes
9
- // /api/workflow to it: ui/.env WORKFLOW_URL for Vite, nginx in production.
10
- //
11
- // ONE DATABASE workflow's tables (workflows, workflow_steps,
12
- // workflow_runs…) sit beside the forms' tables, with their
13
- // own migration record (workflow_migrations).
14
- // THIS APP'S embedded workflow registers no tenancy of its own. Its
15
- // TENANCY rows are filtered by the levels this app registered — or
16
- // none, in an app without tenancy — and the membership gate
17
- // below is the app's own. It never creates companies or
18
- // workspaces tables.
19
- // PERMISSIONS every route checks the caller's permissions ("List flows",
20
- // "Start flow run"…), granted by @xeplr/workflow's
21
- // migrations-auth — in XEPLR_AUTH_MIGRATIONS.
22
- //
23
- // Mounted by routes/index.js at /api/workflow, behind this app's auth gate.
24
- // Everything Workflow serves lives under it:
25
- // /api/workflow/flows the flows people walk through (Journey)
26
- // /api/workflow/workflows the designer's document API
27
- // /api/workflow/actions what a step can DO, for the palette
28
- var express = require('express');
29
-
30
- var router = express.Router();
31
- var workflow = null;
32
-
33
- router.use(function(req, res, next) {
34
- if (process.env.WORKFLOW_PORT) {
35
- return res.status(404).json({ message: 'Workflow runs on its own port (WORKFLOW_PORT) — route /api/workflow there (ui/.env WORKFLOW_URL, or nginx)' });
36
- }
37
- // Only before startup has finished — bin/www mounts it before listening.
38
- if (!workflow) return res.status(503).json({ message: 'Flows are still starting' });
39
- return workflow(req, res, next);
40
- });
41
-
42
- /** bin/www hands over registerWorkflow's router, already gated. */
43
- router.mount = function(workflowRouter) {
44
- workflow = workflowRouter;
45
- };
46
-
47
- module.exports = router;
@@ -1,34 +0,0 @@
1
- // FLOWS — how @xeplr/workflow starts, in ONE place, for both ways it runs:
2
- //
3
- // inside the API bin/www calls this when WORKFLOW_PORT is blank
4
- // its own service bin/workflow (npm run start-workflow) when it is set
5
- //
6
- // Either way: this app's own database (its tables beside the forms', with its
7
- // own migration record), this app's tenancy (it creates no companies or
8
- // workspaces of its own), and a permission check on every route — granted by
9
- // @xeplr/workflow's migrations-auth, which XEPLR_AUTH_MIGRATIONS loads.
10
- var { registerWorkflow } = require('@xeplr/workflow');
11
-
12
- /**
13
- * Runs workflow's migrations and returns its ROUTER — already gated.
14
- *
15
- * The whole router, not just the flows facade: it carries `/flows` (the
16
- * screens a person walks through) AND `/workflows` (the designer's own
17
- * document API). One designer serves both — @xeplr/ui-workflow's canvas, the
18
- * same one every xeplr product uses — so the app has to serve what it reads.
19
- */
20
- module.exports = async function startWorkflow(memberGate) {
21
- var workflow = await registerWorkflow({
22
- applicationId: '__NAME__',
23
- db: {
24
- name: process.env.DB_API,
25
- connection: process.env.__PREFIX___CONNECTION,
26
- connectionName: 'workflow',
27
- migrationsTable: 'workflow_migrations'
28
- },
29
- tenantTables: false,
30
- access: true,
31
- mtMembershipGate: memberGate
32
- });
33
- return workflow.router;
34
- };
@@ -1,8 +0,0 @@
1
- // Flows are at /api/workflow/flows — served by @xeplr/workflow inside this
2
- // app's API, or on its own port; the dev proxy (vite.config.js) and nginx
3
- // decide which, never this file. authFetch carries the same sign-in and
4
- // company as every other call.
5
- import { authFetch } from '@xeplr/ui-account'
6
- import { createFlowsApi } from '@xeplr/ui-factory'
7
-
8
- export const flows = createFlowsApi({ fetch: authFetch, base: '/api/workflow' })
@@ -1,54 +0,0 @@
1
- // ONE FLOW, ON WORKFLOW'S OWN CANVAS.
2
- //
3
- // The designer is a component from @xeplr/ui-workflow — the same one BI and
4
- // every other xeplr product uses — dropped straight onto this page. It is not
5
- // a section mounted at URLs of its own: this app already has a page for it,
6
- // inside its own shell, and the designer renders where it is put.
7
- //
8
- // There used to be a second, screens-only builder here (@xeplr/ui-factory's
9
- // FlowBuilder). One designer over one engine is the point: the trigger box,
10
- // the condition boxes and the reference pickers are built once, and this app
11
- // gets them by upgrading a package.
12
- //
13
- // THIS APP'S FORMS ARE THE FLOW'S SCREENS. A step can show one and wait for
14
- // somebody to fill it in, and `useScreenSource` is @xeplr/ui-factory's answer
15
- // to the two things the designer asks its host: which screens exist (with
16
- // what each one hands back, so a later step can pick `title` by name) and how
17
- // to design one without leaving the flow. Two props, because the alternative
18
- // was the same twenty lines in every app — and a copy in your app could never
19
- // be fixed once.
20
- //
21
- // WHERE THE API IS: /api/workflow, always — the dev proxy (vite.config.js) or
22
- // nginx decides whether that is this API or Workflow on its own port.
23
- import { useParams, useNavigate } from 'react-router-dom'
24
- import { WorkflowDesigner } from '@xeplr/ui-workflow'
25
- import { useScreenSource, FactoryBuilder } from '@xeplr/ui-factory'
26
- import { factory } from '../api/factory.js'
27
-
28
- export default function FlowDesigner() {
29
- const { flow: key } = useParams()
30
- const navigate = useNavigate()
31
- const { screens, screenEditor } = useScreenSource(factory, { Builder: FactoryBuilder })
32
-
33
- return (
34
- <div className="app-designer">
35
- <WorkflowDesigner
36
- apiBase="/api/workflow"
37
- screens={screens}
38
- screenEditor={screenEditor}
39
- // BY KEY, because that is what this app's URLs carry:
40
- // /configure/flows/leave_request. A flow is created on the Flows page
41
- // with a key made from its name, and that key is how it is addressed
42
- // for the rest of its life — a readable link that survives a rebuild
43
- // minting new ids.
44
- workflowKey={key === 'new' ? undefined : key}
45
- newKind={key === 'new' ? 'workflow' : undefined}
46
- // Only reachable from /configure/flows/new, which this app does not
47
- // link to — the Flows page creates the flow first, so the designer
48
- // always opens on one that exists.
49
- onOpened={(saved) => navigate('/configure/flows/' + saved, { replace: true })}
50
- onBack={() => navigate('/configure?tab=flows')}
51
- />
52
- </div>
53
- )
54
- }
@@ -1,125 +0,0 @@
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
- }
@@ -1,65 +0,0 @@
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
- }
@@ -1,33 +0,0 @@
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
- }