@xeplr/ui-factory 1.0.3 → 1.0.5
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 +37 -0
- 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/designs/factory.css +8 -5
- 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 +37 -18
- package/src/scaffold.js +78 -3
- package/src/useFactoryScreen.js +80 -14
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
|
@@ -92,6 +92,43 @@ If `onSave` throws an error with `fields: [{ field, message }]` (what `createFac
|
|
|
92
92
|
|
|
93
93
|
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
94
|
|
|
95
|
+
### Front-end hooks
|
|
96
|
+
|
|
97
|
+
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`:
|
|
98
|
+
|
|
99
|
+
```jsx
|
|
100
|
+
import { FactoryScreen, FactoryHooks } from '@xeplr/ui-factory'
|
|
101
|
+
|
|
102
|
+
class TaskHooks extends FactoryHooks {
|
|
103
|
+
async save(values, ctx) {
|
|
104
|
+
const saved = await super.save({ ...values, title: values.title.trim() }, ctx) // before
|
|
105
|
+
toast('Saved ' + saved.title) // after
|
|
106
|
+
return saved
|
|
107
|
+
}
|
|
108
|
+
async get(ctx) {
|
|
109
|
+
const rows = await super.get(ctx)
|
|
110
|
+
return ctx.many ? rows.filter((r) => r.status !== 'archived') : rows
|
|
111
|
+
}
|
|
112
|
+
actions(ctx) {
|
|
113
|
+
return [{ label: 'Mark done', onClick: async (record) => { await markDone(record.id); ctx.refresh() } }]
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const taskHooks = new TaskHooks()
|
|
118
|
+
<FactoryScreen document={taskList} {...api} hooks={taskHooks} />
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
| method | default | ctx |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| `get(ctx)` | `fetchRecords` for a list, `fetchRecord` for the record Edit opens | `many`, `id`, `screen`, `source` |
|
|
124
|
+
| `save(values, ctx)` | `onSave` — on every autosave; return the saved record | `id`, `isNew`, `screen`, `source` |
|
|
125
|
+
| `delete(record, ctx)` | `onDelete`, after the person confirmed | `id`, `screen`, `source` |
|
|
126
|
+
| `actions(ctx)` | `[]` — extra row buttons `{ label, onClick(record, ctx) }` beside Edit / Delete | `screen`, `refresh()` |
|
|
127
|
+
|
|
128
|
+
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.
|
|
129
|
+
|
|
130
|
+
Rules that must hold belong in the server's hooks; the browser only shapes what the person sees and sends.
|
|
131
|
+
|
|
95
132
|
### Lists of saved records
|
|
96
133
|
|
|
97
134
|
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.
|
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.5",
|
|
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/designs/factory.css
CHANGED
|
@@ -50,8 +50,11 @@
|
|
|
50
50
|
.xeplr-factory-required { color: var(--xf-danger); }
|
|
51
51
|
.xeplr-factory-input {
|
|
52
52
|
flex: 1; min-height: 0; width: 100%; box-sizing: border-box;
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
/* The screen's own colours, never the host theme's input tokens: a dark theme
|
|
54
|
+
would give the box a dark background while the text stays the screen's
|
|
55
|
+
dark ink — unreadable. A screen's look is its style properties. */
|
|
56
|
+
font: inherit; color: var(--xf-ink); background: var(--xf-surface);
|
|
57
|
+
border: 1px solid var(--xf-border); border-radius: calc(6 * var(--xf-u));
|
|
55
58
|
padding: 0 calc(10 * var(--xf-u));
|
|
56
59
|
}
|
|
57
60
|
.xeplr-factory-textarea { padding-block: calc(8 * var(--xf-u)); resize: none; }
|
|
@@ -89,7 +92,7 @@
|
|
|
89
92
|
.xeplr-factory-list-mock { width: 100%; border-collapse: collapse; }
|
|
90
93
|
.xeplr-factory-list-mock th { text-align: left; font-weight: 600; font-size: .9em; padding: calc(6 * var(--xf-u)) calc(10 * var(--xf-u)); border-bottom: 1px solid var(--xf-border); color: var(--xf-muted); }
|
|
91
94
|
.xeplr-factory-list-mock td { padding: calc(8 * var(--xf-u)) calc(10 * var(--xf-u)); border-bottom: 1px solid var(--xf-border); }
|
|
92
|
-
.xeplr-factory-list-bar { display: block; height: calc(8 * var(--xf-u)); width: 70%; border-radius: 4px; background:
|
|
95
|
+
.xeplr-factory-list-bar { display: block; height: calc(8 * var(--xf-u)); width: 70%; border-radius: 4px; background: #ecebe4; }
|
|
93
96
|
.xeplr-factory-list .xeplr-table-row.xeplr-factory-row-open td,
|
|
94
97
|
.xeplr-factory-list tr.xeplr-factory-row-open td { background: color-mix(in srgb, var(--xf-accent) 10%, transparent); }
|
|
95
98
|
|
|
@@ -177,8 +180,8 @@
|
|
|
177
180
|
.xeplr-factory-prop { margin-bottom: 10px; }
|
|
178
181
|
.xeplr-factory-prop-label { display: block; margin-bottom: 3px; font-weight: 600; font-size: 12px; }
|
|
179
182
|
.xeplr-factory-prop-input {
|
|
180
|
-
width: 100%; box-sizing: border-box; font: inherit; padding: 5px 7px;
|
|
181
|
-
border: 1px solid var(--xf-border); border-radius: 5px; background: var(--
|
|
183
|
+
width: 100%; box-sizing: border-box; font: inherit; padding: 5px 7px;
|
|
184
|
+
border: 1px solid var(--xf-border); border-radius: 5px; background: #fff; color: var(--xf-ink, #1a1a19);
|
|
182
185
|
}
|
|
183
186
|
.xeplr-factory-prop.has-error .xeplr-factory-prop-input { border-color: var(--xf-danger); }
|
|
184
187
|
.xeplr-factory-prop-help { margin-top: 3px; color: var(--xf-muted); font-size: 11px; line-height: 1.4; }
|
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
|
@@ -16,27 +16,44 @@
|
|
|
16
16
|
export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
17
17
|
if (typeof doFetch !== 'function') throw new Error('createFactoryApi: pass { fetch } — usually authFetch from @xeplr/ui-account')
|
|
18
18
|
|
|
19
|
+
// Two kinds of fetch are accepted, because both are what apps have:
|
|
20
|
+
// window.fetch-like → a Response: read it, and throw on !res.ok
|
|
21
|
+
// authFetch-like → the PARSED body, and on failure it throws an Error
|
|
22
|
+
// carrying status and the body (err.status, err.body)
|
|
23
|
+
// Either way a failure becomes the same Error, with the server's fields,
|
|
24
|
+
// confirm and refused lists on it.
|
|
19
25
|
async function call(method, path, body) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
err.status = res.status
|
|
30
|
-
err.fields = json && json.error && json.error.fields
|
|
31
|
-
const extra = (json && json.dataArray && json.dataArray[0]) || {}
|
|
32
|
-
// Publish: columns it would drop, waiting for a yes — and what it keeps.
|
|
33
|
-
err.confirm = extra.confirm
|
|
34
|
-
err.keep = extra.keep
|
|
35
|
-
err.refused = extra.refused
|
|
36
|
-
if (extra.statements) err.detail = extra.statements.join('\n')
|
|
26
|
+
let res
|
|
27
|
+
try {
|
|
28
|
+
res = await doFetch(`${base}/factory${path}`, {
|
|
29
|
+
method,
|
|
30
|
+
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
|
|
31
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
32
|
+
})
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (err && err.body !== undefined && err.status) throw failure(method, path, err.status, err.body, err.message)
|
|
37
35
|
throw err
|
|
38
36
|
}
|
|
39
|
-
|
|
37
|
+
if (res && typeof res.json === 'function' && typeof res.ok === 'boolean') {
|
|
38
|
+
let json = null
|
|
39
|
+
try { json = await res.json() } catch (_) { /* not JSON — reported below */ }
|
|
40
|
+
if (!res.ok) throw failure(method, path, res.status, json)
|
|
41
|
+
return json ? json.dataArray : []
|
|
42
|
+
}
|
|
43
|
+
return res && Array.isArray(res.dataArray) ? res.dataArray : []
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function failure(method, path, status, json, fallback) {
|
|
47
|
+
const err = new Error((json && json.message) || fallback || `${method} ${path} failed (${status})`)
|
|
48
|
+
err.status = status
|
|
49
|
+
err.fields = json && json.error && json.error.fields
|
|
50
|
+
const extra = (json && json.dataArray && json.dataArray[0]) || {}
|
|
51
|
+
// Publish: columns it would drop, waiting for a yes — and what it keeps.
|
|
52
|
+
err.confirm = extra.confirm
|
|
53
|
+
err.keep = extra.keep
|
|
54
|
+
err.refused = extra.refused
|
|
55
|
+
if (extra.statements) err.detail = extra.statements.join('\n')
|
|
56
|
+
return err
|
|
40
57
|
}
|
|
41
58
|
const one = (rows) => (Array.isArray(rows) ? rows[0] : rows)
|
|
42
59
|
const enc = encodeURIComponent
|
|
@@ -51,6 +68,8 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
51
68
|
// when a removed field would drop a column; call again with { confirmDrop: [names] }.
|
|
52
69
|
publish: async (doc, options) => one(await call('POST', `/screens/${enc(doc.id)}/publish`, { confirmDrop: (options && options.confirmDrop) || [] })),
|
|
53
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)),
|
|
54
73
|
|
|
55
74
|
// records
|
|
56
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,23 +42,48 @@ 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
|
|
53
58
|
// `department` is a dropdown whose 2 reads "Finance" — lives on the screen it
|
|
54
59
|
// edits in. Those fields are borrowed for display, and their table options
|
|
55
60
|
// loaded alongside this screen's own.
|
|
61
|
+
//
|
|
62
|
+
// An edit screen not passed in `screens` is fetched with loadScreen as soon
|
|
63
|
+
// as the list shows — not when Edit is first pressed — or the list's columns
|
|
64
|
+
// would show stored values ("in_progress") until then.
|
|
65
|
+
const [loadedScreens, setLoadedScreens] = useState({})
|
|
66
|
+
const knownScreens = useMemo(() => ({ ...loadedScreens, ...(screens || {}) }), [loadedScreens, screens])
|
|
67
|
+
const editScreenIds = check.ok && !inputs.length
|
|
68
|
+
? [...new Set(doc.nodes.filter((n) => n.type === 'list' && n.props.editScreen).map((n) => n.props.editScreen))].join('|')
|
|
69
|
+
: ''
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!editScreenIds || !loadScreen) return undefined
|
|
72
|
+
let cancelled = false
|
|
73
|
+
editScreenIds.split('|').filter((id) => !(screens && screens[id])).forEach((id) => {
|
|
74
|
+
loadScreen(id).then(
|
|
75
|
+
(found) => { if (!cancelled && found) setLoadedScreens((m) => (m[id] ? m : { ...m, [id]: found })) },
|
|
76
|
+
() => { /* shown when Edit is pressed, where the person is looking */ }
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
return () => { cancelled = true }
|
|
80
|
+
}, [editScreenIds]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
81
|
+
|
|
56
82
|
const fieldsFor = useCallback((node) => {
|
|
57
83
|
if (inputs.length || !node || !node.props.editScreen) return inputs
|
|
58
|
-
const editDoc =
|
|
84
|
+
const editDoc = knownScreens[node.props.editScreen]
|
|
59
85
|
return editDoc && validateDocument(editDoc, controls).ok ? inputNodes(editDoc, controls) : []
|
|
60
|
-
}, [inputs,
|
|
86
|
+
}, [inputs, knownScreens, controls])
|
|
61
87
|
const optionNodes = useMemo(() => {
|
|
62
88
|
if (!check.ok) return []
|
|
63
89
|
const borrowed = doc.nodes.filter((n) => n.type === 'list').flatMap((n) => (inputs.length ? [] : fieldsFor(n)))
|
|
@@ -142,13 +168,19 @@ export function useFactoryScreen({
|
|
|
142
168
|
const l = live.current
|
|
143
169
|
const st = saveState(l.doc, l.values, l.touched, l.loadedItems, l.controls)
|
|
144
170
|
if (!st.canSave) { setStatus(st.status); return { ok: false, errors: st.errors } }
|
|
145
|
-
|
|
171
|
+
const customSave = hooksRef.current && typeof hooksRef.current.save === 'function'
|
|
172
|
+
if (!l.onSave && !customSave) { setStatus('saved'); return { ok: true } }
|
|
146
173
|
|
|
147
174
|
setStatus('saving')
|
|
148
175
|
setSaveError(null)
|
|
149
176
|
const task = (async () => {
|
|
150
177
|
try {
|
|
151
|
-
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
|
+
})
|
|
152
184
|
// A new record's first save creates it; its id makes the next save an update.
|
|
153
185
|
if (saved && typeof saved === 'object' && saved[l.recordKey] !== undefined && saved[l.recordKey] !== null) {
|
|
154
186
|
setRecordId(saved[l.recordKey])
|
|
@@ -263,9 +295,15 @@ export function useFactoryScreen({
|
|
|
263
295
|
listNodes.forEach((node) => {
|
|
264
296
|
const src = listSource(doc, node)
|
|
265
297
|
setLists((l) => ({ ...l, [node.id]: { rows: l[node.id]?.rows || [], loading: true, error: null } }))
|
|
266
|
-
const run =
|
|
267
|
-
|
|
268
|
-
:
|
|
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
|
+
}))
|
|
269
307
|
run
|
|
270
308
|
.then((rows) => { if (!cancelled) setLists((l) => ({ ...l, [node.id]: { rows: Array.isArray(rows) ? rows : [], loading: false, error: null } })) })
|
|
271
309
|
.catch((err) => { if (!cancelled) setLists((l) => ({ ...l, [node.id]: { rows: l[node.id]?.rows || [], loading: false, error: err.message || 'Could not load records' } })) })
|
|
@@ -275,11 +313,33 @@ export function useFactoryScreen({
|
|
|
275
313
|
|
|
276
314
|
const listFor = useCallback((node) => lists[node.id] || { rows: [], loading: false, error: null }, [lists])
|
|
277
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
|
+
|
|
278
331
|
const onDeleteRef = useRef(onDelete); onDeleteRef.current = onDelete
|
|
279
332
|
const deleteRecord = useCallback(async (node, rec) => {
|
|
280
333
|
const id = rec ? rec[recordKey] : undefined
|
|
281
|
-
|
|
282
|
-
|
|
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
|
+
})
|
|
283
343
|
// Deleting the record that is open leaves nothing to save into.
|
|
284
344
|
if (id !== undefined && id === live.current.recordId) {
|
|
285
345
|
clearTimeout(timer.current); timer.current = null
|
|
@@ -290,7 +350,7 @@ export function useFactoryScreen({
|
|
|
290
350
|
|
|
291
351
|
// ── popup: a list's Edit / New in its edit screen ─────────────────────
|
|
292
352
|
const [popup, setPopup] = useState(null) // { node, screenId, record, document, loading, error }
|
|
293
|
-
const screensRef = useRef(
|
|
353
|
+
const screensRef = useRef(knownScreens); screensRef.current = knownScreens
|
|
294
354
|
const fetchRecordRef = useRef(fetchRecord); fetchRecordRef.current = fetchRecord
|
|
295
355
|
const loadScreenRef = useRef(loadScreen); loadScreenRef.current = loadScreen
|
|
296
356
|
|
|
@@ -308,8 +368,12 @@ export function useFactoryScreen({
|
|
|
308
368
|
// Edit opens the record as it is NOW, through the server's get hooks —
|
|
309
369
|
// not the list's copy, which may be stale or shaped for the list.
|
|
310
370
|
let fresh = rec || null
|
|
311
|
-
if (rec &&
|
|
312
|
-
|
|
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
|
+
})
|
|
313
377
|
}
|
|
314
378
|
setPopup((p) => (p && p.screenId === screenId ? { ...p, document: found, record: fresh, loading: false } : p))
|
|
315
379
|
} catch (err) {
|
|
@@ -355,7 +419,9 @@ export function useFactoryScreen({
|
|
|
355
419
|
listFor,
|
|
356
420
|
deleteRecord,
|
|
357
421
|
recordKey,
|
|
358
|
-
canDelete: Boolean(onDelete)
|
|
422
|
+
canDelete: Boolean(onDelete) || Boolean(hooks && typeof hooks.delete === 'function'),
|
|
423
|
+
actionsFor,
|
|
424
|
+
hooks
|
|
359
425
|
}
|
|
360
426
|
}
|
|
361
427
|
|