@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
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- "Designer" — the drawer item for designing screens (ui/src/pages/Designer.jsx).
|
|
2
|
+
--
|
|
3
|
+
-- NOT public: Publish changes database tables, so only the roles that hold
|
|
4
|
+
-- @xeplr/factory's design permission see the item. The API refuses everyone
|
|
5
|
+
-- else anyway ("Access denied: Publish factory screen"); this keeps the menu
|
|
6
|
+
-- honest 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'), 'Designer', '', false, true, '*', now(), now()
|
|
12
|
+
WHERE NOT EXISTS (SELECT 1 FROM "menus" WHERE name = 'Designer');
|
|
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 IN ('Super Admin', 'CompanyAdmin')
|
|
18
|
+
AND m.name = 'Designer'
|
|
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 with the Designer (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,11 @@
|
|
|
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.3",
|
|
24
|
+
"@xeplr/utils": "^1.0.8",
|
|
23
25
|
"dotenv": "^17.4.2",
|
|
24
26
|
"express": "^5.2.0"
|
|
25
27
|
},
|
|
@@ -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
|
+
// Screens made in the Designer (tasks) 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,32 @@
|
|
|
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 in the app's Designer 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 AN ENTITY:
|
|
14
|
+
//
|
|
15
|
+
// npx xeplr-factory screens screens/project/project.entity.json -o screens/project
|
|
16
|
+
//
|
|
17
|
+
// then list its two screens and its hooks below, and add a page and a menu
|
|
18
|
+
// entry for it (see the UI's src/pages/Tasks.jsx and migrations-auth/).
|
|
19
|
+
|
|
20
|
+
module.exports = {
|
|
21
|
+
// Publish order is worked out for you: a table before the dropdowns that
|
|
22
|
+
// point at it, lists last.
|
|
23
|
+
documents: [
|
|
24
|
+
require('./task/task-edit.screen.json'),
|
|
25
|
+
require('./task/task-list.screen.json')
|
|
26
|
+
],
|
|
27
|
+
|
|
28
|
+
// Your own code around save / get / delete, per screen — see task/task.hooks.js.
|
|
29
|
+
hooks: {
|
|
30
|
+
task_edit: require('./task/task.hooks')
|
|
31
|
+
}
|
|
32
|
+
};
|
|
@@ -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
|
+
}
|
|
@@ -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.4",
|
|
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,8 @@ 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 Designer from './pages/Designer.jsx'
|
|
7
|
+
__MT_APP_UI_IMPORTS__
|
|
6
8
|
|
|
7
9
|
// The rail is ICON-ONLY when collapsed, so a drawer item without one is
|
|
8
10
|
// invisible until somebody widens the rail. Same 24x24 stroke language the
|
|
@@ -15,6 +17,15 @@ const TasksIcon = (
|
|
|
15
17
|
</svg>
|
|
16
18
|
)
|
|
17
19
|
|
|
20
|
+
const DesignerIcon = (
|
|
21
|
+
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
|
|
22
|
+
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
23
|
+
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
24
|
+
<path d="M3 9h18M9 21V9" />
|
|
25
|
+
</svg>
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__MT_SWITCH_ICON__
|
|
18
29
|
const HomeIcon = (
|
|
19
30
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
|
|
20
31
|
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
@@ -42,7 +53,10 @@ function Shell() {
|
|
|
42
53
|
// item with no error anywhere.
|
|
43
54
|
const drawerItems = useMemo(() => [
|
|
44
55
|
{ name: 'Home', icon: HomeIcon, clickHandler: () => navigate('/home') },
|
|
45
|
-
{ name: 'Tasks', icon: TasksIcon, clickHandler: () => navigate('/tasks') }
|
|
56
|
+
{ name: 'Tasks', icon: TasksIcon, clickHandler: () => navigate('/tasks') },
|
|
57
|
+
// Shown only to roles given the "Designer" menu (migrations-auth/0003).
|
|
58
|
+
{ name: 'Designer', icon: DesignerIcon, clickHandler: () => navigate('/designer') },
|
|
59
|
+
__MT_DRAWER_ITEM__
|
|
46
60
|
], [navigate])
|
|
47
61
|
|
|
48
62
|
return (
|
|
@@ -72,10 +86,12 @@ export default function App() {
|
|
|
72
86
|
our own chrome. */}
|
|
73
87
|
{authRoutes({}, { layout: <Shell /> })}
|
|
74
88
|
|
|
75
|
-
|
|
89
|
+
__MT_SELECT_ROUTE__
|
|
90
|
+
<Route element={<ProtectedRoute>__MT_GATE_OPEN__<Shell />__MT_GATE_CLOSE__</ProtectedRoute>}>
|
|
76
91
|
<Route path="/" element={<Navigate to="/home" replace />} />
|
|
77
92
|
<Route path="/home" element={<Home />} />
|
|
78
93
|
<Route path="/tasks" element={<Tasks />} />
|
|
94
|
+
<Route path="/designer" element={<Designer />} />
|
|
79
95
|
</Route>
|
|
80
96
|
|
|
81
97
|
<Route path="*" element={<Navigate to="/auth/login" replace />} />
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// MODEL LAYER — every call the screens and the Designer 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,28 @@ 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
|
+
/* ── Designer ─────────────────────────────────────────────────────────── */
|
|
278
|
+
/* The builder fills the space it is given, so it needs a height: the page
|
|
279
|
+
below the top bar, less .main's padding. */
|
|
280
|
+
.app-designer {
|
|
281
|
+
display: flex;
|
|
282
|
+
flex-direction: column;
|
|
283
|
+
height: calc(100vh - 110px);
|
|
284
|
+
min-height: 520px;
|
|
285
|
+
gap: 10px;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.app-designer-bar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
289
|
+
|
|
290
|
+
.app-designer-title { font-weight: 600; margin-right: 10px; }
|
|
291
|
+
|
|
292
|
+
.app-designer-bar .is-active { background: var(--xeplr-bg-secondary); }
|
|
293
|
+
|
|
294
|
+
.app-designer-canvas {
|
|
295
|
+
flex: 1;
|
|
296
|
+
min-height: 0;
|
|
297
|
+
border: 1px solid var(--xeplr-border-primary);
|
|
298
|
+
border-radius: 8px;
|
|
299
|
+
overflow: hidden;
|
|
300
|
+
}
|
|
@@ -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>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// THE DESIGNER — change any screen: move and resize fields, add new ones,
|
|
2
|
+
// change labels, rules, fonts and colours.
|
|
3
|
+
//
|
|
4
|
+
// Your edits are kept as a DRAFT automatically. Nobody else sees them until
|
|
5
|
+
// you press Publish, which also changes the screen's database table to match:
|
|
6
|
+
// a new field adds a column; removing one asks first, because its column and
|
|
7
|
+
// every value in it would be deleted.
|
|
8
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
9
|
+
import { FactoryBuilder } from '@xeplr/ui-factory'
|
|
10
|
+
import { factory } from '../api/factory.js'
|
|
11
|
+
|
|
12
|
+
export default function Designer() {
|
|
13
|
+
const [screens, setScreens] = useState([])
|
|
14
|
+
const [current, setCurrent] = useState(null) // { key, document, lockedNames }
|
|
15
|
+
const [error, setError] = useState(null)
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
factory.listScreens().then((rows) => {
|
|
19
|
+
setScreens(rows)
|
|
20
|
+
if (rows.length) open(rows[0].screenKey)
|
|
21
|
+
}, (e) => setError(e.message))
|
|
22
|
+
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
23
|
+
|
|
24
|
+
const open = useCallback(async (key) => {
|
|
25
|
+
try {
|
|
26
|
+
// The draft if there is one, else the published version — with the field
|
|
27
|
+
// names that are already columns, which the Designer will not let you rename.
|
|
28
|
+
const row = await factory.loadDraft(key)
|
|
29
|
+
setCurrent({ key, document: row.document, lockedNames: row.lockedNames || [] })
|
|
30
|
+
setError(null)
|
|
31
|
+
} catch (e) {
|
|
32
|
+
setError(e.message)
|
|
33
|
+
}
|
|
34
|
+
}, [])
|
|
35
|
+
|
|
36
|
+
const publish = useCallback(async (doc, options) => {
|
|
37
|
+
const result = await factory.publish(doc, options)
|
|
38
|
+
// New columns are now locked names too.
|
|
39
|
+
const row = await factory.loadDraft(doc.id)
|
|
40
|
+
setCurrent((c) => (c && c.key === doc.id ? { ...c, lockedNames: row.lockedNames || [] } : c))
|
|
41
|
+
return result
|
|
42
|
+
}, [])
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="app-designer">
|
|
46
|
+
<div className="app-designer-bar">
|
|
47
|
+
<span className="app-designer-title">Designer</span>
|
|
48
|
+
{screens.map((s) => (
|
|
49
|
+
<button
|
|
50
|
+
key={s.screenKey}
|
|
51
|
+
type="button"
|
|
52
|
+
className={'app-btn app-btn-quiet' + (current && current.key === s.screenKey ? ' is-active' : '')}
|
|
53
|
+
onClick={() => open(s.screenKey)}
|
|
54
|
+
>
|
|
55
|
+
{s.name || s.screenKey}
|
|
56
|
+
</button>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
{error && <div className="app-error">{error}</div>}
|
|
60
|
+
{current && (
|
|
61
|
+
<div className="app-designer-canvas">
|
|
62
|
+
<FactoryBuilder
|
|
63
|
+
key={current.key}
|
|
64
|
+
{...factory.builderProps}
|
|
65
|
+
document={current.document}
|
|
66
|
+
lockedNames={current.lockedNames}
|
|
67
|
+
onPublish={publish}
|
|
68
|
+
screens={screens.map((s) => ({ id: s.screenKey, name: s.name || s.screenKey }))}
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -58,6 +58,10 @@ export default function Home() {
|
|
|
58
58
|
<code>public/logo.svg</code> and <code>public/logo-wide.svg</code> — the
|
|
59
59
|
sample logo, in the top bar and the side rail.
|
|
60
60
|
</li>
|
|
61
|
+
<li>
|
|
62
|
+
<strong>Designer</strong>, in the side rail — change the sample task
|
|
63
|
+
screens, or see how they are made. Publish changes the table too.
|
|
64
|
+
</li>
|
|
61
65
|
<li>
|
|
62
66
|
<code>src/App.jsx</code> — add your own pages, and the side-rail items
|
|
63
67
|
that open them.
|
|
@@ -1,105 +1,29 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
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
|
|
14
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
)
|