@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 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
- - **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.
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
@@ -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
- __MT_DRAWER_ITEM__: on ? " { name: 'Switch " + first.label.toLowerCase() + "', icon: SwitchIcon, clickHandler: () => navigate('/select') }," : '',
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Creates a working xeplr application — API, sign-in and UI — from a few answers.",
5
5
  "license": "MIT",
6
6
  "author": "xeppie <admin@xeplr.com>",
@@ -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`, 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).
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 on the Forms page (or, never published yet, restart the API) |
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 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.
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 on the Forms page.
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.
@@ -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 — the Forms page owns them.
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** on the Forms page — see `screens/`.
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 entry EXACTLY.
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 page, screens/) need no model: @xeplr/factory saves
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.2",
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.4",
23
+ "@xeplr/factory": "^1.0.5",
24
24
  "@xeplr/ui-factory": "^1.0.5",
25
- "@xeplr/utils": "^1.0.8",
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 on the Forms page) need nothing here — @xeplr/factory
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 on the app's Forms page, where Publish changes the
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
  //
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@tanstack/react-table": "^8.21.3",
13
- "@xeplr/ui-account": "^1.0.6",
13
+ "@xeplr/ui-account": "^1.0.7",
14
14
  "@xeplr/ui-canvas": "^1.0.2",
15
15
  "@xeplr/ui-factory": "^1.0.5",
16
16
  "@xeplr/ui-table": "^1.0.6",
@@ -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 Forms from './pages/Forms.jsx'
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
- { name: 'Admin', path: authPath('userRoles') }
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
- // The drawer's catalog. A name here must match a seeded `menus` row —
54
- // @xeplr/ui-account drops unrecognised names SILENTLY, so a typo removes the
55
- // item with no error anywhere.
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
- { name: 'Home', icon: HomeIcon, clickHandler: () => navigate('/home') },
58
- { name: 'Tasks', icon: TasksIcon, clickHandler: () => navigate('/tasks') },
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
- ], [navigate])
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="/forms/:form/design/:part" element={<FormDesigner />} />
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 the Forms pages make, to the API's
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
- .app-forms-new { display: flex; gap: 8px; max-width: 640px; margin: 0 0 18px; }
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 on the Forms page. This
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('/forms')}>← Forms</button>
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 form in the app, for Super Admin: see them, make a new one,
2
- // design it, publish it, open it.
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
- // 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.
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 (api/screens/, `npx xeplr-factory screens`); this
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 [name, setName] = useState('')
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({ entity: name })
55
- // Straight into its form, to add the fields.
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-page app-forms">
89
- <h1>Forms</h1>
125
+ <div className="app-forms">
90
126
  <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.
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
- <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'}>
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>Name</th><th>Table</th><th>Form</th><th>List</th><th /></tr>
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>Forms</strong>, in the side rail — make a new form, or change the
63
- sample task screens. Publish creates or changes the table too.
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 on the Forms page and stored by the API; this page only
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>Forms</strong> in the side rail and{' '}
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>: name it, design it, publish it, open
57
- it — no code. The full steps for a form with its own page, hooks and
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 entry EXACTLY.
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);