@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 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
- ## The workflow
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
- A request for "a form for employees" is an **entity**, and an entity is **two screens**: a list, and an add / edit form the list opens in a popup.
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`. 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`.
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. **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).
33
- 7. **Hand over.** The person opens the screens in the designer to move, resize and restyle controls.
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.
@@ -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, a hooks stub
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 hooks file is the app's own code — kept as it is, even with --force.
85
- const hooksFile = Object.keys(result.files).find((f) => f.endsWith('.hooks.js'))
86
- const keptHooks = hooksFile && existsSync(path.join(dir, hooksFile)) ? hooksFile : null
87
- const writing = Object.keys(result.files).filter((f) => f !== keptHooks)
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 (keptHooks) process.stderr.write(`kept ${path.join(dir, keptHooks)} — it is your code\n`)
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",
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",
@@ -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)),
@@ -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
- font: inherit; color: var(--xf-ink); background: var(--xeplr-input-bg, var(--xf-surface));
54
- border: 1px solid var(--xeplr-input-border, var(--xf-border)); border-radius: calc(6 * var(--xf-u));
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: var(--xeplr-border-secondary, #ecebe4); }
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; color: inherit;
181
- border: 1px solid var(--xf-border); border-radius: 5px; background: var(--xeplr-input-bg, #fff);
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)}` // "EditEmployee"
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
- const res = await doFetch(`${base}/factory${path}`, {
21
- method,
22
- headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
23
- body: body === undefined ? undefined : JSON.stringify(body)
24
- })
25
- let json = null
26
- try { json = await res.json() } catch (_) { /* not JSON — reported below */ }
27
- if (!res.ok) {
28
- const err = new Error((json && json.message) || `${method} ${path} failed (${res.status})`)
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
- return json ? json.dataArray : []
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
  }
@@ -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 = screens && screens[node.props.editScreen]
84
+ const editDoc = knownScreens[node.props.editScreen]
59
85
  return editDoc && validateDocument(editDoc, controls).ok ? inputNodes(editDoc, controls) : []
60
- }, [inputs, screens, controls])
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
- if (!l.onSave) { setStatus('saved'); return { ok: true } }
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 saved = await l.onSave(l.values, { id: l.recordId, source: l.source, screen: l.doc.id, document: l.doc })
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 = fetchRecordsRef.current
267
- ? Promise.resolve().then(() => fetchRecordsRef.current({ source: src, screen: doc.id, node }))
268
- : Promise.reject(new Error(`No fetchRecords was provided to list "${src}"`))
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
- if (!onDeleteRef.current) throw new Error('No onDelete was provided')
282
- await onDeleteRef.current({ id, source: listSource(doc, node), screen: doc.id, record: rec })
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(screens); screensRef.current = screens
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 && fetchRecordRef.current && rec[live.current.recordKey] !== undefined) {
312
- fresh = await fetchRecordRef.current({ screen: screenId, id: rec[live.current.recordKey] })
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