@xeplr/cli 0.1.1 → 0.1.3

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.
Files changed (43) hide show
  1. package/README.md +47 -13
  2. package/bin/xeplr.js +10 -4
  3. package/lib/generate/index.js +131 -4
  4. package/lib/nextSteps.js +33 -18
  5. package/lib/plan.js +7 -0
  6. package/lib/questions.js +60 -1
  7. package/lib/setup.js +59 -0
  8. package/package.json +2 -1
  9. package/templates/api/app.js +20 -1
  10. package/templates/api/bin/www +18 -4
  11. package/templates/api/db/setup.js +1 -0
  12. package/templates/api/development.env.tpl +8 -2
  13. package/templates/api/migrations/README.md +9 -0
  14. package/templates/api/migrations-auth/0003_designer_menu.sql +19 -0
  15. package/templates/api/models/index.js +7 -6
  16. package/templates/api/package.json +5 -3
  17. package/templates/api/routes/index.js +6 -15
  18. package/templates/api/screens/index.js +32 -0
  19. package/templates/api/screens/task/task-edit.screen.json +103 -0
  20. package/templates/api/screens/task/task-list.screen.json +45 -0
  21. package/templates/api/screens/task/task.entity.json +10 -0
  22. package/templates/api/screens/task/task.hooks.js +37 -0
  23. package/templates/ui/package.json +4 -0
  24. package/templates/ui/src/App.jsx +18 -2
  25. package/templates/ui/src/api/factory.js +10 -0
  26. package/templates/ui/src/index.css +25 -0
  27. package/templates/ui/src/main.jsx +2 -0
  28. package/templates/ui/src/pages/Designer.jsx +74 -0
  29. package/templates/ui/src/pages/Home.jsx +4 -0
  30. package/templates/ui/src/pages/Tasks.jsx +18 -94
  31. package/templates/ui/src/pages/TasksHelp.jsx +35 -33
  32. package/templates/ui/vite.config.js +1 -1
  33. package/templates-tenancy/api/migrations/0001_tenants.sql +8 -0
  34. package/templates-tenancy/api/migrations-auth/0004_switch_menu.sql +10 -0
  35. package/templates-tenancy/api/routes/tenants.js +130 -0
  36. package/templates-tenancy/api/tenancy.js +12 -0
  37. package/templates-tenancy/ui/src/pages/SelectScope.css +45 -0
  38. package/templates-tenancy/ui/src/pages/SelectScope.jsx +154 -0
  39. package/templates-tenancy/ui/src/tenancy.js +3 -0
  40. package/templates/api/migrations/0001_tasks.sql +0 -23
  41. package/templates/api/models/Task.js +0 -48
  42. package/templates/ui/src/api/tasks.js +0 -34
  43. package/templates/ui/src/useTasksController.js +0 -83
@@ -1,105 +1,29 @@
1
- // DESIGN LAYER — presentation only. Every value and every handler comes from
2
- // the controller; there is no logic here to get out of step with it.
3
- import { useTasksController } from '../useTasksController.js'
1
+ // THE SAMPLE — a list of tasks. New and Edit open the task form in a popup,
2
+ // where it saves itself as you type; there is no Save button.
3
+ //
4
+ // Nothing about tasks is written in this file. The list and the form are
5
+ // SCREENS, designed in the Designer page and stored by the API; this page only
6
+ // loads the published "task_list" and hands it the calls it needs.
7
+ import { useEffect, useState } from 'react'
8
+ import { FactoryScreen } from '@xeplr/ui-factory'
9
+ import { factory } from '../api/factory.js'
4
10
  import TasksHelp from './TasksHelp.jsx'
5
11
 
6
- const STATUSES = [
7
- { value: 'todo', label: 'To do' },
8
- { value: 'in_progress', label: 'In progress' },
9
- { value: 'done', label: 'Done' }
10
- ]
11
-
12
12
  export default function Tasks() {
13
- const { tasks, form, setField, submit, edit, cancel, remove, editingId, busy, error } =
14
- useTasksController()
13
+ const [screen, setScreen] = useState(null)
14
+ const [error, setError] = useState(null)
15
+
16
+ useEffect(() => {
17
+ factory.loadScreen('task_list').then(setScreen, (e) => setError(e.message))
18
+ }, [])
15
19
 
16
20
  return (
17
21
  <div className="app-page app-page-split">
18
22
  <div className="app-page-main">
19
- <h1>Tasks</h1>
20
- <p className="app-page-lead">
21
- A sample form and list. One model, one migration, one line of routing.
22
- </p>
23
-
24
- {error && <div className="app-error">{error}</div>}
25
-
26
- <form className="app-form" onSubmit={submit}>
27
- <label className="app-field">
28
- <span>Title</span>
29
- <input
30
- value={form.title}
31
- onChange={(e) => setField('title', e.target.value)}
32
- placeholder="What needs doing?"
33
- required
34
- />
35
- </label>
36
-
37
- <label className="app-field">
38
- <span>Description</span>
39
- <textarea
40
- rows={2}
41
- value={form.description}
42
- onChange={(e) => setField('description', e.target.value)}
43
- placeholder="Optional"
44
- />
45
- </label>
46
-
47
- <div className="app-field-row">
48
- <label className="app-field">
49
- <span>Status</span>
50
- <select value={form.status} onChange={(e) => setField('status', e.target.value)}>
51
- {STATUSES.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
52
- </select>
53
- </label>
54
-
55
- <label className="app-field">
56
- <span>Due date</span>
57
- <input
58
- type="date"
59
- value={form.dueDate}
60
- onChange={(e) => setField('dueDate', e.target.value)}
61
- />
62
- </label>
63
- </div>
64
-
65
- <div className="app-form-actions">
66
- <button type="submit" className="app-btn" disabled={busy}>
67
- {editingId ? 'Save changes' : 'Add task'}
68
- </button>
69
- {editingId && (
70
- <button type="button" className="app-btn app-btn-quiet" onClick={cancel}>
71
- Cancel
72
- </button>
73
- )}
74
- </div>
75
- </form>
76
-
77
- <table className="app-table">
78
- <thead>
79
- <tr><th>Title</th><th>Status</th><th>Due</th><th /></tr>
80
- </thead>
81
- <tbody>
82
- {tasks.length === 0 && (
83
- <tr><td colSpan={4} className="app-empty">No tasks yet. Add one above.</td></tr>
84
- )}
85
- {tasks.map((t) => (
86
- <tr key={t.id} className={editingId === t.id ? 'is-editing' : undefined}>
87
- <td>
88
- <strong>{t.title}</strong>
89
- {t.description && <div className="app-sub">{t.description}</div>}
90
- </td>
91
- <td><span className={'app-tag is-' + t.status}>{t.status.replace('_', ' ')}</span></td>
92
- <td>{t.dueDate ? String(t.dueDate).slice(0, 10) : '—'}</td>
93
- <td className="app-row-actions">
94
- <button className="app-btn app-btn-quiet" onClick={() => edit(t)}>Edit</button>
95
- <button className="app-btn app-btn-quiet" onClick={() => remove(t.id)}>Delete</button>
96
- </td>
97
- </tr>
98
- ))}
99
- </tbody>
100
- </table>
23
+ {error && <div className="app-error">Could not load the task list — {error}</div>}
24
+ {/* loadScreen lets the list's Edit / New fetch "task_edit" when first opened. */}
25
+ {screen && <FactoryScreen document={screen} {...factory.screenProps} />}
101
26
  </div>
102
-
103
27
  <TasksHelp />
104
28
  </div>
105
29
  )
@@ -1,64 +1,66 @@
1
- // The panel beside the sample form. It is here so the first thing anybody
2
- // builds is explained where they are looking, rather than in a document they
3
- // have to go and find.
1
+ // The panel beside the sample. It is here so the first thing anybody builds is
2
+ // explained where they are looking, rather than in a document they have to go
3
+ // and find.
4
4
  export default function TasksHelp() {
5
5
  return (
6
6
  <aside className="app-help">
7
7
  <div className="app-help-badge">Sample</div>
8
8
  <h2>This page is an example</h2>
9
9
  <p>
10
- A task has a title, a description, a status and a due date. Nothing here
11
- is special — it is the same five pieces you will write for anything your
12
- app stores. Delete it once you have your own.
10
+ A task has a title, a status, a due date and a description. Neither the
11
+ list nor the form is written by hand — both are <strong>screens</strong>,
12
+ and the tasks are saved in an ordinary <code>tasks</code> table with one
13
+ column per field.
13
14
  </p>
14
15
 
15
- <h3>Add your own, in five pieces</h3>
16
+ <h3>Change it</h3>
17
+ <p>
18
+ Open <strong>Designer</strong> in the side rail. Add a field, move one,
19
+ change a label or a colour — then <strong>Publish</strong>. The table
20
+ gains its new column as you publish.
21
+ </p>
22
+
23
+ <h3>Add your own</h3>
16
24
  <ol className="app-help-steps">
17
25
  <li>
18
- <strong>The table</strong>
19
- <code>api/migrations/0002_things.sql</code>
20
- <span>Runs by itself when the API next starts.</span>
21
- </li>
22
- <li>
23
- <strong>The model</strong>
24
- <code>api/models/Thing.js</code>
25
- <span>
26
- Names the table and lists the fields, with the rules for each. Those
27
- rules are checked on every save.
28
- </span>
26
+ <strong>Describe it</strong>
27
+ <code>api/screens/project/project.entity.json</code>
28
+ <span>Its name and fields — copy <code>task/task.entity.json</code>.</span>
29
29
  </li>
30
30
  <li>
31
- <strong>The routes</strong>
32
- <code>api/routes/index.js</code>
33
- <span>
34
- One line — hand the model to <code>genericRoute</code> and you get
35
- list, fetch, save and delete. You write none of them.
36
- </span>
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>
37
34
  </li>
38
35
  <li>
39
36
  <strong>The page</strong>
40
- <code>ui/src/pages/Things.jsx</code>
41
- <span>
42
- Plus a file for the calls and one for the form's behaviour, so a new
43
- look means replacing the page only.
44
- </span>
37
+ <code>ui/src/pages/Projects.jsx</code>
38
+ <span>Copy <code>Tasks.jsx</code> and load <code>project_list</code>.</span>
45
39
  </li>
46
40
  <li>
47
41
  <strong>The menu entry</strong>
48
- <code>api/migrations-auth/000N_things_menu.sql</code>
42
+ <code>api/migrations-auth/000N_projects_menu.sql</code>
49
43
  <span>
50
44
  The side-rail item appears only if its name exists in the database,
51
45
  spelled exactly the same.
52
46
  </span>
53
47
  </li>
54
48
  </ol>
49
+ <p>Restart the API and the new table is created for you.</p>
50
+
51
+ <h3>More than saving</h3>
52
+ <p>
53
+ A value worked out on save, an email afterwards, a list only managers
54
+ see: <code>api/screens/task/task.hooks.js</code> — before, after, error
55
+ and override, for save, get and delete.
56
+ </p>
55
57
 
56
58
  <h3>Two things that catch people</h3>
57
59
  <ul className="app-help-notes">
58
60
  <li>
59
- Keep every data address under <code>/api</code>. Serve data at{' '}
60
- <code>/things</code> and it hides the <em>page</em> at the same
61
- address — you get raw data instead of your app.
61
+ Once a screen is published, the <em>database</em> holds its design.
62
+ Editing its <code>.screen.json</code> afterwards changes nothing — use
63
+ the Designer.
62
64
  </li>
63
65
  <li>
64
66
  A new menu entry is remembered for ten minutes. Restart the sign-in
@@ -43,7 +43,7 @@ export default defineConfig(({ mode }) => {
43
43
  plugins: [react()],
44
44
  // Force every import to THIS app's copy, so hook-using libraries don't
45
45
  // load twice and break the rules of hooks in ways that read as impossible.
46
- resolve: { dedupe: ['react', 'react-dom', 'react-router-dom'] },
46
+ resolve: { dedupe: ['react', 'react-dom', 'react-router-dom', '@xeplr/ui-canvas', '@xeplr/ui-table'] },
47
47
  server: { port, strictPort: true, proxy },
48
48
  preview: { port, strictPort: true }
49
49
  }
@@ -0,0 +1,8 @@
1
+ -- The tables the picker lists and adds to: __MT_LEVEL_NAMES__.
2
+ --
3
+ -- Deliberately plain — an id and a name. Add what your app needs (an address,
4
+ -- a plan) with a later migration. Who may use one is NOT here: that is a
5
+ -- membership row in the sign-in database (userTenantsMapping), made for
6
+ -- whoever adds it.
7
+
8
+ __MT_TABLES_SQL__
@@ -0,0 +1,10 @@
1
+ -- "__MT_SWITCH_NAME__" — the drawer item that goes back to the picker.
2
+ --
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.
5
+
6
+ INSERT INTO "menus" (id, name, "menuGroup", "isPublic", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
7
+ SELECT encode(gen_random_bytes(12), 'hex'), '__MT_SWITCH_NAME__', '', true, true, '*', now(), now()
8
+ WHERE NOT EXISTS (SELECT 1 FROM "menus" WHERE name = '__MT_SWITCH_NAME__');
9
+
10
+ UPDATE "menus" SET "isPublic" = true WHERE name = '__MT_SWITCH_NAME__';
@@ -0,0 +1,130 @@
1
+ // THE TENANTS — what the picker lists and adds to (__MT_LEVEL_NAMES__), and
2
+ // the check that a caller belongs to the one their request names.
3
+ //
4
+ // GET /api/tenants/l1 the ones at that level the caller belongs to
5
+ // POST /api/tenants/l1 { name } add one — and make the caller a member of it
6
+ //
7
+ // A level below the first lists only the children of the one chosen above it,
8
+ // which arrives as that level's header like on every other request.
9
+ //
10
+ // MEMBERSHIP lives in the sign-in database (userTenantsMapping): user, level,
11
+ // id, role. Adding one makes you its member. Super Admin sees and may enter
12
+ // every one.
13
+
14
+ var express = require('express');
15
+ var { getMtContext } = require('@xeplr/db');
16
+ var { generateId } = require('@xeplr/utils');
17
+ var { respond } = require('@xeplr/utils/lib/response');
18
+ var { HTTP, STATUS } = require('@xeplr/utils/isomorphic');
19
+ var authLib = require('@xeplr/auth');
20
+ var tenancy = require('../tenancy');
21
+ var appDatabase = require('../db/setup');
22
+
23
+ // The sign-in database, read directly — this app does not run the sign-in
24
+ // service in its own process, it only needs its membership table.
25
+ var auth = authLib.attach();
26
+
27
+ // Whoever creates one is its member, with this role: the outermost level's
28
+ // admin, a creator below it. Roles from @xeplr/factory's access migration.
29
+ var CREATOR_ROLE = { 1: 'CompanyAdmin' };
30
+ var DEEPER_ROLE = 'Creator';
31
+
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
+ var membership = null;
38
+
39
+ /**
40
+ * Refuses a request naming a company (or any level) the caller is not a
41
+ * member of — 403 "Not authorized for companyId …". A header that is absent
42
+ * is let through: nothing is read or written without one anyway.
43
+ */
44
+ async function memberGate(req, res, next) {
45
+ if (isSuperAdmin(req)) return next();
46
+ if (!membership) {
47
+ await auth.ready();
48
+ membership = authLib.mtMembershipMiddleware({ userTenantsMapping: auth.model('UserTenantsMapping') });
49
+ }
50
+ return membership(req, res, next);
51
+ }
52
+
53
+ function levelOf(key) {
54
+ var level = tenancy.levels.filter(function(l) { return l.key === key; })[0];
55
+ if (!level) throw status(HTTP.NOT_FOUND, 'No tenancy level "' + key + '"');
56
+ return level;
57
+ }
58
+
59
+ /** The chosen parents, as mtIdN values — refusing if one is not chosen yet. */
60
+ function parentsOf(level) {
61
+ var ctx = getMtContext();
62
+ var index = Number(level.key.slice(1));
63
+ var values = {};
64
+ for (var i = 1; i < index; i++) {
65
+ var parent = tenancy.levels[i - 1];
66
+ if (!ctx['mtId' + i]) throw status(HTTP.BAD_REQUEST, 'Choose a ' + parent.label.toLowerCase() + ' first');
67
+ values['mtId' + i] = ctx['mtId' + i];
68
+ }
69
+ return values;
70
+ }
71
+
72
+ var router = express.Router();
73
+ router.use(express.json());
74
+ router.use(memberGate);
75
+
76
+ router.get('/:level', handle(async function(req) {
77
+ var level = levelOf(req.params.level);
78
+ var knex = await appDatabase;
79
+ var query = knex(level.table).select('id', 'name').where('isActive', true).where(parentsOf(level)).orderBy('name');
80
+ if (!isSuperAdmin(req)) {
81
+ await auth.ready();
82
+ var ids = await auth.conn()('userTenantsMapping')
83
+ .where({ userId: req.user.id, level: level.key, isActive: true })
84
+ .pluck('value');
85
+ query.whereIn('id', ids);
86
+ }
87
+ return query;
88
+ }));
89
+
90
+ router.post('/:level', handle(async function(req) {
91
+ var level = levelOf(req.params.level);
92
+ var name = String((req.body && req.body.name) || '').trim();
93
+ if (!name || name.length > 200) throw status(HTTP.VALIDATION_ERROR, 'Give the ' + level.label.toLowerCase() + ' a name, up to 200 characters');
94
+ var who = String(req.user.id).slice(0, 25);
95
+ var now = new Date();
96
+ var row = Object.assign({ id: generateId(), name: name, isActive: true, recordCreatedDate: now, recordModifiedDate: now, recordCreatedBy: who, recordModifiedBy: who }, parentsOf(level));
97
+
98
+ var knex = await appDatabase;
99
+ await knex(level.table).insert(row);
100
+
101
+ await auth.ready();
102
+ var conn = auth.conn();
103
+ var roleName = CREATOR_ROLE[Number(level.key.slice(1))] || DEEPER_ROLE;
104
+ var role = await conn('roles').where({ name: roleName, isActive: true }).first('id');
105
+ await conn('userTenantsMapping').insert({
106
+ id: generateId(), userId: req.user.id, level: level.key, value: row.id, roleId: role ? role.id : null,
107
+ isActive: true, mtId1: '*', recordCreatedDate: now, recordModifiedDate: now, recordCreatedBy: who, recordModifiedBy: who
108
+ });
109
+ return [{ id: row.id, name: row.name }];
110
+ }));
111
+
112
+ function handle(fn) {
113
+ return async function(req, res) {
114
+ try {
115
+ respond(res, HTTP.OK, STATUS.SUCCESS, 'success', { dataArray: (await fn(req)) || [] });
116
+ } catch (err) {
117
+ var code = err.status || HTTP.SERVER_ERROR;
118
+ if (code >= 500) console.error('[tenants]', err);
119
+ res.status(code).send({ code: code >= 500 ? STATUS.SERVER_ERROR : STATUS.BAD_REQUEST, message: code >= 500 ? 'Something went wrong' : err.message, error: null, dataArray: [] });
120
+ }
121
+ };
122
+ }
123
+
124
+ function status(code, message) {
125
+ var e = new Error(message);
126
+ e.status = code;
127
+ return e;
128
+ }
129
+
130
+ module.exports = { router: router, memberGate: memberGate };
@@ -0,0 +1,12 @@
1
+ // TENANCY — the levels this app's data belongs to, outermost first
2
+ // (__MT_LEVEL_NAMES__).
3
+ //
4
+ // Every row a screen saves is stamped with the chosen one(s) in mtId1… and
5
+ // every read sees only those rows. The UI registers the SAME levels
6
+ // (ui/src/tenancy.js) so its requests carry the same headers; keep the two in
7
+ // step if you ever change them.
8
+ //
9
+ // levels one entry per level: its header, the column name it stands for,
10
+ // and the table its choices live in
11
+ // slots the shape @xeplr/db's registerMTs takes
12
+ module.exports = __MT_TENANCY_JSON__;
@@ -0,0 +1,45 @@
1
+ .app-select { max-width: 560px; }
2
+
3
+ .app-select h1 { font-size: 26px; margin: 0 0 6px; }
4
+
5
+ .app-select-level {
6
+ margin: 22px 0;
7
+ padding: 16px 18px;
8
+ border: 1px solid var(--xeplr-border-primary);
9
+ border-radius: 10px;
10
+ background: var(--xeplr-bg-secondary);
11
+ }
12
+
13
+ .app-select-level.is-disabled { opacity: 0.55; }
14
+
15
+ .app-select-level h2 { font-size: 15px; margin: 0 0 10px; }
16
+
17
+ .app-select-items { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
18
+
19
+ .app-select-item {
20
+ padding: 8px 14px;
21
+ border-radius: 8px;
22
+ border: 1px solid var(--xeplr-border-primary);
23
+ background: var(--xeplr-bg-primary);
24
+ color: var(--xeplr-text-primary);
25
+ font: inherit;
26
+ cursor: pointer;
27
+ }
28
+
29
+ .app-select-item.is-chosen {
30
+ border-color: var(--xeplr-accent);
31
+ background: var(--xeplr-accent-muted);
32
+ color: var(--xeplr-text-primary);
33
+ }
34
+
35
+ .app-select-add { display: flex; gap: 8px; }
36
+
37
+ .app-select-add input {
38
+ flex: 1;
39
+ padding: 8px 10px;
40
+ border-radius: 8px;
41
+ border: 1px solid var(--xeplr-input-border);
42
+ background: var(--xeplr-input-bg);
43
+ color: var(--xeplr-input-text);
44
+ font: inherit;
45
+ }
@@ -0,0 +1,154 @@
1
+ // THE FIRST SCREEN AFTER SIGNING IN — choose the __MT_LEVEL_NAMES__ to work in.
2
+ //
3
+ // Everything the app shows and saves afterwards belongs to that choice:
4
+ // authFetch sends it as a header on every request, the API stamps new rows
5
+ // with it and reads only its rows.
6
+ //
7
+ // Shown once per browser tab (ScopeGate below), with the last choice already
8
+ // selected, so returning is one click. "__MT_SWITCH_NAME__" in the side rail
9
+ // comes back here.
10
+ import { useCallback, useEffect, useState } from 'react'
11
+ import { Navigate, useLocation, useNavigate } from 'react-router-dom'
12
+ import {
13
+ authFetch, getActiveScope, setActiveScope, getLastScope, setLastScope, saveReturnTo, consumeReturnTo
14
+ } from '@xeplr/ui-account'
15
+ import { tenancy } from '../tenancy.js'
16
+ import './SelectScope.css'
17
+
18
+ // Per tab: a new tab, or a new sign-in, asks again.
19
+ const CONFIRMED = 'app:scopeConfirmed'
20
+ const confirmed = () => { try { return sessionStorage.getItem(CONFIRMED) === '1' } catch (e) { return false } }
21
+ const setConfirmed = (yes) => { try { yes ? sessionStorage.setItem(CONFIRMED, '1') : sessionStorage.removeItem(CONFIRMED) } catch (e) {} }
22
+
23
+ /** Lets the app's pages render only once every level is chosen in this tab. */
24
+ export function ScopeGate({ children }) {
25
+ const location = useLocation()
26
+ const complete = tenancy.levels.every((l) => getActiveScope(l.key))
27
+ if (!complete || !confirmed()) {
28
+ saveReturnTo(location)
29
+ return <Navigate to="/select" replace />
30
+ }
31
+ return children
32
+ }
33
+
34
+ export default function SelectScope() {
35
+ const navigate = useNavigate()
36
+ // What is chosen so far, per level — starting from the active or last choice.
37
+ // Made active straight away — before the lists below load, since a level's
38
+ // list is asked for with the level above it as a header.
39
+ const [chosen, setChosen] = useState(() => {
40
+ const start = Object.fromEntries(tenancy.levels.map((l) => [l.key, getActiveScope(l.key) || getLastScope(l.key) || null]))
41
+ tenancy.levels.forEach((l) => setActiveScope(l.key, start[l.key]))
42
+ return start
43
+ })
44
+
45
+ const choose = useCallback((index, item) => {
46
+ setChosen((c) => {
47
+ const next = { ...c, [tenancy.levels[index].key]: item }
48
+ // A different parent: whatever was chosen below it belonged to the old one.
49
+ tenancy.levels.slice(index + 1).forEach((l) => { next[l.key] = null })
50
+ return next
51
+ })
52
+ // Set as it is chosen, so the level below lists this one's children.
53
+ setActiveScope(tenancy.levels[index].key, item)
54
+ tenancy.levels.slice(index + 1).forEach((l) => setActiveScope(l.key, null))
55
+ setConfirmed(false)
56
+ }, [])
57
+
58
+ const complete = tenancy.levels.every((l) => chosen[l.key])
59
+
60
+ const go = () => {
61
+ tenancy.levels.forEach((l) => { setActiveScope(l.key, chosen[l.key]); setLastScope(l.key, chosen[l.key]) })
62
+ setConfirmed(true)
63
+ navigate(consumeReturnTo() || '/home', { replace: true })
64
+ }
65
+
66
+ return (
67
+ <div className="app-select">
68
+ <h1>Where are you working?</h1>
69
+ <p className="app-page-lead">Everything you see and save next belongs to this choice.</p>
70
+
71
+ {tenancy.levels.map((level, i) => {
72
+ const parentChosen = tenancy.levels.slice(0, i).every((l) => chosen[l.key])
73
+ return (
74
+ <LevelChoice
75
+ key={level.key + (i ? ':' + (chosen[tenancy.levels[i - 1].key] || {}).id : '')}
76
+ level={level}
77
+ enabled={parentChosen}
78
+ value={chosen[level.key]}
79
+ onChoose={(item) => choose(i, item)}
80
+ />
81
+ )
82
+ })}
83
+
84
+ <button type="button" className="app-btn" disabled={!complete} onClick={go}>Continue</button>
85
+ </div>
86
+ )
87
+ }
88
+
89
+ function LevelChoice({ level, enabled, value, onChoose }) {
90
+ const [items, setItems] = useState(null)
91
+ const [error, setError] = useState(null)
92
+ const [adding, setAdding] = useState('')
93
+ const [busy, setBusy] = useState(false)
94
+ const noun = level.label.toLowerCase()
95
+
96
+ useEffect(() => {
97
+ if (!enabled) return
98
+ authFetch('/api/tenants/' + level.key).then((body) => {
99
+ const rows = body.dataArray || []
100
+ setItems(rows)
101
+ // A remembered choice that is no longer on the list is not a choice.
102
+ if (value && !rows.some((r) => r.id === value.id)) onChoose(null)
103
+ else if (!value && rows.length === 1) onChoose(rows[0])
104
+ }, (e) => setError(e.message))
105
+ }, [enabled, level.key]) // eslint-disable-line react-hooks/exhaustive-deps
106
+
107
+ const add = async (e) => {
108
+ e.preventDefault()
109
+ setBusy(true)
110
+ try {
111
+ const body = await authFetch('/api/tenants/' + level.key, { method: 'POST', body: JSON.stringify({ name: adding }) })
112
+ const created = body.dataArray[0]
113
+ setItems((list) => [...(list || []), created].sort((a, b) => a.name.localeCompare(b.name)))
114
+ setAdding('')
115
+ setError(null)
116
+ onChoose(created)
117
+ } catch (err) {
118
+ setError(err.message)
119
+ } finally {
120
+ setBusy(false)
121
+ }
122
+ }
123
+
124
+ return (
125
+ <section className={'app-select-level' + (enabled ? '' : ' is-disabled')}>
126
+ <h2>{level.label}</h2>
127
+ {!enabled && <p className="app-sub">Choose the one above first.</p>}
128
+ {enabled && items === null && !error && <p className="app-sub">Loading…</p>}
129
+ {error && <div className="app-error">{error}</div>}
130
+ {enabled && items && (
131
+ <>
132
+ {items.length === 0 && <p className="app-sub">No {noun} yet — add the first one.</p>}
133
+ <div className="app-select-items">
134
+ {items.map((item) => (
135
+ <button
136
+ key={item.id}
137
+ type="button"
138
+ className={'app-select-item' + (value && value.id === item.id ? ' is-chosen' : '')}
139
+ aria-pressed={!!value && value.id === item.id}
140
+ onClick={() => onChoose(item)}
141
+ >
142
+ {item.name}
143
+ </button>
144
+ ))}
145
+ </div>
146
+ <form className="app-select-add" onSubmit={add}>
147
+ <input value={adding} onChange={(e) => setAdding(e.target.value)} placeholder={'New ' + noun + ' name'} aria-label={'New ' + noun + ' name'} />
148
+ <button type="submit" className="app-btn app-btn-quiet" disabled={busy || !adding.trim()}>Add {noun}</button>
149
+ </form>
150
+ </>
151
+ )}
152
+ </section>
153
+ )
154
+ }
@@ -0,0 +1,3 @@
1
+ // TENANCY — the same levels as the API's api/tenancy.js (__MT_LEVEL_NAMES__).
2
+ // Keep the two in step.
3
+ export const tenancy = __MT_TENANCY_JSON__
@@ -1,23 +0,0 @@
1
- -- The sample table.
2
- --
3
- -- `id` is varchar(25) because ids are minted by the framework
4
- -- (@xeplr/utils' generateId) rather than by the database.
5
- --
6
- -- `isActive` is what makes a delete SOFT: the generic delete route sets it
7
- -- false and every query filters on it, so a deleted row stops appearing
8
- -- without ever leaving the table.
9
-
10
- CREATE TABLE IF NOT EXISTS "tasks" (
11
- "id" varchar(25) PRIMARY KEY,
12
- "title" varchar(200) NOT NULL,
13
- "description" varchar(1000),
14
- "status" varchar(20) NOT NULL DEFAULT 'todo',
15
- "dueDate" date,
16
- "isActive" boolean DEFAULT true,
17
- "recordCreatedDate" timestamptz,
18
- "recordModifiedDate" timestamptz,
19
- "recordCreatedBy" varchar(25),
20
- "recordModifiedBy" varchar(25)
21
- );
22
-
23
- CREATE INDEX IF NOT EXISTS "tasks_status_idx" ON "tasks" ("status");