@xeplr/cli 0.1.4 → 0.1.6
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 +64 -6
- package/lib/generate/index.js +2 -1
- package/package.json +1 -1
- package/templates/CLAUDE.md +7 -5
- package/templates/api/bin/www +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 +3 -3
- package/templates/api/routes/index.js +1 -1
- package/templates/api/screens/index.js +1 -1
- package/templates/ui/package.json +1 -1
- package/templates/ui/src/App.jsx +25 -13
- package/templates/ui/src/api/factory.js +1 -1
- package/templates/ui/src/index.css +25 -1
- package/templates/ui/src/menu.js +17 -0
- package/templates/ui/src/pages/ConfigureUI.jsx +42 -0
- package/templates/ui/src/pages/EditTask.jsx +1 -1
- package/templates/ui/src/pages/FormDesigner.jsx +2 -2
- package/templates/ui/src/pages/Forms.jsx +67 -26
- 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 +1 -1
- package/templates/ui/src/pages/TasksHelp.jsx +3 -3
- package/templates-tenancy/api/migrations-auth/0004_switch_menu.sql +2 -1
- package/templates/api/migrations-auth/0003_forms_menu.sql +0 -19
package/README.md
CHANGED
|
@@ -4,9 +4,11 @@ Creates a working xeplr application — an API, sign-in, and a UI — from a few
|
|
|
4
4
|
questions, installs it, and sets up its databases.
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
|
-
npx @xeplr/cli new myapp
|
|
7
|
+
npx @xeplr/cli@latest new myapp
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
+
`@latest` makes npx fetch the newest version rather than one it cached earlier.
|
|
11
|
+
|
|
10
12
|
## What you get
|
|
11
13
|
|
|
12
14
|
```
|
|
@@ -28,10 +30,15 @@ Already working, with nothing to wire up:
|
|
|
28
30
|
table. Neither is written by hand: both are screens
|
|
29
31
|
([`@xeplr/ui-factory`](https://www.npmjs.com/package/@xeplr/ui-factory)),
|
|
30
32
|
published when the API first starts.
|
|
31
|
-
- **
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
- **Configure UI** for Super Admin, in the settings menu (top right) — not in
|
|
34
|
+
the side rail, which is for the pages people use:
|
|
35
|
+
- **Forms**: every UI in the app. New form (a **label** people see and a
|
|
36
|
+
**key** that names its table), design its form and its list, publish (which
|
|
37
|
+
creates or changes its table), open it, **add it to the side rail** — no
|
|
38
|
+
code, no Claude needed.
|
|
39
|
+
- **Menu**: rename, reorder and hide the side rail's items.
|
|
40
|
+
|
|
41
|
+
Nobody else sees it, and the API refuses everyone else.
|
|
35
42
|
- **Multi-tenancy, if you ask for it** — companies (or companies and
|
|
36
43
|
workspaces, up to four levels). Every row is stamped with the one it was made
|
|
37
44
|
in, every read sees only its rows, membership is checked on every request,
|
|
@@ -47,6 +54,21 @@ Already working, with nothing to wire up:
|
|
|
47
54
|
> acres (number, at least 0), Started on (date), Notes (long text). Show Name,
|
|
48
55
|
> Region and Area in the list. Follow "Create a new UI" in CLAUDE.md.
|
|
49
56
|
|
|
57
|
+
## Rules the generated app follows
|
|
58
|
+
|
|
59
|
+
These are architecture decisions, not defaults to tune — each one exists because the alternative failed quietly.
|
|
60
|
+
|
|
61
|
+
| Rule | Where | Why |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| **Records live in real tables, one column per field — never JSON.** | `@xeplr/factory` | `select * from tasks` and plain-SQL reports must work. Only a screen's *design* is JSON (the `factory_screens` table). |
|
|
64
|
+
| **A key and a label, everywhere.** A form's key (`farming_department`) names its screens and table and never changes once published; its label is renamed any time. A menu item's key (`menus.name`, used by `drawerItems` / `settingsOverrides` in `ui/src/App.jsx` and `ui/src/menu.js`) is what code matches; its label is what the rail shows. | `@xeplr/auth`, `@xeplr/ui-account` | Code must never break because someone renamed something people read. No label is written in code. |
|
|
65
|
+
| **`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:`. |
|
|
66
|
+
| **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. |
|
|
67
|
+
| **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` | |
|
|
68
|
+
| **Every data address is under `/api`.** | `api/routes`, `ui/vite.config.js` | A page path and a data path must never collide. |
|
|
69
|
+
| **No guessed database names or connections.** | `api/development.env` | A wrong-but-present value starts cleanly and reads as empty data. |
|
|
70
|
+
| **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. |
|
|
71
|
+
|
|
50
72
|
## Before you start
|
|
51
73
|
|
|
52
74
|
- **Node.js** 18 or later
|
|
@@ -83,13 +105,49 @@ cd myapp/api && npm run start-api # your API — publishes the sample screens
|
|
|
83
105
|
cd myapp/ui && npm run dev # the app
|
|
84
106
|
```
|
|
85
107
|
|
|
86
|
-
Open the UI and sign in with the account you named.
|
|
108
|
+
Open the UI and sign in with the account you named. As Super Admin you will
|
|
109
|
+
see **Configure UI** in the settings menu, top right.
|
|
110
|
+
|
|
111
|
+
**Use a new project name for a new app.** The name decides the database names
|
|
112
|
+
(`<name>_auth`, `<name>_api`); an existing database is kept as it is, with its
|
|
113
|
+
accounts — the super admin you enter is created only in a fresh one.
|
|
87
114
|
|
|
88
115
|
`xeplr new myapp --no-install` only writes the files; then run `npm install` in
|
|
89
116
|
`api` and `ui`, and `npm run setup` in `api`, yourself. If set-up stops part
|
|
90
117
|
way (Postgres not running, say), the installer says which step and the command
|
|
91
118
|
to run again.
|
|
92
119
|
|
|
120
|
+
## Creating a new UI
|
|
121
|
+
|
|
122
|
+
Two ways, both in the running app's project:
|
|
123
|
+
|
|
124
|
+
- **No code** — Configure UI → Forms → New form → design → Publish → Add to menu.
|
|
125
|
+
- **With Claude** — open Claude in the project folder and give it this (it is
|
|
126
|
+
also beside the sample, with a Copy button):
|
|
127
|
+
|
|
128
|
+
> Create a new UI for farming departments. One record is a farming department
|
|
129
|
+
> with: Name (required), Region (dropdown: North, South, East, West), Area in
|
|
130
|
+
> acres (number, at least 0), Started on (date), Notes (long text). Show Name,
|
|
131
|
+
> Region and Area in the list. Follow "Create a new UI" in CLAUDE.md.
|
|
132
|
+
|
|
133
|
+
Claude follows `CLAUDE.md`: the screens and server hooks and model
|
|
134
|
+
(`npx xeplr-factory screens … --no-pages`), the page with its front-end hooks,
|
|
135
|
+
the route, the side-rail item and its menu row. Then restart the sign-in
|
|
136
|
+
service and the API — the API publishes the new screens and creates the table.
|
|
137
|
+
|
|
138
|
+
## Commands in a generated project
|
|
139
|
+
|
|
140
|
+
| Where | Command | Does |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `api/` | `npm run setup` | create both databases, the sign-in tables, permissions and menus |
|
|
143
|
+
| `api/` | `npm run start-auth` | the sign-in service |
|
|
144
|
+
| `api/` | `npm run start-api` | the API — runs migrations and publishes never-published screens on start |
|
|
145
|
+
| `api/` | `npm run migrate:auth` | apply new `migrations-auth/*.sql` (e.g. a new menu row) |
|
|
146
|
+
| `api/` | `npm run db:encrypt` | redo the encrypted database connection |
|
|
147
|
+
| `api/` | `npx xeplr-factory screens screens/<form>/<form>.entity.json -o screens/<form> --no-pages` | a form's screens, server hooks and model from its spec |
|
|
148
|
+
| `api/` | `npx xeplr-factory validate <screen>.json` | check a screen document |
|
|
149
|
+
| `ui/` | `npm run dev` | the app |
|
|
150
|
+
|
|
93
151
|
## Changing the database connection later
|
|
94
152
|
|
|
95
153
|
A password rotates, the database moves, or the first answer was wrong. From
|
package/lib/generate/index.js
CHANGED
|
@@ -94,7 +94,8 @@ function tenancySubstitutions(levels) {
|
|
|
94
94
|
|
|
95
95
|
// ui/src/App.jsx
|
|
96
96
|
__MT_APP_UI_IMPORTS__: on ? "import SelectScope, { ScopeGate } from './pages/SelectScope.jsx'" : '',
|
|
97
|
-
|
|
97
|
+
__MT_MENU_KEY__: on ? " 'Switch " + first.label.toLowerCase() + "'," : '',
|
|
98
|
+
__MT_DRAWER_ITEM__: on ? " { key: 'Switch " + first.label.toLowerCase() + "', icon: SwitchIcon, clickHandler: () => navigate('/select') }," : '',
|
|
98
99
|
__MT_SWITCH_ICON__: on ? [
|
|
99
100
|
'const SwitchIcon = (',
|
|
100
101
|
' <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"',
|
package/package.json
CHANGED
package/templates/CLAUDE.md
CHANGED
|
@@ -22,8 +22,8 @@ Create every file below — the hooks and the model run the defaults (`super`) u
|
|
|
22
22
|
3. **Register them** in `api/screens/index.js`: both screens in `documents` (edit before list), `<form>_edit: require('./<form>/<form>.hooks')` in `hooks`, `require('./<form>/<form>.model')` in `models`.
|
|
23
23
|
4. **The form's page, with the front-end hooks** — `ui/src/pages/Edit<Form>.jsx`: copy `EditTask.jsx`; rename `TaskHooks` → `<Form>Hooks`, `taskHooks` → `<form>Hooks`, `'task_edit'` → `'<form>_edit'`. Keep every method calling `super`.
|
|
24
24
|
5. **The list's page** — `ui/src/pages/<Forms>.jsx`: copy `Tasks.jsx`; load `'<form>_list'`, import `<form>Hooks` from `./Edit<Form>.jsx`, drop `TasksHelp`.
|
|
25
|
-
6. **Route and side-rail item** — `ui/src/App.jsx`: import the list page, add `<Route path="/<forms>" element={<Forms />} />` next to `/tasks`, and a `drawerItems` entry 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`,
|
|
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
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
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
29
|
|
|
@@ -31,13 +31,15 @@ Then change only what the request asks for:
|
|
|
31
31
|
|
|
32
32
|
| the request says… | change |
|
|
33
33
|
|---|---|
|
|
34
|
-
| a field, label, rule, dropdown, layout, colour | the screen JSON → validate → publish
|
|
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 | front-end hooks — `ui/src/pages/Edit<Form>.jsx` |
|
|
36
36
|
| must / only if / check against / email when / only managers see | server hooks — `api/screens/<form>/<form>.hooks.js` |
|
|
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
|
|
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.
|
|
41
43
|
|
|
42
44
|
### The prompt
|
|
43
45
|
|
|
@@ -49,7 +51,7 @@ Replace the bold parts. Add behaviour in the same message if you want it — "a
|
|
|
49
51
|
|
|
50
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.
|
|
51
53
|
- **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
|
|
54
|
+
- A published screen's design lives in the **database**. Editing its `.screen.json` changes nothing afterwards: publish the change in Configure UI → Forms.
|
|
53
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.
|
|
54
56
|
- Designing and publishing are **Super Admin only** (`api/routes/access.js`); every other route checks the permission catalog.
|
|
55
57
|
- The browser is not a security boundary: anything that must hold goes in server hooks.
|
package/templates/api/bin/www
CHANGED
|
@@ -64,7 +64,7 @@ async function start() {
|
|
|
64
64
|
await factory.init({ knex: knex, hooks: screens.hooks, models: screens.models });
|
|
65
65
|
|
|
66
66
|
// Screens never published yet are published now, creating their tables.
|
|
67
|
-
// Ones already published are left alone —
|
|
67
|
+
// Ones already published are left alone — Configure UI → Forms owns them.
|
|
68
68
|
var published = await factory.publishScreens(screens.documents);
|
|
69
69
|
if (published.published.length) console.log('[api] published screens: ' + published.published.join(', '));
|
|
70
70
|
|
|
@@ -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** 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 as forms (Forms
|
|
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,12 +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.
|
|
23
|
+
"@xeplr/factory": "^1.0.5",
|
|
24
24
|
"@xeplr/ui-factory": "^1.0.5",
|
|
25
|
-
"@xeplr/utils": "^1.0.
|
|
25
|
+
"@xeplr/utils": "^1.0.11",
|
|
26
26
|
"dotenv": "^17.4.2",
|
|
27
27
|
"express": "^5.2.0"
|
|
28
28
|
},
|
|
@@ -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
|
-
// Forms (tasks, and any made
|
|
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,7 +6,7 @@
|
|
|
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 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
|
//
|
package/templates/ui/src/App.jsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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'
|
|
7
8
|
import FormDesigner from './pages/FormDesigner.jsx'
|
|
8
9
|
import FormRecords from './pages/FormRecords.jsx'
|
|
9
10
|
__MT_APP_UI_IMPORTS__
|
|
@@ -40,26 +41,37 @@ const HomeIcon = (
|
|
|
40
41
|
const notifications = { count: 0, onClick: () => {} }
|
|
41
42
|
|
|
42
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.
|
|
43
46
|
const settingsOverrides = [
|
|
44
|
-
{
|
|
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' }
|
|
45
50
|
]
|
|
46
51
|
|
|
52
|
+
|
|
47
53
|
// Layout route: NavPage and the routed page are independent siblings, and this
|
|
48
54
|
// holds no state of its own — lifting page state up here would re-render the
|
|
49
55
|
// nav on every unrelated change.
|
|
50
56
|
function Shell() {
|
|
51
57
|
const navigate = useNavigate()
|
|
52
58
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
56
66
|
const drawerItems = useMemo(() => [
|
|
57
|
-
{
|
|
58
|
-
{
|
|
59
|
-
// Super Admin only — the "Forms" menu (migrations-auth/0003).
|
|
60
|
-
{ name: 'Forms', icon: FormsIcon, clickHandler: () => navigate('/forms') },
|
|
67
|
+
{ key: 'Home', icon: HomeIcon, clickHandler: () => navigate('/home') },
|
|
68
|
+
{ key: 'Tasks', icon: TasksIcon, clickHandler: () => navigate('/tasks') },
|
|
61
69
|
__MT_DRAWER_ITEM__
|
|
62
|
-
|
|
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])
|
|
63
75
|
|
|
64
76
|
return (
|
|
65
77
|
<div className="app">
|
|
@@ -93,9 +105,9 @@ __MT_SELECT_ROUTE__
|
|
|
93
105
|
<Route path="/" element={<Navigate to="/home" replace />} />
|
|
94
106
|
<Route path="/home" element={<Home />} />
|
|
95
107
|
<Route path="/tasks" element={<Tasks />} />
|
|
96
|
-
<Route path="/forms" element={<Forms />} />
|
|
97
108
|
<Route path="/forms/:form" element={<FormRecords />} />
|
|
98
|
-
<Route path="/
|
|
109
|
+
<Route path="/configure" element={<ConfigureUI />} />
|
|
110
|
+
<Route path="/configure/forms/:form/design/:part" element={<FormDesigner />} />
|
|
99
111
|
</Route>
|
|
100
112
|
|
|
101
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
|
|
@@ -275,8 +275,26 @@ body { margin: 0; }
|
|
|
275
275
|
}
|
|
276
276
|
|
|
277
277
|
/* ── Forms ────────────────────────────────────────────────────────────── */
|
|
278
|
-
|
|
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); }
|
|
279
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,
|
|
280
298
|
.app-forms-new input {
|
|
281
299
|
flex: 1;
|
|
282
300
|
padding: 9px 11px;
|
|
@@ -333,3 +351,9 @@ body { margin: 0; }
|
|
|
333
351
|
background: var(--xeplr-bg-tertiary);
|
|
334
352
|
border: 1px solid var(--xeplr-border-primary);
|
|
335
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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// THE TASK FORM — and what the task screens do in the browser.
|
|
2
2
|
//
|
|
3
|
-
// The form itself is the "task_edit" screen, designed
|
|
3
|
+
// The form itself is the "task_edit" screen, designed in Configure UI → Forms. This
|
|
4
4
|
// module holds its HOOKS, used by this page, by the Tasks list and by the
|
|
5
5
|
// popup the list opens.
|
|
6
6
|
import { useEffect, useState } from 'react'
|
|
@@ -43,14 +43,14 @@ export default function FormDesigner() {
|
|
|
43
43
|
return (
|
|
44
44
|
<div className="app-designer">
|
|
45
45
|
<div className="app-designer-bar">
|
|
46
|
-
<button type="button" className="app-btn app-btn-quiet" onClick={() => navigate('/
|
|
46
|
+
<button type="button" className="app-btn app-btn-quiet" onClick={() => navigate('/configure')}>← Configure UI</button>
|
|
47
47
|
<span className="app-designer-title">{current ? current.document.name : form}</span>
|
|
48
48
|
{PARTS.map((p) => (
|
|
49
49
|
<button
|
|
50
50
|
key={p.part}
|
|
51
51
|
type="button"
|
|
52
52
|
className={'app-btn app-btn-quiet' + ((part === 'list') === (p.part === 'list') ? ' is-active' : '')}
|
|
53
|
-
onClick={() => navigate('/forms/' + form + '/design/' + p.part)}
|
|
53
|
+
onClick={() => navigate('/configure/forms/' + form + '/design/' + p.part)}
|
|
54
54
|
>
|
|
55
55
|
{p.label}
|
|
56
56
|
</button>
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
// FORMS — every
|
|
2
|
-
//
|
|
1
|
+
// FORMS — every UI in the app (Configure UI → Forms). Make one, design it,
|
|
2
|
+
// publish it, open it, add it to the side rail.
|
|
3
3
|
//
|
|
4
|
-
// A form is two screens: a LIST of its records, and the add / edit FORM the
|
|
5
|
-
//
|
|
6
|
-
// (
|
|
4
|
+
// A form is two screens: a LIST of its records, and the add / edit FORM the list
|
|
5
|
+
// opens in a popup. Its KEY (farming_department) names the screens and the table
|
|
6
|
+
// (farming_departments) and cannot change once published; its LABEL is what
|
|
7
|
+
// people see, renamed any time in its design.
|
|
7
8
|
//
|
|
8
|
-
// Claude can make forms too
|
|
9
|
-
// page is the same thing by hand.
|
|
9
|
+
// Claude can make forms too — see "Create a new UI" in CLAUDE.md.
|
|
10
10
|
import { useCallback, useEffect, useState } from 'react'
|
|
11
11
|
import { useNavigate } from 'react-router-dom'
|
|
12
|
+
import { listMenuItems, addMenuItem, removeMenuItem, useAccess } from '@xeplr/ui-account'
|
|
12
13
|
import { factory } from '../api/factory.js'
|
|
14
|
+
import { FORM_MENU_PREFIX } from '../menu.js'
|
|
13
15
|
|
|
14
16
|
/** listScreens rows → one entry per form: { key, name, source, list, edit }. */
|
|
15
17
|
function groupForms(rows) {
|
|
@@ -34,26 +36,48 @@ function state(screen) {
|
|
|
34
36
|
return { label: 'v' + screen.version + (screen.hasDraft ? ' · unpublished changes' : ''), unpublished: screen.hasDraft }
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
/** "Farming departments" → "farming_department": a suggestion, editable. */
|
|
40
|
+
function suggestKey(label) {
|
|
41
|
+
const words = String(label).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().split(' ').filter(Boolean)
|
|
42
|
+
if (!words.length) return ''
|
|
43
|
+
const last = words[words.length - 1]
|
|
44
|
+
words[words.length - 1] = /ies$/.test(last) ? last.slice(0, -3) + 'y' : /(ss|us)$/.test(last) ? last : last.replace(/s$/, '')
|
|
45
|
+
const key = words.join('_')
|
|
46
|
+
return /^[a-z]/.test(key) ? key : 'form_' + key
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
export default function Forms() {
|
|
38
50
|
const navigate = useNavigate()
|
|
51
|
+
const { refreshAccess } = useAccess()
|
|
39
52
|
const [forms, setForms] = useState(null)
|
|
53
|
+
const [inMenu, setInMenu] = useState(new Set())
|
|
40
54
|
const [error, setError] = useState(null)
|
|
41
55
|
const [notice, setNotice] = useState(null)
|
|
42
|
-
const [
|
|
56
|
+
const [label, setLabel] = useState('')
|
|
57
|
+
const [key, setKey] = useState('')
|
|
58
|
+
const [keyEdited, setKeyEdited] = useState(false)
|
|
43
59
|
const [busy, setBusy] = useState(null)
|
|
44
60
|
|
|
45
61
|
const load = useCallback(() => {
|
|
46
62
|
factory.listScreens().then((rows) => { setForms(groupForms(rows)); setError(null) }, (e) => setError(e.message))
|
|
63
|
+
listMenuItems().then(
|
|
64
|
+
(items) => setInMenu(new Set(items.filter((m) => m.name.startsWith(FORM_MENU_PREFIX) && !m.isHidden).map((m) => m.name.slice(FORM_MENU_PREFIX.length)))),
|
|
65
|
+
() => {}
|
|
66
|
+
)
|
|
47
67
|
}, [])
|
|
48
68
|
useEffect(load, [load])
|
|
49
69
|
|
|
70
|
+
const onLabel = (value) => {
|
|
71
|
+
setLabel(value)
|
|
72
|
+
if (!keyEdited) setKey(suggestKey(value))
|
|
73
|
+
}
|
|
74
|
+
|
|
50
75
|
const create = async (e) => {
|
|
51
76
|
e.preventDefault()
|
|
52
77
|
setBusy('new')
|
|
53
78
|
try {
|
|
54
|
-
const made = await factory.createEntity({
|
|
55
|
-
|
|
56
|
-
navigate('/forms/' + made.edit.replace(/_edit$/, '') + '/design/form')
|
|
79
|
+
const made = await factory.createEntity({ key: key.trim(), label: label.trim() })
|
|
80
|
+
navigate('/configure/forms/' + made.key + '/design/form')
|
|
57
81
|
} catch (err) {
|
|
58
82
|
setError(err.message)
|
|
59
83
|
setBusy(null)
|
|
@@ -74,8 +98,6 @@ export default function Forms() {
|
|
|
74
98
|
setError(null)
|
|
75
99
|
load()
|
|
76
100
|
} 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
101
|
setError(err.confirm
|
|
80
102
|
? 'Publishing "' + form.name + '" would remove columns and their data — open Design form and publish there to see what, and confirm.'
|
|
81
103
|
: err.message)
|
|
@@ -84,21 +106,37 @@ export default function Forms() {
|
|
|
84
106
|
}
|
|
85
107
|
}
|
|
86
108
|
|
|
109
|
+
const toggleMenu = async (form) => {
|
|
110
|
+
setBusy(form.key)
|
|
111
|
+
try {
|
|
112
|
+
if (inMenu.has(form.key)) await removeMenuItem(FORM_MENU_PREFIX + form.key)
|
|
113
|
+
else await addMenuItem({ name: FORM_MENU_PREFIX + form.key, label: form.name })
|
|
114
|
+
await refreshAccess()
|
|
115
|
+
setNotice(inMenu.has(form.key) ? 'Removed "' + form.name + '" from the menu' : 'Added "' + form.name + '" to the menu — rename or move it in the Menu tab')
|
|
116
|
+
load()
|
|
117
|
+
} catch (err) {
|
|
118
|
+
setError(err.message)
|
|
119
|
+
} finally {
|
|
120
|
+
setBusy(null)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
87
124
|
return (
|
|
88
|
-
<div className="app-
|
|
89
|
-
<h1>Forms</h1>
|
|
125
|
+
<div className="app-forms">
|
|
90
126
|
<p className="app-page-lead">
|
|
91
|
-
Every
|
|
127
|
+
Every UI in the app. A new one starts with a Name field — add the rest in its design, then publish to create its table.
|
|
92
128
|
</p>
|
|
93
129
|
|
|
94
130
|
<form className="app-forms-new" onSubmit={create}>
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
onChange={(e) =>
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
131
|
+
<label className="app-forms-field">
|
|
132
|
+
<span>Label — what people see</span>
|
|
133
|
+
<input value={label} onChange={(e) => onLabel(e.target.value)} placeholder="Farming departments" />
|
|
134
|
+
</label>
|
|
135
|
+
<label className="app-forms-field">
|
|
136
|
+
<span>Key — names its table; fixed once published</span>
|
|
137
|
+
<input value={key} onChange={(e) => { setKey(e.target.value); setKeyEdited(true) }} placeholder="farming_department" />
|
|
138
|
+
</label>
|
|
139
|
+
<button type="submit" className="app-btn" disabled={!key.trim() || busy === 'new'}>
|
|
102
140
|
{busy === 'new' ? 'Creating…' : 'New form'}
|
|
103
141
|
</button>
|
|
104
142
|
</form>
|
|
@@ -110,7 +148,7 @@ export default function Forms() {
|
|
|
110
148
|
{forms && forms.length > 0 && (
|
|
111
149
|
<table className="app-table">
|
|
112
150
|
<thead>
|
|
113
|
-
<tr><th>
|
|
151
|
+
<tr><th>Label</th><th>Table</th><th>Form</th><th>List</th><th /></tr>
|
|
114
152
|
</thead>
|
|
115
153
|
<tbody>
|
|
116
154
|
{forms.map((form) => {
|
|
@@ -125,8 +163,11 @@ export default function Forms() {
|
|
|
125
163
|
<td className={list.unpublished ? 'app-forms-pending' : undefined}>{list.label}</td>
|
|
126
164
|
<td className="app-row-actions">
|
|
127
165
|
<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>}
|
|
166
|
+
{form.edit && <button className="app-btn app-btn-quiet" onClick={() => navigate('/configure/forms/' + form.key + '/design/form')}>Design form</button>}
|
|
167
|
+
{form.list && <button className="app-btn app-btn-quiet" onClick={() => navigate('/configure/forms/' + form.key + '/design/list')}>Design list</button>}
|
|
168
|
+
<button className="app-btn app-btn-quiet" disabled={!canOpen || busy === form.key} title={canOpen ? '' : 'Publish it first'} onClick={() => toggleMenu(form)}>
|
|
169
|
+
{inMenu.has(form.key) ? 'Remove from menu' : 'Add to menu'}
|
|
170
|
+
</button>
|
|
130
171
|
{(edit.unpublished || list.unpublished) && (
|
|
131
172
|
<button className="app-btn" disabled={busy === form.key} onClick={() => publish(form)}>
|
|
132
173
|
{busy === form.key ? 'Publishing…' : 'Publish'}
|
|
@@ -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>Configure UI</strong>, in the settings menu — every form (make one,
|
|
63
|
+
design it, publish it, add it to the menu) and the side rail's labels.
|
|
64
64
|
</li>
|
|
65
65
|
<li>
|
|
66
66
|
<code>src/App.jsx</code> — add your own pages, and the side-rail items
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// THE SIDE RAIL'S ITEMS (Configure UI → Menu) — rename, reorder, hide.
|
|
2
|
+
//
|
|
3
|
+
// Each item has a KEY, which the code matches on and which never changes here,
|
|
4
|
+
// and a LABEL, which is what people read. Leave the label empty to show the key.
|
|
5
|
+
// Saved for everyone at once; your own rail updates straight away.
|
|
6
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
7
|
+
import { listMenuItems, saveMenuItems, useAccess } from '@xeplr/ui-account'
|
|
8
|
+
import { APP_MENU_KEYS, FORM_MENU_PREFIX } from '../menu.js'
|
|
9
|
+
|
|
10
|
+
// The app's own items (ui/src/menu.js) and forms added to the menu. The rest —
|
|
11
|
+
// sign-in's pages (Login, Register …) and admin screens — only with "Show all".
|
|
12
|
+
const isAppItem = (name) => APP_MENU_KEYS.includes(name) || name.startsWith(FORM_MENU_PREFIX)
|
|
13
|
+
|
|
14
|
+
export default function MenuSettings() {
|
|
15
|
+
const { refreshAccess } = useAccess()
|
|
16
|
+
const [items, setItems] = useState(null)
|
|
17
|
+
const [error, setError] = useState(null)
|
|
18
|
+
const [notice, setNotice] = useState(null)
|
|
19
|
+
const [saving, setSaving] = useState(false)
|
|
20
|
+
const [showAll, setShowAll] = useState(false)
|
|
21
|
+
|
|
22
|
+
const load = useCallback(() => {
|
|
23
|
+
listMenuItems().then((rows) => { setItems(rows.map((r) => ({ ...r, label: r.label || '' }))); setError(null) }, (e) => setError(e.message))
|
|
24
|
+
}, [])
|
|
25
|
+
useEffect(load, [load])
|
|
26
|
+
|
|
27
|
+
const shown = (it) => showAll || isAppItem(it.name)
|
|
28
|
+
const change = (name, patch) => setItems((list) => list.map((it) => (it.name === name ? { ...it, ...patch } : it)))
|
|
29
|
+
// Swap with the next row ON SCREEN — entries filtered out keep their places.
|
|
30
|
+
const move = (name, by) => setItems((list) => {
|
|
31
|
+
const visible = list.filter(shown).map((it) => it.name)
|
|
32
|
+
const k = visible.indexOf(name) + by
|
|
33
|
+
if (k < 0 || k >= visible.length) return list
|
|
34
|
+
const a = list.findIndex((it) => it.name === name)
|
|
35
|
+
const b = list.findIndex((it) => it.name === visible[k])
|
|
36
|
+
const next = [...list]
|
|
37
|
+
;[next[a], next[b]] = [next[b], next[a]]
|
|
38
|
+
return next
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const save = async () => {
|
|
42
|
+
setSaving(true)
|
|
43
|
+
setNotice(null)
|
|
44
|
+
try {
|
|
45
|
+
// The order on screen becomes the order in the rail.
|
|
46
|
+
await saveMenuItems(items.map((it, i) => ({ name: it.name, label: it.label, sortOrder: i, isHidden: it.isHidden })))
|
|
47
|
+
await refreshAccess()
|
|
48
|
+
setNotice('Saved — the menu shows it now')
|
|
49
|
+
setError(null)
|
|
50
|
+
load()
|
|
51
|
+
} catch (err) {
|
|
52
|
+
setError(err.message)
|
|
53
|
+
} finally {
|
|
54
|
+
setSaving(false)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="app-menu-settings">
|
|
60
|
+
<p className="app-page-lead">
|
|
61
|
+
What the side rail and the settings menu show, and in what order. Add a form to the menu from the Forms tab.
|
|
62
|
+
</p>
|
|
63
|
+
<label className="app-menu-shown">
|
|
64
|
+
<input type="checkbox" checked={showAll} onChange={(e) => setShowAll(e.target.checked)} /> Show all menu entries, including sign-in and admin pages
|
|
65
|
+
</label>
|
|
66
|
+
{error && <div className="app-error">{error}</div>}
|
|
67
|
+
{notice && <div className="app-notice">{notice}</div>}
|
|
68
|
+
{items && (
|
|
69
|
+
<table className="app-table">
|
|
70
|
+
<thead>
|
|
71
|
+
<tr><th>Order</th><th>Label</th><th>Key</th><th>Shown</th></tr>
|
|
72
|
+
</thead>
|
|
73
|
+
<tbody>
|
|
74
|
+
{items.filter(shown).map((it, i, visible) => (
|
|
75
|
+
<tr key={it.name} className={it.isHidden ? 'app-menu-hidden' : undefined}>
|
|
76
|
+
<td className="app-row-actions">
|
|
77
|
+
<button type="button" className="app-btn app-btn-quiet" aria-label={'Move ' + it.shown + ' up'} disabled={i === 0} onClick={() => move(it.name, -1)}>↑</button>
|
|
78
|
+
<button type="button" className="app-btn app-btn-quiet" aria-label={'Move ' + it.shown + ' down'} disabled={i === visible.length - 1} onClick={() => move(it.name, 1)}>↓</button>
|
|
79
|
+
</td>
|
|
80
|
+
<td>
|
|
81
|
+
<input className="app-menu-label" value={it.label} placeholder={it.name} aria-label={'Label for ' + it.name} onChange={(e) => change(it.name, { label: e.target.value })} />
|
|
82
|
+
</td>
|
|
83
|
+
<td><code>{it.name}</code></td>
|
|
84
|
+
<td>
|
|
85
|
+
<label className="app-menu-shown">
|
|
86
|
+
<input type="checkbox" checked={!it.isHidden} onChange={(e) => change(it.name, { isHidden: !e.target.checked })} /> Shown
|
|
87
|
+
</label>
|
|
88
|
+
</td>
|
|
89
|
+
</tr>
|
|
90
|
+
))}
|
|
91
|
+
</tbody>
|
|
92
|
+
</table>
|
|
93
|
+
)}
|
|
94
|
+
{items && (
|
|
95
|
+
<button type="button" className="app-btn" disabled={saving} onClick={save}>{saving ? 'Saving…' : 'Save menu'}</button>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
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
|
|
5
|
+
// SCREENS, designed in Configure UI → Forms and stored by the API; this page only
|
|
6
6
|
// loads the published "task_list" and hands it the calls it needs — and the
|
|
7
7
|
// task hooks (EditTask.jsx), which the popup uses too.
|
|
8
8
|
import { useEffect, useState } from 'react'
|
|
@@ -45,7 +45,7 @@ export default function TasksHelp() {
|
|
|
45
45
|
|
|
46
46
|
<h3>Change it</h3>
|
|
47
47
|
<p>
|
|
48
|
-
Open <strong>
|
|
48
|
+
Open <strong>Configure UI</strong> in the settings menu (top right) and{' '}
|
|
49
49
|
<strong>Design form</strong> on Tasks. Add a field, move one, change a
|
|
50
50
|
label or a colour — then <strong>Publish</strong>. The table gains its
|
|
51
51
|
new column as you publish.
|
|
@@ -53,8 +53,8 @@ export default function TasksHelp() {
|
|
|
53
53
|
|
|
54
54
|
<h3>Or by hand</h3>
|
|
55
55
|
<p>
|
|
56
|
-
<strong>Forms → New form</strong>:
|
|
57
|
-
|
|
56
|
+
<strong>Configure UI → Forms → New form</strong>: label and key, design,
|
|
57
|
+
publish, add to menu — no code. The full steps for a form with its own page, hooks and
|
|
58
58
|
model are in <code>CLAUDE.md</code>, under "Create a new UI".
|
|
59
59
|
</p>
|
|
60
60
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
-- "__MT_SWITCH_NAME__" — the drawer item that goes back to the picker.
|
|
2
2
|
--
|
|
3
3
|
-- Public: everybody who signs in works inside one, so everybody can change it.
|
|
4
|
-
-- The name must match ui/src/App.jsx's drawerItems
|
|
4
|
+
-- The name is the KEY: it must match ui/src/App.jsx's drawerItems `key` EXACTLY.
|
|
5
|
+
-- What people read is the label, changed in Configure UI → Menu.
|
|
5
6
|
|
|
6
7
|
INSERT INTO "menus" (id, name, "menuGroup", "isPublic", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
|
|
7
8
|
SELECT encode(gen_random_bytes(12), 'hex'), '__MT_SWITCH_NAME__', '', true, true, '*', now(), now()
|
|
@@ -1,19 +0,0 @@
|
|
|
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);
|