@xeplr/cli 0.3.3 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -3
- package/lib/generate/index.js +58 -1
- package/lib/plan.js +1 -0
- package/lib/questions.js +21 -2
- package/package.json +1 -1
- package/templates/CLAUDE.md +8 -7
- package/templates/README.md +14 -7
- package/templates/api/README.md +5 -3
- package/templates/api/app.js +2 -0
- package/templates/api/bin/www +13 -5
- package/templates/api/db/setup.js +1 -1
- package/templates/api/development.env.tpl +10 -7
- package/templates/api/env.required.js +4 -0
- package/templates/api/functions/index.js +47 -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 +6 -4
- package/templates/api/routes/flows.js +23 -0
- package/templates/api/routes/index.js +4 -0
- package/templates/api/screens/index.js +4 -6
- package/templates/api/screens/task/task-edit.screen.json +4 -1
- package/templates/api/screens/task/task.model.js +2 -2
- package/templates/api/workflow.js +32 -0
- package/templates/ui/package.json +1 -1
- package/templates/ui/src/App.jsx +20 -0
- package/templates/ui/src/api/flows.js +6 -0
- package/templates/ui/src/index.css +16 -0
- package/templates/ui/src/menu.js +1 -0
- package/templates/ui/src/pages/ConfigureUI.jsx +4 -0
- package/templates/ui/src/pages/EditTask.jsx +3 -3
- package/templates/ui/src/pages/FlowDesigner.jsx +30 -0
- package/templates/ui/src/pages/Flows.jsx +106 -0
- package/templates/ui/src/pages/FlowsHome.jsx +65 -0
- package/templates/ui/src/pages/Journey.jsx +33 -0
- package/templates/ui/src/pages/TasksHelp.jsx +3 -2
- package/templates/ui/vite.config.js +4 -1
- package/templates/api/screens/task/task.hooks.js +0 -37
package/README.md
CHANGED
|
@@ -41,6 +41,19 @@ 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. Designed in
|
|
45
|
+
Configure UI → Flows: for each screen, the app's functions that run around
|
|
46
|
+
its records (its events) and where the journey goes once it is saved — a
|
|
47
|
+
screen, a function that decides, or the end. Walked at `/journey/<key>`, and
|
|
48
|
+
resumable. Run by `@xeplr/workflow-api` **inside the API**, in a database of
|
|
49
|
+
its own (`<name>_workflow`, created on first start) — nothing extra to start.
|
|
50
|
+
It follows the app's tenancy (none, or the levels you chose) and never creates
|
|
51
|
+
companies or workspaces of its own. Approvals (another person's turn) are not
|
|
52
|
+
part of it.
|
|
53
|
+
- **Functions** — `api/functions/`: the app's own code, registered with
|
|
54
|
+
`@xeplr/actions` by the event it is for (`beforeSave`, `afterSave`, …,
|
|
55
|
+
`decide`) and picked by name in a screen's events or a flow step. Two to start
|
|
56
|
+
from: `tidyTaskTitle` (Tasks' `beforeSave`) and `byPriority` (a `decide`).
|
|
44
57
|
- **Multi-tenancy, if you ask for it** — companies (or companies and
|
|
45
58
|
workspaces, up to four levels). Every row is stamped with the one it was made
|
|
46
59
|
in, every read sees only its rows, membership is checked on every request,
|
|
@@ -71,7 +84,7 @@ These are architecture decisions, not defaults to tune — each one exists becau
|
|
|
71
84
|
| **`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:`. |
|
|
72
85
|
| **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. |
|
|
73
86
|
| **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` | |
|
|
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
|
|
87
|
+
| **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, its functions and its flows, the wiring went into `@xeplr/ui-factory` (`FlowDesignerPage`) and the generated page passes its two API clients. | `@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. |
|
|
75
88
|
| **Every data address is under `/api`.** | `api/routes`, `ui/vite.config.js` | A page path and a data path must never collide. |
|
|
76
89
|
| **No guessed database names or connections.** | `api/development.env` | A wrong-but-present value starts cleanly and reads as empty data. |
|
|
77
90
|
| **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. |
|
|
@@ -119,6 +132,12 @@ see **Configure UI** in the settings menu, top right.
|
|
|
119
132
|
(`<name>_auth`, `<name>_api`); an existing database is kept as it is, with its
|
|
120
133
|
accounts — the super admin you enter is created only in a fresh one.
|
|
121
134
|
|
|
135
|
+
Dashes in the name become underscores in the database names: `my-app` gets
|
|
136
|
+
`my_app_auth` and `my_app_api`. `@xeplr/db` creates only plain identifiers
|
|
137
|
+
(letters, digits, `_`) because `CREATE DATABASE` cannot take its name as a
|
|
138
|
+
parameter, and the project name itself cannot hold `_` (it is also a folder and
|
|
139
|
+
a package name).
|
|
140
|
+
|
|
122
141
|
`xeplr new myapp --no-install` only writes the files; then run `npm install` in
|
|
123
142
|
`api` and `ui`, and `npm run setup` in `api`, yourself. If set-up stops part
|
|
124
143
|
way (Postgres not running, say), the installer says which step and the command
|
|
@@ -139,7 +158,7 @@ Two ways, both in the running app's project:
|
|
|
139
158
|
> (file: .pdf or .jpg), Notes (long text). Show Name, Region and Area in the
|
|
140
159
|
> list. Follow "Create a new UI" in CLAUDE.md.
|
|
141
160
|
|
|
142
|
-
Claude follows `CLAUDE.md`: the screens and server
|
|
161
|
+
Claude follows `CLAUDE.md`: the screens and server functions and model
|
|
143
162
|
(`npx xeplr-factory screens … --no-pages`), the page with its front-end hooks,
|
|
144
163
|
the route, the side-rail item and its menu row. Then restart the sign-in
|
|
145
164
|
service and the API — the API publishes the new screens and creates the table.
|
|
@@ -153,7 +172,7 @@ Two ways, both in the running app's project:
|
|
|
153
172
|
| `api/` | `npm run start-api` | the API — runs migrations and publishes never-published screens on start |
|
|
154
173
|
| `api/` | `npm run migrate:auth` | apply new `migrations-auth/*.sql` (e.g. a new menu row) |
|
|
155
174
|
| `api/` | `npm run db:encrypt` | redo the encrypted database connection |
|
|
156
|
-
| `api/` | `npx xeplr-factory screens screens/<form>/<form>.entity.json -o screens/<form> --no-pages` | a form's screens, server
|
|
175
|
+
| `api/` | `npx xeplr-factory screens screens/<form>/<form>.entity.json -o screens/<form> --no-pages` | a form's screens, server functions and model from its spec |
|
|
157
176
|
| `api/` | `npx xeplr-factory validate <screen>.json` | check a screen document |
|
|
158
177
|
| `ui/` | `npm run dev` | the app |
|
|
159
178
|
|
|
@@ -200,6 +219,17 @@ no defaults, ever.
|
|
|
200
219
|
|
|
201
220
|
Encryption keys and signing secrets are **generated, never asked**.
|
|
202
221
|
|
|
222
|
+
**Flows, inside the API.** `@xeplr/workflow-api` is a dependency of `api/`,
|
|
223
|
+
and `bin/www` starts it with `registerWorkflow` after the screens — in a
|
|
224
|
+
database of its own (`DB_WORKFLOW`, `<name>_workflow`: a sub-product embedded in
|
|
225
|
+
an app keeps its tables apart, and creates the database on first start), with
|
|
226
|
+
`tenantTables: false` so it follows the app's tenancy rather than creating
|
|
227
|
+
`companies` / `workspaces`, and `access: true` so each flows route checks the
|
|
228
|
+
caller's permissions, which its `migrations-auth` grants (loaded through
|
|
229
|
+
`XEPLR_AUTH_MIGRATIONS`). The designer is `@xeplr/ui-factory`'s
|
|
230
|
+
`FlowDesignerPage`; the paid canvas designer (X-Flow) is not part of a
|
|
231
|
+
generated app.
|
|
232
|
+
|
|
203
233
|
## Licence
|
|
204
234
|
|
|
205
235
|
MIT
|
package/lib/generate/index.js
CHANGED
|
@@ -24,6 +24,9 @@ var RENAME = {
|
|
|
24
24
|
function substitutions(answers) {
|
|
25
25
|
return Object.assign({
|
|
26
26
|
__NAME__: answers.name,
|
|
27
|
+
__DB_API__: answers.databases.api,
|
|
28
|
+
__DB_AUTH__: answers.databases.auth,
|
|
29
|
+
__DB_WORKFLOW__: answers.databases.workflow,
|
|
27
30
|
__PREFIX__: answers.prefix,
|
|
28
31
|
__UI_PORT__: String(answers.ports.ui),
|
|
29
32
|
__AUTH_PORT__: String(answers.ports.auth),
|
|
@@ -34,7 +37,25 @@ function substitutions(answers) {
|
|
|
34
37
|
__ADMIN_PASSWORD__: answers.admin.password,
|
|
35
38
|
__DB_CONNECTION__: answers.connection || '',
|
|
36
39
|
__CONNECTION_NOTE__: connectionNote(answers)
|
|
37
|
-
}, tenancySubstitutions(answers.tenancy || []));
|
|
40
|
+
}, tenancySubstitutions(answers.tenancy || []), flowsSubstitutions(answers));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* FLOWS ARE IN EVERY APP, and so is what runs them: @xeplr/workflow, started
|
|
45
|
+
* INSIDE the API (bin/www → registerWorkflow) in the app's own database. There
|
|
46
|
+
* is no Workflow server to run, no port, no setting and nothing to connect, so
|
|
47
|
+
* nothing here depends on an answer — these tokens only carry text.
|
|
48
|
+
*/
|
|
49
|
+
function flowsSubstitutions(answers) {
|
|
50
|
+
return {
|
|
51
|
+
__WF_README__: flowsReadme(answers),
|
|
52
|
+
__WF_CLAUDE__: [
|
|
53
|
+
'**Flows** — forms one after another, run by `@xeplr/workflow-api` INSIDE this API (bin/www starts it, workflow.js) in a database of its own (`DB_WORKFLOW`). Design them in Configure UI → Flows, never in code: a flow is data, reached through `/api/workflow/flows`, and walked at `/journey/<key>`. After a step, a flow goes to a screen, lets one of the app\'s `decide` functions choose, or finishes. Workflow follows this app\'s tenancy — it never creates companies or workspaces of its own.',
|
|
54
|
+
'',
|
|
55
|
+
'**Functions** — the code around a form\'s records lives in `api/functions/`, registered with `@xeplr/actions` under the event it is for (`beforeLoad`, `beforeSave`, `afterSave`, `beforeDelete`, `onError`, or `decide` for a flow), and is picked BY NAME in the screen\'s `events` or a flow step\'s navigation. There are no hook files. A screen naming a function nobody registered is refused at publish.',
|
|
56
|
+
''
|
|
57
|
+
].join('\n')
|
|
58
|
+
};
|
|
38
59
|
}
|
|
39
60
|
|
|
40
61
|
/**
|
|
@@ -118,6 +139,42 @@ function tenancySubstitutions(levels) {
|
|
|
118
139
|
};
|
|
119
140
|
}
|
|
120
141
|
|
|
142
|
+
/** The README's Flows section. */
|
|
143
|
+
function flowsReadme(answers) {
|
|
144
|
+
var tenancy = answers.tenancy || [];
|
|
145
|
+
return [
|
|
146
|
+
'## Flows',
|
|
147
|
+
'',
|
|
148
|
+
'Your forms, one after another. Design one in **Configure UI → Flows**: pick the screens, and for each one say what runs around its records (its **events**) and where the journey goes once it is saved — a screen, a **function that decides**, or the end. 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.',
|
|
149
|
+
'',
|
|
150
|
+
'Flows are run by **`@xeplr/workflow-api`**, **inside this API** — `bin/www` starts it after the screens. Nothing else to start:',
|
|
151
|
+
'',
|
|
152
|
+
'| | |',
|
|
153
|
+
'|---|---|',
|
|
154
|
+
'| **Database** | its own, `DB_WORKFLOW` (`' + answers.databases.workflow + '`), beside this app\'s — a sub-product embedded in an app keeps its tables apart. Created and migrated the first time the API starts. Each screen still saves into its own table in `DB_API`. |',
|
|
155
|
+
'| **Tenancy** | ' + (tenancy.length
|
|
156
|
+
? '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.'
|
|
157
|
+
: 'none, like the rest of this app — flows belong to the whole app.') + ' Workflow never creates companies or workspaces of its own. |',
|
|
158
|
+
'| **Permissions** | each route checks the caller\'s (`List flows`, `Start flow run`, `Publish flow`…), granted by `@xeplr/workflow-api`\'s `migrations-auth`, which `XEPLR_AUTH_MIGRATIONS` loads. |',
|
|
159
|
+
'',
|
|
160
|
+
'## Functions — your code, picked by name',
|
|
161
|
+
'',
|
|
162
|
+
'Code around a form\'s records — a check on save, an email after, a list narrowed to the user\'s own — is a **function** in `api/functions/`, registered with `@xeplr/actions` under the **event** it is for. A screen names it in its `events`; a flow step names a `decide` function to choose where to go next. The designer shows each event only the functions written for it, with what they are handed and return; the code stays in `api/functions/` and runs on the server.',
|
|
163
|
+
'',
|
|
164
|
+
'| event | runs | return |',
|
|
165
|
+
'|---|---|---|',
|
|
166
|
+
'| `beforeLoad` | before records are read | nothing, or a narrower `ctx.query` |',
|
|
167
|
+
'| `beforeSave` | before a record is written | the values to save; `ctx.reject(message, { field })` refuses |',
|
|
168
|
+
'| `afterSave` | once it is saved | (ignored) |',
|
|
169
|
+
'| `beforeDelete` | before a delete | nothing; `ctx.reject` refuses |',
|
|
170
|
+
'| `onError` | when load / save / delete failed | (ignored) |',
|
|
171
|
+
'| `decide` | a flow step is done | the next step\'s key |',
|
|
172
|
+
'',
|
|
173
|
+
'`api/functions/index.js` registers two to start from: `tidyTaskTitle` (Tasks\' `beforeSave`) and `byPriority` (a `decide`). A screen that names a function nobody registered is refused when it is published, by name.',
|
|
174
|
+
''
|
|
175
|
+
].join('\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
121
178
|
/** One level's own table: what the picker lists. Its parents' ids in mtIdN. */
|
|
122
179
|
function tenantTableSql(level, i) {
|
|
123
180
|
var parents = i === 0 ? 'none — the outermost level' : i === 1 ? 'mtId1 holds the one above' : 'mtId1–mtId' + i + ' hold the ones above';
|
package/lib/plan.js
CHANGED
|
@@ -18,6 +18,7 @@ function describe(answers) {
|
|
|
18
18
|
L.push(' Databases (created for you on first run):');
|
|
19
19
|
L.push(' ' + answers.databases.auth + ' accounts, roles, permissions');
|
|
20
20
|
L.push(' ' + answers.databases.api + ' your own data');
|
|
21
|
+
L.push(' ' + answers.databases.workflow + ' flows and their runs');
|
|
21
22
|
L.push('');
|
|
22
23
|
L.push(' You will sign in as ' + answers.admin.email + '.');
|
|
23
24
|
L.push('');
|
package/lib/questions.js
CHANGED
|
@@ -20,6 +20,21 @@ function envPrefix(name) {
|
|
|
20
20
|
return name.toUpperCase().replace(/-/g, '_');
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// What @xeplr/db's ensureDatabase accepts: CREATE DATABASE cannot take its
|
|
24
|
+
// name as a parameter, so it allows plain identifiers only.
|
|
25
|
+
var DB_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
26
|
+
|
|
27
|
+
function databaseNames(name) {
|
|
28
|
+
// my-app → my_app_auth, my_app_api. Dashes are fine in a folder and a
|
|
29
|
+
// package name but not in an unquoted identifier, and @xeplr/db refuses
|
|
30
|
+
// them ("Unsafe database name"). NAME_RE does not allow underscores, so
|
|
31
|
+
// without this every name with a separator in it failed at set-up.
|
|
32
|
+
var base = name.replace(/-/g, '_');
|
|
33
|
+
// Workflow gets a database of its own: a sub-product embedded in an app is
|
|
34
|
+
// given its own store, never a share of the app's (see the API's workflow.js).
|
|
35
|
+
return { auth: base + '_auth', api: base + '_api', workflow: base + '_workflow' };
|
|
36
|
+
}
|
|
37
|
+
|
|
23
38
|
// ── tenancy ──────────────────────────────────────────────────────────────
|
|
24
39
|
//
|
|
25
40
|
// At most four levels — @xeplr/db has four tenant columns (mtId1-4) — named
|
|
@@ -134,6 +149,10 @@ async function collect(session, argvName) {
|
|
|
134
149
|
tenancy = tenancyLevels(levelsText);
|
|
135
150
|
}
|
|
136
151
|
|
|
152
|
+
// ── flows ─────────────────────────────────────────────────────────────
|
|
153
|
+
// Nothing to ask: every app has flows, run by @xeplr/workflow inside its own
|
|
154
|
+
// API and database (see lib/generate's flowsSubstitutions).
|
|
155
|
+
|
|
137
156
|
// ── the database ──────────────────────────────────────────────────────
|
|
138
157
|
//
|
|
139
158
|
// ASKED, not defaulted, and not left for the reader to do by hand either.
|
|
@@ -171,7 +190,7 @@ async function collect(session, argvName) {
|
|
|
171
190
|
name: name,
|
|
172
191
|
prefix: envPrefix(name),
|
|
173
192
|
ports: { ui: base, auth: base + 1, api: base + 2 },
|
|
174
|
-
databases:
|
|
193
|
+
databases: databaseNames(name),
|
|
175
194
|
admin: { email: adminEmail, password: adminPassword },
|
|
176
195
|
tenancy: tenancy,
|
|
177
196
|
|
|
@@ -181,4 +200,4 @@ async function collect(session, argvName) {
|
|
|
181
200
|
};
|
|
182
201
|
}
|
|
183
202
|
|
|
184
|
-
module.exports = { collect: collect, envPrefix: envPrefix, tenancyLevels: tenancyLevels, NAME_RE: NAME_RE };
|
|
203
|
+
module.exports = { collect: collect, envPrefix: envPrefix, databaseNames: databaseNames, DB_NAME_RE: DB_NAME_RE, tenancyLevels: tenancyLevels, NAME_RE: NAME_RE };
|
package/package.json
CHANGED
package/templates/CLAUDE.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
An xeplr app: `api/` (Node, @xeplr/base-apis, sign-in by @xeplr/auth) and `ui/` (React + Vite, @xeplr/ui-account).
|
|
4
4
|
Forms are **screens** made with @xeplr/ui-factory and served by @xeplr/factory. The sample is **Tasks**.
|
|
5
5
|
|
|
6
|
-
Read `api/node_modules/@xeplr/ui-factory/AUTHORING.md` for what screens, hooks and models mean. This file says where they go in **this** app.
|
|
6
|
+
Read `api/node_modules/@xeplr/ui-factory/AUTHORING.md` for what screens, functions, front-end hooks and models mean. This file says where they go in **this** app.
|
|
7
7
|
|
|
8
8
|
## Create a new UI
|
|
9
9
|
|
|
10
10
|
A new UI is a **form**: a list, and an add / edit form it opens — on a page of its own (like Tasks) or in a popup — saved in its own table. Use the singular for `<form>` (`farming_department`), the plural for the page (`FarmingDepartments`).
|
|
11
11
|
|
|
12
|
-
Create every file below — the hooks and the model run the defaults (`super`) until the request needs more.
|
|
12
|
+
Create every file below — the front-end hooks and the model run the defaults (`super`) until the request needs more, and the server functions file registers nothing until it does.
|
|
13
13
|
|
|
14
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/`:
|
|
@@ -18,8 +18,8 @@ Create every file below — the hooks and the model run the defaults (`super`) u
|
|
|
18
18
|
npx xeplr-factory validate screens/<form>/<form>-edit.screen.json
|
|
19
19
|
npx xeplr-factory validate screens/<form>/<form>-list.screen.json
|
|
20
20
|
```
|
|
21
|
-
Writes `<form>-list.screen.json`, `<form>-edit.screen.json`, `<form>.
|
|
22
|
-
3. **Register them** in `api/screens/index.js`: both screens in `documents` (edit before list),
|
|
21
|
+
Writes `<form>-list.screen.json`, `<form>-edit.screen.json`, `<form>.functions.js` (server functions, none registered yet), `<form>.model.js` (server model).
|
|
22
|
+
3. **Register them** in `api/screens/index.js`: both screens in `documents` (edit before list), `require('./<form>/<form>.model')` in `models`; and in `api/functions/index.js`, `require('../screens/<form>/<form>.functions')`.
|
|
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
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.
|
|
@@ -33,11 +33,11 @@ Then change only what the request asks for:
|
|
|
33
33
|
|---|---|
|
|
34
34
|
| a field, label, rule, dropdown, layout, colour | the screen JSON → validate → publish in Configure UI → Forms (or, never published yet, restart the API) |
|
|
35
35
|
| hide / filter rows on screen, an extra button on each row, fill in a value before saving, check something before Next moves on | front-end hooks — `ui/src/pages/Edit<Form>.jsx` (`get`, `save`, `delete`, `actions`, `step`) |
|
|
36
|
-
| must / only if / check against / email when / only managers see | server
|
|
36
|
+
| must / only if / check against / email when / only managers see | a server function — registered in `api/screens/<form>/<form>.functions.js` with its `event`, then named in `<form>-edit.screen.json`'s `events` (or picked in Configure UI → Flows → Events). Register before naming: a screen naming an unregistered function is refused at publish |
|
|
37
37
|
| store as / convert / comma-separated ↔ array / work out X from Y | server model — `api/screens/<form>/<form>.model.js` |
|
|
38
38
|
| a query in your own server code | `factory.table('<forms table>')` — this company, active rows, the model applied |
|
|
39
39
|
|
|
40
|
-
**Without code:** Super Admin can also make a form in **Configure UI → Forms** (settings menu, top right): New form (label + key) → design → Publish → Add to menu. It gets no page,
|
|
40
|
+
**Without code:** Super Admin can also make a form in **Configure UI → Forms** (settings menu, top right): New form (label + key) → design → Publish → Add to menu. It gets no page, functions or model files; add them with the steps above when it needs them.
|
|
41
41
|
|
|
42
42
|
### The controls a field can be
|
|
43
43
|
|
|
@@ -49,6 +49,7 @@ 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__
|
|
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
|
|
@@ -64,6 +65,6 @@ Replace the bold parts. Add behaviour in the same message if you want it — "a
|
|
|
64
65
|
- A published screen's design lives in the **database**. Editing its `.screen.json` changes nothing afterwards: publish the change in Configure UI → Forms.
|
|
65
66
|
- Every data address is under `/api` (a page and a data path must never collide). New routes go in `api/routes/index.js`, and `ui/vite.config.js` must proxy their prefix.
|
|
66
67
|
- Designing and publishing are **Super Admin only** (`api/routes/access.js`); every other route checks the permission catalog.
|
|
67
|
-
- The browser is not a security boundary: anything that must hold goes in server
|
|
68
|
+
- The browser is not a security boundary: anything that must hold goes in a server function, named in the screen's events.
|
|
68
69
|
- Hooks and models are **methods** calling `super` — never arrow-function properties.
|
|
69
70
|
- `authFetch` returns the parsed body, not a Response.
|
package/templates/README.md
CHANGED
|
@@ -21,6 +21,9 @@ 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-api`) run **inside the API**, in a database of their
|
|
25
|
+
own that the API creates on first start — nothing else to start.
|
|
26
|
+
|
|
24
27
|
## Where each address goes
|
|
25
28
|
|
|
26
29
|
The browser only ever calls **paths** on the app's own address — never a port.
|
|
@@ -30,6 +33,7 @@ locally (`ui/vite.config.js`, from `ui/.env`), nginx in production.
|
|
|
30
33
|
| path | served by | local (`ui/.env`) | production (nginx) |
|
|
31
34
|
|---|---|---|---|
|
|
32
35
|
| `/auth/api/…` | the sign-in service | `AUTH_URL` | `127.0.0.1:<AUTH_PORT>` |
|
|
36
|
+
| `/api/workflow/…` | flows — `@xeplr/workflow-api`, inside the API | `API_URL` | `127.0.0.1:<__PREFIX___PORT>` |
|
|
33
37
|
| `/api/…`, `/health`, `/whoami` | the API | `API_URL` | `127.0.0.1:<__PREFIX___PORT>` |
|
|
34
38
|
|
|
35
39
|
### Production
|
|
@@ -43,11 +47,11 @@ server {
|
|
|
43
47
|
server_name app.example.com;
|
|
44
48
|
root /srv/__NAME__/ui/dist;
|
|
45
49
|
|
|
46
|
-
location /auth/api/
|
|
47
|
-
location /api/
|
|
48
|
-
location = /health
|
|
49
|
-
location = /whoami
|
|
50
|
-
location /
|
|
50
|
+
location /auth/api/ { proxy_pass http://127.0.0.1:__AUTH_PORT__; }
|
|
51
|
+
location /api/ { proxy_pass http://127.0.0.1:__API_PORT__; }
|
|
52
|
+
location = /health { proxy_pass http://127.0.0.1:__API_PORT__; }
|
|
53
|
+
location = /whoami { proxy_pass http://127.0.0.1:__API_PORT__; }
|
|
54
|
+
location / { try_files $uri /index.html; } # the React app's own pages
|
|
51
55
|
}
|
|
52
56
|
```
|
|
53
57
|
|
|
@@ -57,7 +61,9 @@ Every one of these checks the same sign-in token, so one login covers them all.
|
|
|
57
61
|
|
|
58
62
|
- **Sign-in** — login, register, password reset, profile, and the admin screens for users, roles and permissions (`@xeplr/auth`, `@xeplr/ui-account`).
|
|
59
63
|
- **Tasks** — a sample built from screens: a list, and a form it opens on a page of its own, saved into an ordinary `tasks` table. The form is filled in over three steps and uses every control there is — text, a dropdown, radio buttons, a tick box, a date, a date and time, several tags at once, and a file.
|
|
60
|
-
- **Configure UI** — in the settings menu, for Super Admin: every form (make one, design it, publish it, add it to the side rail) and the side rail itself (rename, reorder, hide).
|
|
64
|
+
- **Configure UI** — in the settings menu, for Super Admin: every form (make one, design it, publish it, add it to the side rail), every flow (screens one after another — each screen's events, and where it goes next) and the side rail itself (rename, reorder, hide).
|
|
65
|
+
- **Flows** — in the side rail: the journeys people can start, run by `@xeplr/workflow-api` inside the API.
|
|
66
|
+
- **Functions** — `api/functions/`: your code, registered by event and picked by name in a screen's events or a flow step (see "Functions" below).
|
|
61
67
|
|
|
62
68
|
## Create a new UI
|
|
63
69
|
|
|
@@ -66,6 +72,7 @@ Every one of these checks the same sign-in token, so one login covers them all.
|
|
|
66
72
|
|
|
67
73
|
> 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.
|
|
68
74
|
|
|
75
|
+
__WF_README__
|
|
69
76
|
## Rules this app follows
|
|
70
77
|
|
|
71
78
|
| Rule | Why |
|
|
@@ -74,7 +81,7 @@ Every one of these checks the same sign-in token, so one login covers them all.
|
|
|
74
81
|
| A **key** and a **label**, everywhere: code uses keys (form keys, menu keys); people read labels, renamed in Configure UI. | Renaming what people see never breaks code. |
|
|
75
82
|
| **`REDIS_PREFIX`** in `api/development.env` is required and unique to this app. | Apps sharing a Redis would otherwise serve each other's sessions and permissions. |
|
|
76
83
|
| Designing and publishing forms is **Super Admin only**. | Publishing changes database tables. |
|
|
77
|
-
| Rules that must hold go in **server
|
|
84
|
+
| Rules that must hold go in **server functions** (`api/functions/`), named in a screen's events; the browser only shapes what people see and send. | The browser is not a security boundary. |
|
|
78
85
|
| Every data address is under **`/api`**. | A page and a data path must never collide. |
|
|
79
86
|
|
|
80
87
|
The full list, and where each piece lives, is in `CLAUDE.md`.
|
package/templates/api/README.md
CHANGED
|
@@ -6,13 +6,13 @@ The API (`@xeplr/base-apis`) and the sign-in service (`@xeplr/auth`), with this
|
|
|
6
6
|
|
|
7
7
|
| Command | Does |
|
|
8
8
|
|---|---|
|
|
9
|
-
| `npm run setup` | create both databases (`
|
|
9
|
+
| `npm run setup` | create both databases (`__DB_AUTH__`, `__DB_API__`), the sign-in tables, permissions and menus |
|
|
10
10
|
| `npm run start-auth` | the sign-in service, port __AUTH_PORT__ — refuses to start without Redis or `REDIS_PREFIX`; its banner says whether email works |
|
|
11
11
|
| `npm run start-api` | the API, port __API_PORT__ — runs `migrations/` and publishes never-published screens on start |
|
|
12
12
|
| `npm run migrate:auth` | apply new `migrations-auth/*.sql` (a new menu row, say), then restart sign-in |
|
|
13
13
|
| `npm run db:encrypt` | redo the encrypted database connection |
|
|
14
14
|
| `npm run check-env` | check every required setting is present |
|
|
15
|
-
| `npx xeplr-factory screens screens/<form>/<form>.entity.json -o screens/<form> --no-pages` | a form's screens, server
|
|
15
|
+
| `npx xeplr-factory screens screens/<form>/<form>.entity.json -o screens/<form> --no-pages` | a form's screens, server functions and model from its spec |
|
|
16
16
|
| `npx xeplr-factory validate <screen>.json` | check a screen document |
|
|
17
17
|
|
|
18
18
|
## Layout
|
|
@@ -24,7 +24,9 @@ The API (`@xeplr/base-apis`) and the sign-in service (`@xeplr/auth`), with this
|
|
|
24
24
|
| `app.js` | the Express app: the auth gate, and the routes |
|
|
25
25
|
| `routes/index.js` | this app's own routes — every data address under `/api` |
|
|
26
26
|
| `routes/access.js` | who may design and publish forms (Super Admin) |
|
|
27
|
-
| `screens/` | the forms this app ships with: per form, its spec, list and edit screens, server
|
|
27
|
+
| `screens/` | the forms this app ships with: per form, its spec, list and edit screens, server functions and model — registered in `screens/index.js` |
|
|
28
|
+
| `functions/` | this app's functions — the code screens' events and flows' `decide` pick by name — registered with `@xeplr/actions` by event, loaded at startup |
|
|
29
|
+
| `workflow.js` | flows: `@xeplr/workflow-api` started inside this API, in its own database (`DB_WORKFLOW`) |
|
|
28
30
|
| `uploads/` | what people attach through a form's **file** field (`FACTORY_FILES_DIR`). Never committed, and never served: a file comes back only through the API's own route, which checks the caller the way reading a record does |
|
|
29
31
|
| `migrations/` | hand-written SQL for tables no form owns |
|
|
30
32
|
| `migrations-auth/` | this app's rows in the sign-in database — menus (by key), permissions |
|
package/templates/api/app.js
CHANGED
|
@@ -13,6 +13,8 @@ var memberGate = __MT_MEMBER_GATE__;
|
|
|
13
13
|
var port = process.env.__PREFIX___PORT;
|
|
14
14
|
|
|
15
15
|
module.exports = buildApp;
|
|
16
|
+
// bin/www hands the same gate to the flows router it starts (workflow.js).
|
|
17
|
+
module.exports.memberGate = memberGate;
|
|
16
18
|
|
|
17
19
|
function buildApp(extraRoutes) {
|
|
18
20
|
return createApp(port, '__NAME___api', {
|
package/templates/api/bin/www
CHANGED
|
@@ -33,6 +33,10 @@ var path = require('path');
|
|
|
33
33
|
var { resolveConfig } = require('@xeplr/db');
|
|
34
34
|
var { up } = require('@xeplr/db').sqlMigrator;
|
|
35
35
|
var factory = require('@xeplr/factory');
|
|
36
|
+
// This app's functions — what screens' events and flows' "let a function
|
|
37
|
+
// decide" pick by name. Registered before anything can publish a screen that
|
|
38
|
+
// names one.
|
|
39
|
+
require('../functions');
|
|
36
40
|
var screens = require('../screens');
|
|
37
41
|
|
|
38
42
|
async function start() {
|
|
@@ -58,15 +62,13 @@ async function start() {
|
|
|
58
62
|
// time has no connection at all until this has finished.
|
|
59
63
|
var knex = await require('../db/setup');
|
|
60
64
|
|
|
61
|
-
// The screens
|
|
62
|
-
//
|
|
63
|
-
// silently never running.
|
|
65
|
+
// The screens and their models. A malformed model stops startup here,
|
|
66
|
+
// rather than silently never running.
|
|
64
67
|
// filesDir: where a form's file field puts what people upload. Nothing is
|
|
65
68
|
// served from it — a file comes back only through the factory's own route,
|
|
66
69
|
// which checks the caller the way reading a record does.
|
|
67
70
|
await factory.init({
|
|
68
71
|
knex: knex,
|
|
69
|
-
hooks: screens.hooks,
|
|
70
72
|
models: screens.models,
|
|
71
73
|
filesDir: process.env.FACTORY_FILES_DIR
|
|
72
74
|
});
|
|
@@ -76,7 +78,13 @@ async function start() {
|
|
|
76
78
|
var published = await factory.publishScreens(screens.documents);
|
|
77
79
|
if (published.published.length) console.log('[api] published screens: ' + published.published.join(', '));
|
|
78
80
|
|
|
79
|
-
|
|
81
|
+
// FLOWS — @xeplr/workflow-api, inside this API, in a database of its own
|
|
82
|
+
// (DB_WORKFLOW — created and migrated on first start). It follows this
|
|
83
|
+
// app's tenancy and creates no companies or workspaces of its own.
|
|
84
|
+
var buildApp = require('../app');
|
|
85
|
+
require('../routes/flows').mount(await require('../workflow')(buildApp.memberGate));
|
|
86
|
+
|
|
87
|
+
buildApp();
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
start().catch(function(err) {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
var { getConnection } = require('@xeplr/db');
|
|
5
5
|
__MT_DB_REGISTER__
|
|
6
6
|
|
|
7
|
-
// No `|| '
|
|
7
|
+
// No `|| '__DB_API__'` fallback. DB_API is required at startup
|
|
8
8
|
// (env.required.js), so a default could only ever fire in a process that
|
|
9
9
|
// skipped that check — and silently connecting to a database nobody named is
|
|
10
10
|
// exactly what the check exists to prevent.
|
|
@@ -18,10 +18,12 @@ __PREFIX___CONNECTION=__DB_CONNECTION__
|
|
|
18
18
|
AUTH_DB_CONNECTION_INFO_ENCRYPTED=__DB_CONNECTION__
|
|
19
19
|
|
|
20
20
|
# ── Databases ──
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
# Three of them: accounts, your own data, and flows (workflow's own, kept apart
|
|
22
|
+
# from yours). The first two are created by the setup; the flows one by the API
|
|
23
|
+
# the first time it starts.
|
|
24
|
+
DB_API=__DB_API__
|
|
25
|
+
AUTH_DB_NAME=__DB_AUTH__
|
|
26
|
+
DB_WORKFLOW=__DB_WORKFLOW__
|
|
25
27
|
|
|
26
28
|
# ── Sign-in service ──
|
|
27
29
|
#
|
|
@@ -40,9 +42,10 @@ AUTH_JWT_SECRET=__JWT_SECRET__
|
|
|
40
42
|
# Where the sign-in service is. Your API asks it to check every token.
|
|
41
43
|
AUTH_URL=http://localhost:__AUTH_PORT__
|
|
42
44
|
|
|
43
|
-
# Sign-in setup files: the screens' permissions (@xeplr/factory's),
|
|
44
|
-
# app's own — menu entries and anything else
|
|
45
|
-
|
|
45
|
+
# Sign-in setup files: the screens' permissions (@xeplr/factory's), flows'
|
|
46
|
+
# (@xeplr/workflow-api's), then this app's own — menu entries and anything else
|
|
47
|
+
# it adds. Comma-separated, in order.
|
|
48
|
+
XEPLR_AUTH_MIGRATIONS=./node_modules/@xeplr/factory/migrations-auth,./node_modules/@xeplr/workflow-api/migrations-auth,./migrations-auth
|
|
46
49
|
|
|
47
50
|
# ── The first account you sign in with ──
|
|
48
51
|
# Created for you when you set up the database. Change the password here and
|
|
@@ -26,4 +26,8 @@ module.exports = [
|
|
|
26
26
|
// error — the most expensive way for this to go wrong.
|
|
27
27
|
'DB_API',
|
|
28
28
|
'__PREFIX___CONNECTION',
|
|
29
|
+
|
|
30
|
+
// Flows' own database (DB_WORKFLOW) — workflow is embedded in this API but
|
|
31
|
+
// keeps its tables apart from yours. The library names it.
|
|
32
|
+
...require('@xeplr/workflow-api').embedRequiredEnv,
|
|
29
33
|
];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// THIS APP'S FUNCTIONS — the code a screen's events and a flow's "let a
|
|
2
|
+
// function decide" run. Loaded once at startup (bin/www), before the screens.
|
|
3
|
+
//
|
|
4
|
+
// A function is registered under the EVENT it is written for, and picked BY
|
|
5
|
+
// NAME in the designer: Configure UI → Flows → a step → Events & navigation
|
|
6
|
+
// (or a screen's own `events` in its .screen.json). The designer only ever
|
|
7
|
+
// sees the name, the description and what the event hands it — the code
|
|
8
|
+
// stays here, and runs on the server.
|
|
9
|
+
//
|
|
10
|
+
// event runs ctx adds return
|
|
11
|
+
// beforeLoad before records are read query (narrow it) nothing, or a new query
|
|
12
|
+
// beforeSave before a record is written values, isNew, the values to save
|
|
13
|
+
// previous
|
|
14
|
+
// afterSave once it is saved result (ignored)
|
|
15
|
+
// beforeDelete before a delete previous nothing
|
|
16
|
+
// onError when load / save / delete failed error (ignored)
|
|
17
|
+
// decide a flow step is done: where next? values, targets a step key
|
|
18
|
+
//
|
|
19
|
+
// Every ctx has user, tenant, knex, db() (this form's table, scoped) and
|
|
20
|
+
// reject(message, { field }) — which refuses with the message on that field.
|
|
21
|
+
//
|
|
22
|
+
// A screen that names a function nobody registered is refused when it is
|
|
23
|
+
// published, by name — so renaming one here means renaming it there too.
|
|
24
|
+
// Add files beside this one and require them below as the app grows.
|
|
25
|
+
var { register } = require('@xeplr/actions');
|
|
26
|
+
|
|
27
|
+
register({
|
|
28
|
+
name: 'tidyTaskTitle',
|
|
29
|
+
event: 'beforeSave',
|
|
30
|
+
description: 'Trims a task\'s title and squeezes repeated spaces, so " Plant seeds " is saved as "Plant seeds".',
|
|
31
|
+
execute: function(ctx) {
|
|
32
|
+
var title = String(ctx.values.title || '').replace(/\s+/g, ' ').trim();
|
|
33
|
+
if (!title) ctx.reject('A task needs a title', { field: 'title' });
|
|
34
|
+
return Object.assign({}, ctx.values, { title: title });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
register({
|
|
39
|
+
name: 'byPriority',
|
|
40
|
+
event: 'decide',
|
|
41
|
+
description: 'High-priority tasks go to the first screen it may choose; everything else to the second.',
|
|
42
|
+
execute: function(ctx) {
|
|
43
|
+
// ctx.targets: the steps ticked under "It may choose", in the flow's order.
|
|
44
|
+
var high = ctx.values && ctx.values.priority === 'high';
|
|
45
|
+
return high ? ctx.targets[0] : (ctx.targets[1] || ctx.targets[0]);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
@@ -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';
|
|
@@ -20,12 +20,14 @@
|
|
|
20
20
|
"@xeplr/auth": "^1.1.4",
|
|
21
21
|
"@xeplr/base-apis": "^2.0.0",
|
|
22
22
|
"@xeplr/db": "^1.0.6",
|
|
23
|
-
"@xeplr/factory": "^
|
|
24
|
-
"@xeplr/ui-factory": "^1.1
|
|
23
|
+
"@xeplr/factory": "^2.0.0",
|
|
24
|
+
"@xeplr/ui-factory": "^1.7.1",
|
|
25
25
|
"@xeplr/utils": "^1.0.11",
|
|
26
26
|
"dotenv": "^17.4.2",
|
|
27
27
|
"express": "^5.2.0",
|
|
28
|
-
"multer": "^2.0.2"
|
|
28
|
+
"multer": "^2.0.2",
|
|
29
|
+
"@xeplr/actions": "^1.4.0",
|
|
30
|
+
"@xeplr/workflow-api": "^1.1.0"
|
|
29
31
|
},
|
|
30
32
|
"devDependencies": {
|
|
31
33
|
"dotenv-cli": "^8.0.0",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# __NAME__ API — PRODUCTION settings. Copy to production.env on the server
|
|
2
|
-
# and fill in; start with NODE_ENV=production (bin/www reads <NODE_ENV>.env).
|
|
2
|
+
# and fill in; start with NODE_ENV=production (bin/www reads <NODE_ENV>.env).
|
|
3
|
+
# production.env is never committed (.gitignore).
|
|
3
4
|
#
|
|
4
5
|
# Every value that is a secret is blank here. Generate new ones for production
|
|
5
6
|
# — never reuse development's.
|
|
@@ -15,15 +16,16 @@ AUTH_PORT=__AUTH_PORT__
|
|
|
15
16
|
ENCRYPTION_KEY=
|
|
16
17
|
__PREFIX___CONNECTION=
|
|
17
18
|
AUTH_DB_CONNECTION_INFO_ENCRYPTED=
|
|
18
|
-
DB_API=
|
|
19
|
-
AUTH_DB_NAME=
|
|
19
|
+
DB_API=__DB_API__
|
|
20
|
+
AUTH_DB_NAME=__DB_AUTH__
|
|
21
|
+
DB_WORKFLOW=__DB_WORKFLOW__
|
|
20
22
|
|
|
21
23
|
# ── Sign-in ──
|
|
22
24
|
REDIS_PREFIX=__NAME__:
|
|
23
25
|
AUTH_JWT_SECRET=
|
|
24
26
|
# Where THIS server reaches the sign-in service (not the public address).
|
|
25
27
|
AUTH_URL=http://127.0.0.1:__AUTH_PORT__
|
|
26
|
-
XEPLR_AUTH_MIGRATIONS=./node_modules/@xeplr/factory/migrations-auth,./migrations-auth
|
|
28
|
+
XEPLR_AUTH_MIGRATIONS=./node_modules/@xeplr/factory/migrations-auth,./node_modules/@xeplr/workflow-api/migrations-auth,./migrations-auth
|
|
27
29
|
AUTH_SUPER_ADMIN_EMAIL=
|
|
28
30
|
AUTH_SUPER_ADMIN_PASSWORD=
|
|
29
31
|
# none for one set of roles; a multi-tenant app names its company header, e.g. x-company-id
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// FLOWS — screens one after another, run by @xeplr/workflow-api inside this
|
|
2
|
+
// API (bin/www starts it; workflow.js says where its data lives).
|
|
3
|
+
//
|
|
4
|
+
// Mounted by routes/index.js at /api/workflow, behind this app's auth gate:
|
|
5
|
+
// /api/workflow/flows the flows people walk through (Journey) and design
|
|
6
|
+
// /api/workflow/workflows the workflow document API underneath
|
|
7
|
+
var express = require('express');
|
|
8
|
+
|
|
9
|
+
var router = express.Router();
|
|
10
|
+
var workflow = null;
|
|
11
|
+
|
|
12
|
+
router.use(function(req, res, next) {
|
|
13
|
+
// Only before startup has finished — bin/www mounts it before listening.
|
|
14
|
+
if (!workflow) return res.status(503).json({ message: 'Flows are still starting' });
|
|
15
|
+
return workflow(req, res, next);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** bin/www hands over registerWorkflow's router, already gated. */
|
|
19
|
+
router.mount = function(workflowRouter) {
|
|
20
|
+
workflow = workflowRouter;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
module.exports = router;
|
|
@@ -34,4 +34,8 @@ router.get('/whoami', function(req, res) {
|
|
|
34
34
|
// var { genericRoute } = require('@xeplr/base-apis');
|
|
35
35
|
// router.use('/api/things', genericRoute({ key: 'thing', model: require('../models').Thing }));
|
|
36
36
|
|
|
37
|
+
// Flows: @xeplr/workflow-api, at /api/workflow, running inside this API (see
|
|
38
|
+
// routes/flows.js and workflow.js).
|
|
39
|
+
router.use('/api/workflow', require('./flows'));
|
|
40
|
+
|
|
37
41
|
module.exports = router;
|
|
@@ -16,8 +16,11 @@
|
|
|
16
16
|
// screens/<form>/<form>.entity.json its name and fields
|
|
17
17
|
// screens/<form>/<form>-list.screen.json the list
|
|
18
18
|
// screens/<form>/<form>-edit.screen.json the add / edit form
|
|
19
|
-
// screens/<form>/<form>.hooks.js server hooks — save / get / delete
|
|
20
19
|
// screens/<form>/<form>.model.js server model — getters / setters
|
|
20
|
+
//
|
|
21
|
+
// Code around a form's records — checks on save, an email after — is not in
|
|
22
|
+
// these folders: it is a FUNCTION in functions/, named in the screen's
|
|
23
|
+
// `events` (task-edit.screen.json names tidyTaskTitle).
|
|
21
24
|
|
|
22
25
|
module.exports = {
|
|
23
26
|
// Publish order is worked out for you: a table before the dropdowns that
|
|
@@ -27,11 +30,6 @@ module.exports = {
|
|
|
27
30
|
require('./task/task-list.screen.json')
|
|
28
31
|
],
|
|
29
32
|
|
|
30
|
-
// Your own code around save / get / delete, per screen — see task/task.hooks.js.
|
|
31
|
-
hooks: {
|
|
32
|
-
task_edit: require('./task/task.hooks')
|
|
33
|
-
},
|
|
34
|
-
|
|
35
33
|
// How each table's values are shaped — see task/task.model.js.
|
|
36
34
|
models: [
|
|
37
35
|
require('./task/task.model')
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// The model for task records — how values are shaped between your code and
|
|
2
2
|
// the "tasks" table, like Sequelize's getters and setters.
|
|
3
3
|
//
|
|
4
|
-
// Register with @xeplr/factory: factory.init({ knex,
|
|
4
|
+
// Register with @xeplr/factory: factory.init({ knex, models: [require('./task.model')] })
|
|
5
5
|
//
|
|
6
6
|
// Used everywhere: the screens' routes and factory.table('tasks'). Models shape
|
|
7
|
-
// data; the
|
|
7
|
+
// data; the functions its events name decide behaviour (api/functions/).
|
|
8
8
|
|
|
9
9
|
var { FactoryModel } = require('@xeplr/factory');
|
|
10
10
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// FLOWS — how @xeplr/workflow-api starts, inside this API.
|
|
2
|
+
//
|
|
3
|
+
// ITS OWN DATABASE DB_WORKFLOW, beside yours rather than in it — a
|
|
4
|
+
// sub-product embedded in an app gets a store of its own.
|
|
5
|
+
// Created and migrated on first start, with the same
|
|
6
|
+
// server login as the app (__PREFIX___CONNECTION).
|
|
7
|
+
// THIS APP'S it registers no tenancy of its own: flows and runs are
|
|
8
|
+
// TENANCY filtered by the levels this app registered (or none),
|
|
9
|
+
// behind this app's membership gate.
|
|
10
|
+
// PERMISSIONS every route checks the caller's ("List flows", "Start
|
|
11
|
+
// flow run"…), granted by @xeplr/workflow-api's
|
|
12
|
+
// migrations-auth, which XEPLR_AUTH_MIGRATIONS loads.
|
|
13
|
+
//
|
|
14
|
+
// A flow's "let a function decide" picks one of this app's functions
|
|
15
|
+
// (functions/), the same registry the screens' events use.
|
|
16
|
+
var { registerWorkflow } = require('@xeplr/workflow-api');
|
|
17
|
+
|
|
18
|
+
/** Creates and migrates workflow's database, and returns its router — already gated. */
|
|
19
|
+
module.exports = async function startWorkflow(memberGate) {
|
|
20
|
+
var workflow = await registerWorkflow({
|
|
21
|
+
applicationId: '__NAME__',
|
|
22
|
+
db: {
|
|
23
|
+
name: process.env.DB_WORKFLOW,
|
|
24
|
+
connection: process.env.__PREFIX___CONNECTION,
|
|
25
|
+
connectionName: 'workflow'
|
|
26
|
+
},
|
|
27
|
+
tenantTables: false,
|
|
28
|
+
access: true,
|
|
29
|
+
mtMembershipGate: memberGate
|
|
30
|
+
});
|
|
31
|
+
return workflow.router;
|
|
32
|
+
};
|
package/templates/ui/src/App.jsx
CHANGED
|
@@ -4,6 +4,9 @@ import { authRoutes, authPath, NavPage, ProtectedRoute, useAccess } from '@xeplr
|
|
|
4
4
|
import Home from './pages/Home.jsx'
|
|
5
5
|
import Tasks from './pages/Tasks.jsx'
|
|
6
6
|
import EditTask from './pages/EditTask.jsx'
|
|
7
|
+
import FlowDesigner from './pages/FlowDesigner.jsx'
|
|
8
|
+
import Journey from './pages/Journey.jsx'
|
|
9
|
+
import FlowsHome from './pages/FlowsHome.jsx'
|
|
7
10
|
import ConfigureUI from './pages/ConfigureUI.jsx'
|
|
8
11
|
import { FORM_MENU_PREFIX } from './menu.js'
|
|
9
12
|
import FormDesigner from './pages/FormDesigner.jsx'
|
|
@@ -21,6 +24,15 @@ const TasksIcon = (
|
|
|
21
24
|
</svg>
|
|
22
25
|
)
|
|
23
26
|
|
|
27
|
+
const FlowsIcon = (
|
|
28
|
+
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
|
|
29
|
+
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
30
|
+
<rect x="3" y="3" width="6" height="6" rx="1" />
|
|
31
|
+
<rect x="15" y="15" width="6" height="6" rx="1" />
|
|
32
|
+
<path d="M9 6h4a2 2 0 0 1 2 2v7" />
|
|
33
|
+
</svg>
|
|
34
|
+
)
|
|
35
|
+
|
|
24
36
|
const FormsIcon = (
|
|
25
37
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
|
|
26
38
|
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
@@ -68,6 +80,8 @@ function Shell() {
|
|
|
68
80
|
const drawerItems = useMemo(() => [
|
|
69
81
|
{ key: 'Home', icon: HomeIcon, path: '/home', clickHandler: () => navigate('/home') },
|
|
70
82
|
{ key: 'Tasks', icon: TasksIcon, path: '/tasks', clickHandler: () => navigate('/tasks') },
|
|
83
|
+
// Always in the rail — the page says when Xeplr Workflow is not connected.
|
|
84
|
+
{ key: 'Flows', icon: FlowsIcon, path: '/flows', clickHandler: () => navigate('/flows') },
|
|
71
85
|
__MT_DRAWER_ITEM__
|
|
72
86
|
// Every form added to the menu, with no code: "form:crop" opens /forms/crop.
|
|
73
87
|
...menus.filter((key) => key.startsWith(FORM_MENU_PREFIX)).map((key) => ({
|
|
@@ -115,6 +129,12 @@ __MT_SELECT_ROUTE__
|
|
|
115
129
|
<Route path="/forms/:form" element={<FormRecords />} />
|
|
116
130
|
<Route path="/configure" element={<ConfigureUI />} />
|
|
117
131
|
<Route path="/configure/forms/:form/design/:part" element={<FormDesigner />} />
|
|
132
|
+
{/* Flows — run by @xeplr/workflow inside this app's own API. */}
|
|
133
|
+
<Route path="/flows" element={<FlowsHome />} />
|
|
134
|
+
<Route path="/configure/flows/:flow" element={<FlowDesigner />} />
|
|
135
|
+
{/* A journey: its run id joins the address once it starts, so it can be resumed. */}
|
|
136
|
+
<Route path="/journey/:flow" element={<Journey />} />
|
|
137
|
+
<Route path="/journey/:flow/:run" element={<Journey />} />
|
|
118
138
|
</Route>
|
|
119
139
|
|
|
120
140
|
<Route path="*" element={<Navigate to="/auth/login" replace />} />
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Flows are at /api/workflow/flows — @xeplr/workflow-api, running inside this
|
|
2
|
+
// app's API. authFetch carries the same sign-in and company as every other call.
|
|
3
|
+
import { authFetch } from '@xeplr/ui-account'
|
|
4
|
+
import { createFlowsApi } from '@xeplr/ui-factory'
|
|
5
|
+
|
|
6
|
+
export const flows = createFlowsApi({ fetch: authFetch, base: '/api/workflow' })
|
|
@@ -358,3 +358,19 @@ body { margin: 0; }
|
|
|
358
358
|
.app-menu-shown { display: inline-flex; gap: 6px; align-items: center; }
|
|
359
359
|
|
|
360
360
|
.app-menu-settings .app-table { margin-bottom: 14px; }
|
|
361
|
+
|
|
362
|
+
/* ── Flows, before Workflow is connected (src/pages/Flows.jsx) ─────────────── */
|
|
363
|
+
.app-connect {
|
|
364
|
+
max-width: 720px;
|
|
365
|
+
padding: 22px 24px;
|
|
366
|
+
border: 1px solid var(--xeplr-border-primary);
|
|
367
|
+
border-radius: 10px;
|
|
368
|
+
background: var(--xeplr-bg-tertiary);
|
|
369
|
+
line-height: 1.55;
|
|
370
|
+
}
|
|
371
|
+
.app-connect h2 { margin: 0 0 8px; font-size: 18px; }
|
|
372
|
+
.app-connect p { margin: 0 0 14px; }
|
|
373
|
+
.app-connect-steps { margin: 0 0 14px; padding-left: 20px; }
|
|
374
|
+
.app-connect-steps li { margin-bottom: 14px; }
|
|
375
|
+
.app-connect code { font-size: 12.5px; padding: 1px 5px; border-radius: 4px; background: var(--xeplr-bg-secondary); border: 1px solid var(--xeplr-border-primary); }
|
|
376
|
+
.app-connect pre { margin: 6px 0 0; font-size: 12.5px; }
|
package/templates/ui/src/menu.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// countries. Ordinary forms with a flag, gathered in one place because
|
|
7
7
|
// that is what they are: reference data, not daily work
|
|
8
8
|
// Menu the side rail's items: rename, reorder, hide
|
|
9
|
+
// Flows forms one after another — what is filled in decides the next; run by
|
|
10
|
+
// Xeplr Workflow, and says how to connect it until it is
|
|
9
11
|
//
|
|
10
12
|
// Configuration lives here, not in the side rail — the rail is for the pages
|
|
11
13
|
// people use.
|
|
@@ -13,10 +15,12 @@ import { useSearchParams } from 'react-router-dom'
|
|
|
13
15
|
import Forms from './Forms.jsx'
|
|
14
16
|
import Masters from './Masters.jsx'
|
|
15
17
|
import MenuSettings from './MenuSettings.jsx'
|
|
18
|
+
import Flows from './Flows.jsx'
|
|
16
19
|
|
|
17
20
|
const TABS = [
|
|
18
21
|
{ id: 'forms', label: 'Forms', Page: Forms },
|
|
19
22
|
{ id: 'masters', label: 'Masters', Page: Masters },
|
|
23
|
+
{ id: 'flows', label: 'Flows', Page: Flows },
|
|
20
24
|
{ id: 'menu', label: 'Menu', Page: MenuSettings }
|
|
21
25
|
]
|
|
22
26
|
|
|
@@ -18,8 +18,8 @@ import { factory } from '../api/factory.js'
|
|
|
18
18
|
//
|
|
19
19
|
// ctx.screen says which screen is calling ("task_list" or "task_edit").
|
|
20
20
|
// Keep them as methods (save(values, ctx) { … }) — super does not work in
|
|
21
|
-
// arrow functions. Rules that must hold belong in
|
|
22
|
-
// (api/
|
|
21
|
+
// arrow functions. Rules that must hold belong in a server function
|
|
22
|
+
// (api/functions/), named in the screen's events.
|
|
23
23
|
export class TaskHooks extends FactoryHooks {
|
|
24
24
|
/** A list's rows (ctx.many), or the record Edit opens (ctx.id). */
|
|
25
25
|
get(ctx) {
|
|
@@ -82,7 +82,7 @@ export default function EditTask({ record }) {
|
|
|
82
82
|
factory.loadScreen('task_edit').then(setScreen, (e) => setError(e.message))
|
|
83
83
|
}, [])
|
|
84
84
|
|
|
85
|
-
// The record as it is NOW, through the server's
|
|
85
|
+
// The record as it is NOW, through the server's events — not a copy the
|
|
86
86
|
// list happened to have.
|
|
87
87
|
useEffect(() => {
|
|
88
88
|
if (record || !id || id === 'new') return
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// ONE FLOW, DESIGNED — /configure/flows/<key>.
|
|
2
|
+
//
|
|
3
|
+
// @xeplr/ui-factory's designer: the flow's screens down the left; for the one
|
|
4
|
+
// picked, its Form design (the screen's own builder) and its Events &
|
|
5
|
+
// navigation — this app's functions (api/functions/) picked by name for each
|
|
6
|
+
// event, and where the journey goes once the screen is saved. The selected
|
|
7
|
+
// step and tab live in the address (?flowSelected=…&flowTab=…), so a reload or
|
|
8
|
+
// a shared link opens the same place.
|
|
9
|
+
//
|
|
10
|
+
// Everything it needs comes from the two API clients — the wiring lives in
|
|
11
|
+
// the package, so an upgrade reaches this app.
|
|
12
|
+
import { useNavigate, useParams } from 'react-router-dom'
|
|
13
|
+
import { FlowDesignerPage } from '@xeplr/ui-factory'
|
|
14
|
+
import { factory } from '../api/factory.js'
|
|
15
|
+
import { flows } from '../api/flows.js'
|
|
16
|
+
|
|
17
|
+
export default function FlowDesigner() {
|
|
18
|
+
const { flow } = useParams()
|
|
19
|
+
const navigate = useNavigate()
|
|
20
|
+
return (
|
|
21
|
+
<div className="app-designer">
|
|
22
|
+
<div className="app-designer-bar">
|
|
23
|
+
<button type="button" className="app-btn app-btn-quiet" onClick={() => navigate('/configure?tab=flows')}>← Flows</button>
|
|
24
|
+
</div>
|
|
25
|
+
<div className="app-designer-canvas">
|
|
26
|
+
<FlowDesignerPage factory={factory} flows={flows} flowKey={flow} urlKey="flow" />
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// CONFIGURE UI → FLOWS — every journey across the app's forms.
|
|
2
|
+
//
|
|
3
|
+
// A flow is what someone is walked through: screens one after another. For
|
|
4
|
+
// each screen it says what runs around its records (its events — this app's
|
|
5
|
+
// functions, picked by name) and where the journey goes once it is saved: a
|
|
6
|
+
// screen, a function that decides, or the end. @xeplr/workflow-api runs it,
|
|
7
|
+
// inside this app's API, so a journey can be left and picked up later.
|
|
8
|
+
//
|
|
9
|
+
// A flow is addressed by a key made from its name — /configure/flows/<key> to
|
|
10
|
+
// design it, /journey/<key> to walk it.
|
|
11
|
+
import { useEffect, useState } from 'react'
|
|
12
|
+
import { Link, useNavigate } from 'react-router-dom'
|
|
13
|
+
import { flowKey } from '@xeplr/ui-factory'
|
|
14
|
+
import { flows } from '../api/flows.js'
|
|
15
|
+
|
|
16
|
+
export default function Flows() {
|
|
17
|
+
const [list, setList] = useState(null)
|
|
18
|
+
const [error, setError] = useState(null)
|
|
19
|
+
const [offline, setOffline] = useState(null) // API_NOT_RESTARTED
|
|
20
|
+
const [name, setName] = useState('')
|
|
21
|
+
const navigate = useNavigate()
|
|
22
|
+
|
|
23
|
+
const load = () => flows.listFlows().then(setList, (e) => {
|
|
24
|
+
const off = flowsOffline(e)
|
|
25
|
+
if (off) setOffline(off)
|
|
26
|
+
else setError(e.message)
|
|
27
|
+
})
|
|
28
|
+
useEffect(() => { load() }, [])
|
|
29
|
+
|
|
30
|
+
const create = async (e) => {
|
|
31
|
+
e.preventDefault()
|
|
32
|
+
const label = name.trim()
|
|
33
|
+
if (!label) return
|
|
34
|
+
try {
|
|
35
|
+
// No steps yet: the designer is where those come from, and it opens on
|
|
36
|
+
// the flow this creates. `key` is what every link to it uses from here on.
|
|
37
|
+
const made = await flows.createFlow({ key: flowKey(label), name: label })
|
|
38
|
+
navigate(`/configure/flows/${made.key}`)
|
|
39
|
+
} catch (err) {
|
|
40
|
+
setError(err.message)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (offline) return <NotConnected offline={offline} />
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="app-forms">
|
|
48
|
+
<p className="app-page-lead">
|
|
49
|
+
A flow is your forms, one after another. For each one, pick what runs around its records and
|
|
50
|
+
where the journey goes next — a screen, one of the app’s functions, or the end.
|
|
51
|
+
</p>
|
|
52
|
+
{error && <div className="app-error">{error}</div>}
|
|
53
|
+
|
|
54
|
+
<form className="app-forms-new" onSubmit={create}>
|
|
55
|
+
<label className="app-forms-field">
|
|
56
|
+
<span>Name</span>
|
|
57
|
+
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Employee registration" />
|
|
58
|
+
</label>
|
|
59
|
+
<button type="submit" className="app-btn" disabled={!name.trim()}>New flow</button>
|
|
60
|
+
</form>
|
|
61
|
+
|
|
62
|
+
{list && list.length === 0 && <p className="app-sub">No flows yet.</p>}
|
|
63
|
+
{list && list.length > 0 && (
|
|
64
|
+
<table className="app-table">
|
|
65
|
+
<thead><tr><th>Flow</th><th>Key</th><th>Status</th><th>Screens</th><th /></tr></thead>
|
|
66
|
+
<tbody>
|
|
67
|
+
{list.map((f) => (
|
|
68
|
+
<tr key={f.key}>
|
|
69
|
+
<td><strong>{f.name}</strong></td>
|
|
70
|
+
<td className="app-sub">{f.key}</td>
|
|
71
|
+
<td>{f.status}</td>
|
|
72
|
+
<td>{typeof f.steps === 'number' ? f.steps : (f.steps || []).length}</td>
|
|
73
|
+
<td className="app-row-actions">
|
|
74
|
+
<Link to={`/configure/flows/${f.key}`}>Design</Link>
|
|
75
|
+
{f.status === 'published' && <Link to={`/journey/${f.key}`}>Open</Link>}
|
|
76
|
+
</td>
|
|
77
|
+
</tr>
|
|
78
|
+
))}
|
|
79
|
+
</tbody>
|
|
80
|
+
</table>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Why flows cannot run right now, or null when the error is something else.
|
|
88
|
+
* Workflow runs inside this app's API, so there is one such case: an API
|
|
89
|
+
* started before flows existed (404 on /api/workflow) — restart it.
|
|
90
|
+
*/
|
|
91
|
+
export function flowsOffline(err) {
|
|
92
|
+
if (err && err.status === 404) return { code: 'API_NOT_RESTARTED', message: 'This app\'s API does not have the flows route yet.' }
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Flows before the API has been restarted with them. */
|
|
97
|
+
export function NotConnected({ offline }) {
|
|
98
|
+
return (
|
|
99
|
+
<div className="app-forms">
|
|
100
|
+
<div className="app-connect">
|
|
101
|
+
<h2>Restart the API to use flows</h2>
|
|
102
|
+
<p>{offline.message} It was started before flows were added — restart it, then reload this page.</p>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// FLOWS — the side-rail page: the journeys people can start, one click each.
|
|
2
|
+
//
|
|
3
|
+
// Always in the rail. Flows are run by @xeplr/workflow inside this app's own
|
|
4
|
+
// API, so they are simply there. Designing them is Configure UI → Flows.
|
|
5
|
+
import { useEffect, useState } from 'react'
|
|
6
|
+
import { Link, useNavigate } from 'react-router-dom'
|
|
7
|
+
import { useAccess } from '@xeplr/ui-account'
|
|
8
|
+
import { flows } from '../api/flows.js'
|
|
9
|
+
import { NotConnected, flowsOffline } from './Flows.jsx'
|
|
10
|
+
|
|
11
|
+
export default function FlowsHome() {
|
|
12
|
+
const [list, setList] = useState(null)
|
|
13
|
+
const [error, setError] = useState(null)
|
|
14
|
+
const [offline, setOffline] = useState(null)
|
|
15
|
+
const navigate = useNavigate()
|
|
16
|
+
const { access } = useAccess()
|
|
17
|
+
// Whoever may open Configure UI may design flows and connect Workflow.
|
|
18
|
+
const admin = ((access && access.menus) || []).includes('Configure UI')
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
flows.listFlows().then(setList, (e) => {
|
|
22
|
+
const off = flowsOffline(e)
|
|
23
|
+
if (off) setOffline(off)
|
|
24
|
+
else setError(e.message)
|
|
25
|
+
})
|
|
26
|
+
}, [])
|
|
27
|
+
|
|
28
|
+
const published = (list || []).filter((f) => f.status === 'published')
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="app-page">
|
|
32
|
+
<h1>Flows</h1>
|
|
33
|
+
<p className="app-page-lead">
|
|
34
|
+
Forms one after another — what you fill in on one decides which comes next. A journey you
|
|
35
|
+
leave is kept, and picks up where it stopped.
|
|
36
|
+
</p>
|
|
37
|
+
{offline && <NotConnected offline={offline} />}
|
|
38
|
+
{error && <div className="app-error">{error}</div>}
|
|
39
|
+
{list && published.length === 0 && (
|
|
40
|
+
<p className="app-sub">
|
|
41
|
+
No flows to start yet.{admin && <> <Link to="/configure?tab=flows">Design one</Link> in Configure UI.</>}
|
|
42
|
+
</p>
|
|
43
|
+
)}
|
|
44
|
+
{published.length > 0 && (
|
|
45
|
+
<table className="app-table">
|
|
46
|
+
<thead><tr><th>Flow</th><th>Screens</th><th /></tr></thead>
|
|
47
|
+
<tbody>
|
|
48
|
+
{published.map((f) => (
|
|
49
|
+
<tr key={f.key}>
|
|
50
|
+
<td><strong>{f.name}</strong></td>
|
|
51
|
+
<td>{typeof f.steps === 'number' ? f.steps : (f.steps || []).length}</td>
|
|
52
|
+
<td className="app-row-actions">
|
|
53
|
+
<button type="button" className="app-btn" onClick={() => navigate(`/journey/${f.key}`)}>Start</button>
|
|
54
|
+
</td>
|
|
55
|
+
</tr>
|
|
56
|
+
))}
|
|
57
|
+
</tbody>
|
|
58
|
+
</table>
|
|
59
|
+
)}
|
|
60
|
+
{admin && list && published.length > 0 && (
|
|
61
|
+
<p className="app-sub" style={{ marginTop: 16 }}><Link to="/configure?tab=flows">Design flows</Link> in Configure UI.</p>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// A FLOW, being walked through — /journey/<key>, and /journey/<key>/<run> once
|
|
2
|
+
// it has started. The run id in the address is what lets a journey be
|
|
3
|
+
// reloaded, left, or handed to someone else and opened on the step it is on.
|
|
4
|
+
//
|
|
5
|
+
// This page holds no route through the journey: Workflow says which screen
|
|
6
|
+
// comes next, from what was filled in.
|
|
7
|
+
import { useNavigate, useParams } from 'react-router-dom'
|
|
8
|
+
import { FlowRunner } from '@xeplr/ui-factory'
|
|
9
|
+
import { flows } from '../api/flows.js'
|
|
10
|
+
import { factory } from '../api/factory.js'
|
|
11
|
+
|
|
12
|
+
export default function Journey() {
|
|
13
|
+
const { flow, run } = useParams()
|
|
14
|
+
const navigate = useNavigate()
|
|
15
|
+
return (
|
|
16
|
+
<div className="app-page">
|
|
17
|
+
<FlowRunner
|
|
18
|
+
{...flows.runnerProps}
|
|
19
|
+
{...factory.screenProps}
|
|
20
|
+
flowKey={flow}
|
|
21
|
+
runId={run}
|
|
22
|
+
onStep={(step) => { if (step.runId && step.runId !== run) navigate(`/journey/${flow}/${step.runId}`, { replace: true }) }}
|
|
23
|
+
renderDone={() => (
|
|
24
|
+
<div className="app-help">
|
|
25
|
+
<h2>All done</h2>
|
|
26
|
+
<p>Everything in this journey is filled in.</p>
|
|
27
|
+
<button type="button" className="app-btn" onClick={() => navigate(`/journey/${flow}`)}>Start another</button>
|
|
28
|
+
</div>
|
|
29
|
+
)}
|
|
30
|
+
/>
|
|
31
|
+
</div>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -63,7 +63,7 @@ export default function TasksHelp() {
|
|
|
63
63
|
<h3>Or without code</h3>
|
|
64
64
|
<p>
|
|
65
65
|
<strong>Configure UI → Forms → New form</strong>: label and key, design,
|
|
66
|
-
publish, add to menu — no code. The full steps for a form with its own page,
|
|
66
|
+
publish, add to menu — no code. The full steps for a form with its own page, functions and
|
|
67
67
|
model are in <code>CLAUDE.md</code>, under "Create a new UI".
|
|
68
68
|
</p>
|
|
69
69
|
|
|
@@ -75,7 +75,8 @@ export default function TasksHelp() {
|
|
|
75
75
|
</p>
|
|
76
76
|
<p>
|
|
77
77
|
On the server — a check against other records, an email afterwards, a
|
|
78
|
-
list only managers see: <code>api/
|
|
78
|
+
list only managers see: a function in <code>api/functions/</code>, registered by the event it
|
|
79
|
+
is for and named in the screen’s events — Tasks names <code>tidyTaskTitle</code>.
|
|
79
80
|
</p>
|
|
80
81
|
|
|
81
82
|
<h3>Two things that catch people</h3>
|
|
@@ -44,7 +44,10 @@ export default defineConfig(({ mode }) => {
|
|
|
44
44
|
// Force every import to THIS app's copy, so hook-using libraries don't
|
|
45
45
|
// load twice and break the rules of hooks in ways that read as impossible.
|
|
46
46
|
resolve: { dedupe: ['react', 'react-dom', 'react-router-dom', '@xeplr/ui-canvas', '@xeplr/ui-table'] },
|
|
47
|
-
|
|
47
|
+
// 127.0.0.1, not 'localhost': Node resolves 'localhost' to IPv6 (::1)
|
|
48
|
+
// first, so the dev server listened on ::1 alone and a browser going to
|
|
49
|
+
// 127.0.0.1 was refused.
|
|
50
|
+
server: { host: '127.0.0.1', port, strictPort: true, proxy },
|
|
48
51
|
preview: { port, strictPort: true }
|
|
49
52
|
}
|
|
50
53
|
})
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
// Server hooks for task records — screen "task_edit" (and "task_list", which edits in it).
|
|
2
|
-
//
|
|
3
|
-
// Register with @xeplr/factory:
|
|
4
|
-
// factory.init({ knex, hooks: { task_edit: require('./task.hooks') } })
|
|
5
|
-
//
|
|
6
|
-
// Each operation has four hooks. Delete the ones you do not need.
|
|
7
|
-
// before(ctx) first — save: return new values to replace them (they are then checked
|
|
8
|
-
// against the screen's rules); get: narrow ctx.query; any: ctx.reject(message, { field })
|
|
9
|
-
// after(ctx) once it succeeded — ctx.id, ctx.result; return a value to replace the result.
|
|
10
|
-
// If it throws, the operation still happened (and error is told).
|
|
11
|
-
// error(ctx, err) when the operation failed
|
|
12
|
-
// override(ctx) does the WHOLE operation instead — no rules, no before / after / error,
|
|
13
|
-
// no generic query. What it returns is the response.
|
|
14
|
-
//
|
|
15
|
-
// ctx: op, screenKey, screen, table, id, isNew (save), many (get), input (what the UI sent, read-only),
|
|
16
|
-
// values (save), previous (save / delete: the row before), result, user, tenant, knex, query (get), reject
|
|
17
|
-
|
|
18
|
-
module.exports = {
|
|
19
|
-
save: {
|
|
20
|
-
// before: async function(ctx) { return ctx.values },
|
|
21
|
-
// after: async function(ctx) {},
|
|
22
|
-
// error: async function(ctx, err) {},
|
|
23
|
-
// override: async function(ctx) { return savedRecord },
|
|
24
|
-
},
|
|
25
|
-
get: {
|
|
26
|
-
// before: async function(ctx) { /* ctx.query.where(...) */ },
|
|
27
|
-
// after: async function(ctx) { return ctx.result },
|
|
28
|
-
// error: async function(ctx, err) {},
|
|
29
|
-
// override: async function(ctx) { return ctx.id ? record : records },
|
|
30
|
-
},
|
|
31
|
-
delete: {
|
|
32
|
-
// before: async function(ctx) {},
|
|
33
|
-
// after: async function(ctx) {},
|
|
34
|
-
// error: async function(ctx, err) {},
|
|
35
|
-
// override: async function(ctx) { return { id: ctx.id } },
|
|
36
|
-
}
|
|
37
|
-
}
|