@xeplr/cli 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -3
- package/lib/generate/index.js +4 -5
- package/lib/plan.js +1 -0
- package/package.json +1 -1
- package/templates/CLAUDE.md +59 -0
- package/templates/api/app.js +13 -2
- package/templates/api/bin/www +5 -4
- package/templates/api/development.env.tpl +5 -0
- package/templates/api/env.required.js +1 -1
- package/templates/api/migrations/README.md +1 -1
- package/templates/api/migrations-auth/0001_home_menu.sql +1 -1
- package/templates/api/migrations-auth/0002_tasks_menu.sql +2 -1
- package/templates/api/migrations-auth/0003_configure_ui_menu.sql +20 -0
- package/templates/api/models/index.js +1 -1
- package/templates/api/package.json +4 -3
- package/templates/api/routes/access.js +25 -0
- package/templates/api/routes/index.js +1 -1
- package/templates/api/screens/index.js +14 -7
- package/templates/api/screens/task/task.model.js +29 -0
- package/templates/ui/package.json +2 -2
- package/templates/ui/src/App.jsx +29 -13
- package/templates/ui/src/api/factory.js +1 -1
- package/templates/ui/src/index.css +59 -0
- package/templates/ui/src/menu.js +17 -0
- package/templates/ui/src/pages/ConfigureUI.jsx +42 -0
- package/templates/ui/src/pages/EditTask.jsx +57 -0
- package/templates/ui/src/pages/FormDesigner.jsx +74 -0
- package/templates/ui/src/pages/FormRecords.jsx +24 -0
- package/templates/ui/src/pages/Forms.jsx +185 -0
- package/templates/ui/src/pages/Home.jsx +2 -2
- package/templates/ui/src/pages/MenuSettings.jsx +99 -0
- package/templates/ui/src/pages/Tasks.jsx +5 -3
- package/templates/ui/src/pages/TasksHelp.jsx +49 -35
- package/templates-tenancy/api/migrations-auth/0004_switch_menu.sql +2 -1
- package/templates-tenancy/api/routes/tenants.js +1 -5
- package/templates/api/migrations-auth/0003_designer_menu.sql +0 -19
- package/templates/ui/src/pages/Designer.jsx +0 -74
package/README.md
CHANGED
|
@@ -28,15 +28,24 @@ Already working, with nothing to wire up:
|
|
|
28
28
|
table. Neither is written by hand: both are screens
|
|
29
29
|
([`@xeplr/ui-factory`](https://www.npmjs.com/package/@xeplr/ui-factory)),
|
|
30
30
|
published when the API first starts.
|
|
31
|
-
- **A
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
- **A Forms page** for Super Admin — every form in the app; make a new one by
|
|
32
|
+
hand (no Claude needed), design its form and its list, publish (which creates
|
|
33
|
+
or changes its table), open it. Nobody else sees it, and the API refuses
|
|
34
|
+
everyone else.
|
|
34
35
|
- **Multi-tenancy, if you ask for it** — companies (or companies and
|
|
35
36
|
workspaces, up to four levels). Every row is stamped with the one it was made
|
|
36
37
|
in, every read sees only its rows, membership is checked on every request,
|
|
37
38
|
and choosing one is the first screen after signing in.
|
|
38
39
|
- **A welcome screen** that checks it can reach everything and tells you which
|
|
39
40
|
files to change first.
|
|
41
|
+
- **A `CLAUDE.md`** that tells Claude how to create a new UI in this app — the
|
|
42
|
+
screens, the page with its front-end hooks, the server hooks and model, the
|
|
43
|
+
route and menu entry — and a ready prompt (also shown beside the sample):
|
|
44
|
+
|
|
45
|
+
> Create a new UI for farming departments. One record is a farming department
|
|
46
|
+
> with: Name (required), Region (dropdown: North, South, East, West), Area in
|
|
47
|
+
> acres (number, at least 0), Started on (date), Notes (long text). Show Name,
|
|
48
|
+
> Region and Area in the list. Follow "Create a new UI" in CLAUDE.md.
|
|
40
49
|
|
|
41
50
|
## Before you start
|
|
42
51
|
|
package/lib/generate/index.js
CHANGED
|
@@ -77,10 +77,8 @@ function tenancySubstitutions(levels) {
|
|
|
77
77
|
' // The ' + levels.map(function (l) { return l.table; }).join(' / ') + ' the picker lists and adds to.',
|
|
78
78
|
" '/api/tenants': tenants.router,"
|
|
79
79
|
].join('\n') : '',
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
' auth: tenants.memberGate'
|
|
83
|
-
].join('\n') : '',
|
|
80
|
+
__MT_MEMBER_GATE__: on ? 'tenants.memberGate' : 'access.anyone',
|
|
81
|
+
__MT_MEMBER_NOUN__: on ? levels.map(function (l) { return l.label.toLowerCase(); }).join(' and ') : 'data (no tenancy: nothing to check)',
|
|
84
82
|
|
|
85
83
|
// ui/src/main.jsx
|
|
86
84
|
__MT_UI_IMPORTS__: on ? [
|
|
@@ -96,7 +94,8 @@ function tenancySubstitutions(levels) {
|
|
|
96
94
|
|
|
97
95
|
// ui/src/App.jsx
|
|
98
96
|
__MT_APP_UI_IMPORTS__: on ? "import SelectScope, { ScopeGate } from './pages/SelectScope.jsx'" : '',
|
|
99
|
-
|
|
97
|
+
__MT_MENU_KEY__: on ? " 'Switch " + first.label.toLowerCase() + "'," : '',
|
|
98
|
+
__MT_DRAWER_ITEM__: on ? " { key: 'Switch " + first.label.toLowerCase() + "', icon: SwitchIcon, clickHandler: () => navigate('/select') }," : '',
|
|
100
99
|
__MT_SWITCH_ICON__: on ? [
|
|
101
100
|
'const SwitchIcon = (',
|
|
102
101
|
' <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"',
|
package/lib/plan.js
CHANGED
|
@@ -30,6 +30,7 @@ function describe(answers) {
|
|
|
30
30
|
L.push('');
|
|
31
31
|
L.push(' Settings written for you:');
|
|
32
32
|
L.push(' ' + p + '_PORT, AUTH_PORT, AUTH_URL, DB_API, AUTH_DB_NAME');
|
|
33
|
+
L.push(' REDIS_PREFIX ' + answers.name + ':');
|
|
33
34
|
L.push(' ENCRYPTION_KEY and AUTH_JWT_SECRET generated, not asked');
|
|
34
35
|
L.push(' AUTH_SUPER_ADMIN_EMAIL / _PASSWORD from your answers');
|
|
35
36
|
L.push('');
|
package/package.json
CHANGED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# __NAME__
|
|
2
|
+
|
|
3
|
+
An xeplr app: `api/` (Node, @xeplr/base-apis, sign-in by @xeplr/auth) and `ui/` (React + Vite, @xeplr/ui-account).
|
|
4
|
+
Forms are **screens** made with @xeplr/ui-factory and served by @xeplr/factory. The sample is **Tasks**.
|
|
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.
|
|
7
|
+
|
|
8
|
+
## Create a new UI
|
|
9
|
+
|
|
10
|
+
A new UI is a **form**: a list, and an add / edit form it opens in a popup, saved in its own table. Use the singular for `<form>` (`farming_department`), the plural for the page (`FarmingDepartments`).
|
|
11
|
+
|
|
12
|
+
Create every file below — the hooks and the model run the defaults (`super`) until the request needs more.
|
|
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.
|
|
15
|
+
2. **Screens + server files** — from `api/`:
|
|
16
|
+
```sh
|
|
17
|
+
npx xeplr-factory screens screens/<form>/<form>.entity.json -o screens/<form> --no-pages
|
|
18
|
+
npx xeplr-factory validate screens/<form>/<form>-edit.screen.json
|
|
19
|
+
npx xeplr-factory validate screens/<form>/<form>-list.screen.json
|
|
20
|
+
```
|
|
21
|
+
Writes `<form>-list.screen.json`, `<form>-edit.screen.json`, `<form>.hooks.js` (server hooks), `<form>.model.js` (server model).
|
|
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
|
+
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
|
+
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.
|
|
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
|
+
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; the popup says "All changes saved" and the row is in the table.
|
|
29
|
+
|
|
30
|
+
Then change only what the request asks for:
|
|
31
|
+
|
|
32
|
+
| the request says… | change |
|
|
33
|
+
|---|---|
|
|
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
|
+
| hide / filter rows on screen, an extra button on each row, fill in a value before saving | front-end hooks — `ui/src/pages/Edit<Form>.jsx` |
|
|
36
|
+
| must / only if / check against / email when / only managers see | server hooks — `api/screens/<form>/<form>.hooks.js` |
|
|
37
|
+
| store as / convert / comma-separated ↔ array / work out X from Y | server model — `api/screens/<form>/<form>.model.js` |
|
|
38
|
+
| a query in your own server code | `factory.table('<forms table>')` — this company, active rows, the model applied |
|
|
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, hooks or model files; add them with the steps above when it needs them.
|
|
41
|
+
|
|
42
|
+
**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.
|
|
43
|
+
|
|
44
|
+
### The prompt
|
|
45
|
+
|
|
46
|
+
> 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.
|
|
47
|
+
|
|
48
|
+
Replace the bold parts. Add behaviour in the same message if you want it — "a Mark active button on each row", "Region is required when Area is over 100", "store tags as an array".
|
|
49
|
+
|
|
50
|
+
## Rules
|
|
51
|
+
|
|
52
|
+
- **Records live in real tables, one column per field — never JSON.** A screen's design is the only JSON, in the `factory_screens` table.
|
|
53
|
+
- **Never rename a field that is already a column** — it would drop the column and its data. Add a new field instead.
|
|
54
|
+
- A published screen's design lives in the **database**. Editing its `.screen.json` changes nothing afterwards: publish the change in Configure UI → Forms.
|
|
55
|
+
- 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.
|
|
56
|
+
- Designing and publishing are **Super Admin only** (`api/routes/access.js`); every other route checks the permission catalog.
|
|
57
|
+
- The browser is not a security boundary: anything that must hold goes in server hooks.
|
|
58
|
+
- Hooks and models are **methods** calling `super` — never arrow-function properties.
|
|
59
|
+
- `authFetch` returns the parsed body, not a Response.
|
package/templates/api/app.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
var createApp = require('@xeplr/base-apis/express');
|
|
2
2
|
var factory = require('@xeplr/factory');
|
|
3
3
|
var routes = require('./routes');
|
|
4
|
+
var access = require('./routes/access');
|
|
4
5
|
__MT_APP_REQUIRE__
|
|
5
6
|
|
|
7
|
+
// Every screen route first checks the caller may use the request's
|
|
8
|
+
// __MT_MEMBER_NOUN__.
|
|
9
|
+
var memberGate = __MT_MEMBER_GATE__;
|
|
10
|
+
|
|
6
11
|
// No `|| __API_PORT__` fallback — the port is required in env.required.js, so a
|
|
7
12
|
// default could only ever fire in a process that skipped that check.
|
|
8
13
|
var port = process.env.__PREFIX___PORT;
|
|
@@ -23,7 +28,7 @@ __MT_APP_MIDDLEWARE__
|
|
|
23
28
|
'/': routes,
|
|
24
29
|
__MT_APP_ROUTES__
|
|
25
30
|
|
|
26
|
-
// THE SCREENS — every
|
|
31
|
+
// THE SCREENS — every form, one set of routes:
|
|
27
32
|
// /api/factory/screens/... designs: load, save draft, publish
|
|
28
33
|
// /api/factory/records/... a screen's records: list, one, save, delete
|
|
29
34
|
//
|
|
@@ -33,7 +38,13 @@ __MT_APP_ROUTES__
|
|
|
33
38
|
// Viewer (read) by node_modules/@xeplr/factory/migrations-auth.
|
|
34
39
|
'/api': factory.router({
|
|
35
40
|
access: true,
|
|
36
|
-
|
|
41
|
+
auth: {
|
|
42
|
+
view: memberGate,
|
|
43
|
+
write: memberGate,
|
|
44
|
+
// Designing, new forms and publishing change tables: Super Admin
|
|
45
|
+
// only (routes/access.js).
|
|
46
|
+
design: [memberGate, access.superAdminOnly]
|
|
47
|
+
}
|
|
37
48
|
})
|
|
38
49
|
})
|
|
39
50
|
});
|
package/templates/api/bin/www
CHANGED
|
@@ -58,12 +58,13 @@ async function start() {
|
|
|
58
58
|
// time has no connection at all until this has finished.
|
|
59
59
|
var knex = await require('../db/setup');
|
|
60
60
|
|
|
61
|
-
// The screens. A hooks file with a typo
|
|
62
|
-
//
|
|
63
|
-
|
|
61
|
+
// The screens, their hooks and their models. A hooks file with a typo
|
|
62
|
+
// ("beforeSave") or a malformed model stops startup here, rather than
|
|
63
|
+
// silently never running.
|
|
64
|
+
await factory.init({ knex: knex, hooks: screens.hooks, models: screens.models });
|
|
64
65
|
|
|
65
66
|
// Screens never published yet are published now, creating their tables.
|
|
66
|
-
// Ones already published are left alone —
|
|
67
|
+
// Ones already published are left alone — Configure UI → Forms owns them.
|
|
67
68
|
var published = await factory.publishScreens(screens.documents);
|
|
68
69
|
if (published.published.length) console.log('[api] published screens: ' + published.published.join(', '));
|
|
69
70
|
|
|
@@ -29,6 +29,11 @@ AUTH_DB_NAME=__NAME___auth
|
|
|
29
29
|
# localhost:6379; set REDIS_HOST / REDIS_PORT to point elsewhere. Without it
|
|
30
30
|
# the service refuses to start and says so — rather than letting every login
|
|
31
31
|
# succeed and every request afterwards fail as "Invalid or expired token".
|
|
32
|
+
#
|
|
33
|
+
# REDIS_PREFIX keeps this app's keys apart from any other app on the same
|
|
34
|
+
# Redis. Required, and unique per app: two apps sharing one see each other's
|
|
35
|
+
# menus and permissions.
|
|
36
|
+
REDIS_PREFIX=__NAME__:
|
|
32
37
|
AUTH_PORT=__AUTH_PORT__
|
|
33
38
|
AUTH_JWT_SECRET=__JWT_SECRET__
|
|
34
39
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
module.exports = [
|
|
11
11
|
// ENCRYPTION_KEY, AUTH_JWT_SECRET, AUTH_PORT, AUTH_DB_NAME,
|
|
12
|
-
// XEPLR_AUTH_MIGRATIONS
|
|
12
|
+
// XEPLR_AUTH_MIGRATIONS, AUTH_SUPER_ADMIN_EMAIL / _PASSWORD, REDIS_PREFIX
|
|
13
13
|
...require('@xeplr/auth').requiredEnv,
|
|
14
14
|
|
|
15
15
|
// ── this app's own ──
|
|
@@ -4,6 +4,6 @@ Hand-written `.sql` files, applied once each, in name order, every time the API
|
|
|
4
4
|
starts (`0001_something.sql`, `0002_…`).
|
|
5
5
|
|
|
6
6
|
**Tables for screens are not written here.** A screen's table (`tasks`) is
|
|
7
|
-
created and changed by **Publish** in
|
|
7
|
+
created and changed by **Publish** in Configure UI → Forms — see `screens/`.
|
|
8
8
|
|
|
9
9
|
Put here only tables no screen owns: lookup data, a table your own code writes.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
-- "Home" — this app's own drawer item, matching the name used in the UI's
|
|
2
|
-
-- drawerItems list (ui/src/App.jsx).
|
|
2
|
+
-- drawerItems list (ui/src/App.jsx), by key. Its label is changed in Configure UI → Menu.
|
|
3
3
|
--
|
|
4
4
|
-- Without this row the item is dropped from the drawer SILENTLY: the name has
|
|
5
5
|
-- to exist here for @xeplr/ui-account to show it, and an unrecognised one
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
-- "Tasks" — the drawer item for the sample page.
|
|
2
2
|
--
|
|
3
|
-
-- The name must match ui/src/App.jsx's drawerItems
|
|
3
|
+
-- The name is the KEY: it must match ui/src/App.jsx's drawerItems `key` EXACTLY.
|
|
4
|
+
-- What people read is the label, changed in Configure UI → Menu.
|
|
4
5
|
-- @xeplr/ui-account drops unrecognised names silently, so a typo removes the
|
|
5
6
|
-- item with no error anywhere.
|
|
6
7
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- "Configure UI" — in the settings menu (top right): every form (design, publish,
|
|
2
|
+
-- open, add to the menu) and the side rail's items (rename, reorder, hide).
|
|
3
|
+
-- ui/src/pages/ConfigureUI.jsx.
|
|
4
|
+
--
|
|
5
|
+
-- SUPER ADMIN ONLY: publishing changes database tables. The API refuses
|
|
6
|
+
-- everyone else as well (api/routes/access.js, and @xeplr/auth's menu routes).
|
|
7
|
+
--
|
|
8
|
+
-- The name is the KEY the app matches on (App.jsx settingsOverrides). What
|
|
9
|
+
-- people read is the label, which Configure UI → Menu can change.
|
|
10
|
+
|
|
11
|
+
INSERT INTO "menus" (id, name, "menuGroup", "isPublic", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
|
|
12
|
+
SELECT encode(gen_random_bytes(12), 'hex'), 'Configure UI', '', false, true, '*', now(), now()
|
|
13
|
+
WHERE NOT EXISTS (SELECT 1 FROM "menus" WHERE name = 'Configure UI');
|
|
14
|
+
|
|
15
|
+
INSERT INTO "menuRolesMapping" (id, "roleId", "menuId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
|
|
16
|
+
SELECT encode(gen_random_bytes(12), 'hex'), r.id, m.id, true, '*', now(), now()
|
|
17
|
+
FROM "roles" r CROSS JOIN "menus" m
|
|
18
|
+
WHERE r.name = 'Super Admin'
|
|
19
|
+
AND m.name = 'Configure UI'
|
|
20
|
+
AND NOT EXISTS (SELECT 1 FROM "menuRolesMapping" x WHERE x."roleId" = r.id AND x."menuId" = m.id);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// class extending @xeplr/db's BaseModel, handed to genericRoute in
|
|
3
3
|
// routes/index.js.
|
|
4
4
|
//
|
|
5
|
-
// Tables made
|
|
5
|
+
// Tables made as forms (Configure UI → Forms, screens/) need no model: @xeplr/factory saves
|
|
6
6
|
// and reads them from the screen itself.
|
|
7
7
|
|
|
8
8
|
module.exports = {};
|
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
"start-api": "node ./bin/www"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@xeplr/auth": "^1.1.
|
|
20
|
+
"@xeplr/auth": "^1.1.4",
|
|
21
21
|
"@xeplr/base-apis": "^2.0.0",
|
|
22
22
|
"@xeplr/db": "^1.0.6",
|
|
23
|
-
"@xeplr/factory": "^1.0.
|
|
24
|
-
"@xeplr/
|
|
23
|
+
"@xeplr/factory": "^1.0.5",
|
|
24
|
+
"@xeplr/ui-factory": "^1.0.5",
|
|
25
|
+
"@xeplr/utils": "^1.0.11",
|
|
25
26
|
"dotenv": "^17.4.2",
|
|
26
27
|
"express": "^5.2.0"
|
|
27
28
|
},
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// WHO MAY DO WHAT — beyond the permission catalog every route already checks.
|
|
2
|
+
//
|
|
3
|
+
// Designing screens, making new forms and publishing them change the
|
|
4
|
+
// database's tables, so they are kept to Super Admin alone, whatever else a
|
|
5
|
+
// role has been granted. Relax superAdminOnly here when your app has decided
|
|
6
|
+
// who else should.
|
|
7
|
+
|
|
8
|
+
var FORBIDDEN = { code: 'FORBIDDEN', message: 'Only Super Admin can design and publish forms', error: null, dataArray: [] };
|
|
9
|
+
|
|
10
|
+
function isSuperAdmin(req) {
|
|
11
|
+
var roles = (req.access && req.access.roles) || (req.user && req.user.roles) || [];
|
|
12
|
+
return roles.indexOf('Super Admin') !== -1;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function superAdminOnly(req, res, next) {
|
|
16
|
+
if (isSuperAdmin(req)) return next();
|
|
17
|
+
res.status(403).send(FORBIDDEN);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** No check of its own — the token and the permission catalog still apply. */
|
|
21
|
+
function anyone(req, res, next) {
|
|
22
|
+
next();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { isSuperAdmin: isSuperAdmin, superAdminOnly: superAdminOnly, anyone: anyone };
|
|
@@ -26,7 +26,7 @@ router.get('/whoami', function(req, res) {
|
|
|
26
26
|
// path which to forward. Serve data at /tasks and the UI's own /tasks page is
|
|
27
27
|
// shadowed by it — opening that page shows raw JSON instead of the app.
|
|
28
28
|
//
|
|
29
|
-
//
|
|
29
|
+
// Forms (tasks, and any made in Configure UI) need nothing here — @xeplr/factory
|
|
30
30
|
// serves them at /api/factory/..., mounted in app.js.
|
|
31
31
|
//
|
|
32
32
|
// A table you write by hand gets its four addresses from one line:
|
|
@@ -6,16 +6,18 @@
|
|
|
6
6
|
//
|
|
7
7
|
// On startup (bin/www) every screen here that has never been published is
|
|
8
8
|
// published — which creates its table. After that the DATABASE holds the
|
|
9
|
-
// design: change it in the app's
|
|
9
|
+
// design: change it in the app's Configure UI → Forms, where Publish changes the
|
|
10
10
|
// table to match. Editing a .screen.json later changes nothing for an
|
|
11
11
|
// already-published screen.
|
|
12
12
|
//
|
|
13
|
-
// ADD
|
|
13
|
+
// ADD ONE: follow "Create a new UI" in the project's CLAUDE.md — or ask Claude
|
|
14
|
+
// to, with the prompt there. Every form's folder holds the same files:
|
|
14
15
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
16
|
+
// screens/<form>/<form>.entity.json its name and fields
|
|
17
|
+
// screens/<form>/<form>-list.screen.json the list
|
|
18
|
+
// screens/<form>/<form>-edit.screen.json the add / edit form
|
|
19
|
+
// screens/<form>/<form>.hooks.js server hooks — save / get / delete
|
|
20
|
+
// screens/<form>/<form>.model.js server model — getters / setters
|
|
19
21
|
|
|
20
22
|
module.exports = {
|
|
21
23
|
// Publish order is worked out for you: a table before the dropdowns that
|
|
@@ -28,5 +30,10 @@ module.exports = {
|
|
|
28
30
|
// Your own code around save / get / delete, per screen — see task/task.hooks.js.
|
|
29
31
|
hooks: {
|
|
30
32
|
task_edit: require('./task/task.hooks')
|
|
31
|
-
}
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
// How each table's values are shaped — see task/task.model.js.
|
|
36
|
+
models: [
|
|
37
|
+
require('./task/task.model')
|
|
38
|
+
]
|
|
32
39
|
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The model for task records — how values are shaped between your code and
|
|
2
|
+
// the "tasks" table, like Sequelize's getters and setters.
|
|
3
|
+
//
|
|
4
|
+
// Register with @xeplr/factory: factory.init({ knex, hooks, models: [require('./task.model')] })
|
|
5
|
+
//
|
|
6
|
+
// Used everywhere: the screens' routes and factory.table('tasks'). Models shape
|
|
7
|
+
// data; the hooks file decides behaviour.
|
|
8
|
+
|
|
9
|
+
var { FactoryModel } = require('@xeplr/factory');
|
|
10
|
+
|
|
11
|
+
class TaskModel extends FactoryModel {
|
|
12
|
+
static table = 'tasks';
|
|
13
|
+
|
|
14
|
+
// Per field: set(value, values) before it is written, get(value, row) after it is read.
|
|
15
|
+
// tags: { set: (v) => (Array.isArray(v) ? v.join(',') : v), get: (v) => (v ? v.split(',') : []) }
|
|
16
|
+
static fields = {};
|
|
17
|
+
|
|
18
|
+
/** Every value about to be written. Override for more than one field at a time. */
|
|
19
|
+
static toDb(values) {
|
|
20
|
+
return super.toDb(values);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Every row just read. */
|
|
24
|
+
static fromDb(row) {
|
|
25
|
+
return super.fromDb(row);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = TaskModel;
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@tanstack/react-table": "^8.21.3",
|
|
13
|
-
"@xeplr/ui-account": "^1.0.
|
|
13
|
+
"@xeplr/ui-account": "^1.0.7",
|
|
14
14
|
"@xeplr/ui-canvas": "^1.0.2",
|
|
15
|
-
"@xeplr/ui-factory": "^1.0.
|
|
15
|
+
"@xeplr/ui-factory": "^1.0.5",
|
|
16
16
|
"@xeplr/ui-table": "^1.0.6",
|
|
17
17
|
"react": "^19.2.4",
|
|
18
18
|
"react-dom": "^19.2.4",
|
package/templates/ui/src/App.jsx
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { useMemo } from 'react'
|
|
2
2
|
import { Routes, Route, Outlet, Navigate, useNavigate } from 'react-router-dom'
|
|
3
|
-
import { authRoutes, authPath, NavPage, ProtectedRoute } from '@xeplr/ui-account'
|
|
3
|
+
import { authRoutes, authPath, NavPage, ProtectedRoute, useAccess } from '@xeplr/ui-account'
|
|
4
4
|
import Home from './pages/Home.jsx'
|
|
5
5
|
import Tasks from './pages/Tasks.jsx'
|
|
6
|
-
import
|
|
6
|
+
import ConfigureUI from './pages/ConfigureUI.jsx'
|
|
7
|
+
import { FORM_MENU_PREFIX } from './menu.js'
|
|
8
|
+
import FormDesigner from './pages/FormDesigner.jsx'
|
|
9
|
+
import FormRecords from './pages/FormRecords.jsx'
|
|
7
10
|
__MT_APP_UI_IMPORTS__
|
|
8
11
|
|
|
9
12
|
// The rail is ICON-ONLY when collapsed, so a drawer item without one is
|
|
@@ -17,7 +20,7 @@ const TasksIcon = (
|
|
|
17
20
|
</svg>
|
|
18
21
|
)
|
|
19
22
|
|
|
20
|
-
const
|
|
23
|
+
const FormsIcon = (
|
|
21
24
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
|
|
22
25
|
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
23
26
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
@@ -38,26 +41,37 @@ const HomeIcon = (
|
|
|
38
41
|
const notifications = { count: 0, onClick: () => {} }
|
|
39
42
|
|
|
40
43
|
// Rendered above the framework's built-in Profile / Change Password section.
|
|
44
|
+
// `key` is the menu row it matches (shown only to roles given that row); what
|
|
45
|
+
// people read is its label, changed in Configure UI → Menu.
|
|
41
46
|
const settingsOverrides = [
|
|
42
|
-
{
|
|
47
|
+
{ key: 'Admin', path: authPath('userRoles') },
|
|
48
|
+
// Super Admin only (migrations-auth/0003): every form, and the side rail.
|
|
49
|
+
{ key: 'Configure UI', path: '/configure' }
|
|
43
50
|
]
|
|
44
51
|
|
|
52
|
+
|
|
45
53
|
// Layout route: NavPage and the routed page are independent siblings, and this
|
|
46
54
|
// holds no state of its own — lifting page state up here would re-render the
|
|
47
55
|
// nav on every unrelated change.
|
|
48
56
|
function Shell() {
|
|
49
57
|
const navigate = useNavigate()
|
|
50
58
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
59
|
+
const { access } = useAccess()
|
|
60
|
+
const menus = (access && access.menus) || []
|
|
61
|
+
|
|
62
|
+
// The drawer's catalog, by KEY. A key must match a `menus` row's name —
|
|
63
|
+
// @xeplr/ui-account drops unknown keys SILENTLY, so a typo removes the item
|
|
64
|
+
// with no error anywhere. The text shown is the row's label (Configure UI →
|
|
65
|
+
// Menu), in the order set there — never written here.
|
|
54
66
|
const drawerItems = useMemo(() => [
|
|
55
|
-
{
|
|
56
|
-
{
|
|
57
|
-
// Shown only to roles given the "Designer" menu (migrations-auth/0003).
|
|
58
|
-
{ name: 'Designer', icon: DesignerIcon, clickHandler: () => navigate('/designer') },
|
|
67
|
+
{ key: 'Home', icon: HomeIcon, clickHandler: () => navigate('/home') },
|
|
68
|
+
{ key: 'Tasks', icon: TasksIcon, clickHandler: () => navigate('/tasks') },
|
|
59
69
|
__MT_DRAWER_ITEM__
|
|
60
|
-
|
|
70
|
+
// Every form added to the menu, with no code: "form:crop" opens /forms/crop.
|
|
71
|
+
...menus.filter((key) => key.startsWith(FORM_MENU_PREFIX)).map((key) => ({
|
|
72
|
+
key, icon: FormsIcon, clickHandler: () => navigate('/forms/' + key.slice(FORM_MENU_PREFIX.length))
|
|
73
|
+
}))
|
|
74
|
+
], [navigate, menus])
|
|
61
75
|
|
|
62
76
|
return (
|
|
63
77
|
<div className="app">
|
|
@@ -91,7 +105,9 @@ __MT_SELECT_ROUTE__
|
|
|
91
105
|
<Route path="/" element={<Navigate to="/home" replace />} />
|
|
92
106
|
<Route path="/home" element={<Home />} />
|
|
93
107
|
<Route path="/tasks" element={<Tasks />} />
|
|
94
|
-
<Route path="/
|
|
108
|
+
<Route path="/forms/:form" element={<FormRecords />} />
|
|
109
|
+
<Route path="/configure" element={<ConfigureUI />} />
|
|
110
|
+
<Route path="/configure/forms/:form/design/:part" element={<FormDesigner />} />
|
|
95
111
|
</Route>
|
|
96
112
|
|
|
97
113
|
<Route path="*" element={<Navigate to="/auth/login" replace />} />
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// MODEL LAYER — every call the screens and
|
|
1
|
+
// MODEL LAYER — every call the screens and Configure UI make, to the API's
|
|
2
2
|
// @xeplr/factory routes (mounted at /api in api/app.js).
|
|
3
3
|
//
|
|
4
4
|
// authFetch attaches the signed-in user's token (and the active company, when
|
|
@@ -274,6 +274,48 @@ body { margin: 0; }
|
|
|
274
274
|
.app-help { position: static; margin-top: 28px; width: auto; }
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
/* ── Forms ────────────────────────────────────────────────────────────── */
|
|
278
|
+
/* ── Configure UI ─────────────────────────────────────────────────────── */
|
|
279
|
+
.app-tabs { display: flex; gap: 4px; margin: 4px 0 18px; border-bottom: 1px solid var(--xeplr-border-primary); }
|
|
280
|
+
|
|
281
|
+
.app-tab {
|
|
282
|
+
padding: 8px 14px;
|
|
283
|
+
border: none;
|
|
284
|
+
border-bottom: 2px solid transparent;
|
|
285
|
+
background: none;
|
|
286
|
+
color: var(--xeplr-text-muted);
|
|
287
|
+
font: inherit;
|
|
288
|
+
cursor: pointer;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.app-tab.is-active { color: var(--xeplr-text-primary); border-bottom-color: var(--xeplr-accent); }
|
|
292
|
+
|
|
293
|
+
.app-forms-new { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; max-width: 820px; margin: 0 0 18px; }
|
|
294
|
+
|
|
295
|
+
.app-forms-field { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 220px; font-size: 12px; color: var(--xeplr-text-muted); }
|
|
296
|
+
|
|
297
|
+
.app-menu-label,
|
|
298
|
+
.app-forms-new input {
|
|
299
|
+
flex: 1;
|
|
300
|
+
padding: 9px 11px;
|
|
301
|
+
border-radius: 6px;
|
|
302
|
+
border: 1px solid var(--xeplr-input-border);
|
|
303
|
+
background: var(--xeplr-input-bg);
|
|
304
|
+
color: var(--xeplr-input-text);
|
|
305
|
+
font: inherit;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.app-forms-pending { color: var(--xeplr-warning); }
|
|
309
|
+
|
|
310
|
+
.app-notice {
|
|
311
|
+
margin: 0 0 14px;
|
|
312
|
+
padding: 10px 14px;
|
|
313
|
+
border-radius: 6px;
|
|
314
|
+
border: 1px solid var(--xeplr-success-border);
|
|
315
|
+
background: var(--xeplr-success-bg);
|
|
316
|
+
color: var(--xeplr-success);
|
|
317
|
+
}
|
|
318
|
+
|
|
277
319
|
/* ── Designer ─────────────────────────────────────────────────────────── */
|
|
278
320
|
/* The builder fills the space it is given, so it needs a height: the page
|
|
279
321
|
below the top bar, less .main's padding. */
|
|
@@ -298,3 +340,20 @@ body { margin: 0; }
|
|
|
298
340
|
border-radius: 8px;
|
|
299
341
|
overflow: hidden;
|
|
300
342
|
}
|
|
343
|
+
|
|
344
|
+
.app-help-prompt {
|
|
345
|
+
white-space: pre-wrap;
|
|
346
|
+
font-size: 12px;
|
|
347
|
+
line-height: 1.5;
|
|
348
|
+
padding: 10px 12px;
|
|
349
|
+
margin: 8px 0;
|
|
350
|
+
border-radius: 6px;
|
|
351
|
+
background: var(--xeplr-bg-tertiary);
|
|
352
|
+
border: 1px solid var(--xeplr-border-primary);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
.app-menu-hidden td { opacity: 0.5; }
|
|
356
|
+
|
|
357
|
+
.app-menu-shown { display: inline-flex; gap: 6px; align-items: center; }
|
|
358
|
+
|
|
359
|
+
.app-menu-settings .app-table { margin-bottom: 14px; }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// THE APP'S MENU KEYS — one place, used by App.jsx and Configure UI → Menu.
|
|
2
|
+
//
|
|
3
|
+
// A key is a `menus` row's name: what the side rail and the settings menu match
|
|
4
|
+
// on. It never changes. What people read is that row's LABEL, renamed in
|
|
5
|
+
// Configure UI → Menu — so no label is written anywhere in code.
|
|
6
|
+
|
|
7
|
+
/** A form added to the menu from Configure UI is the menu row "form:<its key>". */
|
|
8
|
+
export const FORM_MENU_PREFIX = 'form:'
|
|
9
|
+
|
|
10
|
+
/** Every key this app shows — Configure UI → Menu lists these, and the forms added to the menu. */
|
|
11
|
+
export const APP_MENU_KEYS = [
|
|
12
|
+
'Home',
|
|
13
|
+
'Tasks',
|
|
14
|
+
__MT_MENU_KEY__
|
|
15
|
+
'Admin',
|
|
16
|
+
'Configure UI'
|
|
17
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// CONFIGURE UI — Super Admin, from the settings menu (top right).
|
|
2
|
+
//
|
|
3
|
+
// Forms every UI in the app: make one, design it, publish it, open it, add it
|
|
4
|
+
// to the side rail
|
|
5
|
+
// Menu the side rail's items: rename, reorder, hide
|
|
6
|
+
//
|
|
7
|
+
// Configuration lives here, not in the side rail — the rail is for the pages
|
|
8
|
+
// people use.
|
|
9
|
+
import { useSearchParams } from 'react-router-dom'
|
|
10
|
+
import Forms from './Forms.jsx'
|
|
11
|
+
import MenuSettings from './MenuSettings.jsx'
|
|
12
|
+
|
|
13
|
+
const TABS = [
|
|
14
|
+
{ id: 'forms', label: 'Forms', Page: Forms },
|
|
15
|
+
{ id: 'menu', label: 'Menu', Page: MenuSettings }
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
export default function ConfigureUI() {
|
|
19
|
+
const [params, setParams] = useSearchParams()
|
|
20
|
+
const current = TABS.find((t) => t.id === params.get('tab')) || TABS[0]
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="app-page app-configure">
|
|
24
|
+
<h1>Configure UI</h1>
|
|
25
|
+
<div className="app-tabs" role="tablist">
|
|
26
|
+
{TABS.map((t) => (
|
|
27
|
+
<button
|
|
28
|
+
key={t.id}
|
|
29
|
+
type="button"
|
|
30
|
+
role="tab"
|
|
31
|
+
aria-selected={t.id === current.id}
|
|
32
|
+
className={'app-tab' + (t.id === current.id ? ' is-active' : '')}
|
|
33
|
+
onClick={() => setParams({ tab: t.id })}
|
|
34
|
+
>
|
|
35
|
+
{t.label}
|
|
36
|
+
</button>
|
|
37
|
+
))}
|
|
38
|
+
</div>
|
|
39
|
+
<current.Page />
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|