@xeplr/ui-factory 1.0.4 → 1.0.6
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/AUTHORING.md +65 -5
- package/README.md +49 -6
- package/bin/xeplr-factory.js +9 -7
- package/package.json +1 -1
- package/src/designs/ListView.jsx +6 -1
- package/src/designs/ScreenSample.jsx +1 -0
- package/src/generate.js +7 -1
- package/src/hooks.js +69 -0
- package/src/index.js +3 -0
- package/src/pages.jsx +2 -0
- package/src/remote.js +2 -0
- package/src/scaffold.js +78 -3
- package/src/useFactoryScreen.js +56 -11
package/AUTHORING.md
CHANGED
|
@@ -2,16 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
This guide is for whoever **writes** a screen definition — usually Claude, from a request like *"make a form for a new employee with name, email, department and start date"*. A person then refines the result in the builder.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> **In an app made by `@xeplr/cli`**, read the project's own `CLAUDE.md` first — it says where each file below goes in that app and how to wire it in. This guide is what the files mean.
|
|
6
|
+
|
|
7
|
+
## What a new UI is made of
|
|
8
|
+
|
|
9
|
+
A request for "a form for employees" is an **entity**. Every entity is the same set of files — create all of them, even the ones that only run the defaults:
|
|
10
|
+
|
|
11
|
+
| file | what | runs |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `employee.entity.json` | the spec: name and fields | — |
|
|
14
|
+
| `employee-list.screen.json` | the **list** screen | browser |
|
|
15
|
+
| `employee-edit.screen.json` | the **add / edit form** the list opens in a popup | browser |
|
|
16
|
+
| `EditEmployee.jsx` | the form's page, holding the **front-end hooks** class (`EmployeeHooks extends FactoryHooks`) | browser |
|
|
17
|
+
| `EmployeeList.jsx` | the list's page — uses `EditEmployee.jsx`'s hooks | browser |
|
|
18
|
+
| `employee.hooks.js` | **server hooks**: `save` / `get` / `delete` × `before` / `after` / `error` / `override` | server |
|
|
19
|
+
| `employee.model.js` | **server model**: `EmployeeModel extends FactoryModel` — getters / setters for values | server |
|
|
20
|
+
|
|
21
|
+
`npx xeplr-factory screens employee.entity.json -o <dir>` writes all of them from the spec (`--no-pages` leaves out the two `.jsx`). The hooks and the model start as defaults — every method calling `super` — and stay that way unless the request needs more.
|
|
22
|
+
|
|
23
|
+
**Which file for which request:**
|
|
6
24
|
|
|
7
|
-
|
|
25
|
+
| the request says… | change |
|
|
26
|
+
|---|---|
|
|
27
|
+
| a field, a label, a rule, a dropdown, the layout, a colour | the screen JSON (then validate) |
|
|
28
|
+
| "show / hide / filter rows on screen", "an extra button on each row", "fill in X before saving" (convenience) | front-end hooks in `EditEmployee.jsx` |
|
|
29
|
+
| "must", "only if", "check against…", "email when…", "only managers see…" | server hooks in `employee.hooks.js` |
|
|
30
|
+
| "store as…", "convert…", "comma-separated / array", "work out X from Y" (data shape) | server model in `employee.model.js` |
|
|
31
|
+
| a query in your own server code | `factory.table('employees')` — this company, active rows, the model applied |
|
|
32
|
+
|
|
33
|
+
## The workflow
|
|
8
34
|
|
|
9
35
|
1. **Write an entity spec** — the entity's name and its fields, in reading order. Do not write coordinates.
|
|
10
36
|
2. **Generate** both screens and their pages:
|
|
11
37
|
```sh
|
|
12
38
|
npx xeplr-factory screens employee.entity.json -o src/screens/employee
|
|
13
39
|
```
|
|
14
|
-
This writes `employee-list.screen.json`, `employee-edit.screen.json`, `EmployeeList.jsx`, `EditEmployee.jsx` and an empty `employee.hooks.js
|
|
40
|
+
This writes `employee-list.screen.json`, `employee-edit.screen.json`, `EmployeeList.jsx`, `EditEmployee.jsx` (which holds the front-end hooks — see below) and an empty `employee.hooks.js` (the server's). Wire the two pages into the app's menu next to two `<FactoryBuilder>` pages for designing them (Screen · List, Screen · Edit, Designer · List, Designer · Edit). It refuses to overwrite existing files — they may have been refined in the designer — unless given `--force`, which replaces the screen JSON only: the `.jsx` pages and the hooks file are the app's code and are always kept.
|
|
15
41
|
|
|
16
42
|
A single screen that is not an entity (a settings form, say) uses `npx xeplr-factory generate spec.json -o screen.json` with the screen spec below.
|
|
17
43
|
3. **The table comes from Publish.** Records live in a **real table** — `employees`, one column per field — never in JSON, and there are no migration files to write: **publishing** the edit screen creates its table (`CREATE TABLE`, a column per field, real foreign keys for table dropdowns, the standard `id` / `isActive` / `mtId1–4` / audit columns), and publishing a later version changes it:
|
|
@@ -29,8 +55,42 @@ A request for "a form for employees" is an **entity**, and an entity is **two sc
|
|
|
29
55
|
```
|
|
30
56
|
Every problem names a path (`nodes[3].props.data.table`) and what belongs there. Fix and re-run until it prints `ok`.
|
|
31
57
|
5. **Save and publish.** Save the screens as drafts, then publish — edit screens of referenced tables first. Publishing changes the table; if it would drop columns it returns them for the person to confirm.
|
|
32
|
-
6. **
|
|
33
|
-
|
|
58
|
+
6. **Front-end hooks, if the request needs them in the browser.** `EditEmployee.jsx` holds a class, used by both pages and by the popup:
|
|
59
|
+
|
|
60
|
+
```jsx
|
|
61
|
+
export class EmployeeHooks extends FactoryHooks {
|
|
62
|
+
get(ctx) { return super.get(ctx) } // a list's rows (ctx.many), or the record Edit opens (ctx.id)
|
|
63
|
+
save(values, ctx) { return super.save(values, ctx) } // every autosave; returns the saved record
|
|
64
|
+
delete(record, ctx) { return super.delete(record, ctx) } // a list row, after the person confirmed
|
|
65
|
+
actions(ctx) { return super.actions(ctx) } // extra row buttons: [{ label, onClick: (record, ctx) => … }]
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Every method already runs the default. **Change only the method the request is about** and keep calling `super`:
|
|
70
|
+
- *before* — change the input, then `return super.save(changed, ctx)`
|
|
71
|
+
- *after* — `const saved = await super.save(values, ctx)`, then use or change it
|
|
72
|
+
- *override* — do not call `super`
|
|
73
|
+
- *extra row buttons* — `actions(ctx) { return [{ label: 'Mark done', onClick: async (record) => { …; ctx.refresh() } }] }`
|
|
74
|
+
|
|
75
|
+
`ctx.screen` says which screen is calling (`employee_list` or `employee_edit`). Keep them **methods** — `super` does not work in arrow functions. Do not delete the unchanged ones; they show what can be changed.
|
|
76
|
+
|
|
77
|
+
Front-end hooks shape what the person sees and sends. **Anything that must hold goes in the server's hooks** (next step). The server keeps only values that are fields of the form: a value added in `save` that is not a field needs a server `save.before` that accepts it.
|
|
78
|
+
7. **Server model, if values need shaping.** `employee.model.js`:
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
class EmployeeModel extends FactoryModel {
|
|
82
|
+
static table = 'employees';
|
|
83
|
+
static fields = {
|
|
84
|
+
skills: { set: (v) => (Array.isArray(v) ? v.join(',') : v), get: (v) => (v ? v.split(',') : []) }
|
|
85
|
+
};
|
|
86
|
+
static toDb(values) { return super.toDb(values); } // override for more than one field at a time
|
|
87
|
+
static fromDb(row) { return super.fromDb(row); }
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Setters run on every write (after `save.before`, before the screen's rules), getters on every read, on the routes and in `factory.table()`. Register it: `factory.init({ knex, hooks, models: [require('./employee.model')] })`.
|
|
92
|
+
8. **Server hooks, if the request needs them.** Anything beyond storing what the form shows — a value worked out on save, a check against other records, an email afterwards, a list narrowed to the user — goes in `employee.hooks.js`, never in the screen: `save`, `get` (list and one record) and `delete`, each with `before`, `after`, `error` and `override`. Register it with `factory.init({ hooks: { employee_edit: require('./employee.hooks') } })`. `override` replaces the whole operation — rules and the other hooks included — so use it only when the generic save / get / delete does not apply at all. The `screens` command never overwrites an existing hooks file, even with `--force`. Details: the [`@xeplr/factory` README](https://www.npmjs.com/package/@xeplr/factory#hooks).
|
|
93
|
+
9. **Hand over.** The person opens the screens in the designer to move, resize and restyle controls.
|
|
34
94
|
|
|
35
95
|
There is **no submit button** to add: a screen saves itself as it is filled in. If the request mentions seeing or editing what was entered ("…and show the employees below"), add a `list`.
|
|
36
96
|
|
package/README.md
CHANGED
|
@@ -15,11 +15,16 @@ It is built to work with Claude: ask for *"a form for employees with name, email
|
|
|
15
15
|
|
|
16
16
|
```sh
|
|
17
17
|
npx xeplr-factory screens employee.entity.json -o src/screens/employee
|
|
18
|
-
# employee-list.screen.json EmployeeList.jsx
|
|
19
|
-
# employee-edit.screen.json EditEmployee.jsx
|
|
20
|
-
# employee.hooks.js
|
|
18
|
+
# employee-list.screen.json EmployeeList.jsx the list page — uses EditEmployee.jsx's hooks
|
|
19
|
+
# employee-edit.screen.json EditEmployee.jsx the form page, holding the FRONT-END hooks (EmployeeHooks extends FactoryHooks)
|
|
20
|
+
# employee.hooks.js SERVER hooks: save / get / delete × before / after / error / override
|
|
21
|
+
# employee.model.js SERVER model: EmployeeModel extends FactoryModel — getters / setters for values
|
|
21
22
|
```
|
|
22
23
|
|
|
24
|
+
`--no-pages` leaves out the two `.jsx` (for an app whose pages live elsewhere). `--force` replaces only the screen JSON: the pages, hooks and model are your code and are always kept.
|
|
25
|
+
|
|
26
|
+
**Rules:** records live in real tables, never JSON · a form's **key** (`employee`) names its screens and table and never changes once published; its **label** (the screens' name) is renamed any time · rules that must hold go in server hooks, never front-end hooks.
|
|
27
|
+
|
|
23
28
|
See [examples/](./examples) for exactly what it writes.
|
|
24
29
|
|
|
25
30
|
(The package name on npm is `@xeplr/ui-factory` — the GitHub repo and folder are named `xeplr-ui-factory`.)
|
|
@@ -92,6 +97,43 @@ If `onSave` throws an error with `fields: [{ field, message }]` (what `createFac
|
|
|
92
97
|
|
|
93
98
|
Values arrive typed: numbers as numbers, checkboxes as booleans, dates as `"YYYY-MM-DD"`, and a dropdown as the option's own `id` (a table's numeric id stays a number).
|
|
94
99
|
|
|
100
|
+
### Front-end hooks
|
|
101
|
+
|
|
102
|
+
What a screen does in the browser — load, save, delete, extra row buttons — is a class you extend. Every method is the default; override the one you need and call `super`:
|
|
103
|
+
|
|
104
|
+
```jsx
|
|
105
|
+
import { FactoryScreen, FactoryHooks } from '@xeplr/ui-factory'
|
|
106
|
+
|
|
107
|
+
class TaskHooks extends FactoryHooks {
|
|
108
|
+
async save(values, ctx) {
|
|
109
|
+
const saved = await super.save({ ...values, title: values.title.trim() }, ctx) // before
|
|
110
|
+
toast('Saved ' + saved.title) // after
|
|
111
|
+
return saved
|
|
112
|
+
}
|
|
113
|
+
async get(ctx) {
|
|
114
|
+
const rows = await super.get(ctx)
|
|
115
|
+
return ctx.many ? rows.filter((r) => r.status !== 'archived') : rows
|
|
116
|
+
}
|
|
117
|
+
actions(ctx) {
|
|
118
|
+
return [{ label: 'Mark done', onClick: async (record) => { await markDone(record.id); ctx.refresh() } }]
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const taskHooks = new TaskHooks()
|
|
123
|
+
<FactoryScreen document={taskList} {...api} hooks={taskHooks} />
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
| method | default | ctx |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `get(ctx)` | `fetchRecords` for a list, `fetchRecord` for the record Edit opens | `many`, `id`, `screen`, `source` |
|
|
129
|
+
| `save(values, ctx)` | `onSave` — on every autosave; return the saved record | `id`, `isNew`, `screen`, `source` |
|
|
130
|
+
| `delete(record, ctx)` | `onDelete`, after the person confirmed | `id`, `screen`, `source` |
|
|
131
|
+
| `actions(ctx)` | `[]` — extra row buttons `{ label, onClick(record, ctx) }` beside Edit / Delete | `screen`, `refresh()` |
|
|
132
|
+
|
|
133
|
+
Not calling `super` replaces the default. The same hooks run in the popup a list opens (`ctx.screen` says which screen). Methods, not arrow functions — `super` needs them. `xeplr-factory screens` writes this class, every method calling `super`, into the edit page.
|
|
134
|
+
|
|
135
|
+
Rules that must hold belong in the server's hooks; the browser only shapes what the person sees and sends.
|
|
136
|
+
|
|
95
137
|
### Lists of saved records
|
|
96
138
|
|
|
97
139
|
A **List** control shows the records of the table the screen saves to (or another), in `@xeplr/ui-table`, with the columns you tick. Its **Edit in** names the edit screen: **Edit** and **New** open that screen in a popup, where it saves itself and the list refreshes behind it. **Delete** removes a row after a confirmation. Dropdown columns show names, not ids — read from the edit screen's fields.
|
|
@@ -159,7 +201,7 @@ const screen = screenFromSpec({
|
|
|
159
201
|
It lays the fields out as a tidy one- or two-column form and returns a checked document. The same is available on the command line:
|
|
160
202
|
|
|
161
203
|
```sh
|
|
162
|
-
npx xeplr-factory screens entity.json -o dir # an entity → list + edit screens
|
|
204
|
+
npx xeplr-factory screens entity.json -o dir # an entity → list + edit screens, pages, server hooks + model (--no-pages, --force)
|
|
163
205
|
npx xeplr-factory migration edit.screen.json # preview the SQL Publish would run
|
|
164
206
|
npx xeplr-factory generate spec.json -o screen.json # one screen from a spec
|
|
165
207
|
npx xeplr-factory validate screen.json # every problem, with its path
|
|
@@ -211,9 +253,10 @@ src/
|
|
|
211
253
|
validateDocument.js ─ the document checker
|
|
212
254
|
values.js ─ values, validation, autosave readiness, records and list columns, formSchema
|
|
213
255
|
generate.js ─ screenFromSpec, screensFromSpec (list + edit), entity names
|
|
214
|
-
scaffold.js ─ an entity → its screen JSON, .jsx pages
|
|
256
|
+
scaffold.js ─ an entity → its screen JSON, .jsx pages (with front-end hooks), server hooks + model stubs
|
|
257
|
+
hooks.js ─ FactoryHooks: get / save / delete / actions — extend and call super
|
|
215
258
|
tableSchema.js ─ a form → its table; the publish plan (create / add / widen / confirmed drops)
|
|
216
|
-
remote.js ─ createFactoryApi: the calls to @xeplr/factory's routes
|
|
259
|
+
remote.js ─ createFactoryApi({ fetch }): the calls to @xeplr/factory's routes — fetch may be window.fetch or @xeplr/ui-account's authFetch (which returns parsed bodies); includes createEntity({ key, label })
|
|
217
260
|
model.js ─ everything above, React-free
|
|
218
261
|
useFactoryBuilder.js ─ builder controller
|
|
219
262
|
useFactoryScreen.js ─ screen controller
|
package/bin/xeplr-factory.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// turn a short spec into a laid-out document, and check a document before
|
|
6
6
|
// handing it to an app, with errors precise enough to fix without guessing.
|
|
7
7
|
//
|
|
8
|
-
// xeplr-factory screens entity.json [-o dir] an entity → list + edit screens, their .jsx pages,
|
|
8
|
+
// xeplr-factory screens entity.json [-o dir] an entity → list + edit screens, their .jsx pages, server hooks + model stubs
|
|
9
9
|
// xeplr-factory migration edit.screen.json [--from previous.screen.json] [-o migrations/]
|
|
10
10
|
// the SQL that makes the table match the form
|
|
11
11
|
// xeplr-factory generate spec.json [-o screen.json] spec → laid-out document
|
|
@@ -25,7 +25,7 @@ import { validateDocument } from '../src/validateDocument.js'
|
|
|
25
25
|
import { formSchema } from '../src/values.js'
|
|
26
26
|
|
|
27
27
|
const USAGE = `usage:
|
|
28
|
-
xeplr-factory screens <entity.json|-> [-o <dir>] [--force]
|
|
28
|
+
xeplr-factory screens <entity.json|-> [-o <dir>] [--force] [--no-pages]
|
|
29
29
|
xeplr-factory migration <edit.screen.json> [--from <previous.screen.json>] [-o <migrations dir>]
|
|
30
30
|
xeplr-factory generate <spec.json|-> [-o <out.json>]
|
|
31
31
|
xeplr-factory validate <screen.json|->
|
|
@@ -81,10 +81,12 @@ switch (cmd) {
|
|
|
81
81
|
} catch (err) {
|
|
82
82
|
fail(err.message)
|
|
83
83
|
}
|
|
84
|
-
// The
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
// The app's own code — the server hooks, and the pages with their front-end
|
|
85
|
+
// hooks — is kept as it is, even with --force. Only the screen JSON is replaced.
|
|
86
|
+
// --no-pages: only the screens and the server files — for an app whose pages live elsewhere (ui/src/pages).
|
|
87
|
+
if (args.includes('--no-pages')) Object.keys(result.files).filter((f) => f.endsWith('.jsx')).forEach((f) => delete result.files[f])
|
|
88
|
+
const kept = Object.keys(result.files).filter((f) => (f.endsWith('.hooks.js') || f.endsWith('.model.js') || f.endsWith('.jsx')) && existsSync(path.join(dir, f)))
|
|
89
|
+
const writing = Object.keys(result.files).filter((f) => !kept.includes(f))
|
|
88
90
|
const targets = writing.map((f) => path.join(dir, f))
|
|
89
91
|
// Never overwrite a screen someone has since refined in the designer.
|
|
90
92
|
const existing = targets.filter((t) => existsSync(t))
|
|
@@ -92,7 +94,7 @@ switch (cmd) {
|
|
|
92
94
|
mkdirSync(dir, { recursive: true })
|
|
93
95
|
writing.forEach((f) => writeFileSync(path.join(dir, f), result.files[f]))
|
|
94
96
|
process.stderr.write(`wrote ${targets.join(', ')}\n`)
|
|
95
|
-
if (
|
|
97
|
+
if (kept.length) process.stderr.write(`kept ${kept.map((f) => path.join(dir, f)).join(', ')} — your code\n`)
|
|
96
98
|
process.stderr.write(`list screen "${result.list.id}" opens "${result.edit.id}" for Edit / New; both use table "${result.edit.source}"\n`)
|
|
97
99
|
break
|
|
98
100
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/ui-factory",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Design data-entry screens on @xeplr/ui-canvas, save them as JSON, run them as auto-saving forms with lists of saved records — built for Claude to draft and people to refine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/designs/ListView.jsx
CHANGED
|
@@ -9,7 +9,7 @@ import { boxStyle, fieldStyle } from './styles.js'
|
|
|
9
9
|
// Edit and New open the list's edit screen in a popup (`editScreen`), or, for a
|
|
10
10
|
// list on a form, load the row into the form's own fields. Delete removes it.
|
|
11
11
|
|
|
12
|
-
export default function ListView({ node, doc, design, list, onEdit, onDelete, onNew, currentId, recordKey = 'id', canDelete, fieldNodes, optionsFor }) {
|
|
12
|
+
export default function ListView({ node, doc, design, list, onEdit, onDelete, onNew, currentId, recordKey = 'id', canDelete, fieldNodes, optionsFor, extraActions = [] }) {
|
|
13
13
|
const p = node.props || {}
|
|
14
14
|
const columns = listColumns(doc, node)
|
|
15
15
|
const actions = p.actions || ['new', 'edit', 'delete']
|
|
@@ -68,6 +68,11 @@ export default function ListView({ node, doc, design, list, onEdit, onDelete, on
|
|
|
68
68
|
})
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// The app's own buttons (hooks.actions), after the built-in ones.
|
|
72
|
+
extraActions.forEach((a, i) => {
|
|
73
|
+
rowActions.push({ key: 'extra-' + i + '-' + a.label, label: a.label, variant: a.variant, onClick: (row) => a.onClick(row.__record) })
|
|
74
|
+
})
|
|
75
|
+
|
|
71
76
|
return (
|
|
72
77
|
<div className="xeplr-factory-list" style={style}>
|
|
73
78
|
<div className="xeplr-factory-list-head">
|
|
@@ -88,6 +88,7 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
|
|
|
88
88
|
currentId: ctrl.recordId,
|
|
89
89
|
recordKey: ctrl.recordKey,
|
|
90
90
|
canDelete: ctrl.canDelete,
|
|
91
|
+
extraActions: ctrl.actionsFor ? ctrl.actionsFor(node) : [],
|
|
91
92
|
// A list with an edit screen opens it in a popup; one on a form
|
|
92
93
|
// opens the row in the form's own fields.
|
|
93
94
|
onEdit: (rec) => (node.props.editScreen ? ctrl.openEditor(node, rec) : ctrl.openRecord(rec)),
|
package/src/generate.js
CHANGED
|
@@ -263,7 +263,9 @@ export function entityNames(entity, plural) {
|
|
|
263
263
|
file: lower(words).join('-'), // "employee" → employee-list.screen.json
|
|
264
264
|
table: lower(pluralWords).join('_'), // "employees"
|
|
265
265
|
listComponent: `${pascal(words)}List`, // "EmployeeList"
|
|
266
|
-
editComponent: `Edit${pascal(words)}
|
|
266
|
+
editComponent: `Edit${pascal(words)}`, // "EditEmployee"
|
|
267
|
+
hooksClass: `${pascal(words)}Hooks`, // "EmployeeHooks" — in EditEmployee.jsx
|
|
268
|
+
hooksInstance: `${lowerFirst(pascal(words))}Hooks` // "employeeHooks"
|
|
267
269
|
}
|
|
268
270
|
}
|
|
269
271
|
|
|
@@ -273,6 +275,10 @@ function pluralise(word) {
|
|
|
273
275
|
return word + 's'
|
|
274
276
|
}
|
|
275
277
|
|
|
278
|
+
function lowerFirst(s) {
|
|
279
|
+
return s.charAt(0).toLowerCase() + s.slice(1)
|
|
280
|
+
}
|
|
281
|
+
|
|
276
282
|
function capitalise(s) {
|
|
277
283
|
return s.charAt(0).toUpperCase() + s.slice(1)
|
|
278
284
|
}
|
package/src/hooks.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// FRONT-END HOOKS — the app's own code around what a screen does in the
|
|
2
|
+
// browser: load its rows, save, delete, and extra buttons on a list's rows.
|
|
3
|
+
//
|
|
4
|
+
// Every method here IS the default. Extend the class, override only what you
|
|
5
|
+
// need, and call super to run the default where you want it:
|
|
6
|
+
//
|
|
7
|
+
// class TaskHooks extends FactoryHooks {
|
|
8
|
+
// async save(values, ctx) {
|
|
9
|
+
// values = { ...values, priority: values.priority || 'normal' } // before
|
|
10
|
+
// const saved = await super.save(values, ctx) // the default save
|
|
11
|
+
// notify('Saved ' + saved.title) // after
|
|
12
|
+
// return saved
|
|
13
|
+
// }
|
|
14
|
+
// }
|
|
15
|
+
// <FactoryScreen document={taskList} hooks={new TaskHooks()} />
|
|
16
|
+
//
|
|
17
|
+
// before change the input, then call super
|
|
18
|
+
// after call super, then use or change what it returns
|
|
19
|
+
// override do not call super
|
|
20
|
+
// error try { return await super.save(values, ctx) } catch (err) { … }
|
|
21
|
+
//
|
|
22
|
+
// Write hooks as METHODS (save(values, ctx) { … }), not as arrow-function
|
|
23
|
+
// properties (save = () => …) — super only works in methods.
|
|
24
|
+
//
|
|
25
|
+
// The same hooks run in the popup a list opens for Edit / New. ctx.screen says
|
|
26
|
+
// which screen is calling ("task_list", "task_edit").
|
|
27
|
+
//
|
|
28
|
+
// The browser is not a security boundary: anything that must hold (a price, a
|
|
29
|
+
// permission) belongs in the server's hooks. And the server keeps only values
|
|
30
|
+
// that are fields of the form — a value added here that is not one needs a
|
|
31
|
+
// server save.before that accepts it.
|
|
32
|
+
|
|
33
|
+
export class FactoryHooks {
|
|
34
|
+
/**
|
|
35
|
+
* The rows of a list (ctx.many), or one record when Edit opens it (ctx.id).
|
|
36
|
+
* @returns rows | record
|
|
37
|
+
*/
|
|
38
|
+
get(ctx) {
|
|
39
|
+
return ctx.defaults.get()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Save — called on every autosave, so keep it quick and safe to repeat.
|
|
44
|
+
* @param values what the form holds
|
|
45
|
+
* @returns the saved record (its id makes the next save an update)
|
|
46
|
+
*/
|
|
47
|
+
save(values, ctx) {
|
|
48
|
+
return ctx.defaults.save(values)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Delete a row of a list — after the person confirmed. */
|
|
52
|
+
delete(record, ctx) {
|
|
53
|
+
return ctx.defaults.delete(record)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extra buttons on each row, beside Edit and Delete:
|
|
58
|
+
* [{ label: 'Mark done', onClick: (record, ctx) => … }]
|
|
59
|
+
* ctx.refresh() reloads the list.
|
|
60
|
+
*/
|
|
61
|
+
actions(ctx) {
|
|
62
|
+
return []
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A method of the given hooks, or the default when they do not have it — so a plain object with only save() works too. */
|
|
67
|
+
export function hookMethod(hooks, name) {
|
|
68
|
+
return hooks && typeof hooks[name] === 'function' ? hooks[name].bind(hooks) : FactoryHooks.prototype[name]
|
|
69
|
+
}
|
package/src/index.js
CHANGED
|
@@ -15,5 +15,8 @@ export { useFactoryScreen, normaliseOptions, AUTOSAVE_DELAY } from './useFactory
|
|
|
15
15
|
// Designs
|
|
16
16
|
export { BuilderSample, ScreenSample, ListView, ScreenModal, ControlView, VIEWS, Palette, DRAG_TYPE, PropertyPanel, EDITORS } from './designs/index.js'
|
|
17
17
|
|
|
18
|
+
// Front-end hooks — extend FactoryHooks, override what you need, call super
|
|
19
|
+
export { FactoryHooks } from './hooks.js'
|
|
20
|
+
|
|
18
21
|
// Ready-made
|
|
19
22
|
export { FactoryBuilder, FactoryScreen } from './pages.jsx'
|
package/src/pages.jsx
CHANGED
|
@@ -53,6 +53,7 @@ export function FactoryBuilder(props) {
|
|
|
53
53
|
* @prop screens { id → document } — the screens a list's Edit / New open in a popup
|
|
54
54
|
* @prop loadScreen async (id) → document — for screens not in `screens`
|
|
55
55
|
* @prop controls control registry (default: built-ins)
|
|
56
|
+
* @prop hooks your FactoryHooks subclass (get / save / delete / actions) — also used by the popup
|
|
56
57
|
*/
|
|
57
58
|
export function FactoryScreen(props) {
|
|
58
59
|
const ctrl = useFactoryScreen(props)
|
|
@@ -85,6 +86,7 @@ export function FactoryScreen(props) {
|
|
|
85
86
|
screens={props.screens}
|
|
86
87
|
loadScreen={props.loadScreen}
|
|
87
88
|
controls={props.controls}
|
|
89
|
+
hooks={props.hooks}
|
|
88
90
|
/>
|
|
89
91
|
)}
|
|
90
92
|
</ScreenModal>
|
package/src/remote.js
CHANGED
|
@@ -68,6 +68,8 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
68
68
|
// when a removed field would drop a column; call again with { confirmDrop: [names] }.
|
|
69
69
|
publish: async (doc, options) => one(await call('POST', `/screens/${enc(doc.id)}/publish`, { confirmDrop: (options && options.confirmDrop) || [] })),
|
|
70
70
|
listTables: () => call('GET', '/tables'),
|
|
71
|
+
// A new form from its name: { entity, plural? } → { entity, name, source, edit, list } — both screens as drafts.
|
|
72
|
+
createEntity: async (spec) => one(await call('POST', '/entities', spec)),
|
|
71
73
|
|
|
72
74
|
// records
|
|
73
75
|
fetchRecords: ({ screen }) => call('GET', `/records/${enc(screen)}`),
|
package/src/scaffold.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// employee-list.screen.json EmployeeList.jsx — the list; Edit / New open a popup
|
|
7
7
|
// employee-edit.screen.json EditEmployee.jsx — the add / edit form, also a page on its own
|
|
8
8
|
// employee.hooks.js — the server's hooks for its records (all empty)
|
|
9
|
+
// employee.model.js — the server's model: getters / setters for its values (none yet)
|
|
9
10
|
//
|
|
10
11
|
// The pages are thin on purpose: a screen document and the app's data calls,
|
|
11
12
|
// nothing else. Changing what a screen shows is editing its JSON in the
|
|
@@ -30,7 +31,8 @@ export function scaffoldEntity(spec, controls) {
|
|
|
30
31
|
[editJson]: JSON.stringify(edit, null, 2) + '\n',
|
|
31
32
|
[`${names.listComponent}.jsx`]: listPage(names, listJson, editJson, list, edit),
|
|
32
33
|
[`${names.editComponent}.jsx`]: editPage(names, editJson, edit),
|
|
33
|
-
[`${names.file}.hooks.js`]: hooksFile(names, edit)
|
|
34
|
+
[`${names.file}.hooks.js`]: hooksFile(names, edit),
|
|
35
|
+
[`${names.file}.model.js`]: modelFile(names, edit)
|
|
34
36
|
}
|
|
35
37
|
return { files, names, list, edit }
|
|
36
38
|
}
|
|
@@ -39,6 +41,7 @@ function listPage(n, listJson, editJson, list, edit) {
|
|
|
39
41
|
return `import { FactoryScreen } from '@xeplr/ui-factory'
|
|
40
42
|
import listScreen from './${listJson}'
|
|
41
43
|
import editScreen from './${editJson}'
|
|
44
|
+
import { ${n.hooksInstance} } from './${n.editComponent}.jsx'
|
|
42
45
|
|
|
43
46
|
// ${n.pluralTitle} — the saved ${list.source}, with New / Edit / Delete.
|
|
44
47
|
// Edit and New open "${edit.name}" (${edit.id}) in a popup.
|
|
@@ -48,12 +51,15 @@ import editScreen from './${editJson}'
|
|
|
48
51
|
// fetchRecords({ source }) → records
|
|
49
52
|
// onDelete({ id, source, record })
|
|
50
53
|
// fetchOptions({ table }) → [{ id, name }] for dropdowns
|
|
54
|
+
//
|
|
55
|
+
// Its hooks are ${n.editComponent}.jsx's — the same ones run in the popup.
|
|
51
56
|
export default function ${n.listComponent}({ api }) {
|
|
52
57
|
return (
|
|
53
58
|
<FactoryScreen
|
|
54
59
|
document={listScreen}
|
|
55
60
|
screens={{ [editScreen.id]: editScreen }}
|
|
56
61
|
{...api}
|
|
62
|
+
hooks={${n.hooksInstance}}
|
|
57
63
|
/>
|
|
58
64
|
)
|
|
59
65
|
}
|
|
@@ -101,16 +107,85 @@ module.exports = {
|
|
|
101
107
|
`
|
|
102
108
|
}
|
|
103
109
|
|
|
110
|
+
function modelFile(n, edit) {
|
|
111
|
+
return `// The model for ${n.singular} records — how values are shaped between your code and
|
|
112
|
+
// the "${edit.source}" table, like Sequelize's getters and setters.
|
|
113
|
+
//
|
|
114
|
+
// Register with @xeplr/factory: factory.init({ knex, hooks, models: [require('./${n.file}.model')] })
|
|
115
|
+
//
|
|
116
|
+
// Used everywhere: the screens' routes and factory.table('${edit.source}'). Models shape
|
|
117
|
+
// data; the hooks file decides behaviour.
|
|
118
|
+
|
|
119
|
+
var { FactoryModel } = require('@xeplr/factory');
|
|
120
|
+
|
|
121
|
+
class ${n.hooksClass.replace(/Hooks$/, 'Model')} extends FactoryModel {
|
|
122
|
+
static table = '${edit.source}';
|
|
123
|
+
|
|
124
|
+
// Per field: set(value, values) before it is written, get(value, row) after it is read.
|
|
125
|
+
// tags: { set: (v) => (Array.isArray(v) ? v.join(',') : v), get: (v) => (v ? v.split(',') : []) }
|
|
126
|
+
static fields = {};
|
|
127
|
+
|
|
128
|
+
/** Every value about to be written. Override for more than one field at a time. */
|
|
129
|
+
static toDb(values) {
|
|
130
|
+
return super.toDb(values);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Every row just read. */
|
|
134
|
+
static fromDb(row) {
|
|
135
|
+
return super.fromDb(row);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = ${n.hooksClass.replace(/Hooks$/, 'Model')};
|
|
140
|
+
`
|
|
141
|
+
}
|
|
142
|
+
|
|
104
143
|
function editPage(n, editJson, edit) {
|
|
105
|
-
return `import { FactoryScreen } from '@xeplr/ui-factory'
|
|
144
|
+
return `import { FactoryScreen, FactoryHooks } from '@xeplr/ui-factory'
|
|
106
145
|
import editScreen from './${editJson}'
|
|
107
146
|
|
|
108
147
|
// ${edit.name} — saves itself as it is filled in; there is no submit.
|
|
109
148
|
// Opened in a popup from ${n.listComponent}, or on its own page:
|
|
110
149
|
// <${n.editComponent} api={api} /> a new ${n.singular}
|
|
111
150
|
// <${n.editComponent} api={api} record={row} /> an existing one
|
|
151
|
+
|
|
152
|
+
// ── WHAT THE SCREENS DO IN THE BROWSER ──────────────────────────────────
|
|
153
|
+
// Every method runs the default (super). Change the ones you need; leave the
|
|
154
|
+
// rest as they are. Used by this page, by ${n.listComponent} and by its popup.
|
|
155
|
+
//
|
|
156
|
+
// before change the input, then call super
|
|
157
|
+
// after call super, then use or change what it returns
|
|
158
|
+
// override do not call super
|
|
159
|
+
//
|
|
160
|
+
// ctx.screen says which screen is calling ("${n.key}_list" or "${edit.id}").
|
|
161
|
+
// Keep them as methods (save(values, ctx) { … }) — super does not work in
|
|
162
|
+
// arrow functions. Rules that must hold belong in the server's hooks.
|
|
163
|
+
export class ${n.hooksClass} extends FactoryHooks {
|
|
164
|
+
/** A list's rows (ctx.many), or the record Edit opens (ctx.id). */
|
|
165
|
+
get(ctx) {
|
|
166
|
+
return super.get(ctx)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Every autosave — keep it quick. Returns the saved record. */
|
|
170
|
+
save(values, ctx) {
|
|
171
|
+
return super.save(values, ctx)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A list row, after the person confirmed. */
|
|
175
|
+
delete(record, ctx) {
|
|
176
|
+
return super.delete(record, ctx)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Extra row buttons beside Edit / Delete: [{ label, onClick: (record, ctx) => … }]. ctx.refresh() reloads. */
|
|
180
|
+
actions(ctx) {
|
|
181
|
+
return super.actions(ctx)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export const ${n.hooksInstance} = new ${n.hooksClass}()
|
|
186
|
+
|
|
112
187
|
export default function ${n.editComponent}({ api, record }) {
|
|
113
|
-
return <FactoryScreen document={editScreen} record={record} {...api} />
|
|
188
|
+
return <FactoryScreen document={editScreen} record={record} {...api} hooks={${n.hooksInstance}} />
|
|
114
189
|
}
|
|
115
190
|
`
|
|
116
191
|
}
|
package/src/useFactoryScreen.js
CHANGED
|
@@ -3,6 +3,7 @@ import { CONTROLS } from './controls.js'
|
|
|
3
3
|
import { inputNodes } from './document.js'
|
|
4
4
|
import { validateDocument } from './validateDocument.js'
|
|
5
5
|
import { initialValues, parseInput, saveState, fieldError, optionValue, recordValues, listSource } from './values.js'
|
|
6
|
+
import { hookMethod } from './hooks.js'
|
|
6
7
|
|
|
7
8
|
// A saved screen, running. No JSX — designs/ScreenSample.jsx draws it.
|
|
8
9
|
//
|
|
@@ -41,12 +42,16 @@ export const AUTOSAVE_DELAY = 700
|
|
|
41
42
|
* @param loadScreen async (id) → document — for screens not in `screens`
|
|
42
43
|
* @param autosaveDelay ms (default 700)
|
|
43
44
|
* @param controls the control registry (default: the built-ins)
|
|
45
|
+
* @param hooks a FactoryHooks (or an object with some of its methods) — get / save / delete / actions
|
|
44
46
|
*/
|
|
45
47
|
export function useFactoryScreen({
|
|
46
|
-
document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen,
|
|
48
|
+
document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks,
|
|
47
49
|
autosaveDelay = AUTOSAVE_DELAY, controls = CONTROLS
|
|
48
50
|
} = {}) {
|
|
49
51
|
const check = useMemo(() => validateDocument(doc, controls), [doc, controls])
|
|
52
|
+
// Read at call time, so a new hooks object never re-runs loads by itself.
|
|
53
|
+
const hooksRef = useRef(hooks); hooksRef.current = hooks
|
|
54
|
+
const hook = (name) => hookMethod(hooksRef.current, name)
|
|
50
55
|
const inputs = useMemo(() => (check.ok ? inputNodes(doc, controls) : []), [doc, controls, check.ok])
|
|
51
56
|
|
|
52
57
|
// A LIST SCREEN has no fields of its own; what its columns mean — that
|
|
@@ -163,13 +168,19 @@ export function useFactoryScreen({
|
|
|
163
168
|
const l = live.current
|
|
164
169
|
const st = saveState(l.doc, l.values, l.touched, l.loadedItems, l.controls)
|
|
165
170
|
if (!st.canSave) { setStatus(st.status); return { ok: false, errors: st.errors } }
|
|
166
|
-
|
|
171
|
+
const customSave = hooksRef.current && typeof hooksRef.current.save === 'function'
|
|
172
|
+
if (!l.onSave && !customSave) { setStatus('saved'); return { ok: true } }
|
|
167
173
|
|
|
168
174
|
setStatus('saving')
|
|
169
175
|
setSaveError(null)
|
|
170
176
|
const task = (async () => {
|
|
171
177
|
try {
|
|
172
|
-
const
|
|
178
|
+
const meta = { id: l.recordId, source: l.source, screen: l.doc.id, document: l.doc }
|
|
179
|
+
const saved = await hook('save')(l.values, {
|
|
180
|
+
...meta,
|
|
181
|
+
isNew: l.recordId === null || l.recordId === undefined,
|
|
182
|
+
defaults: { save: (vals) => (l.onSave ? l.onSave(vals, meta) : undefined) }
|
|
183
|
+
})
|
|
173
184
|
// A new record's first save creates it; its id makes the next save an update.
|
|
174
185
|
if (saved && typeof saved === 'object' && saved[l.recordKey] !== undefined && saved[l.recordKey] !== null) {
|
|
175
186
|
setRecordId(saved[l.recordKey])
|
|
@@ -284,9 +295,15 @@ export function useFactoryScreen({
|
|
|
284
295
|
listNodes.forEach((node) => {
|
|
285
296
|
const src = listSource(doc, node)
|
|
286
297
|
setLists((l) => ({ ...l, [node.id]: { rows: l[node.id]?.rows || [], loading: true, error: null } }))
|
|
287
|
-
const run =
|
|
288
|
-
|
|
289
|
-
:
|
|
298
|
+
const run = Promise.resolve().then(() => hook('get')({
|
|
299
|
+
many: true, id: null, screen: doc.id, source: src, node, document: doc,
|
|
300
|
+
defaults: {
|
|
301
|
+
get: () => {
|
|
302
|
+
if (!fetchRecordsRef.current) throw new Error(`No fetchRecords was provided to list "${src}"`)
|
|
303
|
+
return fetchRecordsRef.current({ source: src, screen: doc.id, node })
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}))
|
|
290
307
|
run
|
|
291
308
|
.then((rows) => { if (!cancelled) setLists((l) => ({ ...l, [node.id]: { rows: Array.isArray(rows) ? rows : [], loading: false, error: null } })) })
|
|
292
309
|
.catch((err) => { if (!cancelled) setLists((l) => ({ ...l, [node.id]: { rows: l[node.id]?.rows || [], loading: false, error: err.message || 'Could not load records' } })) })
|
|
@@ -296,11 +313,33 @@ export function useFactoryScreen({
|
|
|
296
313
|
|
|
297
314
|
const listFor = useCallback((node) => lists[node.id] || { rows: [], loading: false, error: null }, [lists])
|
|
298
315
|
|
|
316
|
+
/** Extra row buttons from hooks.actions — each onClick gets the row's record. */
|
|
317
|
+
const actionsFor = useCallback((node) => {
|
|
318
|
+
const ctx = { screen: doc.id, source: listSource(doc, node), node, document: doc, refresh: () => setListVersion((v) => v + 1) }
|
|
319
|
+
let extra
|
|
320
|
+
try { extra = hook('actions')(ctx) } catch (err) { console.error('[factory] hooks.actions failed:', err); extra = [] }
|
|
321
|
+
return (Array.isArray(extra) ? extra : []).filter((a) => a && a.label && typeof a.onClick === 'function').map((a) => ({
|
|
322
|
+
...a,
|
|
323
|
+
onClick: (rec) => Promise.resolve().then(() => a.onClick(rec, ctx)).catch((err) => {
|
|
324
|
+
console.error('[factory] action "' + a.label + '" failed:', err)
|
|
325
|
+
// eslint-disable-next-line no-alert
|
|
326
|
+
if (typeof window !== 'undefined') window.alert(err.message || 'Could not ' + a.label)
|
|
327
|
+
})
|
|
328
|
+
}))
|
|
329
|
+
}, [doc, lists]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
330
|
+
|
|
299
331
|
const onDeleteRef = useRef(onDelete); onDeleteRef.current = onDelete
|
|
300
332
|
const deleteRecord = useCallback(async (node, rec) => {
|
|
301
333
|
const id = rec ? rec[recordKey] : undefined
|
|
302
|
-
|
|
303
|
-
|
|
334
|
+
await hook('delete')(rec, {
|
|
335
|
+
id, source: listSource(doc, node), screen: doc.id, node, document: doc,
|
|
336
|
+
defaults: {
|
|
337
|
+
delete: (r) => {
|
|
338
|
+
if (!onDeleteRef.current) throw new Error('No onDelete was provided')
|
|
339
|
+
return onDeleteRef.current({ id: r ? r[recordKey] : id, source: listSource(doc, node), screen: doc.id, record: r })
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
})
|
|
304
343
|
// Deleting the record that is open leaves nothing to save into.
|
|
305
344
|
if (id !== undefined && id === live.current.recordId) {
|
|
306
345
|
clearTimeout(timer.current); timer.current = null
|
|
@@ -329,8 +368,12 @@ export function useFactoryScreen({
|
|
|
329
368
|
// Edit opens the record as it is NOW, through the server's get hooks —
|
|
330
369
|
// not the list's copy, which may be stale or shaped for the list.
|
|
331
370
|
let fresh = rec || null
|
|
332
|
-
if (rec &&
|
|
333
|
-
|
|
371
|
+
if (rec && rec[live.current.recordKey] !== undefined) {
|
|
372
|
+
const id = rec[live.current.recordKey]
|
|
373
|
+
fresh = await hook('get')({
|
|
374
|
+
many: false, id, screen: screenId, document: found,
|
|
375
|
+
defaults: { get: () => (fetchRecordRef.current ? fetchRecordRef.current({ screen: screenId, id }) : rec) }
|
|
376
|
+
})
|
|
334
377
|
}
|
|
335
378
|
setPopup((p) => (p && p.screenId === screenId ? { ...p, document: found, record: fresh, loading: false } : p))
|
|
336
379
|
} catch (err) {
|
|
@@ -376,7 +419,9 @@ export function useFactoryScreen({
|
|
|
376
419
|
listFor,
|
|
377
420
|
deleteRecord,
|
|
378
421
|
recordKey,
|
|
379
|
-
canDelete: Boolean(onDelete)
|
|
422
|
+
canDelete: Boolean(onDelete) || Boolean(hooks && typeof hooks.delete === 'function'),
|
|
423
|
+
actionsFor,
|
|
424
|
+
hooks
|
|
380
425
|
}
|
|
381
426
|
}
|
|
382
427
|
|