@xeplr/cli 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -13
- package/bin/xeplr.js +10 -4
- package/lib/generate/index.js +121 -3
- package/lib/nextSteps.js +33 -18
- package/lib/plan.js +8 -0
- package/lib/questions.js +60 -1
- package/lib/setup.js +59 -0
- package/package.json +2 -1
- package/templates/CLAUDE.md +57 -0
- package/templates/api/app.js +31 -1
- package/templates/api/bin/www +19 -4
- package/templates/api/db/setup.js +1 -0
- package/templates/api/development.env.tpl +8 -2
- package/templates/api/env.required.js +1 -1
- package/templates/api/migrations/README.md +9 -0
- package/templates/api/migrations-auth/0003_forms_menu.sql +19 -0
- package/templates/api/models/index.js +7 -6
- package/templates/api/package.json +5 -2
- package/templates/api/routes/access.js +25 -0
- package/templates/api/routes/index.js +6 -15
- package/templates/api/screens/index.js +39 -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/api/screens/task/task.model.js +29 -0
- package/templates/ui/package.json +4 -0
- package/templates/ui/src/App.jsx +22 -2
- package/templates/ui/src/api/factory.js +10 -0
- package/templates/ui/src/index.css +60 -0
- package/templates/ui/src/main.jsx +2 -0
- package/templates/ui/src/pages/EditTask.jsx +57 -0
- package/templates/ui/src/pages/FormDesigner.jsx +74 -0
- package/templates/ui/src/pages/FormRecords.jsx +24 -0
- package/templates/ui/src/pages/Forms.jsx +144 -0
- package/templates/ui/src/pages/Home.jsx +4 -0
- package/templates/ui/src/pages/Tasks.jsx +20 -94
- package/templates/ui/src/pages/TasksHelp.jsx +66 -50
- 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 +126 -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
package/templates/api/bin/www
CHANGED
|
@@ -32,6 +32,8 @@ require('@xeplr/base-apis').checkEnv(require('../env.required.js'), { appName: '
|
|
|
32
32
|
var path = require('path');
|
|
33
33
|
var { resolveConfig } = require('@xeplr/db');
|
|
34
34
|
var { up } = require('@xeplr/db').sqlMigrator;
|
|
35
|
+
var factory = require('@xeplr/factory');
|
|
36
|
+
var screens = require('../screens');
|
|
35
37
|
|
|
36
38
|
async function start() {
|
|
37
39
|
// Name the connection before anything uses it — the migrator resolves by
|
|
@@ -40,18 +42,31 @@ async function start() {
|
|
|
40
42
|
|
|
41
43
|
// Migrations run ON BOOT, so starting the API is the only thing anyone has
|
|
42
44
|
// to remember. Already-applied ones are skipped.
|
|
45
|
+
//
|
|
46
|
+
// The screens' own table (factory_screens) first, then this app's.
|
|
43
47
|
var result = await up({
|
|
44
48
|
db: process.env.DB_API,
|
|
45
|
-
dir:
|
|
49
|
+
dir: factory.migrationsDir,
|
|
50
|
+
extDir: path.join(__dirname, '..', 'migrations'),
|
|
46
51
|
connectionName: 'api'
|
|
47
52
|
});
|
|
48
53
|
console.log(result.migrations.length
|
|
49
54
|
? '[api] ran ' + result.migrations.length + ' migrations'
|
|
50
55
|
: '[api] migrations up to date');
|
|
51
56
|
|
|
52
|
-
// AWAITED before the app is built:
|
|
53
|
-
//
|
|
54
|
-
await require('../db/setup');
|
|
57
|
+
// AWAITED before the app is built: a model handed to genericRoute at build
|
|
58
|
+
// time has no connection at all until this has finished.
|
|
59
|
+
var knex = await require('../db/setup');
|
|
60
|
+
|
|
61
|
+
// The screens, their hooks and their models. A hooks file with a typo
|
|
62
|
+
// ("beforeSave") or a malformed model stops startup here, rather than
|
|
63
|
+
// silently never running.
|
|
64
|
+
await factory.init({ knex: knex, hooks: screens.hooks, models: screens.models });
|
|
65
|
+
|
|
66
|
+
// Screens never published yet are published now, creating their tables.
|
|
67
|
+
// Ones already published are left alone — the Forms page owns them.
|
|
68
|
+
var published = await factory.publishScreens(screens.documents);
|
|
69
|
+
if (published.published.length) console.log('[api] published screens: ' + published.published.join(', '));
|
|
55
70
|
|
|
56
71
|
require('../app')();
|
|
57
72
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// wire the models to it. Exports the connection promise, awaited by callers
|
|
3
3
|
// that need it ready.
|
|
4
4
|
var { getConnection } = require('@xeplr/db');
|
|
5
|
+
__MT_DB_REGISTER__
|
|
5
6
|
|
|
6
7
|
// No `|| '__NAME___api'` fallback. DB_API is required at startup
|
|
7
8
|
// (env.required.js), so a default could only ever fire in a process that
|
|
@@ -29,14 +29,20 @@ AUTH_DB_NAME=__NAME___auth
|
|
|
29
29
|
# localhost:6379; set REDIS_HOST / REDIS_PORT to point elsewhere. Without it
|
|
30
30
|
# the service refuses to start and says so — rather than letting every login
|
|
31
31
|
# succeed and every request afterwards fail as "Invalid or expired token".
|
|
32
|
+
#
|
|
33
|
+
# REDIS_PREFIX keeps this app's keys apart from any other app on the same
|
|
34
|
+
# Redis. Required, and unique per app: two apps sharing one see each other's
|
|
35
|
+
# menus and permissions.
|
|
36
|
+
REDIS_PREFIX=__NAME__:
|
|
32
37
|
AUTH_PORT=__AUTH_PORT__
|
|
33
38
|
AUTH_JWT_SECRET=__JWT_SECRET__
|
|
34
39
|
|
|
35
40
|
# Where the sign-in service is. Your API asks it to check every token.
|
|
36
41
|
AUTH_URL=http://localhost:__AUTH_PORT__
|
|
37
42
|
|
|
38
|
-
#
|
|
39
|
-
|
|
43
|
+
# Sign-in setup files: the screens' permissions (@xeplr/factory's), then this
|
|
44
|
+
# app's own — menu entries and anything else it adds. Comma-separated, in order.
|
|
45
|
+
XEPLR_AUTH_MIGRATIONS=./node_modules/@xeplr/factory/migrations-auth,./migrations-auth
|
|
40
46
|
|
|
41
47
|
# ── The first account you sign in with ──
|
|
42
48
|
# Created for you when you set up the database. Change the password here and
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
module.exports = [
|
|
11
11
|
// ENCRYPTION_KEY, AUTH_JWT_SECRET, AUTH_PORT, AUTH_DB_NAME,
|
|
12
|
-
// XEPLR_AUTH_MIGRATIONS
|
|
12
|
+
// XEPLR_AUTH_MIGRATIONS, AUTH_SUPER_ADMIN_EMAIL / _PASSWORD, REDIS_PREFIX
|
|
13
13
|
...require('@xeplr/auth').requiredEnv,
|
|
14
14
|
|
|
15
15
|
// ── this app's own ──
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# migrations
|
|
2
|
+
|
|
3
|
+
Hand-written `.sql` files, applied once each, in name order, every time the API
|
|
4
|
+
starts (`0001_something.sql`, `0002_…`).
|
|
5
|
+
|
|
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/`.
|
|
8
|
+
|
|
9
|
+
Put here only tables no screen owns: lookup data, a table your own code writes.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- "Forms" — the drawer item for every form in the app: see them, make a new
|
|
2
|
+
-- one, design it, publish it (ui/src/pages/Forms.jsx).
|
|
3
|
+
--
|
|
4
|
+
-- SUPER ADMIN ONLY: publishing changes database tables. The API refuses
|
|
5
|
+
-- everyone else as well (api/routes/access.js); this keeps the menu honest
|
|
6
|
+
-- about it.
|
|
7
|
+
--
|
|
8
|
+
-- The name must match ui/src/App.jsx's drawerItems entry EXACTLY.
|
|
9
|
+
|
|
10
|
+
INSERT INTO "menus" (id, name, "menuGroup", "isPublic", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
|
|
11
|
+
SELECT encode(gen_random_bytes(12), 'hex'), 'Forms', '', false, true, '*', now(), now()
|
|
12
|
+
WHERE NOT EXISTS (SELECT 1 FROM "menus" WHERE name = 'Forms');
|
|
13
|
+
|
|
14
|
+
INSERT INTO "menuRolesMapping" (id, "roleId", "menuId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
|
|
15
|
+
SELECT encode(gen_random_bytes(12), 'hex'), r.id, m.id, true, '*', now(), now()
|
|
16
|
+
FROM "roles" r CROSS JOIN "menus" m
|
|
17
|
+
WHERE r.name = 'Super Admin'
|
|
18
|
+
AND m.name = 'Forms'
|
|
19
|
+
AND NOT EXISTS (SELECT 1 FROM "menuRolesMapping" x WHERE x."roleId" = r.id AND x."menuId" = m.id);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
1
|
+
// Models for tables you write by hand (see migrations/README.md) — each one a
|
|
2
|
+
// class extending @xeplr/db's BaseModel, handed to genericRoute in
|
|
3
|
+
// routes/index.js.
|
|
4
|
+
//
|
|
5
|
+
// Tables made as forms (Forms page, screens/) need no model: @xeplr/factory saves
|
|
6
|
+
// and reads them from the screen itself.
|
|
4
7
|
|
|
5
|
-
module.exports = {
|
|
6
|
-
Task: Task
|
|
7
|
-
};
|
|
8
|
+
module.exports = {};
|
|
@@ -17,9 +17,12 @@
|
|
|
17
17
|
"start-api": "node ./bin/www"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@xeplr/auth": "^1.1.
|
|
20
|
+
"@xeplr/auth": "^1.1.2",
|
|
21
21
|
"@xeplr/base-apis": "^2.0.0",
|
|
22
|
-
"@xeplr/db": "^1.0.
|
|
22
|
+
"@xeplr/db": "^1.0.6",
|
|
23
|
+
"@xeplr/factory": "^1.0.4",
|
|
24
|
+
"@xeplr/ui-factory": "^1.0.5",
|
|
25
|
+
"@xeplr/utils": "^1.0.8",
|
|
23
26
|
"dotenv": "^17.4.2",
|
|
24
27
|
"express": "^5.2.0"
|
|
25
28
|
},
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// WHO MAY DO WHAT — beyond the permission catalog every route already checks.
|
|
2
|
+
//
|
|
3
|
+
// Designing screens, making new forms and publishing them change the
|
|
4
|
+
// database's tables, so they are kept to Super Admin alone, whatever else a
|
|
5
|
+
// role has been granted. Relax superAdminOnly here when your app has decided
|
|
6
|
+
// who else should.
|
|
7
|
+
|
|
8
|
+
var FORBIDDEN = { code: 'FORBIDDEN', message: 'Only Super Admin can design and publish forms', error: null, dataArray: [] };
|
|
9
|
+
|
|
10
|
+
function isSuperAdmin(req) {
|
|
11
|
+
var roles = (req.access && req.access.roles) || (req.user && req.user.roles) || [];
|
|
12
|
+
return roles.indexOf('Super Admin') !== -1;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function superAdminOnly(req, res, next) {
|
|
16
|
+
if (isSuperAdmin(req)) return next();
|
|
17
|
+
res.status(403).send(FORBIDDEN);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** No check of its own — the token and the permission catalog still apply. */
|
|
21
|
+
function anyone(req, res, next) {
|
|
22
|
+
next();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { isSuperAdmin: isSuperAdmin, superAdminOnly: superAdminOnly, anyone: anyone };
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
var express = require('express');
|
|
2
|
-
var { genericRoute } = require('@xeplr/base-apis');
|
|
3
|
-
var { Task } = require('../models');
|
|
4
2
|
|
|
5
3
|
var router = express.Router();
|
|
6
4
|
|
|
@@ -21,26 +19,19 @@ router.get('/whoami', function(req, res) {
|
|
|
21
19
|
res.json({ user: req.user, roles: (req.access || {}).roles || [] });
|
|
22
20
|
});
|
|
23
21
|
|
|
24
|
-
// ──
|
|
22
|
+
// ── your data ────────────────────────────────────────────────────────────
|
|
25
23
|
//
|
|
26
24
|
// MOUNTED UNDER /api, and that prefix matters. The browser asks the UI's dev
|
|
27
25
|
// server for BOTH its own pages and its data, and the dev server decides by
|
|
28
26
|
// path which to forward. Serve data at /tasks and the UI's own /tasks page is
|
|
29
27
|
// shadowed by it — opening that page shows raw JSON instead of the app.
|
|
30
28
|
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
29
|
+
// Forms (tasks, and any made on the Forms page) need nothing here — @xeplr/factory
|
|
30
|
+
// serves them at /api/factory/..., mounted in app.js.
|
|
33
31
|
//
|
|
34
|
-
//
|
|
32
|
+
// A table you write by hand gets its four addresses from one line:
|
|
35
33
|
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// POST /api/tasks/save create and update, in one transaction
|
|
39
|
-
// POST /api/tasks/delete soft delete by ids
|
|
40
|
-
//
|
|
41
|
-
// No handler is written for any of them. The model's own jsonSchema is what
|
|
42
|
-
// validates the incoming values, so the rules live in one place rather than
|
|
43
|
-
// being restated here.
|
|
44
|
-
router.use('/api/tasks', genericRoute({ key: 'task', model: Task }));
|
|
34
|
+
// var { genericRoute } = require('@xeplr/base-apis');
|
|
35
|
+
// router.use('/api/things', genericRoute({ key: 'thing', model: require('../models').Thing }));
|
|
45
36
|
|
|
46
37
|
module.exports = router;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// THE SCREENS THIS APP SHIPS WITH — designed with @xeplr/ui-factory.
|
|
2
|
+
//
|
|
3
|
+
// Each entity is two screens: a list, and the add / edit form the list opens
|
|
4
|
+
// in a popup. Their records live in a REAL table named for the entity
|
|
5
|
+
// ("tasks"), one column per field.
|
|
6
|
+
//
|
|
7
|
+
// On startup (bin/www) every screen here that has never been published is
|
|
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
|
|
10
|
+
// table to match. Editing a .screen.json later changes nothing for an
|
|
11
|
+
// already-published screen.
|
|
12
|
+
//
|
|
13
|
+
// ADD ONE: follow "Create a new UI" in the project's CLAUDE.md — or ask Claude
|
|
14
|
+
// to, with the prompt there. Every form's folder holds the same files:
|
|
15
|
+
//
|
|
16
|
+
// screens/<form>/<form>.entity.json its name and fields
|
|
17
|
+
// screens/<form>/<form>-list.screen.json the list
|
|
18
|
+
// screens/<form>/<form>-edit.screen.json the add / edit form
|
|
19
|
+
// screens/<form>/<form>.hooks.js server hooks — save / get / delete
|
|
20
|
+
// screens/<form>/<form>.model.js server model — getters / setters
|
|
21
|
+
|
|
22
|
+
module.exports = {
|
|
23
|
+
// Publish order is worked out for you: a table before the dropdowns that
|
|
24
|
+
// point at it, lists last.
|
|
25
|
+
documents: [
|
|
26
|
+
require('./task/task-edit.screen.json'),
|
|
27
|
+
require('./task/task-list.screen.json')
|
|
28
|
+
],
|
|
29
|
+
|
|
30
|
+
// Your own code around save / get / delete, per screen — see task/task.hooks.js.
|
|
31
|
+
hooks: {
|
|
32
|
+
task_edit: require('./task/task.hooks')
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
// How each table's values are shaped — see task/task.model.js.
|
|
36
|
+
models: [
|
|
37
|
+
require('./task/task.model')
|
|
38
|
+
]
|
|
39
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "xeplr-screen",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"id": "task_edit",
|
|
5
|
+
"name": "Add / edit task",
|
|
6
|
+
"units": "fraction",
|
|
7
|
+
"aspect": 1,
|
|
8
|
+
"width": 800,
|
|
9
|
+
"nodes": [
|
|
10
|
+
{
|
|
11
|
+
"id": "label",
|
|
12
|
+
"type": "label",
|
|
13
|
+
"x": 0.04,
|
|
14
|
+
"y": 0.04,
|
|
15
|
+
"w": 0.92,
|
|
16
|
+
"h": 0.07,
|
|
17
|
+
"z": 1,
|
|
18
|
+
"props": {
|
|
19
|
+
"text": "Add / edit task",
|
|
20
|
+
"variant": "heading"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "title",
|
|
25
|
+
"type": "text",
|
|
26
|
+
"x": 0.04,
|
|
27
|
+
"y": 0.15,
|
|
28
|
+
"w": 0.4475,
|
|
29
|
+
"h": 0.08,
|
|
30
|
+
"z": 2,
|
|
31
|
+
"props": {
|
|
32
|
+
"label": "Title",
|
|
33
|
+
"required": true,
|
|
34
|
+
"validation": {
|
|
35
|
+
"maxLength": 200
|
|
36
|
+
},
|
|
37
|
+
"name": "title"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "status",
|
|
42
|
+
"type": "dropdown",
|
|
43
|
+
"x": 0.5125,
|
|
44
|
+
"y": 0.15,
|
|
45
|
+
"w": 0.4475,
|
|
46
|
+
"h": 0.08,
|
|
47
|
+
"z": 3,
|
|
48
|
+
"props": {
|
|
49
|
+
"label": "Status",
|
|
50
|
+
"placeholder": "Select…",
|
|
51
|
+
"data": {
|
|
52
|
+
"source": "static",
|
|
53
|
+
"options": [
|
|
54
|
+
{
|
|
55
|
+
"id": "to_do",
|
|
56
|
+
"name": "To do"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "in_progress",
|
|
60
|
+
"name": "In progress"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"id": "done",
|
|
64
|
+
"name": "Done"
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
"required": true,
|
|
69
|
+
"name": "status"
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"id": "dueDate",
|
|
74
|
+
"type": "date",
|
|
75
|
+
"x": 0.04,
|
|
76
|
+
"y": 0.27,
|
|
77
|
+
"w": 0.4475,
|
|
78
|
+
"h": 0.08,
|
|
79
|
+
"z": 4,
|
|
80
|
+
"props": {
|
|
81
|
+
"label": "Due date",
|
|
82
|
+
"name": "dueDate"
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"id": "description",
|
|
87
|
+
"type": "textarea",
|
|
88
|
+
"x": 0.04,
|
|
89
|
+
"y": 0.39,
|
|
90
|
+
"w": 0.92,
|
|
91
|
+
"h": 0.18,
|
|
92
|
+
"z": 5,
|
|
93
|
+
"props": {
|
|
94
|
+
"label": "Description",
|
|
95
|
+
"validation": {
|
|
96
|
+
"maxLength": 1000
|
|
97
|
+
},
|
|
98
|
+
"name": "description"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
"source": "tasks"
|
|
103
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "xeplr-screen",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"id": "task_list",
|
|
5
|
+
"name": "Tasks",
|
|
6
|
+
"units": "fraction",
|
|
7
|
+
"aspect": 1,
|
|
8
|
+
"width": 800,
|
|
9
|
+
"nodes": [
|
|
10
|
+
{
|
|
11
|
+
"id": "list",
|
|
12
|
+
"type": "list",
|
|
13
|
+
"x": 0.04,
|
|
14
|
+
"y": 0.04,
|
|
15
|
+
"w": 0.92,
|
|
16
|
+
"h": 0.8,
|
|
17
|
+
"z": 1,
|
|
18
|
+
"props": {
|
|
19
|
+
"title": "Tasks",
|
|
20
|
+
"pageSize": 10,
|
|
21
|
+
"actions": [
|
|
22
|
+
"new",
|
|
23
|
+
"edit",
|
|
24
|
+
"delete"
|
|
25
|
+
],
|
|
26
|
+
"editScreen": "task_edit",
|
|
27
|
+
"columns": [
|
|
28
|
+
{
|
|
29
|
+
"field": "title",
|
|
30
|
+
"label": "Title"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"field": "status",
|
|
34
|
+
"label": "Status"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"field": "dueDate",
|
|
38
|
+
"label": "Due date"
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"source": "tasks"
|
|
45
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"entity": "task",
|
|
3
|
+
"fields": [
|
|
4
|
+
{ "label": "Title", "required": true, "validation": { "maxLength": 200 } },
|
|
5
|
+
{ "label": "Status", "type": "dropdown", "options": ["To do", "In progress", "Done"], "required": true },
|
|
6
|
+
{ "label": "Due date", "type": "date" },
|
|
7
|
+
{ "label": "Description", "type": "textarea", "validation": { "maxLength": 1000 } }
|
|
8
|
+
],
|
|
9
|
+
"listColumns": ["title", "status", "dueDate"]
|
|
10
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Server hooks for task records — screen "task_edit" (and "task_list", which edits in it).
|
|
2
|
+
//
|
|
3
|
+
// Register with @xeplr/factory:
|
|
4
|
+
// factory.init({ knex, hooks: { task_edit: require('./task.hooks') } })
|
|
5
|
+
//
|
|
6
|
+
// Each operation has four hooks. Delete the ones you do not need.
|
|
7
|
+
// before(ctx) first — save: return new values to replace them (they are then checked
|
|
8
|
+
// against the screen's rules); get: narrow ctx.query; any: ctx.reject(message, { field })
|
|
9
|
+
// after(ctx) once it succeeded — ctx.id, ctx.result; return a value to replace the result.
|
|
10
|
+
// If it throws, the operation still happened (and error is told).
|
|
11
|
+
// error(ctx, err) when the operation failed
|
|
12
|
+
// override(ctx) does the WHOLE operation instead — no rules, no before / after / error,
|
|
13
|
+
// no generic query. What it returns is the response.
|
|
14
|
+
//
|
|
15
|
+
// ctx: op, screenKey, screen, table, id, isNew (save), many (get), input (what the UI sent, read-only),
|
|
16
|
+
// values (save), previous (save / delete: the row before), result, user, tenant, knex, query (get), reject
|
|
17
|
+
|
|
18
|
+
module.exports = {
|
|
19
|
+
save: {
|
|
20
|
+
// before: async function(ctx) { return ctx.values },
|
|
21
|
+
// after: async function(ctx) {},
|
|
22
|
+
// error: async function(ctx, err) {},
|
|
23
|
+
// override: async function(ctx) { return savedRecord },
|
|
24
|
+
},
|
|
25
|
+
get: {
|
|
26
|
+
// before: async function(ctx) { /* ctx.query.where(...) */ },
|
|
27
|
+
// after: async function(ctx) { return ctx.result },
|
|
28
|
+
// error: async function(ctx, err) {},
|
|
29
|
+
// override: async function(ctx) { return ctx.id ? record : records },
|
|
30
|
+
},
|
|
31
|
+
delete: {
|
|
32
|
+
// before: async function(ctx) {},
|
|
33
|
+
// after: async function(ctx) {},
|
|
34
|
+
// error: async function(ctx, err) {},
|
|
35
|
+
// override: async function(ctx) { return { id: ctx.id } },
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The model for task records — how values are shaped between your code and
|
|
2
|
+
// the "tasks" table, like Sequelize's getters and setters.
|
|
3
|
+
//
|
|
4
|
+
// Register with @xeplr/factory: factory.init({ knex, hooks, models: [require('./task.model')] })
|
|
5
|
+
//
|
|
6
|
+
// Used everywhere: the screens' routes and factory.table('tasks'). Models shape
|
|
7
|
+
// data; the hooks file decides behaviour.
|
|
8
|
+
|
|
9
|
+
var { FactoryModel } = require('@xeplr/factory');
|
|
10
|
+
|
|
11
|
+
class TaskModel extends FactoryModel {
|
|
12
|
+
static table = 'tasks';
|
|
13
|
+
|
|
14
|
+
// Per field: set(value, values) before it is written, get(value, row) after it is read.
|
|
15
|
+
// tags: { set: (v) => (Array.isArray(v) ? v.join(',') : v), get: (v) => (v ? v.split(',') : []) }
|
|
16
|
+
static fields = {};
|
|
17
|
+
|
|
18
|
+
/** Every value about to be written. Override for more than one field at a time. */
|
|
19
|
+
static toDb(values) {
|
|
20
|
+
return super.toDb(values);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Every row just read. */
|
|
24
|
+
static fromDb(row) {
|
|
25
|
+
return super.fromDb(row);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = TaskModel;
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
"preview": "vite preview"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
+
"@tanstack/react-table": "^8.21.3",
|
|
12
13
|
"@xeplr/ui-account": "^1.0.6",
|
|
14
|
+
"@xeplr/ui-canvas": "^1.0.2",
|
|
15
|
+
"@xeplr/ui-factory": "^1.0.5",
|
|
16
|
+
"@xeplr/ui-table": "^1.0.6",
|
|
13
17
|
"react": "^19.2.4",
|
|
14
18
|
"react-dom": "^19.2.4",
|
|
15
19
|
"react-router-dom": "^7.13.2"
|
package/templates/ui/src/App.jsx
CHANGED
|
@@ -3,6 +3,10 @@ import { Routes, Route, Outlet, Navigate, useNavigate } from 'react-router-dom'
|
|
|
3
3
|
import { authRoutes, authPath, NavPage, ProtectedRoute } from '@xeplr/ui-account'
|
|
4
4
|
import Home from './pages/Home.jsx'
|
|
5
5
|
import Tasks from './pages/Tasks.jsx'
|
|
6
|
+
import Forms from './pages/Forms.jsx'
|
|
7
|
+
import FormDesigner from './pages/FormDesigner.jsx'
|
|
8
|
+
import FormRecords from './pages/FormRecords.jsx'
|
|
9
|
+
__MT_APP_UI_IMPORTS__
|
|
6
10
|
|
|
7
11
|
// The rail is ICON-ONLY when collapsed, so a drawer item without one is
|
|
8
12
|
// invisible until somebody widens the rail. Same 24x24 stroke language the
|
|
@@ -15,6 +19,15 @@ const TasksIcon = (
|
|
|
15
19
|
</svg>
|
|
16
20
|
)
|
|
17
21
|
|
|
22
|
+
const FormsIcon = (
|
|
23
|
+
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
|
|
24
|
+
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
25
|
+
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
26
|
+
<path d="M3 9h18M9 21V9" />
|
|
27
|
+
</svg>
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__MT_SWITCH_ICON__
|
|
18
31
|
const HomeIcon = (
|
|
19
32
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
|
|
20
33
|
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
@@ -42,7 +55,10 @@ function Shell() {
|
|
|
42
55
|
// item with no error anywhere.
|
|
43
56
|
const drawerItems = useMemo(() => [
|
|
44
57
|
{ name: 'Home', icon: HomeIcon, clickHandler: () => navigate('/home') },
|
|
45
|
-
{ name: 'Tasks', icon: TasksIcon, clickHandler: () => navigate('/tasks') }
|
|
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') },
|
|
61
|
+
__MT_DRAWER_ITEM__
|
|
46
62
|
], [navigate])
|
|
47
63
|
|
|
48
64
|
return (
|
|
@@ -72,10 +88,14 @@ export default function App() {
|
|
|
72
88
|
our own chrome. */}
|
|
73
89
|
{authRoutes({}, { layout: <Shell /> })}
|
|
74
90
|
|
|
75
|
-
|
|
91
|
+
__MT_SELECT_ROUTE__
|
|
92
|
+
<Route element={<ProtectedRoute>__MT_GATE_OPEN__<Shell />__MT_GATE_CLOSE__</ProtectedRoute>}>
|
|
76
93
|
<Route path="/" element={<Navigate to="/home" replace />} />
|
|
77
94
|
<Route path="/home" element={<Home />} />
|
|
78
95
|
<Route path="/tasks" element={<Tasks />} />
|
|
96
|
+
<Route path="/forms" element={<Forms />} />
|
|
97
|
+
<Route path="/forms/:form" element={<FormRecords />} />
|
|
98
|
+
<Route path="/forms/:form/design/:part" element={<FormDesigner />} />
|
|
79
99
|
</Route>
|
|
80
100
|
|
|
81
101
|
<Route path="*" element={<Navigate to="/auth/login" replace />} />
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// MODEL LAYER — every call the screens and the Forms pages make, to the API's
|
|
2
|
+
// @xeplr/factory routes (mounted at /api in api/app.js).
|
|
3
|
+
//
|
|
4
|
+
// authFetch attaches the signed-in user's token (and the active company, when
|
|
5
|
+
// the app has tenancy) and returns the PARSED body. createFactoryApi accepts
|
|
6
|
+
// it as it is.
|
|
7
|
+
import { authFetch } from '@xeplr/ui-account'
|
|
8
|
+
import { createFactoryApi } from '@xeplr/ui-factory'
|
|
9
|
+
|
|
10
|
+
export const factory = createFactoryApi({ fetch: authFetch, base: '/api' })
|
|
@@ -273,3 +273,63 @@ body { margin: 0; }
|
|
|
273
273
|
.app-page-split { display: block; }
|
|
274
274
|
.app-help { position: static; margin-top: 28px; width: auto; }
|
|
275
275
|
}
|
|
276
|
+
|
|
277
|
+
/* ── Forms ────────────────────────────────────────────────────────────── */
|
|
278
|
+
.app-forms-new { display: flex; gap: 8px; max-width: 640px; margin: 0 0 18px; }
|
|
279
|
+
|
|
280
|
+
.app-forms-new input {
|
|
281
|
+
flex: 1;
|
|
282
|
+
padding: 9px 11px;
|
|
283
|
+
border-radius: 6px;
|
|
284
|
+
border: 1px solid var(--xeplr-input-border);
|
|
285
|
+
background: var(--xeplr-input-bg);
|
|
286
|
+
color: var(--xeplr-input-text);
|
|
287
|
+
font: inherit;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.app-forms-pending { color: var(--xeplr-warning); }
|
|
291
|
+
|
|
292
|
+
.app-notice {
|
|
293
|
+
margin: 0 0 14px;
|
|
294
|
+
padding: 10px 14px;
|
|
295
|
+
border-radius: 6px;
|
|
296
|
+
border: 1px solid var(--xeplr-success-border);
|
|
297
|
+
background: var(--xeplr-success-bg);
|
|
298
|
+
color: var(--xeplr-success);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/* ── Designer ─────────────────────────────────────────────────────────── */
|
|
302
|
+
/* The builder fills the space it is given, so it needs a height: the page
|
|
303
|
+
below the top bar, less .main's padding. */
|
|
304
|
+
.app-designer {
|
|
305
|
+
display: flex;
|
|
306
|
+
flex-direction: column;
|
|
307
|
+
height: calc(100vh - 110px);
|
|
308
|
+
min-height: 520px;
|
|
309
|
+
gap: 10px;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.app-designer-bar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
313
|
+
|
|
314
|
+
.app-designer-title { font-weight: 600; margin-right: 10px; }
|
|
315
|
+
|
|
316
|
+
.app-designer-bar .is-active { background: var(--xeplr-bg-secondary); }
|
|
317
|
+
|
|
318
|
+
.app-designer-canvas {
|
|
319
|
+
flex: 1;
|
|
320
|
+
min-height: 0;
|
|
321
|
+
border: 1px solid var(--xeplr-border-primary);
|
|
322
|
+
border-radius: 8px;
|
|
323
|
+
overflow: hidden;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.app-help-prompt {
|
|
327
|
+
white-space: pre-wrap;
|
|
328
|
+
font-size: 12px;
|
|
329
|
+
line-height: 1.5;
|
|
330
|
+
padding: 10px 12px;
|
|
331
|
+
margin: 8px 0;
|
|
332
|
+
border-radius: 6px;
|
|
333
|
+
background: var(--xeplr-bg-tertiary);
|
|
334
|
+
border: 1px solid var(--xeplr-border-primary);
|
|
335
|
+
}
|
|
@@ -3,12 +3,14 @@ import ReactDOM from 'react-dom/client'
|
|
|
3
3
|
import { BrowserRouter } from 'react-router-dom'
|
|
4
4
|
import { configure, AccessProvider, ThemeProvider } from '@xeplr/ui-account'
|
|
5
5
|
import App from './App.jsx'
|
|
6
|
+
__MT_UI_IMPORTS__
|
|
6
7
|
import './index.css'
|
|
7
8
|
import '@xeplr/ui-account/src/designs/theme.css'
|
|
8
9
|
|
|
9
10
|
// Auth requests are same-origin in dev — vite proxies /auth/api to AUTH_URL
|
|
10
11
|
// (see vite.config.js, which reads it from .env).
|
|
11
12
|
configure('')
|
|
13
|
+
__MT_UI_REGISTER__
|
|
12
14
|
|
|
13
15
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
14
16
|
<React.StrictMode>
|