@xeplr/cli 0.1.3 → 0.1.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 +12 -3
- package/lib/generate/index.js +2 -4
- package/lib/plan.js +1 -0
- package/package.json +1 -1
- package/templates/CLAUDE.md +57 -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/0003_forms_menu.sql +19 -0
- package/templates/api/models/index.js +1 -1
- package/templates/api/package.json +2 -1
- 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 +1 -1
- package/templates/ui/src/App.jsx +9 -5
- package/templates/ui/src/api/factory.js +1 -1
- package/templates/ui/src/index.css +35 -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 +144 -0
- package/templates/ui/src/pages/Home.jsx +2 -2
- package/templates/ui/src/pages/Tasks.jsx +5 -3
- package/templates/ui/src/pages/TasksHelp.jsx +49 -35
- 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 ? [
|
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,57 @@
|
|
|
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 with an **icon** (a collapsed rail shows icons only).
|
|
26
|
+
7. **The menu row** — `api/migrations-auth/000N_<forms>_menu.sql` (next free number): copy `0002_tasks_menu.sql`, with the item's name spelled **exactly** as in `drawerItems` — an unknown name is dropped silently. Then, in `api/`: `npm run migrate:auth`, and restart the sign-in service (menus are cached for 10 minutes).
|
|
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 on the Forms page (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 on the **Forms** page (New form → design → Publish → Open). It gets no page, hooks or model files; add them with the steps above when it needs them.
|
|
41
|
+
|
|
42
|
+
### The prompt
|
|
43
|
+
|
|
44
|
+
> 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.
|
|
45
|
+
|
|
46
|
+
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".
|
|
47
|
+
|
|
48
|
+
## Rules
|
|
49
|
+
|
|
50
|
+
- **Records live in real tables, one column per field — never JSON.** A screen's design is the only JSON, in the `factory_screens` table.
|
|
51
|
+
- **Never rename a field that is already a column** — it would drop the column and its data. Add a new field instead.
|
|
52
|
+
- A published screen's design lives in the **database**. Editing its `.screen.json` changes nothing afterwards: publish the change on the Forms page.
|
|
53
|
+
- 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.
|
|
54
|
+
- Designing and publishing are **Super Admin only** (`api/routes/access.js`); every other route checks the permission catalog.
|
|
55
|
+
- The browser is not a security boundary: anything that must hold goes in server hooks.
|
|
56
|
+
- Hooks and models are **methods** calling `super` — never arrow-function properties.
|
|
57
|
+
- `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 — the
|
|
67
|
+
// Ones already published are left alone — the Forms page 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**
|
|
7
|
+
created and changed by **Publish** on the Forms page — see `screens/`.
|
|
8
8
|
|
|
9
9
|
Put here only tables no screen owns: lookup data, a table your own code writes.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- "Forms" — the drawer item for every form in the app: see them, make a new
|
|
2
|
+
-- one, design it, publish it (ui/src/pages/Forms.jsx).
|
|
3
|
+
--
|
|
4
|
+
-- SUPER ADMIN ONLY: publishing changes database tables. The API refuses
|
|
5
|
+
-- everyone else as well (api/routes/access.js); this keeps the menu honest
|
|
6
|
+
-- about it.
|
|
7
|
+
--
|
|
8
|
+
-- The name must match ui/src/App.jsx's drawerItems entry EXACTLY.
|
|
9
|
+
|
|
10
|
+
INSERT INTO "menus" (id, name, "menuGroup", "isPublic", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
|
|
11
|
+
SELECT encode(gen_random_bytes(12), 'hex'), 'Forms', '', false, true, '*', now(), now()
|
|
12
|
+
WHERE NOT EXISTS (SELECT 1 FROM "menus" WHERE name = 'Forms');
|
|
13
|
+
|
|
14
|
+
INSERT INTO "menuRolesMapping" (id, "roleId", "menuId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
|
|
15
|
+
SELECT encode(gen_random_bytes(12), 'hex'), r.id, m.id, true, '*', now(), now()
|
|
16
|
+
FROM "roles" r CROSS JOIN "menus" m
|
|
17
|
+
WHERE r.name = 'Super Admin'
|
|
18
|
+
AND m.name = 'Forms'
|
|
19
|
+
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 (Forms page, screens/) need no model: @xeplr/factory saves
|
|
6
6
|
// and reads them from the screen itself.
|
|
7
7
|
|
|
8
8
|
module.exports = {};
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"@xeplr/auth": "^1.1.2",
|
|
21
21
|
"@xeplr/base-apis": "^2.0.0",
|
|
22
22
|
"@xeplr/db": "^1.0.6",
|
|
23
|
-
"@xeplr/factory": "^1.0.
|
|
23
|
+
"@xeplr/factory": "^1.0.4",
|
|
24
|
+
"@xeplr/ui-factory": "^1.0.5",
|
|
24
25
|
"@xeplr/utils": "^1.0.8",
|
|
25
26
|
"dotenv": "^17.4.2",
|
|
26
27
|
"express": "^5.2.0"
|
|
@@ -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 on the Forms page) 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
|
|
9
|
+
// design: change it on the app's Forms page, 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;
|
package/templates/ui/src/App.jsx
CHANGED
|
@@ -3,7 +3,9 @@ import { Routes, Route, Outlet, Navigate, useNavigate } from 'react-router-dom'
|
|
|
3
3
|
import { authRoutes, authPath, NavPage, ProtectedRoute } from '@xeplr/ui-account'
|
|
4
4
|
import Home from './pages/Home.jsx'
|
|
5
5
|
import Tasks from './pages/Tasks.jsx'
|
|
6
|
-
import
|
|
6
|
+
import Forms from './pages/Forms.jsx'
|
|
7
|
+
import FormDesigner from './pages/FormDesigner.jsx'
|
|
8
|
+
import FormRecords from './pages/FormRecords.jsx'
|
|
7
9
|
__MT_APP_UI_IMPORTS__
|
|
8
10
|
|
|
9
11
|
// The rail is ICON-ONLY when collapsed, so a drawer item without one is
|
|
@@ -17,7 +19,7 @@ const TasksIcon = (
|
|
|
17
19
|
</svg>
|
|
18
20
|
)
|
|
19
21
|
|
|
20
|
-
const
|
|
22
|
+
const FormsIcon = (
|
|
21
23
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
|
|
22
24
|
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
23
25
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
@@ -54,8 +56,8 @@ function Shell() {
|
|
|
54
56
|
const drawerItems = useMemo(() => [
|
|
55
57
|
{ name: 'Home', icon: HomeIcon, clickHandler: () => navigate('/home') },
|
|
56
58
|
{ name: 'Tasks', icon: TasksIcon, clickHandler: () => navigate('/tasks') },
|
|
57
|
-
//
|
|
58
|
-
{ name: '
|
|
59
|
+
// Super Admin only — the "Forms" menu (migrations-auth/0003).
|
|
60
|
+
{ name: 'Forms', icon: FormsIcon, clickHandler: () => navigate('/forms') },
|
|
59
61
|
__MT_DRAWER_ITEM__
|
|
60
62
|
], [navigate])
|
|
61
63
|
|
|
@@ -91,7 +93,9 @@ __MT_SELECT_ROUTE__
|
|
|
91
93
|
<Route path="/" element={<Navigate to="/home" replace />} />
|
|
92
94
|
<Route path="/home" element={<Home />} />
|
|
93
95
|
<Route path="/tasks" element={<Tasks />} />
|
|
94
|
-
<Route path="/
|
|
96
|
+
<Route path="/forms" element={<Forms />} />
|
|
97
|
+
<Route path="/forms/:form" element={<FormRecords />} />
|
|
98
|
+
<Route path="/forms/:form/design/:part" element={<FormDesigner />} />
|
|
95
99
|
</Route>
|
|
96
100
|
|
|
97
101
|
<Route path="*" element={<Navigate to="/auth/login" replace />} />
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// MODEL LAYER — every call the screens and the
|
|
1
|
+
// MODEL LAYER — every call the screens and the Forms pages 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,30 @@ body { margin: 0; }
|
|
|
274
274
|
.app-help { position: static; margin-top: 28px; width: auto; }
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
/* ── Forms ────────────────────────────────────────────────────────────── */
|
|
278
|
+
.app-forms-new { display: flex; gap: 8px; max-width: 640px; margin: 0 0 18px; }
|
|
279
|
+
|
|
280
|
+
.app-forms-new input {
|
|
281
|
+
flex: 1;
|
|
282
|
+
padding: 9px 11px;
|
|
283
|
+
border-radius: 6px;
|
|
284
|
+
border: 1px solid var(--xeplr-input-border);
|
|
285
|
+
background: var(--xeplr-input-bg);
|
|
286
|
+
color: var(--xeplr-input-text);
|
|
287
|
+
font: inherit;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.app-forms-pending { color: var(--xeplr-warning); }
|
|
291
|
+
|
|
292
|
+
.app-notice {
|
|
293
|
+
margin: 0 0 14px;
|
|
294
|
+
padding: 10px 14px;
|
|
295
|
+
border-radius: 6px;
|
|
296
|
+
border: 1px solid var(--xeplr-success-border);
|
|
297
|
+
background: var(--xeplr-success-bg);
|
|
298
|
+
color: var(--xeplr-success);
|
|
299
|
+
}
|
|
300
|
+
|
|
277
301
|
/* ── Designer ─────────────────────────────────────────────────────────── */
|
|
278
302
|
/* The builder fills the space it is given, so it needs a height: the page
|
|
279
303
|
below the top bar, less .main's padding. */
|
|
@@ -298,3 +322,14 @@ body { margin: 0; }
|
|
|
298
322
|
border-radius: 8px;
|
|
299
323
|
overflow: hidden;
|
|
300
324
|
}
|
|
325
|
+
|
|
326
|
+
.app-help-prompt {
|
|
327
|
+
white-space: pre-wrap;
|
|
328
|
+
font-size: 12px;
|
|
329
|
+
line-height: 1.5;
|
|
330
|
+
padding: 10px 12px;
|
|
331
|
+
margin: 8px 0;
|
|
332
|
+
border-radius: 6px;
|
|
333
|
+
background: var(--xeplr-bg-tertiary);
|
|
334
|
+
border: 1px solid var(--xeplr-border-primary);
|
|
335
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// THE TASK FORM — and what the task screens do in the browser.
|
|
2
|
+
//
|
|
3
|
+
// The form itself is the "task_edit" screen, designed on the Forms page. This
|
|
4
|
+
// module holds its HOOKS, used by this page, by the Tasks list and by the
|
|
5
|
+
// popup the list opens.
|
|
6
|
+
import { useEffect, useState } from 'react'
|
|
7
|
+
import { FactoryScreen, FactoryHooks } from '@xeplr/ui-factory'
|
|
8
|
+
import { factory } from '../api/factory.js'
|
|
9
|
+
|
|
10
|
+
// ── WHAT THE TASK SCREENS DO IN THE BROWSER ──────────────────────────────
|
|
11
|
+
// Every method runs the default (super). Change the ones you need; leave the
|
|
12
|
+
// rest as they are.
|
|
13
|
+
//
|
|
14
|
+
// before change the input, then call super
|
|
15
|
+
// after call super, then use or change what it returns
|
|
16
|
+
// override do not call super
|
|
17
|
+
//
|
|
18
|
+
// ctx.screen says which screen is calling ("task_list" or "task_edit").
|
|
19
|
+
// Keep them as methods (save(values, ctx) { … }) — super does not work in
|
|
20
|
+
// arrow functions. Rules that must hold belong in the server's hooks
|
|
21
|
+
// (api/screens/task/task.hooks.js).
|
|
22
|
+
export class TaskHooks extends FactoryHooks {
|
|
23
|
+
/** A list's rows (ctx.many), or the record Edit opens (ctx.id). */
|
|
24
|
+
get(ctx) {
|
|
25
|
+
return super.get(ctx)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Every autosave — keep it quick. Returns the saved record. */
|
|
29
|
+
save(values, ctx) {
|
|
30
|
+
return super.save(values, ctx)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A list row, after the person confirmed. */
|
|
34
|
+
delete(record, ctx) {
|
|
35
|
+
return super.delete(record, ctx)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Extra row buttons beside Edit / Delete: [{ label, onClick: (record, ctx) => … }]. ctx.refresh() reloads. */
|
|
39
|
+
actions(ctx) {
|
|
40
|
+
return super.actions(ctx)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const taskHooks = new TaskHooks()
|
|
45
|
+
|
|
46
|
+
/** The task form on a page of its own: <EditTask /> for a new task, <EditTask record={task} /> for one. */
|
|
47
|
+
export default function EditTask({ record }) {
|
|
48
|
+
const [screen, setScreen] = useState(null)
|
|
49
|
+
const [error, setError] = useState(null)
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
factory.loadScreen('task_edit').then(setScreen, (e) => setError(e.message))
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
if (error) return <div className="app-error">Could not load the task form — {error}</div>
|
|
56
|
+
return screen ? <FactoryScreen document={screen} record={record} {...factory.screenProps} hooks={taskHooks} /> : null
|
|
57
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// DESIGN ONE FORM — its add / edit form, or its list. Move and resize fields,
|
|
2
|
+
// add new ones, change labels, rules, fonts and colours.
|
|
3
|
+
//
|
|
4
|
+
// Edits are kept as a DRAFT automatically; nobody sees them until Publish,
|
|
5
|
+
// which also changes the table to match: a new field adds a column; removing
|
|
6
|
+
// one asks first, because its column and every value in it would be deleted.
|
|
7
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
8
|
+
import { useNavigate, useParams } from 'react-router-dom'
|
|
9
|
+
import { FactoryBuilder } from '@xeplr/ui-factory'
|
|
10
|
+
import { factory } from '../api/factory.js'
|
|
11
|
+
|
|
12
|
+
const PARTS = [
|
|
13
|
+
{ part: 'form', suffix: '_edit', label: 'Form' },
|
|
14
|
+
{ part: 'list', suffix: '_list', label: 'List' }
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
export default function FormDesigner() {
|
|
18
|
+
const { form, part } = useParams()
|
|
19
|
+
const navigate = useNavigate()
|
|
20
|
+
const screenKey = form + (part === 'list' ? '_list' : '_edit')
|
|
21
|
+
const [current, setCurrent] = useState(null) // { key, document, lockedNames }
|
|
22
|
+
const [error, setError] = useState(null)
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
let cancelled = false
|
|
26
|
+
setCurrent(null)
|
|
27
|
+
// The draft if there is one, else the published version — with the field
|
|
28
|
+
// names that are already columns, which cannot be renamed.
|
|
29
|
+
factory.loadDraft(screenKey).then(
|
|
30
|
+
(row) => { if (!cancelled) { setCurrent({ key: screenKey, document: row.document, lockedNames: row.lockedNames || [] }); setError(null) } },
|
|
31
|
+
(e) => { if (!cancelled) setError(e.message) }
|
|
32
|
+
)
|
|
33
|
+
return () => { cancelled = true }
|
|
34
|
+
}, [screenKey])
|
|
35
|
+
|
|
36
|
+
const publish = useCallback(async (doc, options) => {
|
|
37
|
+
const result = await factory.publish(doc, options)
|
|
38
|
+
const row = await factory.loadDraft(doc.id)
|
|
39
|
+
setCurrent((c) => (c && c.key === doc.id ? { ...c, lockedNames: row.lockedNames || [] } : c))
|
|
40
|
+
return result
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="app-designer">
|
|
45
|
+
<div className="app-designer-bar">
|
|
46
|
+
<button type="button" className="app-btn app-btn-quiet" onClick={() => navigate('/forms')}>← Forms</button>
|
|
47
|
+
<span className="app-designer-title">{current ? current.document.name : form}</span>
|
|
48
|
+
{PARTS.map((p) => (
|
|
49
|
+
<button
|
|
50
|
+
key={p.part}
|
|
51
|
+
type="button"
|
|
52
|
+
className={'app-btn app-btn-quiet' + ((part === 'list') === (p.part === 'list') ? ' is-active' : '')}
|
|
53
|
+
onClick={() => navigate('/forms/' + form + '/design/' + p.part)}
|
|
54
|
+
>
|
|
55
|
+
{p.label}
|
|
56
|
+
</button>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
{error && <div className="app-error">{error}</div>}
|
|
60
|
+
{current && (
|
|
61
|
+
<div className="app-designer-canvas">
|
|
62
|
+
<FactoryBuilder
|
|
63
|
+
key={current.key}
|
|
64
|
+
{...factory.builderProps}
|
|
65
|
+
document={current.document}
|
|
66
|
+
lockedNames={current.lockedNames}
|
|
67
|
+
onPublish={publish}
|
|
68
|
+
screens={PARTS.map((p) => ({ id: form + p.suffix, name: p.label }))}
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// USE ANY FORM — its published list, with New / Edit opening the form in a
|
|
2
|
+
// popup. Tasks.jsx is the same thing for one form, with a help panel beside it.
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { useParams } from 'react-router-dom'
|
|
5
|
+
import { FactoryScreen } from '@xeplr/ui-factory'
|
|
6
|
+
import { factory } from '../api/factory.js'
|
|
7
|
+
|
|
8
|
+
export default function FormRecords() {
|
|
9
|
+
const { form } = useParams()
|
|
10
|
+
const [screen, setScreen] = useState(null)
|
|
11
|
+
const [error, setError] = useState(null)
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
setScreen(null)
|
|
15
|
+
factory.loadScreen(form + '_list').then((doc) => { setScreen(doc); setError(null) }, (e) => setError(e.message))
|
|
16
|
+
}, [form])
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="app-page">
|
|
20
|
+
{error && <div className="app-error">Could not open this form — {error}</div>}
|
|
21
|
+
{screen && <FactoryScreen key={form} document={screen} {...factory.screenProps} />}
|
|
22
|
+
</div>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// FORMS — every form in the app, for Super Admin: see them, make a new one,
|
|
2
|
+
// design it, publish it, open it.
|
|
3
|
+
//
|
|
4
|
+
// A form is two screens: a LIST of its records, and the add / edit FORM the
|
|
5
|
+
// list opens in a popup. Records live in an ordinary table named for the form
|
|
6
|
+
// ("farming_departments"), created when the form is first published.
|
|
7
|
+
//
|
|
8
|
+
// Claude can make forms too (api/screens/, `npx xeplr-factory screens`); this
|
|
9
|
+
// page is the same thing by hand.
|
|
10
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
11
|
+
import { useNavigate } from 'react-router-dom'
|
|
12
|
+
import { factory } from '../api/factory.js'
|
|
13
|
+
|
|
14
|
+
/** listScreens rows → one entry per form: { key, name, source, list, edit }. */
|
|
15
|
+
function groupForms(rows) {
|
|
16
|
+
const byKey = new Map()
|
|
17
|
+
rows.forEach((row) => {
|
|
18
|
+
const m = /^(.*)_(list|edit)$/.exec(row.screenKey)
|
|
19
|
+
const key = m ? m[1] : row.screenKey
|
|
20
|
+
const entry = byKey.get(key) || { key, name: null, source: null, list: null, edit: null }
|
|
21
|
+
if (m) entry[m[2]] = row
|
|
22
|
+
else entry.edit = row
|
|
23
|
+
if (m && m[2] === 'list') entry.name = row.name
|
|
24
|
+
entry.name = entry.name || row.name
|
|
25
|
+
entry.source = entry.source || row.source
|
|
26
|
+
byKey.set(key, entry)
|
|
27
|
+
})
|
|
28
|
+
return [...byKey.values()].sort((a, b) => String(a.name).localeCompare(String(b.name)))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function state(screen) {
|
|
32
|
+
if (!screen) return { label: '—', unpublished: false }
|
|
33
|
+
if (screen.version === null) return { label: 'Not published yet', unpublished: true }
|
|
34
|
+
return { label: 'v' + screen.version + (screen.hasDraft ? ' · unpublished changes' : ''), unpublished: screen.hasDraft }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default function Forms() {
|
|
38
|
+
const navigate = useNavigate()
|
|
39
|
+
const [forms, setForms] = useState(null)
|
|
40
|
+
const [error, setError] = useState(null)
|
|
41
|
+
const [notice, setNotice] = useState(null)
|
|
42
|
+
const [name, setName] = useState('')
|
|
43
|
+
const [busy, setBusy] = useState(null)
|
|
44
|
+
|
|
45
|
+
const load = useCallback(() => {
|
|
46
|
+
factory.listScreens().then((rows) => { setForms(groupForms(rows)); setError(null) }, (e) => setError(e.message))
|
|
47
|
+
}, [])
|
|
48
|
+
useEffect(load, [load])
|
|
49
|
+
|
|
50
|
+
const create = async (e) => {
|
|
51
|
+
e.preventDefault()
|
|
52
|
+
setBusy('new')
|
|
53
|
+
try {
|
|
54
|
+
const made = await factory.createEntity({ entity: name })
|
|
55
|
+
// Straight into its form, to add the fields.
|
|
56
|
+
navigate('/forms/' + made.edit.replace(/_edit$/, '') + '/design/form')
|
|
57
|
+
} catch (err) {
|
|
58
|
+
setError(err.message)
|
|
59
|
+
setBusy(null)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The form first — publishing it creates or changes the table — then the list.
|
|
64
|
+
const publish = async (form) => {
|
|
65
|
+
setBusy(form.key)
|
|
66
|
+
setNotice(null)
|
|
67
|
+
try {
|
|
68
|
+
for (const screen of [form.edit, form.list]) {
|
|
69
|
+
if (!screen || (screen.version !== null && !screen.hasDraft)) continue
|
|
70
|
+
const row = await factory.loadDraft(screen.screenKey)
|
|
71
|
+
await factory.publish(row.document)
|
|
72
|
+
}
|
|
73
|
+
setNotice('Published "' + form.name + '"')
|
|
74
|
+
setError(null)
|
|
75
|
+
load()
|
|
76
|
+
} catch (err) {
|
|
77
|
+
// Removing a field drops its column — that is confirmed in the designer,
|
|
78
|
+
// where the columns and their values are shown.
|
|
79
|
+
setError(err.confirm
|
|
80
|
+
? 'Publishing "' + form.name + '" would remove columns and their data — open Design form and publish there to see what, and confirm.'
|
|
81
|
+
: err.message)
|
|
82
|
+
} finally {
|
|
83
|
+
setBusy(null)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className="app-page app-forms">
|
|
89
|
+
<h1>Forms</h1>
|
|
90
|
+
<p className="app-page-lead">
|
|
91
|
+
Every form in the app. A new one starts with a Name field — add the rest in its design, then publish to create its table.
|
|
92
|
+
</p>
|
|
93
|
+
|
|
94
|
+
<form className="app-forms-new" onSubmit={create}>
|
|
95
|
+
<input
|
|
96
|
+
value={name}
|
|
97
|
+
onChange={(e) => setName(e.target.value)}
|
|
98
|
+
placeholder='New form — one record is a… e.g. "farming department"'
|
|
99
|
+
aria-label="New form name"
|
|
100
|
+
/>
|
|
101
|
+
<button type="submit" className="app-btn" disabled={!name.trim() || busy === 'new'}>
|
|
102
|
+
{busy === 'new' ? 'Creating…' : 'New form'}
|
|
103
|
+
</button>
|
|
104
|
+
</form>
|
|
105
|
+
|
|
106
|
+
{error && <div className="app-error">{error}</div>}
|
|
107
|
+
{notice && <div className="app-notice">{notice}</div>}
|
|
108
|
+
|
|
109
|
+
{forms && forms.length === 0 && <p className="app-sub">No forms yet.</p>}
|
|
110
|
+
{forms && forms.length > 0 && (
|
|
111
|
+
<table className="app-table">
|
|
112
|
+
<thead>
|
|
113
|
+
<tr><th>Name</th><th>Table</th><th>Form</th><th>List</th><th /></tr>
|
|
114
|
+
</thead>
|
|
115
|
+
<tbody>
|
|
116
|
+
{forms.map((form) => {
|
|
117
|
+
const edit = state(form.edit)
|
|
118
|
+
const list = state(form.list)
|
|
119
|
+
const canOpen = form.list && form.list.version !== null
|
|
120
|
+
return (
|
|
121
|
+
<tr key={form.key}>
|
|
122
|
+
<td><strong>{form.name || form.key}</strong><div className="app-sub">{form.key}</div></td>
|
|
123
|
+
<td><code>{form.source || '—'}</code></td>
|
|
124
|
+
<td className={edit.unpublished ? 'app-forms-pending' : undefined}>{edit.label}</td>
|
|
125
|
+
<td className={list.unpublished ? 'app-forms-pending' : undefined}>{list.label}</td>
|
|
126
|
+
<td className="app-row-actions">
|
|
127
|
+
<button className="app-btn app-btn-quiet" disabled={!canOpen} title={canOpen ? '' : 'Publish it first'} onClick={() => navigate('/forms/' + form.key)}>Open</button>
|
|
128
|
+
{form.edit && <button className="app-btn app-btn-quiet" onClick={() => navigate('/forms/' + form.key + '/design/form')}>Design form</button>}
|
|
129
|
+
{form.list && <button className="app-btn app-btn-quiet" onClick={() => navigate('/forms/' + form.key + '/design/list')}>Design list</button>}
|
|
130
|
+
{(edit.unpublished || list.unpublished) && (
|
|
131
|
+
<button className="app-btn" disabled={busy === form.key} onClick={() => publish(form)}>
|
|
132
|
+
{busy === form.key ? 'Publishing…' : 'Publish'}
|
|
133
|
+
</button>
|
|
134
|
+
)}
|
|
135
|
+
</td>
|
|
136
|
+
</tr>
|
|
137
|
+
)
|
|
138
|
+
})}
|
|
139
|
+
</tbody>
|
|
140
|
+
</table>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
@@ -59,8 +59,8 @@ export default function Home() {
|
|
|
59
59
|
sample logo, in the top bar and the side rail.
|
|
60
60
|
</li>
|
|
61
61
|
<li>
|
|
62
|
-
<strong>
|
|
63
|
-
|
|
62
|
+
<strong>Forms</strong>, in the side rail — make a new form, or change the
|
|
63
|
+
sample task screens. Publish creates or changes the table too.
|
|
64
64
|
</li>
|
|
65
65
|
<li>
|
|
66
66
|
<code>src/App.jsx</code> — add your own pages, and the side-rail items
|
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
// where it saves itself as you type; there is no Save button.
|
|
3
3
|
//
|
|
4
4
|
// Nothing about tasks is written in this file. The list and the form are
|
|
5
|
-
// SCREENS, designed
|
|
6
|
-
// loads the published "task_list" and hands it the calls it needs
|
|
5
|
+
// SCREENS, designed on the Forms page and stored by the API; this page only
|
|
6
|
+
// loads the published "task_list" and hands it the calls it needs — and the
|
|
7
|
+
// task hooks (EditTask.jsx), which the popup uses too.
|
|
7
8
|
import { useEffect, useState } from 'react'
|
|
8
9
|
import { FactoryScreen } from '@xeplr/ui-factory'
|
|
9
10
|
import { factory } from '../api/factory.js'
|
|
10
11
|
import TasksHelp from './TasksHelp.jsx'
|
|
12
|
+
import { taskHooks } from './EditTask.jsx'
|
|
11
13
|
|
|
12
14
|
export default function Tasks() {
|
|
13
15
|
const [screen, setScreen] = useState(null)
|
|
@@ -22,7 +24,7 @@ export default function Tasks() {
|
|
|
22
24
|
<div className="app-page-main">
|
|
23
25
|
{error && <div className="app-error">Could not load the task list — {error}</div>}
|
|
24
26
|
{/* loadScreen lets the list's Edit / New fetch "task_edit" when first opened. */}
|
|
25
|
-
{screen && <FactoryScreen document={screen} {...factory.screenProps} />}
|
|
27
|
+
{screen && <FactoryScreen document={screen} {...factory.screenProps} hooks={taskHooks} />}
|
|
26
28
|
</div>
|
|
27
29
|
<TasksHelp />
|
|
28
30
|
</div>
|
|
@@ -1,11 +1,41 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
|
|
1
3
|
// The panel beside the sample. It is here so the first thing anybody builds is
|
|
2
4
|
// explained where they are looking, rather than in a document they have to go
|
|
3
5
|
// and find.
|
|
6
|
+
|
|
7
|
+
// The same prompt as in CLAUDE.md. Replace the form and its fields.
|
|
8
|
+
const PROMPT = '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.'
|
|
9
|
+
|
|
4
10
|
export default function TasksHelp() {
|
|
11
|
+
const [copied, setCopied] = useState(false)
|
|
12
|
+
const copy = () => {
|
|
13
|
+
navigator.clipboard.writeText(PROMPT).then(() => {
|
|
14
|
+
setCopied(true)
|
|
15
|
+
setTimeout(() => setCopied(false), 2000)
|
|
16
|
+
}, () => {})
|
|
17
|
+
}
|
|
18
|
+
|
|
5
19
|
return (
|
|
6
20
|
<aside className="app-help">
|
|
7
21
|
<div className="app-help-badge">Sample</div>
|
|
8
|
-
<h2>
|
|
22
|
+
<h2>Create your own UI with Claude</h2>
|
|
23
|
+
<p>
|
|
24
|
+
Open Claude in this project's folder (the one with <code>api</code> and{' '}
|
|
25
|
+
<code>ui</code>) and give it this — change the name and the fields:
|
|
26
|
+
</p>
|
|
27
|
+
<pre className="app-help-prompt">{PROMPT}</pre>
|
|
28
|
+
<button type="button" className="app-btn app-btn-quiet" onClick={copy}>
|
|
29
|
+
{copied ? 'Copied' : 'Copy prompt'}
|
|
30
|
+
</button>
|
|
31
|
+
<p>
|
|
32
|
+
Add behaviour in the same message if you want it — "a Mark active button
|
|
33
|
+
on each row", "Region is required when Area is over 100". When Claude is
|
|
34
|
+
done, restart the sign-in service and the API: the menu item appears and
|
|
35
|
+
the table is created.
|
|
36
|
+
</p>
|
|
37
|
+
|
|
38
|
+
<h3>This page is an example</h3>
|
|
9
39
|
<p>
|
|
10
40
|
A task has a title, a status, a due date and a description. Neither the
|
|
11
41
|
list nor the form is written by hand — both are <strong>screens</strong>,
|
|
@@ -15,44 +45,28 @@ export default function TasksHelp() {
|
|
|
15
45
|
|
|
16
46
|
<h3>Change it</h3>
|
|
17
47
|
<p>
|
|
18
|
-
Open <strong>
|
|
19
|
-
|
|
20
|
-
|
|
48
|
+
Open <strong>Forms</strong> in the side rail and{' '}
|
|
49
|
+
<strong>Design form</strong> on Tasks. Add a field, move one, change a
|
|
50
|
+
label or a colour — then <strong>Publish</strong>. The table gains its
|
|
51
|
+
new column as you publish.
|
|
21
52
|
</p>
|
|
22
53
|
|
|
23
|
-
<h3>
|
|
24
|
-
<
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
</li>
|
|
30
|
-
<li>
|
|
31
|
-
<strong>Make its screens</strong>
|
|
32
|
-
<code>npx xeplr-factory screens screens/project/project.entity.json -o screens/project</code>
|
|
33
|
-
<span>From the api folder. Then list them in <code>api/screens/index.js</code>.</span>
|
|
34
|
-
</li>
|
|
35
|
-
<li>
|
|
36
|
-
<strong>The page</strong>
|
|
37
|
-
<code>ui/src/pages/Projects.jsx</code>
|
|
38
|
-
<span>Copy <code>Tasks.jsx</code> and load <code>project_list</code>.</span>
|
|
39
|
-
</li>
|
|
40
|
-
<li>
|
|
41
|
-
<strong>The menu entry</strong>
|
|
42
|
-
<code>api/migrations-auth/000N_projects_menu.sql</code>
|
|
43
|
-
<span>
|
|
44
|
-
The side-rail item appears only if its name exists in the database,
|
|
45
|
-
spelled exactly the same.
|
|
46
|
-
</span>
|
|
47
|
-
</li>
|
|
48
|
-
</ol>
|
|
49
|
-
<p>Restart the API and the new table is created for you.</p>
|
|
54
|
+
<h3>Or by hand</h3>
|
|
55
|
+
<p>
|
|
56
|
+
<strong>Forms → New form</strong>: name it, design it, publish it, open
|
|
57
|
+
it — no code. The full steps for a form with its own page, hooks and
|
|
58
|
+
model are in <code>CLAUDE.md</code>, under "Create a new UI".
|
|
59
|
+
</p>
|
|
50
60
|
|
|
51
61
|
<h3>More than saving</h3>
|
|
52
62
|
<p>
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
63
|
+
In the browser — a value added before saving, rows filtered for display,
|
|
64
|
+
an extra button on each row: <code>ui/src/pages/EditTask.jsx</code>.
|
|
65
|
+
Every method calls <code>super</code>; change the one you need.
|
|
66
|
+
</p>
|
|
67
|
+
<p>
|
|
68
|
+
On the server — a check against other records, an email afterwards, a
|
|
69
|
+
list only managers see: <code>api/screens/task/task.hooks.js</code>.
|
|
56
70
|
</p>
|
|
57
71
|
|
|
58
72
|
<h3>Two things that catch people</h3>
|
|
@@ -60,7 +74,7 @@ export default function TasksHelp() {
|
|
|
60
74
|
<li>
|
|
61
75
|
Once a screen is published, the <em>database</em> holds its design.
|
|
62
76
|
Editing its <code>.screen.json</code> afterwards changes nothing — use
|
|
63
|
-
|
|
77
|
+
Forms.
|
|
64
78
|
</li>
|
|
65
79
|
<li>
|
|
66
80
|
A new menu entry is remembered for ten minutes. Restart the sign-in
|
|
@@ -18,6 +18,7 @@ var { respond } = require('@xeplr/utils/lib/response');
|
|
|
18
18
|
var { HTTP, STATUS } = require('@xeplr/utils/isomorphic');
|
|
19
19
|
var authLib = require('@xeplr/auth');
|
|
20
20
|
var tenancy = require('../tenancy');
|
|
21
|
+
var { isSuperAdmin } = require('./access');
|
|
21
22
|
var appDatabase = require('../db/setup');
|
|
22
23
|
|
|
23
24
|
// The sign-in database, read directly — this app does not run the sign-in
|
|
@@ -29,11 +30,6 @@ var auth = authLib.attach();
|
|
|
29
30
|
var CREATOR_ROLE = { 1: 'CompanyAdmin' };
|
|
30
31
|
var DEEPER_ROLE = 'Creator';
|
|
31
32
|
|
|
32
|
-
function isSuperAdmin(req) {
|
|
33
|
-
var roles = (req.access && req.access.roles) || (req.user && req.user.roles) || [];
|
|
34
|
-
return roles.indexOf('Super Admin') !== -1;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
33
|
var membership = null;
|
|
38
34
|
|
|
39
35
|
/**
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
-- "Designer" — the drawer item for designing screens (ui/src/pages/Designer.jsx).
|
|
2
|
-
--
|
|
3
|
-
-- NOT public: Publish changes database tables, so only the roles that hold
|
|
4
|
-
-- @xeplr/factory's design permission see the item. The API refuses everyone
|
|
5
|
-
-- else anyway ("Access denied: Publish factory screen"); this keeps the menu
|
|
6
|
-
-- honest about it.
|
|
7
|
-
--
|
|
8
|
-
-- The name must match ui/src/App.jsx's drawerItems entry EXACTLY.
|
|
9
|
-
|
|
10
|
-
INSERT INTO "menus" (id, name, "menuGroup", "isPublic", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
|
|
11
|
-
SELECT encode(gen_random_bytes(12), 'hex'), 'Designer', '', false, true, '*', now(), now()
|
|
12
|
-
WHERE NOT EXISTS (SELECT 1 FROM "menus" WHERE name = 'Designer');
|
|
13
|
-
|
|
14
|
-
INSERT INTO "menuRolesMapping" (id, "roleId", "menuId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
|
|
15
|
-
SELECT encode(gen_random_bytes(12), 'hex'), r.id, m.id, true, '*', now(), now()
|
|
16
|
-
FROM "roles" r CROSS JOIN "menus" m
|
|
17
|
-
WHERE r.name IN ('Super Admin', 'CompanyAdmin')
|
|
18
|
-
AND m.name = 'Designer'
|
|
19
|
-
AND NOT EXISTS (SELECT 1 FROM "menuRolesMapping" x WHERE x."roleId" = r.id AND x."menuId" = m.id);
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
// THE DESIGNER — change any screen: move and resize fields, add new ones,
|
|
2
|
-
// change labels, rules, fonts and colours.
|
|
3
|
-
//
|
|
4
|
-
// Your edits are kept as a DRAFT automatically. Nobody else sees them until
|
|
5
|
-
// you press Publish, which also changes the screen's database table to match:
|
|
6
|
-
// a new field adds a column; removing one asks first, because its column and
|
|
7
|
-
// every value in it would be deleted.
|
|
8
|
-
import { useCallback, useEffect, useState } from 'react'
|
|
9
|
-
import { FactoryBuilder } from '@xeplr/ui-factory'
|
|
10
|
-
import { factory } from '../api/factory.js'
|
|
11
|
-
|
|
12
|
-
export default function Designer() {
|
|
13
|
-
const [screens, setScreens] = useState([])
|
|
14
|
-
const [current, setCurrent] = useState(null) // { key, document, lockedNames }
|
|
15
|
-
const [error, setError] = useState(null)
|
|
16
|
-
|
|
17
|
-
useEffect(() => {
|
|
18
|
-
factory.listScreens().then((rows) => {
|
|
19
|
-
setScreens(rows)
|
|
20
|
-
if (rows.length) open(rows[0].screenKey)
|
|
21
|
-
}, (e) => setError(e.message))
|
|
22
|
-
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
23
|
-
|
|
24
|
-
const open = useCallback(async (key) => {
|
|
25
|
-
try {
|
|
26
|
-
// The draft if there is one, else the published version — with the field
|
|
27
|
-
// names that are already columns, which the Designer will not let you rename.
|
|
28
|
-
const row = await factory.loadDraft(key)
|
|
29
|
-
setCurrent({ key, document: row.document, lockedNames: row.lockedNames || [] })
|
|
30
|
-
setError(null)
|
|
31
|
-
} catch (e) {
|
|
32
|
-
setError(e.message)
|
|
33
|
-
}
|
|
34
|
-
}, [])
|
|
35
|
-
|
|
36
|
-
const publish = useCallback(async (doc, options) => {
|
|
37
|
-
const result = await factory.publish(doc, options)
|
|
38
|
-
// New columns are now locked names too.
|
|
39
|
-
const row = await factory.loadDraft(doc.id)
|
|
40
|
-
setCurrent((c) => (c && c.key === doc.id ? { ...c, lockedNames: row.lockedNames || [] } : c))
|
|
41
|
-
return result
|
|
42
|
-
}, [])
|
|
43
|
-
|
|
44
|
-
return (
|
|
45
|
-
<div className="app-designer">
|
|
46
|
-
<div className="app-designer-bar">
|
|
47
|
-
<span className="app-designer-title">Designer</span>
|
|
48
|
-
{screens.map((s) => (
|
|
49
|
-
<button
|
|
50
|
-
key={s.screenKey}
|
|
51
|
-
type="button"
|
|
52
|
-
className={'app-btn app-btn-quiet' + (current && current.key === s.screenKey ? ' is-active' : '')}
|
|
53
|
-
onClick={() => open(s.screenKey)}
|
|
54
|
-
>
|
|
55
|
-
{s.name || s.screenKey}
|
|
56
|
-
</button>
|
|
57
|
-
))}
|
|
58
|
-
</div>
|
|
59
|
-
{error && <div className="app-error">{error}</div>}
|
|
60
|
-
{current && (
|
|
61
|
-
<div className="app-designer-canvas">
|
|
62
|
-
<FactoryBuilder
|
|
63
|
-
key={current.key}
|
|
64
|
-
{...factory.builderProps}
|
|
65
|
-
document={current.document}
|
|
66
|
-
lockedNames={current.lockedNames}
|
|
67
|
-
onPublish={publish}
|
|
68
|
-
screens={screens.map((s) => ({ id: s.screenKey, name: s.name || s.screenKey }))}
|
|
69
|
-
/>
|
|
70
|
-
</div>
|
|
71
|
-
)}
|
|
72
|
-
</div>
|
|
73
|
-
)
|
|
74
|
-
}
|