@xeplr/cli 0.1.2 → 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.
- package/README.md +28 -13
- package/bin/xeplr.js +10 -4
- package/lib/generate/index.js +123 -3
- package/lib/nextSteps.js +33 -18
- package/lib/plan.js +7 -0
- package/lib/questions.js +60 -1
- package/lib/setup.js +59 -0
- package/package.json +2 -1
- package/templates/api/app.js +20 -1
- package/templates/api/bin/www +18 -4
- package/templates/api/db/setup.js +1 -0
- package/templates/api/development.env.tpl +3 -2
- package/templates/api/migrations/README.md +9 -0
- package/templates/api/migrations-auth/0003_designer_menu.sql +19 -0
- package/templates/api/models/index.js +7 -6
- package/templates/api/package.json +4 -2
- package/templates/api/routes/index.js +6 -15
- package/templates/api/screens/index.js +32 -0
- package/templates/api/screens/task/task-edit.screen.json +103 -0
- package/templates/api/screens/task/task-list.screen.json +45 -0
- package/templates/api/screens/task/task.entity.json +10 -0
- package/templates/api/screens/task/task.hooks.js +37 -0
- package/templates/ui/package.json +4 -0
- package/templates/ui/src/App.jsx +18 -2
- package/templates/ui/src/api/factory.js +10 -0
- package/templates/ui/src/index.css +25 -0
- package/templates/ui/src/main.jsx +2 -0
- package/templates/ui/src/pages/Designer.jsx +74 -0
- package/templates/ui/src/pages/Home.jsx +4 -0
- package/templates/ui/src/pages/Tasks.jsx +18 -94
- package/templates/ui/src/pages/TasksHelp.jsx +35 -33
- package/templates/ui/vite.config.js +1 -1
- package/templates-tenancy/api/migrations/0001_tenants.sql +8 -0
- package/templates-tenancy/api/migrations-auth/0004_switch_menu.sql +10 -0
- package/templates-tenancy/api/routes/tenants.js +130 -0
- package/templates-tenancy/api/tenancy.js +12 -0
- package/templates-tenancy/ui/src/pages/SelectScope.css +45 -0
- package/templates-tenancy/ui/src/pages/SelectScope.jsx +154 -0
- package/templates-tenancy/ui/src/tenancy.js +3 -0
- package/templates/api/migrations/0001_tasks.sql +0 -23
- package/templates/api/models/Task.js +0 -48
- package/templates/ui/src/api/tasks.js +0 -34
- package/templates/ui/src/useTasksController.js +0 -83
|
@@ -1,64 +1,66 @@
|
|
|
1
|
-
// The panel beside the sample
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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>
|
|
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>
|
|
19
|
-
<code>api/
|
|
20
|
-
<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>
|
|
32
|
-
<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/
|
|
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/
|
|
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
|
-
|
|
60
|
-
<code
|
|
61
|
-
|
|
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
|
+
}
|
|
@@ -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");
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
var { BaseModel } = require('@xeplr/db');
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* A task. The sample model — deliberately small, and deliberately showing the
|
|
5
|
-
* three things almost every real model needs: required fields, a value
|
|
6
|
-
* restricted to a known set, and a date.
|
|
7
|
-
*
|
|
8
|
-
* `jsonSchema` is not decoration. It is checked on every insert and update, so
|
|
9
|
-
* a bad value is refused by the API with a message naming the field, rather
|
|
10
|
-
* than reaching the database and failing as a constraint error nobody can read.
|
|
11
|
-
*/
|
|
12
|
-
class Task extends BaseModel {
|
|
13
|
-
static get tableName() { return 'tasks'; }
|
|
14
|
-
static get idColumn() { return 'id'; }
|
|
15
|
-
|
|
16
|
-
// Rows are NOT scoped to a company/workspace here, because this app has not
|
|
17
|
-
// declared any tenancy levels. Leave it as the default (true) and add
|
|
18
|
-
// registerMTs() later, and every row written before that has no owner.
|
|
19
|
-
static get multiTenant() { return false; }
|
|
20
|
-
|
|
21
|
-
static get jsonSchema() {
|
|
22
|
-
return {
|
|
23
|
-
type: 'object',
|
|
24
|
-
required: ['title'],
|
|
25
|
-
properties: {
|
|
26
|
-
// 25 characters, because that is what the framework's own id generator
|
|
27
|
-
// produces (see genericController — ids are minted there, not here).
|
|
28
|
-
id: { type: 'string', maxLength: 25 },
|
|
29
|
-
title: { type: 'string', minLength: 1, maxLength: 200 },
|
|
30
|
-
description: { type: ['string', 'null'], maxLength: 1000 },
|
|
31
|
-
status: { type: 'string', enum: ['todo', 'in_progress', 'done'] },
|
|
32
|
-
dueDate: { type: ['string', 'null'] },
|
|
33
|
-
isActive: { type: 'boolean' },
|
|
34
|
-
recordCreatedDate: { type: ['string', 'null'] },
|
|
35
|
-
recordModifiedDate: { type: ['string', 'null'] },
|
|
36
|
-
recordCreatedBy: { type: ['string', 'null'], maxLength: 25 },
|
|
37
|
-
recordModifiedBy: { type: ['string', 'null'], maxLength: 25 }
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
$beforeInsert() {
|
|
43
|
-
super.$beforeInsert(); // keeps recordCreatedDate / isActive handling
|
|
44
|
-
if (!this.status) this.status = 'todo';
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
module.exports = Task;
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
// MODEL LAYER — plain functions, no React.
|
|
2
|
-
//
|
|
3
|
-
// Kept apart from the screen on purpose: this file could be called from a
|
|
4
|
-
// test, a script, or a different design entirely. `authFetch` attaches the
|
|
5
|
-
// signed-in user's token and returns the PARSED body, throwing on failure.
|
|
6
|
-
import { authFetch } from '@xeplr/ui-account'
|
|
7
|
-
|
|
8
|
-
export async function listTasks() {
|
|
9
|
-
const res = await authFetch('/api/tasks')
|
|
10
|
-
return res.dataArray || []
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Create and update in one call.
|
|
15
|
-
*
|
|
16
|
-
* The API takes a CHANGESET — an array where each entry is judged by what it
|
|
17
|
-
* carries: no id means create, an id means update, an id plus `deleted: true`
|
|
18
|
-
* means remove. One request can do all three, and the whole array is applied
|
|
19
|
-
* in a single transaction, so a later failure undoes the earlier entries
|
|
20
|
-
* rather than leaving half of them written.
|
|
21
|
-
*/
|
|
22
|
-
export function saveTasks(changeset) {
|
|
23
|
-
return authFetch('/api/tasks/save', {
|
|
24
|
-
method: 'POST',
|
|
25
|
-
body: JSON.stringify(changeset)
|
|
26
|
-
})
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function deleteTasks(ids) {
|
|
30
|
-
return authFetch('/api/tasks/delete', {
|
|
31
|
-
method: 'POST',
|
|
32
|
-
body: JSON.stringify({ ids })
|
|
33
|
-
})
|
|
34
|
-
}
|