@xeplr/cli 0.1.10 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -7
- package/lib/generate/index.js +63 -1
- package/lib/prompt.js +4 -1
- package/lib/questions.js +4 -0
- package/package.json +1 -1
- package/templates/CLAUDE.md +5 -4
- package/templates/README.md +51 -0
- package/templates/api/app.js +8 -2
- package/templates/api/bin/workflow +52 -0
- package/templates/api/bin/www +15 -1
- package/templates/api/development.env.tpl +2 -1
- package/templates/api/env.required.js +1 -1
- package/templates/api/gitignore.tpl +5 -0
- package/templates/api/migrations-auth/0005_flows_menu.sql +14 -0
- package/templates/api/package.json +5 -3
- package/templates/api/production.env.example +39 -0
- package/templates/api/routes/flows.js +43 -0
- package/templates/api/routes/index.js +5 -0
- package/templates/api/screens/task/task-edit.screen.json +2 -2
- package/templates/api/workflow.js +27 -0
- package/templates/ui/README.md +2 -1
- package/templates/ui/dotenv.tpl +2 -1
- package/templates/ui/package.json +1 -1
- package/templates/ui/src/App.jsx +27 -4
- package/templates/ui/src/api/flows.js +8 -0
- package/templates/ui/src/index.css +19 -2
- package/templates/ui/src/menu.js +1 -0
- package/templates/ui/src/pages/ConfigureUI.jsx +4 -0
- package/templates/ui/src/pages/EditTask.jsx +5 -5
- package/templates/ui/src/pages/FlowDesigner.jsx +37 -0
- package/templates/ui/src/pages/Flows.jsx +103 -0
- package/templates/ui/src/pages/FlowsHome.jsx +65 -0
- package/templates/ui/src/pages/Journey.jsx +33 -0
- package/templates/ui/src/pages/Tasks.jsx +2 -2
- package/templates/ui/src/pages/TasksHelp.jsx +2 -1
- package/templates/ui/vite.config.js +7 -1
package/README.md
CHANGED
|
@@ -26,9 +26,11 @@ Already working, with nothing to wire up:
|
|
|
26
26
|
open is named explicitly; there is no way to leave the whole thing public by
|
|
27
27
|
forgetting.
|
|
28
28
|
- **A sample built with the screen designer** — a task list whose New and Edit
|
|
29
|
-
open the task form
|
|
30
|
-
table.
|
|
31
|
-
|
|
29
|
+
open the task form on a page of its own, filled in over three steps and saving
|
|
30
|
+
as you type into an ordinary `tasks` table. It uses every control there is:
|
|
31
|
+
text, dropdown, radio buttons, tick box, date, date and time, a multi-select
|
|
32
|
+
and a file upload. Neither the list nor the form is written by hand: both are
|
|
33
|
+
screens ([`@xeplr/ui-factory`](https://www.npmjs.com/package/@xeplr/ui-factory)),
|
|
32
34
|
published when the API first starts.
|
|
33
35
|
- **Configure UI** for Super Admin, in the settings menu (top right) — not in
|
|
34
36
|
the side rail, which is for the pages people use:
|
|
@@ -39,6 +41,12 @@ Already working, with nothing to wire up:
|
|
|
39
41
|
- **Menu**: rename, reorder and hide the side rail's items.
|
|
40
42
|
|
|
41
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.
|
|
42
50
|
- **Multi-tenancy, if you ask for it** — companies (or companies and
|
|
43
51
|
workspaces, up to four levels). Every row is stamped with the one it was made
|
|
44
52
|
in, every read sees only its rows, membership is checked on every request,
|
|
@@ -53,8 +61,10 @@ Already working, with nothing to wire up:
|
|
|
53
61
|
|
|
54
62
|
> Create a new UI for farming departments. One record is a farming department
|
|
55
63
|
> with: Name (required), Region (dropdown: North, South, East, West), Area in
|
|
56
|
-
> acres (number, at least 0), Started on (date),
|
|
57
|
-
>
|
|
64
|
+
> acres (number, at least 0), Started on (date), Certifications (choose several:
|
|
65
|
+
> Organic, Fair trade, Rainforest), Inspection at (date and time), Licence
|
|
66
|
+
> (file: .pdf or .jpg), Notes (long text). Show Name, Region and Area in the
|
|
67
|
+
> list. Follow "Create a new UI" in CLAUDE.md.
|
|
58
68
|
|
|
59
69
|
## Rules the generated app follows
|
|
60
70
|
|
|
@@ -129,8 +139,10 @@ Two ways, both in the running app's project:
|
|
|
129
139
|
|
|
130
140
|
> Create a new UI for farming departments. One record is a farming department
|
|
131
141
|
> with: Name (required), Region (dropdown: North, South, East, West), Area in
|
|
132
|
-
> acres (number, at least 0), Started on (date),
|
|
133
|
-
>
|
|
142
|
+
> acres (number, at least 0), Started on (date), Certifications (choose several:
|
|
143
|
+
> Organic, Fair trade, Rainforest), Inspection at (date and time), Licence
|
|
144
|
+
> (file: .pdf or .jpg), Notes (long text). Show Name, Region and Area in the
|
|
145
|
+
> list. Follow "Create a new UI" in CLAUDE.md.
|
|
134
146
|
|
|
135
147
|
Claude follows `CLAUDE.md`: the screens and server hooks and model
|
|
136
148
|
(`npx xeplr-factory screens … --no-pages`), the page with its front-end hooks,
|
|
@@ -193,6 +205,14 @@ no defaults, ever.
|
|
|
193
205
|
|
|
194
206
|
Encryption keys and signing secrets are **generated, never asked**.
|
|
195
207
|
|
|
208
|
+
**Flows, inside the API.** `@xeplr/workflow` is a dependency of `api/`, and
|
|
209
|
+
`bin/www` starts it with `registerWorkflow` after the screens — in the app's
|
|
210
|
+
own database (`DB_API`, its own migration record `workflow_migrations`), with
|
|
211
|
+
`tenantTables: false` so it follows the app's tenancy rather than creating
|
|
212
|
+
`companies` / `workspaces`, and `access: true` so each flows route checks the
|
|
213
|
+
caller's permissions, which its `migrations-auth` grants (loaded through
|
|
214
|
+
`XEPLR_AUTH_MIGRATIONS`).
|
|
215
|
+
|
|
196
216
|
## Licence
|
|
197
217
|
|
|
198
218
|
MIT
|
package/lib/generate/index.js
CHANGED
|
@@ -28,13 +28,52 @@ 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),
|
|
31
33
|
__ENCRYPTION_KEY__: answers.secrets.encryptionKey,
|
|
32
34
|
__JWT_SECRET__: answers.secrets.jwtSecret,
|
|
33
35
|
__ADMIN_EMAIL__: answers.admin.email,
|
|
34
36
|
__ADMIN_PASSWORD__: answers.admin.password,
|
|
35
37
|
__DB_CONNECTION__: answers.connection || '',
|
|
36
38
|
__CONNECTION_NOTE__: connectionNote(answers)
|
|
37
|
-
}, tenancySubstitutions(answers.tenancy || []));
|
|
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
|
+
};
|
|
38
77
|
}
|
|
39
78
|
|
|
40
79
|
/**
|
|
@@ -78,6 +117,8 @@ function tenancySubstitutions(levels) {
|
|
|
78
117
|
" '/api/tenants': tenants.router,"
|
|
79
118
|
].join('\n') : '',
|
|
80
119
|
__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()]' : '[]',
|
|
81
122
|
__MT_MEMBER_NOUN__: on ? levels.map(function (l) { return l.label.toLowerCase(); }).join(' and ') : 'data (no tenancy: nothing to check)',
|
|
82
123
|
|
|
83
124
|
// ui/src/main.jsx
|
|
@@ -118,6 +159,27 @@ function tenancySubstitutions(levels) {
|
|
|
118
159
|
};
|
|
119
160
|
}
|
|
120
161
|
|
|
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
|
+
|
|
121
183
|
/** One level's own table: what the picker lists. Its parents' ids in mtIdN. */
|
|
122
184
|
function tenantTableSql(level, i) {
|
|
123
185
|
var parents = i === 0 ? 'none — the outermost level' : i === 1 ? 'mtId1 holds the one above' : 'mtId1–mtId' + i + ' hold the ones above';
|
package/lib/prompt.js
CHANGED
|
@@ -124,7 +124,10 @@ function createSession(options) {
|
|
|
124
124
|
return /^y/i.test(v);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
/** A line of explanation between questions — no answer expected. */
|
|
128
|
+
function say(text) { output.write(text + '\n'); }
|
|
129
|
+
|
|
130
|
+
return { ask: ask, askSecret: askSecret, confirm: confirm, say: say, close: function () { rl.close(); } };
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
module.exports = { createSession: createSession };
|
package/lib/questions.js
CHANGED
|
@@ -134,6 +134,10 @@ 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
|
+
|
|
137
141
|
// ── the database ──────────────────────────────────────────────────────
|
|
138
142
|
//
|
|
139
143
|
// ASKED, not defaulted, and not left for the reader to do by hand either.
|
package/package.json
CHANGED
package/templates/CLAUDE.md
CHANGED
|
@@ -11,7 +11,7 @@ A new UI is a **form**: a list, and an add / edit form it opens — on a page of
|
|
|
11
11
|
|
|
12
12
|
Create every file below — the hooks and the model run the defaults (`super`) until the request needs more.
|
|
13
13
|
|
|
14
|
-
1. **The spec** — `api/screens/<form>/<form>.entity.json`: the name and the fields in reading order. Copy `api/screens/task/task.entity.json`; the format is "The entity spec" in AUTHORING.md. A dropdown on another form's table needs that form to exist first.
|
|
14
|
+
1. **The spec** — `api/screens/<form>/<form>.entity.json`: the name and the fields in reading order. Copy `api/screens/task/task.entity.json`; the format is "The entity spec" in AUTHORING.md. A dropdown on another form's table needs that form to exist first. **Use the ready-made fields** for email, phone, website, LinkedIn, age, date of birth, gender, country, amount, percentage, yes/no and postal code — `"type": "email"` and so on ("Choosing controls" in AUTHORING.md): they bring the keyboard, format check and options with them, so don't write those by hand.
|
|
15
15
|
2. **Screens + server files** — from `api/`:
|
|
16
16
|
```sh
|
|
17
17
|
npx xeplr-factory screens screens/<form>/<form>.entity.json -o screens/<form> --no-pages
|
|
@@ -22,10 +22,10 @@ Create every file below — the hooks and the model run the defaults (`super`) u
|
|
|
22
22
|
3. **Register them** in `api/screens/index.js`: both screens in `documents` (edit before list), `<form>_edit: require('./<form>/<form>.hooks')` in `hooks`, `require('./<form>/<form>.model')` in `models`.
|
|
23
23
|
4. **The form's page, with the front-end hooks** — `ui/src/pages/Edit<Form>.jsx`: copy `EditTask.jsx`; rename `TaskHooks` → `<Form>Hooks`, `taskHooks` → `<form>Hooks`, `'task_edit'` → `'<form>_edit'`. Keep every method calling `super`.
|
|
24
24
|
5. **The list's page** — `ui/src/pages/<Forms>.jsx`: copy `Tasks.jsx`; load `'<form>_list'`, import `<form>Hooks` from `./Edit<Form>.jsx`, drop `TasksHelp`.
|
|
25
|
-
6. **Route and side-rail item** — `ui/src/App.jsx`: import the list page, add `<Route path="/<forms>" element={<Forms />} />` next to `/tasks`, and a `drawerItems` entry `{ key: '<Forms>', icon, clickHandler }` — by **key**, with an **icon** (a collapsed rail shows icons only). Never write the label in code.
|
|
25
|
+
6. **Route and side-rail item** — `ui/src/App.jsx`: import the list page, add `<Route path="/<forms>" element={<Forms />} />` next to `/tasks`, and a `drawerItems` entry `{ key: '<Forms>', icon, path: '/<forms>', clickHandler }` — by **key**, with an **icon** (a collapsed rail shows icons only) and the **path** (so the rail marks it as the current page). Never write the label in code.
|
|
26
26
|
7. **The menu row** — `api/migrations-auth/000N_<forms>_menu.sql` (next free number): copy `0002_tasks_menu.sql`, its `name` being the **key** spelled exactly as in `drawerItems` — an unknown key is dropped silently. Set `label` only if it should read differently; people can rename it later in Configure UI → Menu. Then, in `api/`: `npm run migrate:auth`, and restart the sign-in service.
|
|
27
27
|
8. **Restart the API.** It publishes screens that were never published — creating the table — and logs `[api] published screens: <form>_edit, <form>_list`.
|
|
28
|
-
9. **Check** — open the page, New, fill in the form
|
|
28
|
+
9. **Check** — open the page, New, fill in the form and press **Save**; it goes back to the list and the row is in the table.
|
|
29
29
|
|
|
30
30
|
Then change only what the request asks for:
|
|
31
31
|
|
|
@@ -47,8 +47,9 @@ Then change only what the request asks for:
|
|
|
47
47
|
- **`file`** — the column holds the **path**, never the file. Say which extensions (`"accept": ".pdf,.docx"`) and how large (`"maxSize": 10`, in MB); the API enforces both, by extension, and keeps the file under `FACTORY_FILES_DIR`. Uploading needs `multer` in `api/` (already in package.json).
|
|
48
48
|
- **`stepper`** — a journey across the top. Every other field says which step it is on (`"step": "Planning"`), and one without a `step` shows on every step. `step(ctx)` in the front-end hooks runs before each move: return `false` to stay, a step's key to go there, `ctx.disable(['planning'])` to rule steps out (struck through on the bar, passed over by Next and Back) and `ctx.enable()` to bring them back.
|
|
49
49
|
|
|
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
|
|
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__
|
|
52
53
|
**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.
|
|
53
54
|
|
|
54
55
|
### The prompt
|
package/templates/README.md
CHANGED
|
@@ -21,6 +21,56 @@ 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
|
+
## Where each address goes
|
|
28
|
+
|
|
29
|
+
The browser only ever calls **paths** on the app's own address — never a port.
|
|
30
|
+
What serves each path is decided in ONE place per environment: Vite's dev proxy
|
|
31
|
+
locally (`ui/vite.config.js`, from `ui/.env`), nginx in production.
|
|
32
|
+
|
|
33
|
+
| path | served by | local (`ui/.env`) | production (nginx) |
|
|
34
|
+
|---|---|---|---|
|
|
35
|
+
| `/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
|
+
| `/api/…`, `/health`, `/whoami` | the API | `API_URL` | `127.0.0.1:<__PREFIX___PORT>` |
|
|
38
|
+
|
|
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
|
+
### Production
|
|
52
|
+
|
|
53
|
+
`api/production.env.example` lists every setting (copy it to
|
|
54
|
+
`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:
|
|
57
|
+
|
|
58
|
+
```nginx
|
|
59
|
+
server {
|
|
60
|
+
server_name app.example.com;
|
|
61
|
+
root /srv/__NAME__/ui/dist;
|
|
62
|
+
|
|
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
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Every one of these checks the same sign-in token, so one login covers them all.
|
|
73
|
+
|
|
24
74
|
## What is in it
|
|
25
75
|
|
|
26
76
|
- **Sign-in** — login, register, password reset, profile, and the admin screens for users, roles and permissions (`@xeplr/auth`, `@xeplr/ui-account`).
|
|
@@ -34,6 +84,7 @@ Open http://localhost:__UI_PORT__ and sign in as `__ADMIN_EMAIL__`.
|
|
|
34
84
|
|
|
35
85
|
> 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.
|
|
36
86
|
|
|
87
|
+
__WF_README__
|
|
37
88
|
## Rules this app follows
|
|
38
89
|
|
|
39
90
|
| Rule | Why |
|
package/templates/api/app.js
CHANGED
|
@@ -12,7 +12,13 @@ var memberGate = __MT_MEMBER_GATE__;
|
|
|
12
12
|
// default could only ever fire in a process that skipped that check.
|
|
13
13
|
var port = process.env.__PREFIX___PORT;
|
|
14
14
|
|
|
15
|
-
module.exports =
|
|
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
|
+
|
|
21
|
+
function buildApp(extraRoutes) {
|
|
16
22
|
return createApp(port, '__NAME___api', {
|
|
17
23
|
// Every route needs a valid token, checked against the auth service at
|
|
18
24
|
// AUTH_URL. This app names no `middleware` of its own — it takes the
|
|
@@ -48,4 +54,4 @@ __MT_APP_ROUTES__
|
|
|
48
54
|
})
|
|
49
55
|
})
|
|
50
56
|
});
|
|
51
|
-
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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/flows — 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 flowsRouter = 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/flows': flowsRouter
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
start().catch(function(err) {
|
|
50
|
+
console.error('[workflow] startup failed:', err.stack || err.message);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
package/templates/api/bin/www
CHANGED
|
@@ -76,7 +76,21 @@ 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
|
-
|
|
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();
|
|
80
94
|
}
|
|
81
95
|
|
|
82
96
|
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-
|
|
45
|
+
XEPLR_AUTH_MIGRATIONS=./node_modules/@xeplr/factory/migrations-auth,./migrations-auth__WF_AUTH_MIGRATIONS__
|
|
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
|
|
@@ -57,6 +57,7 @@ AUTH_SUPER_ADMIN_PASSWORD=__ADMIN_PASSWORD__
|
|
|
57
57
|
AUTH_ACTIVATION_URL=http://localhost:__UI_PORT__/auth/activate
|
|
58
58
|
AUTH_INVITE_URL=http://localhost:__UI_PORT__/auth/accept-invite
|
|
59
59
|
|
|
60
|
+
__WF_ENV__
|
|
60
61
|
# ── Uploaded files ──
|
|
61
62
|
# Where a form's FILE field puts what people upload. The path is what the
|
|
62
63
|
# record stores; the file itself is served only through the API's own route,
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
node_modules/
|
|
2
2
|
logs/
|
|
3
|
+
# Settings hold this app's keys, secrets and database login — never committed.
|
|
4
|
+
# production.env.example lists every name, with no values.
|
|
5
|
+
development.env
|
|
6
|
+
production.env
|
|
7
|
+
*.local.env
|
|
3
8
|
# Files people upload through a form's file field (FACTORY_FILES_DIR).
|
|
4
9
|
uploads/
|
|
@@ -0,0 +1,14 @@
|
|
|
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';
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"version": "1.0.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
|
-
"description": "__NAME__ backend
|
|
6
|
+
"description": "__NAME__ backend \u2014 @xeplr/base-apis app.",
|
|
7
7
|
"main": "app.js",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"check-env": "dotenv -e development.env -- xeplr-check-env",
|
|
@@ -14,15 +14,17 @@
|
|
|
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"
|
|
17
|
+
"start-api": "node ./bin/www",
|
|
18
|
+
"start-workflow": "node ./bin/workflow"
|
|
18
19
|
},
|
|
19
20
|
"dependencies": {
|
|
20
21
|
"@xeplr/auth": "^1.1.4",
|
|
21
22
|
"@xeplr/base-apis": "^2.0.0",
|
|
22
23
|
"@xeplr/db": "^1.0.6",
|
|
23
24
|
"@xeplr/factory": "^1.0.6",
|
|
24
|
-
"@xeplr/ui-factory": "^1.0.
|
|
25
|
+
"@xeplr/ui-factory": "^1.0.14",
|
|
25
26
|
"@xeplr/utils": "^1.0.11",
|
|
27
|
+
"@xeplr/workflow": "^1.1.0",
|
|
26
28
|
"dotenv": "^17.4.2",
|
|
27
29
|
"express": "^5.2.0",
|
|
28
30
|
"multer": "^2.0.2"
|
|
@@ -0,0 +1,39 @@
|
|
|
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).
|
|
4
|
+
#
|
|
5
|
+
# Every value that is a secret is blank here. Generate new ones for production
|
|
6
|
+
# — never reuse development's.
|
|
7
|
+
|
|
8
|
+
NODE_ENV=production
|
|
9
|
+
LOG_DIR=./logs
|
|
10
|
+
|
|
11
|
+
# ── Ports: one per process on this server ──
|
|
12
|
+
__PREFIX___PORT=__API_PORT__
|
|
13
|
+
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
|
+
|
|
18
|
+
# ── Databases (same server, one login) ──
|
|
19
|
+
ENCRYPTION_KEY=
|
|
20
|
+
__PREFIX___CONNECTION=
|
|
21
|
+
AUTH_DB_CONNECTION_INFO_ENCRYPTED=
|
|
22
|
+
DB_API=__NAME___api
|
|
23
|
+
AUTH_DB_NAME=__NAME___auth
|
|
24
|
+
|
|
25
|
+
# ── Sign-in ──
|
|
26
|
+
REDIS_PREFIX=__NAME__:
|
|
27
|
+
AUTH_JWT_SECRET=
|
|
28
|
+
# Where THIS server reaches the sign-in service (not the public address).
|
|
29
|
+
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
|
|
31
|
+
AUTH_SUPER_ADMIN_EMAIL=
|
|
32
|
+
AUTH_SUPER_ADMIN_PASSWORD=
|
|
33
|
+
|
|
34
|
+
# ── Public addresses (what people's browsers open) ──
|
|
35
|
+
AUTH_ACTIVATION_URL=https://app.example.com/auth/activate
|
|
36
|
+
AUTH_INVITE_URL=https://app.example.com/auth/accept-invite
|
|
37
|
+
|
|
38
|
+
# ── Uploaded files — an absolute path on the server ──
|
|
39
|
+
FACTORY_FILES_DIR=/var/lib/__NAME__/uploads/factory
|
|
@@ -0,0 +1,43 @@
|
|
|
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/flows, behind this app's auth gate.
|
|
24
|
+
var express = require('express');
|
|
25
|
+
|
|
26
|
+
var router = express.Router();
|
|
27
|
+
var flows = null;
|
|
28
|
+
|
|
29
|
+
router.use(function(req, res, next) {
|
|
30
|
+
if (process.env.WORKFLOW_PORT) {
|
|
31
|
+
return res.status(404).json({ message: 'Workflow runs on its own port (WORKFLOW_PORT) — route /api/workflow there (ui/.env WORKFLOW_URL, or nginx)' });
|
|
32
|
+
}
|
|
33
|
+
// Only before startup has finished — bin/www mounts it before listening.
|
|
34
|
+
if (!flows) return res.status(503).json({ message: 'Flows are still starting' });
|
|
35
|
+
return flows(req, res, next);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** bin/www hands over registerWorkflow's flowsRouter, already gated. */
|
|
39
|
+
router.mount = function(flowsRouter) {
|
|
40
|
+
flows = flowsRouter;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
module.exports = router;
|
|
@@ -34,4 +34,9 @@ router.get('/whoami', function(req, res) {
|
|
|
34
34
|
// var { genericRoute } = require('@xeplr/base-apis');
|
|
35
35
|
// router.use('/api/things', genericRoute({ key: 'thing', model: require('../models').Thing }));
|
|
36
36
|
|
|
37
|
+
// Flows: Xeplr Workflow, at /api/workflow/flows — 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/flows', require('./flows'));
|
|
41
|
+
|
|
37
42
|
module.exports = router;
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"z": 4,
|
|
78
78
|
"props": {
|
|
79
79
|
"label": "Status",
|
|
80
|
-
"placeholder": "Select
|
|
80
|
+
"placeholder": "Select\u2026",
|
|
81
81
|
"data": {
|
|
82
82
|
"source": "static",
|
|
83
83
|
"options": [
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"x": 0.5125,
|
|
110
110
|
"y": 0.4,
|
|
111
111
|
"w": 0.4475,
|
|
112
|
-
"h": 0.
|
|
112
|
+
"h": 0.08,
|
|
113
113
|
"z": 5,
|
|
114
114
|
"props": {
|
|
115
115
|
"label": "Priority",
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
/** Runs workflow's migrations, and returns its flows router — already gated. */
|
|
13
|
+
module.exports = async function startWorkflow(memberGate) {
|
|
14
|
+
var workflow = await registerWorkflow({
|
|
15
|
+
applicationId: '__NAME__',
|
|
16
|
+
db: {
|
|
17
|
+
name: process.env.DB_API,
|
|
18
|
+
connection: process.env.__PREFIX___CONNECTION,
|
|
19
|
+
connectionName: 'workflow',
|
|
20
|
+
migrationsTable: 'workflow_migrations'
|
|
21
|
+
},
|
|
22
|
+
tenantTables: false,
|
|
23
|
+
access: true,
|
|
24
|
+
mtMembershipGate: memberGate
|
|
25
|
+
});
|
|
26
|
+
return workflow.flowsRouter;
|
|
27
|
+
};
|
package/templates/ui/README.md
CHANGED
|
@@ -24,5 +24,6 @@ npm run build # → dist/
|
|
|
24
24
|
## Rules
|
|
25
25
|
|
|
26
26
|
- `authFetch` returns the **parsed body**, not a Response — no `.json()`.
|
|
27
|
-
- A side-rail item needs an **icon** (a collapsed rail shows icons only) and its key must exist as a menu row, or it is dropped silently.
|
|
27
|
+
- A side-rail item needs an **icon** (a collapsed rail shows icons only) and its key must exist as a menu row, or it is dropped silently. Give it its **`path`** too: that is how the rail marks the page you are on.
|
|
28
|
+
- The rail is 48px when collapsed; `--app-rail-width` in `src/index.css` reserves the same space and must match it.
|
|
28
29
|
- Set page backgrounds on a container inside the theme, never on `body`.
|
package/templates/ui/dotenv.tpl
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# __NAME__ UI — dev port and the
|
|
1
|
+
# __NAME__ UI — dev port and the services it talks to.
|
|
2
2
|
#
|
|
3
3
|
# Read by vite.config.js, so no port is written in code. Change a number here
|
|
4
4
|
# and the dev server and every proxy target follow.
|
|
@@ -6,3 +6,4 @@
|
|
|
6
6
|
UI_PORT=__UI_PORT__
|
|
7
7
|
AUTH_URL=http://localhost:__AUTH_PORT__
|
|
8
8
|
API_URL=http://localhost:__API_PORT__
|
|
9
|
+
__WF_UI_ENV__
|
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">
|
|
@@ -63,14 +75,19 @@ function Shell() {
|
|
|
63
75
|
// The drawer's catalog, by KEY. A key must match a `menus` row's name —
|
|
64
76
|
// @xeplr/ui-account drops unknown keys SILENTLY, so a typo removes the item
|
|
65
77
|
// with no error anywhere. The text shown is the row's label (Configure UI →
|
|
66
|
-
// Menu), in the order set there — never written here.
|
|
78
|
+
// Menu), in the order set there — never written here. `path` is how the rail
|
|
79
|
+
// marks the page you are on (and /tasks/12 still counts as Tasks).
|
|
67
80
|
const drawerItems = useMemo(() => [
|
|
68
|
-
{ key: 'Home', icon: HomeIcon, clickHandler: () => navigate('/home') },
|
|
69
|
-
{ key: 'Tasks', icon: TasksIcon, clickHandler: () => navigate('/tasks') },
|
|
81
|
+
{ key: 'Home', icon: HomeIcon, path: '/home', clickHandler: () => navigate('/home') },
|
|
82
|
+
{ key: 'Tasks', icon: TasksIcon, path: '/tasks', clickHandler: () => navigate('/tasks') },
|
|
83
|
+
// Always in the rail — the page says when Xeplr Workflow is not connected.
|
|
84
|
+
{ key: 'Flows', icon: FlowsIcon, path: '/flows', clickHandler: () => navigate('/flows') },
|
|
70
85
|
__MT_DRAWER_ITEM__
|
|
71
86
|
// Every form added to the menu, with no code: "form:crop" opens /forms/crop.
|
|
72
87
|
...menus.filter((key) => key.startsWith(FORM_MENU_PREFIX)).map((key) => ({
|
|
73
|
-
key, icon: FormsIcon,
|
|
88
|
+
key, icon: FormsIcon,
|
|
89
|
+
path: '/forms/' + key.slice(FORM_MENU_PREFIX.length),
|
|
90
|
+
clickHandler: () => navigate('/forms/' + key.slice(FORM_MENU_PREFIX.length))
|
|
74
91
|
}))
|
|
75
92
|
], [navigate, menus])
|
|
76
93
|
|
|
@@ -112,6 +129,12 @@ __MT_SELECT_ROUTE__
|
|
|
112
129
|
<Route path="/forms/:form" element={<FormRecords />} />
|
|
113
130
|
<Route path="/configure" element={<ConfigureUI />} />
|
|
114
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 />} />
|
|
115
138
|
</Route>
|
|
116
139
|
|
|
117
140
|
<Route path="*" element={<Navigate to="/auth/login" replace />} />
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Flows are at /api/workflow/flows — served by @xeplr/workflow inside this
|
|
2
|
+
// app's API, or on its own port; the dev proxy (vite.config.js) and nginx
|
|
3
|
+
// decide which, never this file. authFetch carries the same sign-in and
|
|
4
|
+
// company as every other call.
|
|
5
|
+
import { authFetch } from '@xeplr/ui-account'
|
|
6
|
+
import { createFlowsApi } from '@xeplr/ui-factory'
|
|
7
|
+
|
|
8
|
+
export const flows = createFlowsApi({ fetch: authFetch, base: '/api/workflow' })
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
of the page rather than pushing it along — the app has to leave the room
|
|
6
6
|
itself. Without this the rail covers the first 60px of every screen, which
|
|
7
7
|
reads as text mysteriously cut off down the left edge rather than as a
|
|
8
|
-
layout problem.
|
|
9
|
-
|
|
8
|
+
layout problem. 48px: compact, like VS Code's activity bar — it must
|
|
9
|
+
match @xeplr/ui-account's collapsed rail. */
|
|
10
|
+
--app-rail-width: 48px;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
body { margin: 0; }
|
|
@@ -357,3 +358,19 @@ body { margin: 0; }
|
|
|
357
358
|
.app-menu-shown { display: inline-flex; gap: 6px; align-items: center; }
|
|
358
359
|
|
|
359
360
|
.app-menu-settings .app-table { margin-bottom: 14px; }
|
|
361
|
+
|
|
362
|
+
/* ── Flows, before Workflow is connected (src/pages/Flows.jsx) ─────────────── */
|
|
363
|
+
.app-connect {
|
|
364
|
+
max-width: 720px;
|
|
365
|
+
padding: 22px 24px;
|
|
366
|
+
border: 1px solid var(--xeplr-border-primary);
|
|
367
|
+
border-radius: 10px;
|
|
368
|
+
background: var(--xeplr-bg-tertiary);
|
|
369
|
+
line-height: 1.55;
|
|
370
|
+
}
|
|
371
|
+
.app-connect h2 { margin: 0 0 8px; font-size: 18px; }
|
|
372
|
+
.app-connect p { margin: 0 0 14px; }
|
|
373
|
+
.app-connect-steps { margin: 0 0 14px; padding-left: 20px; }
|
|
374
|
+
.app-connect-steps li { margin-bottom: 14px; }
|
|
375
|
+
.app-connect code { font-size: 12.5px; padding: 1px 5px; border-radius: 4px; background: var(--xeplr-bg-secondary); border: 1px solid var(--xeplr-border-primary); }
|
|
376
|
+
.app-connect pre { margin: 6px 0 0; font-size: 12.5px; }
|
package/templates/ui/src/menu.js
CHANGED
|
@@ -3,15 +3,19 @@
|
|
|
3
3
|
// Forms every UI in the app: make one, design it, publish it, open it, add it
|
|
4
4
|
// to the side rail
|
|
5
5
|
// Menu the side rail's items: rename, reorder, hide
|
|
6
|
+
// Flows forms one after another — what is filled in decides the next; run by
|
|
7
|
+
// Xeplr Workflow, and says how to connect it until it is
|
|
6
8
|
//
|
|
7
9
|
// Configuration lives here, not in the side rail — the rail is for the pages
|
|
8
10
|
// people use.
|
|
9
11
|
import { useSearchParams } from 'react-router-dom'
|
|
10
12
|
import Forms from './Forms.jsx'
|
|
11
13
|
import MenuSettings from './MenuSettings.jsx'
|
|
14
|
+
import Flows from './Flows.jsx'
|
|
12
15
|
|
|
13
16
|
const TABS = [
|
|
14
17
|
{ id: 'forms', label: 'Forms', Page: Forms },
|
|
18
|
+
{ id: 'flows', label: 'Flows', Page: Flows },
|
|
15
19
|
{ id: 'menu', label: 'Menu', Page: MenuSettings }
|
|
16
20
|
]
|
|
17
21
|
|
|
@@ -26,7 +26,7 @@ export class TaskHooks extends FactoryHooks {
|
|
|
26
26
|
return super.get(ctx)
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/**
|
|
29
|
+
/** The Save button — one AJAX call. Returns the saved record. */
|
|
30
30
|
save(values, ctx) {
|
|
31
31
|
return super.save(values, ctx)
|
|
32
32
|
}
|
|
@@ -65,11 +65,11 @@ export const taskHooks = new TaskHooks()
|
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
67
|
* The task form on a page of its own — /tasks/new, or /tasks/:id for a task
|
|
68
|
-
* that exists. The list sends people here (its "Opens in" is a page)
|
|
69
|
-
* Done brings them back.
|
|
68
|
+
* that exists. The list sends people here (its "Opens in" is a page).
|
|
70
69
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
70
|
+
* Nothing is written while typing. Save sends the task (one AJAX call, never
|
|
71
|
+
* a form submit) and comes back to the list; Cancel comes back without
|
|
72
|
+
* saving, asking first if anything is unsaved. Both go through onDone.
|
|
73
73
|
*/
|
|
74
74
|
export default function EditTask({ record }) {
|
|
75
75
|
const { id } = useParams()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// One flow, on the canvas: the published forms on the left, arrows between
|
|
2
|
+
// them, and on the right what happens after the screen you picked. Saves
|
|
3
|
+
// itself; Publish is what people then walk through.
|
|
4
|
+
import { useEffect, useState } from 'react'
|
|
5
|
+
import { useParams, Link } from 'react-router-dom'
|
|
6
|
+
import { FlowBuilder } from '@xeplr/ui-factory'
|
|
7
|
+
import { flows } from '../api/flows.js'
|
|
8
|
+
import { factory } from '../api/factory.js'
|
|
9
|
+
|
|
10
|
+
export default function FlowDesigner() {
|
|
11
|
+
const { flow: key } = useParams()
|
|
12
|
+
const [flow, setFlow] = useState(null)
|
|
13
|
+
const [screens, setScreens] = useState([])
|
|
14
|
+
const [error, setError] = useState(null)
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
flows.loadFlow(key).then(setFlow, (e) => setError(e.message))
|
|
18
|
+
// A step is a FORM (an edit screen) that is published: what people will
|
|
19
|
+
// actually see, and something they can fill in.
|
|
20
|
+
factory.listScreens().then((rows) => setScreens(rows
|
|
21
|
+
.filter((s) => /_edit$/.test(s.screenKey) && s.version !== null)
|
|
22
|
+
.map((s) => ({ id: s.screenKey, name: s.name }))), () => {})
|
|
23
|
+
}, [key])
|
|
24
|
+
|
|
25
|
+
if (error) return <div className="app-page"><div className="app-error">{error}</div></div>
|
|
26
|
+
if (!flow) return null
|
|
27
|
+
return (
|
|
28
|
+
<div className="app-designer">
|
|
29
|
+
<div className="app-designer-bar">
|
|
30
|
+
<Link to="/configure?tab=flows">← All flows</Link>
|
|
31
|
+
</div>
|
|
32
|
+
<div className="app-designer-canvas">
|
|
33
|
+
<FlowBuilder {...flows.builderProps} flow={flow} screens={screens} loadScreen={factory.loadScreen} />
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// CONFIGURE UI → FLOWS — every journey across the app's forms.
|
|
2
|
+
//
|
|
3
|
+
// A flow is screens one after another: what someone fills in on one decides
|
|
4
|
+
// which comes next. It is run by Xeplr Workflow, so a journey can be left and
|
|
5
|
+
// picked up later — by someone else, on another day. Design it here; open it
|
|
6
|
+
// at /journey/<key>.
|
|
7
|
+
//
|
|
8
|
+
// Run by @xeplr/workflow inside this app's own API and database — nothing to
|
|
9
|
+
// connect.
|
|
10
|
+
import { useEffect, useState } from 'react'
|
|
11
|
+
import { Link, useNavigate } from 'react-router-dom'
|
|
12
|
+
import { flows } from '../api/flows.js'
|
|
13
|
+
|
|
14
|
+
export default function Flows() {
|
|
15
|
+
const [list, setList] = useState(null)
|
|
16
|
+
const [error, setError] = useState(null)
|
|
17
|
+
const [offline, setOffline] = useState(null) // API_NOT_RESTARTED
|
|
18
|
+
const [name, setName] = useState('')
|
|
19
|
+
const navigate = useNavigate()
|
|
20
|
+
|
|
21
|
+
const load = () => flows.listFlows().then(setList, (e) => {
|
|
22
|
+
const off = flowsOffline(e)
|
|
23
|
+
if (off) setOffline(off)
|
|
24
|
+
else setError(e.message)
|
|
25
|
+
})
|
|
26
|
+
useEffect(() => { load() }, [])
|
|
27
|
+
|
|
28
|
+
const create = async (e) => {
|
|
29
|
+
e.preventDefault()
|
|
30
|
+
const label = name.trim()
|
|
31
|
+
if (!label) return
|
|
32
|
+
const key = label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
|
33
|
+
try {
|
|
34
|
+
await flows.createFlow({ key, name: label })
|
|
35
|
+
navigate(`/configure/flows/${key}`)
|
|
36
|
+
} catch (err) {
|
|
37
|
+
setError(err.message)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (offline) return <NotConnected offline={offline} />
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="app-forms">
|
|
45
|
+
<p className="app-page-lead">
|
|
46
|
+
A flow is your forms, one after another — what someone fills in decides the next one.
|
|
47
|
+
Xeplr Workflow runs it, so a journey can be left and picked up later.
|
|
48
|
+
</p>
|
|
49
|
+
{error && <div className="app-error">{error}</div>}
|
|
50
|
+
|
|
51
|
+
<form className="app-forms-new" onSubmit={create}>
|
|
52
|
+
<label className="app-forms-field">
|
|
53
|
+
<span>Name</span>
|
|
54
|
+
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Employee registration" />
|
|
55
|
+
</label>
|
|
56
|
+
<button type="submit" className="app-btn" disabled={!name.trim()}>New flow</button>
|
|
57
|
+
</form>
|
|
58
|
+
|
|
59
|
+
{list && list.length === 0 && <p className="app-sub">No flows yet.</p>}
|
|
60
|
+
{list && list.length > 0 && (
|
|
61
|
+
<table className="app-table">
|
|
62
|
+
<thead><tr><th>Flow</th><th>Key</th><th>Status</th><th>Screens</th><th /></tr></thead>
|
|
63
|
+
<tbody>
|
|
64
|
+
{list.map((f) => (
|
|
65
|
+
<tr key={f.key}>
|
|
66
|
+
<td><strong>{f.name}</strong></td>
|
|
67
|
+
<td className="app-sub">{f.key}</td>
|
|
68
|
+
<td>{f.status}</td>
|
|
69
|
+
<td>{typeof f.steps === 'number' ? f.steps : (f.steps || []).length}</td>
|
|
70
|
+
<td className="app-row-actions">
|
|
71
|
+
<Link to={`/configure/flows/${f.key}`}>Design</Link>
|
|
72
|
+
{f.status === 'published' && <Link to={`/journey/${f.key}`}>Open</Link>}
|
|
73
|
+
</td>
|
|
74
|
+
</tr>
|
|
75
|
+
))}
|
|
76
|
+
</tbody>
|
|
77
|
+
</table>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Why flows cannot run right now, or null when the error is something else.
|
|
85
|
+
* Workflow runs inside this app's API, so there is one such case left: an API
|
|
86
|
+
* started before flows existed (404 on /api/flows) — restart it.
|
|
87
|
+
*/
|
|
88
|
+
export function flowsOffline(err) {
|
|
89
|
+
if (err && err.status === 404) return { code: 'API_NOT_RESTARTED', message: 'This app\'s API does not have the flows route yet.' }
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Flows before the API has been restarted with them. */
|
|
94
|
+
export function NotConnected({ offline }) {
|
|
95
|
+
return (
|
|
96
|
+
<div className="app-forms">
|
|
97
|
+
<div className="app-connect">
|
|
98
|
+
<h2>Restart the API to use flows</h2>
|
|
99
|
+
<p>{offline.message} It was started before flows were added — restart it, then reload this page.</p>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// FLOWS — the side-rail page: the journeys people can start, one click each.
|
|
2
|
+
//
|
|
3
|
+
// Always in the rail. Flows are run by @xeplr/workflow inside this app's own
|
|
4
|
+
// API, so they are simply there. Designing them is Configure UI → Flows.
|
|
5
|
+
import { useEffect, useState } from 'react'
|
|
6
|
+
import { Link, useNavigate } from 'react-router-dom'
|
|
7
|
+
import { useAccess } from '@xeplr/ui-account'
|
|
8
|
+
import { flows } from '../api/flows.js'
|
|
9
|
+
import { NotConnected, flowsOffline } from './Flows.jsx'
|
|
10
|
+
|
|
11
|
+
export default function FlowsHome() {
|
|
12
|
+
const [list, setList] = useState(null)
|
|
13
|
+
const [error, setError] = useState(null)
|
|
14
|
+
const [offline, setOffline] = useState(null)
|
|
15
|
+
const navigate = useNavigate()
|
|
16
|
+
const { access } = useAccess()
|
|
17
|
+
// Whoever may open Configure UI may design flows and connect Workflow.
|
|
18
|
+
const admin = ((access && access.menus) || []).includes('Configure UI')
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
flows.listFlows().then(setList, (e) => {
|
|
22
|
+
const off = flowsOffline(e)
|
|
23
|
+
if (off) setOffline(off)
|
|
24
|
+
else setError(e.message)
|
|
25
|
+
})
|
|
26
|
+
}, [])
|
|
27
|
+
|
|
28
|
+
const published = (list || []).filter((f) => f.status === 'published')
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="app-page">
|
|
32
|
+
<h1>Flows</h1>
|
|
33
|
+
<p className="app-page-lead">
|
|
34
|
+
Forms one after another — what you fill in on one decides which comes next. A journey you
|
|
35
|
+
leave is kept, and picks up where it stopped.
|
|
36
|
+
</p>
|
|
37
|
+
{offline && <NotConnected offline={offline} />}
|
|
38
|
+
{error && <div className="app-error">{error}</div>}
|
|
39
|
+
{list && published.length === 0 && (
|
|
40
|
+
<p className="app-sub">
|
|
41
|
+
No flows to start yet.{admin && <> <Link to="/configure?tab=flows">Design one</Link> in Configure UI.</>}
|
|
42
|
+
</p>
|
|
43
|
+
)}
|
|
44
|
+
{published.length > 0 && (
|
|
45
|
+
<table className="app-table">
|
|
46
|
+
<thead><tr><th>Flow</th><th>Screens</th><th /></tr></thead>
|
|
47
|
+
<tbody>
|
|
48
|
+
{published.map((f) => (
|
|
49
|
+
<tr key={f.key}>
|
|
50
|
+
<td><strong>{f.name}</strong></td>
|
|
51
|
+
<td>{typeof f.steps === 'number' ? f.steps : (f.steps || []).length}</td>
|
|
52
|
+
<td className="app-row-actions">
|
|
53
|
+
<button type="button" className="app-btn" onClick={() => navigate(`/journey/${f.key}`)}>Start</button>
|
|
54
|
+
</td>
|
|
55
|
+
</tr>
|
|
56
|
+
))}
|
|
57
|
+
</tbody>
|
|
58
|
+
</table>
|
|
59
|
+
)}
|
|
60
|
+
{admin && list && published.length > 0 && (
|
|
61
|
+
<p className="app-sub" style={{ marginTop: 16 }}><Link to="/configure?tab=flows">Design flows</Link> in Configure UI.</p>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// A FLOW, being walked through — /journey/<key>, and /journey/<key>/<run> once
|
|
2
|
+
// it has started. The run id in the address is what lets a journey be
|
|
3
|
+
// reloaded, left, or handed to someone else and opened on the step it is on.
|
|
4
|
+
//
|
|
5
|
+
// This page holds no route through the journey: Workflow says which screen
|
|
6
|
+
// comes next, from what was filled in.
|
|
7
|
+
import { useNavigate, useParams } from 'react-router-dom'
|
|
8
|
+
import { FlowRunner } from '@xeplr/ui-factory'
|
|
9
|
+
import { flows } from '../api/flows.js'
|
|
10
|
+
import { factory } from '../api/factory.js'
|
|
11
|
+
|
|
12
|
+
export default function Journey() {
|
|
13
|
+
const { flow, run } = useParams()
|
|
14
|
+
const navigate = useNavigate()
|
|
15
|
+
return (
|
|
16
|
+
<div className="app-page">
|
|
17
|
+
<FlowRunner
|
|
18
|
+
{...flows.runnerProps}
|
|
19
|
+
{...factory.screenProps}
|
|
20
|
+
flowKey={flow}
|
|
21
|
+
runId={run}
|
|
22
|
+
onStep={(step) => { if (step.runId && step.runId !== run) navigate(`/journey/${flow}/${step.runId}`, { replace: true }) }}
|
|
23
|
+
renderDone={() => (
|
|
24
|
+
<div className="app-help">
|
|
25
|
+
<h2>All done</h2>
|
|
26
|
+
<p>Everything in this journey is filled in.</p>
|
|
27
|
+
<button type="button" className="app-btn" onClick={() => navigate(`/journey/${flow}`)}>Start another</button>
|
|
28
|
+
</div>
|
|
29
|
+
)}
|
|
30
|
+
/>
|
|
31
|
+
</div>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// THE SAMPLE — a list of tasks. New and Edit open the task form on a PAGE of
|
|
2
|
-
// its own
|
|
3
|
-
//
|
|
2
|
+
// its own. Its Save button saves over AJAX (never a form submit) and comes
|
|
3
|
+
// back here; Cancel comes back without saving. (A list can open its form in a popup instead: Configure
|
|
4
4
|
// UI → Forms → the list → Opens in.)
|
|
5
5
|
//
|
|
6
6
|
// Nothing about tasks is written in this file. The list and the form are
|
|
@@ -47,7 +47,8 @@ export default function TasksHelp() {
|
|
|
47
47
|
</p>
|
|
48
48
|
<p>
|
|
49
49
|
<strong>New</strong> and <strong>Edit</strong> open the form on a page
|
|
50
|
-
of its own
|
|
50
|
+
of its own. Nothing is saved while you type: <strong>Save</strong> saves
|
|
51
|
+
and comes back here, <strong>Cancel</strong> comes back without saving. A list can open
|
|
51
52
|
it in a popup instead — that is its <strong>Opens in</strong> setting.
|
|
52
53
|
</p>
|
|
53
54
|
|
|
@@ -35,7 +35,13 @@ export default defineConfig(({ mode }) => {
|
|
|
35
35
|
// Only the real network calls (/auth/api/*), NOT bare /auth — that prefix
|
|
36
36
|
// is also used by client-side routes (/auth/login, /auth/profile), which
|
|
37
37
|
// must fall through to Vite's SPA index.html rather than the auth backend.
|
|
38
|
-
'/auth/api': authUrl
|
|
38
|
+
'/auth/api': authUrl,
|
|
39
|
+
// A SERVICE THAT MAY RUN ON ITS OWN PORT, before the /api it sits under —
|
|
40
|
+
// the first matching prefix wins. Blank WORKFLOW_URL: it runs inside the
|
|
41
|
+
// API, so the API answers. The browser always calls /api/workflow/…; which
|
|
42
|
+
// process serves it is decided here (and by nginx in production), never in
|
|
43
|
+
// the UI's code.
|
|
44
|
+
'/api/workflow': env.WORKFLOW_URL || apiUrl
|
|
39
45
|
}
|
|
40
46
|
API_PATHS.forEach((p) => { proxy[p] = apiUrl })
|
|
41
47
|
|