@xeplr/cli 0.1.7 → 0.1.9

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/bin/xeplr.js CHANGED
@@ -54,7 +54,14 @@ async function main() {
54
54
 
55
55
  var session = prompt.createSession();
56
56
  try {
57
- var answers = await questions.collect(session, argv[1] && argv[1].indexOf('--') !== 0 ? argv[1] : undefined);
57
+ // A folder that already has something in it is refused NOW, before a
58
+ // single question is asked — not after the database password.
59
+ var argName = argv[1] && argv[1].indexOf('--') !== 0 ? argv[1] : undefined;
60
+ if (argName && generate.isOccupied(path.resolve(process.cwd(), argName))) {
61
+ console.error('\n ' + path.resolve(process.cwd(), argName) + ' already exists and is not empty.\n');
62
+ return 1;
63
+ }
64
+ var answers = await questions.collect(session, argName);
58
65
  console.log(plan.describe(answers));
59
66
 
60
67
  var go = await session.confirm(' Create it?', true);
@@ -226,7 +226,7 @@ function walk(dir) {
226
226
  * @returns {{ written: string[] }}
227
227
  */
228
228
  function generate(answers, target) {
229
- if (fs.existsSync(target) && fs.readdirSync(target).length > 0) {
229
+ if (isOccupied(target)) {
230
230
  throw new Error(target + ' already exists and is not empty.');
231
231
  }
232
232
 
@@ -270,4 +270,9 @@ function generate(answers, target) {
270
270
  return { written: written.sort() };
271
271
  }
272
272
 
273
- module.exports = { generate: generate, substitutions: substitutions, fill: fill };
273
+ /** A folder that exists and has something in it — generating there could overwrite work. */
274
+ function isOccupied(target) {
275
+ return fs.existsSync(target) && fs.readdirSync(target).length > 0;
276
+ }
277
+
278
+ module.exports = { isOccupied: isOccupied, generate: generate, substitutions: substitutions, fill: fill };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Creates a working xeplr application — API, sign-in and UI — from a few answers.",
5
5
  "license": "MIT",
6
6
  "author": "xeppie <admin@xeplr.com>",
@@ -7,7 +7,7 @@ Read `api/node_modules/@xeplr/ui-factory/AUTHORING.md` for what screens, hooks a
7
7
 
8
8
  ## Create a new UI
9
9
 
10
- A new UI is a **form**: a list, and an add / edit form it opens in a popup, saved in its own table. Use the singular for `<form>` (`farming_department`), the plural for the page (`FarmingDepartments`).
10
+ A new UI is a **form**: a list, and an add / edit form it opens — on a page of its own (like Tasks) or in a popup — saved in its own table. Use the singular for `<form>` (`farming_department`), the plural for the page (`FarmingDepartments`).
11
11
 
12
12
  Create every file below — the hooks and the model run the defaults (`super`) until the request needs more.
13
13
 
@@ -32,18 +32,28 @@ Then change only what the request asks for:
32
32
  | the request says… | change |
33
33
  |---|---|
34
34
  | a field, label, rule, dropdown, layout, colour | the screen JSON → validate → publish in Configure UI → Forms (or, never published yet, restart the API) |
35
- | hide / filter rows on screen, an extra button on each row, fill in a value before saving | front-end hooks — `ui/src/pages/Edit<Form>.jsx` |
35
+ | hide / filter rows on screen, an extra button on each row, fill in a value before saving, check something before Next moves on | front-end hooks — `ui/src/pages/Edit<Form>.jsx` (`get`, `save`, `delete`, `actions`, `step`) |
36
36
  | must / only if / check against / email when / only managers see | server hooks — `api/screens/<form>/<form>.hooks.js` |
37
37
  | store as / convert / comma-separated ↔ array / work out X from Y | server model — `api/screens/<form>/<form>.model.js` |
38
38
  | a query in your own server code | `factory.table('<forms table>')` — this company, active rows, the model applied |
39
39
 
40
40
  **Without code:** Super Admin can also make a form in **Configure UI → Forms** (settings menu, top right): New form (label + key) → design → Publish → Add to menu. It gets no page, hooks or model files; add them with the steps above when it needs them.
41
41
 
42
+ ### The controls a field can be
43
+
44
+ `text` (default), `textarea`, `number`, `date`, `datetime`, `checkbox`, `dropdown`, `radio`, `multiselect`, `file`, plus `stepper` and `label`. The sample uses every one — read `api/screens/task/task.entity.json` for how each is written. Three are worth knowing about:
45
+
46
+ - **`multiselect`** — several ids in ONE text column, comma separated, and an array in your code. An option's id may not contain a comma.
47
+ - **`file`** — the column holds the **path**, never the file. Say which extensions (`"accept": ".pdf,.docx"`) and how large (`"maxSize": 10`, in MB); the API enforces both, by extension, and keeps the file under `FACTORY_FILES_DIR`. Uploading needs `multer` in `api/` (already in package.json).
48
+ - **`stepper`** — a journey across the top. Every other field says which step it is on (`"step": "Planning"`), and one without a `step` shows on every step. `step(ctx)` in the front-end hooks runs before each move.
49
+
50
+ **A list opens its form on a page or in a popup** — `"openIn": "page"` in the entity spec, or Configure UI → Forms → the list. A page needs `onOpenRecord` on the list's page (where to navigate) and `onDone` on the form's page (where Done goes back to): `Tasks.jsx` and `EditTask.jsx` show both, with routes `/tasks/new` and `/tasks/:id` in `App.jsx`.
51
+
42
52
  **Keys and labels.** A form's key names its screens and table and never changes once published; its label is the screens' name. A menu item's key (`menus.name`) is what `drawerItems` matches; its label is what the rail shows, renamed in Configure UI → Menu. Code only ever uses keys.
43
53
 
44
54
  ### The prompt
45
55
 
46
- > Create a new UI for **farming departments**. One record is a farming department with: **Name** (required), **Region** (dropdown: North, South, East, West), **Area in acres** (number, at least 0), **Started on** (date), **Notes** (long text). Show Name, Region and Area in the list. Follow "Create a new UI" in CLAUDE.md.
56
+ > Create a new UI for **farming departments**. One record is a farming department with: **Name** (required), **Region** (dropdown: North, South, East, West), **Area in acres** (number, at least 0), **Started on** (date), **Certifications** (choose several: Organic, Fair trade, Rainforest), **Inspection at** (date and time), **Licence** (file: .pdf or .jpg), **Notes** (long text). Show Name, Region and Area in the list. Follow "Create a new UI" in CLAUDE.md.
47
57
 
48
58
  Replace the bold parts. Add behaviour in the same message if you want it — "a Mark active button on each row", "Region is required when Area is over 100", "store tags as an array".
49
59
 
@@ -24,7 +24,7 @@ Open http://localhost:__UI_PORT__ and sign in as `__ADMIN_EMAIL__`.
24
24
  ## What is in it
25
25
 
26
26
  - **Sign-in** — login, register, password reset, profile, and the admin screens for users, roles and permissions (`@xeplr/auth`, `@xeplr/ui-account`).
27
- - **Tasks** — a sample built from screens: a list, and a form it opens in a popup, saved into an ordinary `tasks` table.
27
+ - **Tasks** — a sample built from screens: a list, and a form it opens on a page of its own, saved into an ordinary `tasks` table. The form is filled in over three steps and uses every control there is — text, a dropdown, radio buttons, a tick box, a date, a date and time, several tags at once, and a file.
28
28
  - **Configure UI** — in the settings menu, for Super Admin: every form (make one, design it, publish it, add it to the side rail) and the side rail itself (rename, reorder, hide).
29
29
 
30
30
  ## Create a new UI
@@ -25,6 +25,7 @@ The API (`@xeplr/base-apis`) and the sign-in service (`@xeplr/auth`), with this
25
25
  | `routes/index.js` | this app's own routes — every data address under `/api` |
26
26
  | `routes/access.js` | who may design and publish forms (Super Admin) |
27
27
  | `screens/` | the forms this app ships with: per form, its spec, list and edit screens, server hooks and model — registered in `screens/index.js` |
28
+ | `uploads/` | what people attach through a form's **file** field (`FACTORY_FILES_DIR`). Never committed, and never served: a file comes back only through the API's own route, which checks the caller the way reading a record does |
28
29
  | `migrations/` | hand-written SQL for tables no form owns |
29
30
  | `migrations-auth/` | this app's rows in the sign-in database — menus (by key), permissions |
30
31
  | `models/` | models for hand-written tables |
@@ -61,7 +61,15 @@ async function start() {
61
61
  // The screens, their hooks and their models. A hooks file with a typo
62
62
  // ("beforeSave") or a malformed model stops startup here, rather than
63
63
  // silently never running.
64
- await factory.init({ knex: knex, hooks: screens.hooks, models: screens.models });
64
+ // filesDir: where a form's file field puts what people upload. Nothing is
65
+ // served from it — a file comes back only through the factory's own route,
66
+ // which checks the caller the way reading a record does.
67
+ await factory.init({
68
+ knex: knex,
69
+ hooks: screens.hooks,
70
+ models: screens.models,
71
+ filesDir: process.env.FACTORY_FILES_DIR
72
+ });
65
73
 
66
74
  // Screens never published yet are published now, creating their tables.
67
75
  // Ones already published are left alone — Configure UI → Forms owns them.
@@ -56,3 +56,22 @@ AUTH_SUPER_ADMIN_PASSWORD=__ADMIN_PASSWORD__
56
56
  # email template can carry a token as a value but cannot build an address.
57
57
  AUTH_ACTIVATION_URL=http://localhost:__UI_PORT__/auth/activate
58
58
  AUTH_INVITE_URL=http://localhost:__UI_PORT__/auth/accept-invite
59
+
60
+ # ── Uploaded files ──
61
+ # Where a form's FILE field puts what people upload. The path is what the
62
+ # record stores; the file itself is served only through the API's own route,
63
+ # which checks the caller the way reading a record does. Relative to api/.
64
+ FACTORY_FILES_DIR=./uploads/factory
65
+
66
+ # ── Email (optional) ──
67
+ # Without it sign-in works, but activation, invite and password-reset links
68
+ # cannot be sent. The sign-in service checks it at start and says so in its
69
+ # banner: "email ✓ smtp — host:587, connected" or "✗ NOT WORKING — reason".
70
+ # Providers: smtp | brevo | aws | azure — see @xeplr/utils' README.
71
+ # EMAIL_PROVIDER=smtp
72
+ # SMTP_HOST=
73
+ # SMTP_PORT=587
74
+ # SMTP_USER=
75
+ # SMTP_PASS=
76
+ # SMTP_FROM=
77
+ # (smtp also needs `npm install nodemailer` in this folder)
@@ -1,2 +1,4 @@
1
1
  node_modules/
2
2
  logs/
3
+ # Files people upload through a form's file field (FACTORY_FILES_DIR).
4
+ uploads/
@@ -20,14 +20,15 @@
20
20
  "@xeplr/auth": "^1.1.4",
21
21
  "@xeplr/base-apis": "^2.0.0",
22
22
  "@xeplr/db": "^1.0.6",
23
- "@xeplr/factory": "^1.0.5",
24
- "@xeplr/ui-factory": "^1.0.5",
23
+ "@xeplr/factory": "^1.0.6",
24
+ "@xeplr/ui-factory": "^1.0.11",
25
25
  "@xeplr/utils": "^1.0.11",
26
26
  "dotenv": "^17.4.2",
27
- "express": "^5.2.0"
27
+ "express": "^5.2.0",
28
+ "multer": "^2.0.2"
28
29
  },
29
30
  "devDependencies": {
30
31
  "dotenv-cli": "^8.0.0",
31
- "@xeplr/cli": "^0.1.0"
32
+ "@xeplr/cli": "^0.1.8"
32
33
  }
33
34
  }
@@ -20,14 +20,40 @@
20
20
  "variant": "heading"
21
21
  }
22
22
  },
23
+ {
24
+ "id": "stepper",
25
+ "type": "stepper",
26
+ "x": 0.04,
27
+ "y": 0.15,
28
+ "w": 0.92,
29
+ "h": 0.09,
30
+ "z": 2,
31
+ "props": {
32
+ "steps": [
33
+ {
34
+ "key": "the_task",
35
+ "label": "The task"
36
+ },
37
+ {
38
+ "key": "planning",
39
+ "label": "Planning"
40
+ },
41
+ {
42
+ "key": "attachment",
43
+ "label": "Attachment"
44
+ }
45
+ ],
46
+ "showNumbers": true
47
+ }
48
+ },
23
49
  {
24
50
  "id": "title",
25
51
  "type": "text",
26
52
  "x": 0.04,
27
- "y": 0.15,
28
- "w": 0.4475,
53
+ "y": 0.28,
54
+ "w": 0.92,
29
55
  "h": 0.08,
30
- "z": 2,
56
+ "z": 3,
31
57
  "props": {
32
58
  "label": "Title",
33
59
  "required": true,
@@ -35,16 +61,20 @@
35
61
  "maxLength": 200
36
62
  },
37
63
  "name": "title"
64
+ },
65
+ "step": {
66
+ "of": "stepper",
67
+ "index": 0
38
68
  }
39
69
  },
40
70
  {
41
71
  "id": "status",
42
72
  "type": "dropdown",
43
- "x": 0.5125,
44
- "y": 0.15,
73
+ "x": 0.04,
74
+ "y": 0.4,
45
75
  "w": 0.4475,
46
76
  "h": 0.08,
47
- "z": 3,
77
+ "z": 4,
48
78
  "props": {
49
79
  "label": "Status",
50
80
  "placeholder": "Select…",
@@ -67,35 +97,175 @@
67
97
  },
68
98
  "required": true,
69
99
  "name": "status"
100
+ },
101
+ "step": {
102
+ "of": "stepper",
103
+ "index": 0
70
104
  }
71
105
  },
72
106
  {
73
- "id": "dueDate",
74
- "type": "date",
75
- "x": 0.04,
76
- "y": 0.27,
107
+ "id": "priority",
108
+ "type": "radio",
109
+ "x": 0.5125,
110
+ "y": 0.4,
77
111
  "w": 0.4475,
78
- "h": 0.08,
79
- "z": 4,
112
+ "h": 0.065,
113
+ "z": 5,
80
114
  "props": {
81
- "label": "Due date",
82
- "name": "dueDate"
115
+ "label": "Priority",
116
+ "data": {
117
+ "source": "static",
118
+ "options": [
119
+ {
120
+ "id": "low",
121
+ "name": "Low"
122
+ },
123
+ {
124
+ "id": "normal",
125
+ "name": "Normal"
126
+ },
127
+ {
128
+ "id": "high",
129
+ "name": "High"
130
+ }
131
+ ]
132
+ },
133
+ "layout": "horizontal",
134
+ "name": "priority"
135
+ },
136
+ "step": {
137
+ "of": "stepper",
138
+ "index": 0
83
139
  }
84
140
  },
85
141
  {
86
142
  "id": "description",
87
143
  "type": "textarea",
88
144
  "x": 0.04,
89
- "y": 0.39,
145
+ "y": 0.52,
90
146
  "w": 0.92,
91
147
  "h": 0.18,
92
- "z": 5,
148
+ "z": 6,
93
149
  "props": {
94
150
  "label": "Description",
95
151
  "validation": {
96
152
  "maxLength": 1000
97
153
  },
98
154
  "name": "description"
155
+ },
156
+ "step": {
157
+ "of": "stepper",
158
+ "index": 0
159
+ }
160
+ },
161
+ {
162
+ "id": "dueDate",
163
+ "type": "date",
164
+ "x": 0.04,
165
+ "y": 0.28,
166
+ "w": 0.4475,
167
+ "h": 0.08,
168
+ "z": 7,
169
+ "props": {
170
+ "label": "Due date",
171
+ "name": "dueDate"
172
+ },
173
+ "step": {
174
+ "of": "stepper",
175
+ "index": 1
176
+ }
177
+ },
178
+ {
179
+ "id": "remindAt",
180
+ "type": "datetime",
181
+ "x": 0.5125,
182
+ "y": 0.28,
183
+ "w": 0.4475,
184
+ "h": 0.08,
185
+ "z": 8,
186
+ "props": {
187
+ "label": "Remind at",
188
+ "name": "remindAt"
189
+ },
190
+ "step": {
191
+ "of": "stepper",
192
+ "index": 1
193
+ }
194
+ },
195
+ {
196
+ "id": "tags",
197
+ "type": "multiselect",
198
+ "x": 0.04,
199
+ "y": 0.4,
200
+ "w": 0.92,
201
+ "h": 0.065,
202
+ "z": 9,
203
+ "props": {
204
+ "label": "Tags",
205
+ "data": {
206
+ "source": "static",
207
+ "options": [
208
+ {
209
+ "id": "bug",
210
+ "name": "Bug"
211
+ },
212
+ {
213
+ "id": "feature",
214
+ "name": "Feature"
215
+ },
216
+ {
217
+ "id": "chore",
218
+ "name": "Chore"
219
+ },
220
+ {
221
+ "id": "customer",
222
+ "name": "Customer"
223
+ }
224
+ ]
225
+ },
226
+ "layout": "horizontal",
227
+ "name": "tags"
228
+ },
229
+ "step": {
230
+ "of": "stepper",
231
+ "index": 1
232
+ }
233
+ },
234
+ {
235
+ "id": "blocked",
236
+ "type": "checkbox",
237
+ "x": 0.04,
238
+ "y": 0.505,
239
+ "w": 0.4475,
240
+ "h": 0.05,
241
+ "z": 10,
242
+ "props": {
243
+ "label": "Blocked",
244
+ "default": false,
245
+ "name": "blocked"
246
+ },
247
+ "step": {
248
+ "of": "stepper",
249
+ "index": 1
250
+ }
251
+ },
252
+ {
253
+ "id": "attachment",
254
+ "type": "file",
255
+ "x": 0.04,
256
+ "y": 0.28,
257
+ "w": 0.92,
258
+ "h": 0.1,
259
+ "z": 11,
260
+ "props": {
261
+ "label": "Attachment",
262
+ "accept": ".pdf,.png,.jpg,.jpeg,.docx,.xlsx",
263
+ "maxSize": 10,
264
+ "name": "attachment"
265
+ },
266
+ "step": {
267
+ "of": "stepper",
268
+ "index": 2
99
269
  }
100
270
  }
101
271
  ],
@@ -23,6 +23,7 @@
23
23
  "edit",
24
24
  "delete"
25
25
  ],
26
+ "openIn": "page",
26
27
  "editScreen": "task_edit",
27
28
  "columns": [
28
29
  {
@@ -33,9 +34,17 @@
33
34
  "field": "status",
34
35
  "label": "Status"
35
36
  },
37
+ {
38
+ "field": "priority",
39
+ "label": "Priority"
40
+ },
36
41
  {
37
42
  "field": "dueDate",
38
43
  "label": "Due date"
44
+ },
45
+ {
46
+ "field": "tags",
47
+ "label": "Tags"
39
48
  }
40
49
  ]
41
50
  }
@@ -1,10 +1,91 @@
1
1
  {
2
2
  "entity": "task",
3
+ "columns": 2,
4
+ "openIn": "page",
3
5
  "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 } }
6
+ {
7
+ "type": "stepper",
8
+ "steps": [
9
+ "The task",
10
+ "Planning",
11
+ "Attachment"
12
+ ]
13
+ },
14
+ {
15
+ "label": "Title",
16
+ "step": "The task",
17
+ "required": true,
18
+ "validation": {
19
+ "maxLength": 200
20
+ },
21
+ "width": "full"
22
+ },
23
+ {
24
+ "label": "Status",
25
+ "type": "dropdown",
26
+ "options": [
27
+ "To do",
28
+ "In progress",
29
+ "Done"
30
+ ],
31
+ "required": true
32
+ },
33
+ {
34
+ "label": "Priority",
35
+ "type": "radio",
36
+ "options": [
37
+ "Low",
38
+ "Normal",
39
+ "High"
40
+ ],
41
+ "layout": "horizontal"
42
+ },
43
+ {
44
+ "label": "Description",
45
+ "type": "textarea",
46
+ "validation": {
47
+ "maxLength": 1000
48
+ }
49
+ },
50
+ {
51
+ "label": "Due date",
52
+ "type": "date",
53
+ "step": "Planning"
54
+ },
55
+ {
56
+ "label": "Remind at",
57
+ "type": "datetime"
58
+ },
59
+ {
60
+ "label": "Tags",
61
+ "type": "multiselect",
62
+ "options": [
63
+ "Bug",
64
+ "Feature",
65
+ "Chore",
66
+ "Customer"
67
+ ],
68
+ "layout": "horizontal",
69
+ "width": "full"
70
+ },
71
+ {
72
+ "label": "Blocked",
73
+ "type": "checkbox"
74
+ },
75
+ {
76
+ "label": "Attachment",
77
+ "type": "file",
78
+ "step": "Attachment",
79
+ "accept": ".pdf,.png,.jpg,.jpeg,.docx,.xlsx",
80
+ "maxSize": 10,
81
+ "width": "full"
82
+ }
8
83
  ],
9
- "listColumns": ["title", "status", "dueDate"]
84
+ "listColumns": [
85
+ "title",
86
+ "status",
87
+ "priority",
88
+ "dueDate",
89
+ "tags"
90
+ ]
10
91
  }
@@ -17,7 +17,7 @@ npm run build # → dist/
17
17
  | `src/App.jsx` | routes, the side rail (`drawerItems`) and the settings menu (`settingsOverrides`) — **by key**, never by label |
18
18
  | `src/menu.js` | every menu key this app uses; forms added to the menu are `form:<key>` |
19
19
  | `src/api/factory.js` | the calls to the API's forms routes |
20
- | `src/pages/Tasks.jsx` | the sample list; `EditTask.jsx` holds the task form's **front-end hooks** (`TaskHooks extends FactoryHooks` — every method calls `super`) |
20
+ | `src/pages/Tasks.jsx` | the sample list. It opens records on a **page** (`onOpenRecord` → `/tasks/new`, `/tasks/:id`), and `EditTask.jsx` is that page — it also holds the task form's **front-end hooks** (`TaskHooks extends FactoryHooks` — every method calls `super`, including `step`, run before a stepper moves) and sends **Done** back to the list |
21
21
  | `src/pages/ConfigureUI.jsx` | Configure UI (Super Admin): `Forms.jsx` and `MenuSettings.jsx` tabs; `FormDesigner.jsx` designs one form |
22
22
  | `src/pages/FormRecords.jsx` | opens any published form — what a form added to the menu links to |
23
23
 
@@ -10,9 +10,9 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@tanstack/react-table": "^8.21.3",
13
- "@xeplr/ui-account": "^1.0.7",
13
+ "@xeplr/ui-account": "^1.0.9",
14
14
  "@xeplr/ui-canvas": "^1.0.2",
15
- "@xeplr/ui-factory": "^1.0.5",
15
+ "@xeplr/ui-factory": "^1.0.11",
16
16
  "@xeplr/ui-table": "^1.0.6",
17
17
  "react": "^19.2.4",
18
18
  "react-dom": "^19.2.4",
@@ -3,6 +3,7 @@ import { Routes, Route, Outlet, Navigate, useNavigate } from 'react-router-dom'
3
3
  import { authRoutes, authPath, NavPage, ProtectedRoute, useAccess } from '@xeplr/ui-account'
4
4
  import Home from './pages/Home.jsx'
5
5
  import Tasks from './pages/Tasks.jsx'
6
+ import EditTask from './pages/EditTask.jsx'
6
7
  import ConfigureUI from './pages/ConfigureUI.jsx'
7
8
  import { FORM_MENU_PREFIX } from './menu.js'
8
9
  import FormDesigner from './pages/FormDesigner.jsx'
@@ -105,6 +106,9 @@ __MT_SELECT_ROUTE__
105
106
  <Route path="/" element={<Navigate to="/home" replace />} />
106
107
  <Route path="/home" element={<Home />} />
107
108
  <Route path="/tasks" element={<Tasks />} />
109
+ {/* The task list opens its form on a page — see Tasks.jsx's onOpenRecord. */}
110
+ <Route path="/tasks/new" element={<EditTask />} />
111
+ <Route path="/tasks/:id" element={<EditTask />} />
108
112
  <Route path="/forms/:form" element={<FormRecords />} />
109
113
  <Route path="/configure" element={<ConfigureUI />} />
110
114
  <Route path="/configure/forms/:form/design/:part" element={<FormDesigner />} />
@@ -4,6 +4,7 @@
4
4
  // module holds its HOOKS, used by this page, by the Tasks list and by the
5
5
  // popup the list opens.
6
6
  import { useEffect, useState } from 'react'
7
+ import { useNavigate, useParams } from 'react-router-dom'
7
8
  import { FactoryScreen, FactoryHooks } from '@xeplr/ui-factory'
8
9
  import { factory } from '../api/factory.js'
9
10
 
@@ -35,6 +36,15 @@ export class TaskHooks extends FactoryHooks {
35
36
  return super.delete(record, ctx)
36
37
  }
37
38
 
39
+ /**
40
+ * Before a stepper moves — Next, Back, or a click on the bar.
41
+ * Return false to stay where you are, or a step number to go there instead.
42
+ * The step's own required fields are checked before this runs.
43
+ */
44
+ step(ctx) {
45
+ return super.step(ctx)
46
+ }
47
+
38
48
  /** Extra row buttons beside Edit / Delete: [{ label, onClick: (record, ctx) => … }]. ctx.refresh() reloads. */
39
49
  actions(ctx) {
40
50
  return super.actions(ctx)
@@ -43,15 +53,41 @@ export class TaskHooks extends FactoryHooks {
43
53
 
44
54
  export const taskHooks = new TaskHooks()
45
55
 
46
- /** The task form on a page of its own: <EditTask /> for a new task, <EditTask record={task} /> for one. */
56
+ /**
57
+ * The task form on a page of its own — /tasks/new, or /tasks/:id for a task
58
+ * that exists. The list sends people here (its "Opens in" is a page), and
59
+ * Done brings them back.
60
+ *
61
+ * There is no Save button: the form saves itself a moment after each change.
62
+ * Done only waits for anything still in flight, then leaves.
63
+ */
47
64
  export default function EditTask({ record }) {
65
+ const { id } = useParams()
66
+ const navigate = useNavigate()
48
67
  const [screen, setScreen] = useState(null)
68
+ const [task, setTask] = useState(record || null)
49
69
  const [error, setError] = useState(null)
50
70
 
51
71
  useEffect(() => {
52
72
  factory.loadScreen('task_edit').then(setScreen, (e) => setError(e.message))
53
73
  }, [])
54
74
 
75
+ // The record as it is NOW, through the server's get hooks — not a copy the
76
+ // list happened to have.
77
+ useEffect(() => {
78
+ if (record || !id || id === 'new') return
79
+ factory.fetchRecord({ screen: 'task_edit', id }).then(setTask, (e) => setError(e.message))
80
+ }, [id, record])
81
+
55
82
  if (error) return <div className="app-error">Could not load the task form — {error}</div>
56
- return screen ? <FactoryScreen document={screen} record={record} {...factory.screenProps} hooks={taskHooks} /> : null
83
+ if (!screen || (id && id !== 'new' && !task)) return null
84
+ return (
85
+ <FactoryScreen
86
+ document={screen}
87
+ record={task}
88
+ {...factory.screenProps}
89
+ hooks={taskHooks}
90
+ onDone={() => navigate('/tasks')}
91
+ />
92
+ )
57
93
  }
@@ -1,11 +1,14 @@
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.
1
+ // THE SAMPLE — a list of tasks. New and Edit open the task form on a PAGE of
2
+ // its own, where it saves itself as you type; there is no Save button, and
3
+ // Done goes back here. (A list can open its form in a popup instead: Configure
4
+ // UI → Forms → the list → Opens in.)
3
5
  //
4
6
  // Nothing about tasks is written in this file. The list and the form are
5
7
  // SCREENS, designed in Configure UI → Forms and stored by the API; this page only
6
8
  // loads the published "task_list" and hands it the calls it needs — and the
7
9
  // task hooks (EditTask.jsx), which the popup uses too.
8
10
  import { useEffect, useState } from 'react'
11
+ import { useNavigate } from 'react-router-dom'
9
12
  import { FactoryScreen } from '@xeplr/ui-factory'
10
13
  import { factory } from '../api/factory.js'
11
14
  import TasksHelp from './TasksHelp.jsx'
@@ -14,6 +17,7 @@ import { taskHooks } from './EditTask.jsx'
14
17
  export default function Tasks() {
15
18
  const [screen, setScreen] = useState(null)
16
19
  const [error, setError] = useState(null)
20
+ const navigate = useNavigate()
17
21
 
18
22
  useEffect(() => {
19
23
  factory.loadScreen('task_list').then(setScreen, (e) => setError(e.message))
@@ -24,7 +28,15 @@ export default function Tasks() {
24
28
  <div className="app-page-main">
25
29
  {error && <div className="app-error">Could not load the task list — {error}</div>}
26
30
  {/* loadScreen lets the list's Edit / New fetch "task_edit" when first opened. */}
27
- {screen && <FactoryScreen document={screen} {...factory.screenProps} hooks={taskHooks} />}
31
+ {screen && (
32
+ <FactoryScreen
33
+ document={screen}
34
+ {...factory.screenProps}
35
+ hooks={taskHooks}
36
+ // The list opens records on a page, so it asks this app where to go.
37
+ onOpenRecord={({ id }) => navigate(id ? `/tasks/${id}` : '/tasks/new')}
38
+ />
39
+ )}
28
40
  </div>
29
41
  <TasksHelp />
30
42
  </div>
@@ -5,7 +5,7 @@ import { useState } from 'react'
5
5
  // and find.
6
6
 
7
7
  // The same prompt as in CLAUDE.md. Replace the form and its fields.
8
- const PROMPT = 'Create a new UI for farming departments. One record is a farming department with: Name (required), Region (dropdown: North, South, East, West), Area in acres (number, at least 0), Started on (date), Notes (long text). Show Name, Region and Area in the list. Follow "Create a new UI" in CLAUDE.md.'
8
+ const PROMPT = 'Create a new UI for farming departments. One record is a farming department with: Name (required), Region (dropdown: North, South, East, West), Area in acres (number, at least 0), Started on (date), Certifications (choose several: Organic, Fair trade, Rainforest), Inspection at (date and time), Licence (file: .pdf or .jpg), Notes (long text). Show Name, Region and Area in the list. Follow "Create a new UI" in CLAUDE.md.'
9
9
 
10
10
  export default function TasksHelp() {
11
11
  const [copied, setCopied] = useState(false)
@@ -37,10 +37,18 @@ export default function TasksHelp() {
37
37
 
38
38
  <h3>This page is an example</h3>
39
39
  <p>
40
- A task has a title, a status, a due date and a description. Neither the
41
- list nor the form is written by hand — both are <strong>screens</strong>,
42
- and the tasks are saved in an ordinary <code>tasks</code> table with one
43
- column per field.
40
+ A task is filled in over three steps — the task, planning, an
41
+ attachment — and shows every kind of control there is: text, a
42
+ dropdown, radio buttons, a tick box, dates, a date and time, several
43
+ tags at once, and a file. Neither the list nor the form is written by
44
+ hand: both are <strong>screens</strong>, and a task is saved in an
45
+ ordinary <code>tasks</code> table with one column per field. The file
46
+ itself is kept outside the database, and its column holds the path.
47
+ </p>
48
+ <p>
49
+ <strong>New</strong> and <strong>Edit</strong> open the form on a page
50
+ of its own, and <strong>Done</strong> comes back here. A list can open
51
+ it in a popup instead — that is its <strong>Opens in</strong> setting.
44
52
  </p>
45
53
 
46
54
  <h3>Change it</h3>
@@ -51,7 +59,7 @@ export default function TasksHelp() {
51
59
  new column as you publish.
52
60
  </p>
53
61
 
54
- <h3>Or by hand</h3>
62
+ <h3>Or without code</h3>
55
63
  <p>
56
64
  <strong>Configure UI → Forms → New form</strong>: label and key, design,
57
65
  publish, add to menu — no code. The full steps for a form with its own page, hooks and