@xeplr/ui-factory 1.0.2 → 1.0.4

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
@@ -11,25 +11,26 @@ A request for "a form for employees" is an **entity**, and an entity is **two sc
11
11
  ```sh
12
12
  npx xeplr-factory screens employee.entity.json -o src/screens/employee
13
13
  ```
14
- This writes `employee-list.screen.json`, `employee-edit.screen.json`, `EmployeeList.jsx` and `EditEmployee.jsx`. 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`.
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`.
15
15
 
16
16
  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
- 3. **Write the table's migration.** Records live in a **real table** — `employees`, one column per field — never in JSON. Draft the migration from the edit screen into the app's migrations folder:
18
- ```sh
19
- npx xeplr-factory migration src/screens/employee/employee-edit.screen.json -o migrations
20
- ```
21
- It writes the next numbered file (`0067_factory_employees_create.sql`): a `CREATE TABLE` with a column per field, real foreign keys for table dropdowns, and the standard `id` / `isActive` / `mtId1–4` / audit columns. Read it, and apply it with the app's `migrate:up`.
17
+ 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:
18
+ - a new field → `ADD COLUMN`; a wider text or number → `ALTER COLUMN … TYPE`
19
+ - narrowing, or a different kind of value → **refused**; tell the person why
20
+ - a **removed field drops its column and all its data** — Publish asks the person to confirm first, showing how many values would go. Never confirm a drop on their behalf.
21
+ - a column not created by a screen, or still used by another company's screen, is never dropped
22
22
 
23
- **Changing an existing form** is a migration too. Pass the version the table was built from: `--from employee-edit.published.json` (from git, or `GET /factory/screens/employee_edit`). New fields become `ADD COLUMN`; a wider text or number becomes `ALTER COLUMN … TYPE`. Narrowing, or changing a field to a different kind of value, is **refused** — explain why to the person and write that migration by hand only if they confirm. A removed field's column is kept.
23
+ Publish a table that others point at first: `departments` before `employees`. To preview the SQL without applying it: `npx xeplr-factory migration employee-edit.screen.json`.
24
24
 
25
- **Never rename a field that is already a column** (the designer locks those names). Renaming would start a new empty column and strand the data in the old one.
25
+ **Never rename a field that is already a column** (the designer locks those names) — it would drop the old column and its data and start an empty one.
26
26
  4. **Validate** — `screens` and `generate` already refuse a bad spec, but check any document you edited by hand:
27
27
  ```sh
28
28
  npx xeplr-factory validate screen.json
29
29
  ```
30
30
  Every problem names a path (`nodes[3].props.data.table`) and what belongs there. Fix and re-run until it prints `ok`.
31
- 5. **Save and publish.** Save the screens as drafts and publish them. Publishing refuses a screen whose table is missing a column, and returns the SQL that would add it — run the migration first.
32
- 6. **Hand over.** The person opens the screens in the designer to move, resize and restyle controls.
31
+ 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.
33
34
 
34
35
  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`.
35
36
 
@@ -211,7 +212,7 @@ Keep labels short and in sentence case ("Start date", not "START DATE:"). Mark o
211
212
 
212
213
  ```sh
213
214
  npx xeplr-factory screens <entity.json|-> [-o <dir>] [--force] # entity → list + edit screens and pages
214
- npx xeplr-factory migration <edit.screen.json> [--from <previous.screen.json>] [-o <migrations dir>] # the table's SQL
215
+ npx xeplr-factory migration <edit.screen.json> [--from <previous.screen.json>] # preview a table's SQL (Publish applies it)
215
216
  npx xeplr-factory generate <spec.json|-> [-o <screen.json>] # spec → document
216
217
  npx xeplr-factory validate <screen.json|-> # exit 1 with every problem listed
217
218
  npx xeplr-factory controls # controls, props, validation rules
package/README.md CHANGED
@@ -17,6 +17,7 @@ It is built to work with Claude: ask for *"a form for employees with name, email
17
17
  npx xeplr-factory screens employee.entity.json -o src/screens/employee
18
18
  # employee-list.screen.json EmployeeList.jsx
19
19
  # employee-edit.screen.json EditEmployee.jsx
20
+ # employee.hooks.js server hooks for its records: save / get / delete × before / after / error / override
20
21
  ```
21
22
 
22
23
  See [examples/](./examples) for exactly what it writes.
@@ -33,7 +34,7 @@ Most internal apps are a long tail of forms, and every one of them is hand-built
33
34
 
34
35
  **The factory never has a database.** Your app stores screen documents and supplies the rows dropdowns read. The factory draws, edits and validates.
35
36
 
36
- **Records live in real tables** — `employees`, one column per field — never in JSON, so `select * from employees` and ordinary reporting just work. `xeplr-factory migration` drafts each table's migration from its form, and [`@xeplr/factory`](https://www.npmjs.com/package/@xeplr/factory) is the server package that saves screens (draft / publish) and reads and writes those tables with the screen's own validation. `createFactoryApi({ fetch })` connects the components to it.
37
+ **Records live in real tables** — `employees`, one column per field — never in JSON, so `select * from employees` and ordinary reporting just work. **Publishing** a screen creates or changes its table directly: new fields add columns, and a removed field drops its column and data only after you confirm. [`@xeplr/factory`](https://www.npmjs.com/package/@xeplr/factory) is the server package that does it, saves screens (draft / publish) and reads and writes those tables with the screen's own validation. `createFactoryApi({ fetch })` connects the components to it.
37
38
 
38
39
  ## Install
39
40
 
@@ -65,7 +66,7 @@ import { FactoryBuilder } from '@xeplr/ui-factory'
65
66
  - **Every look is a property:** font, size, weight, italic, alignment, text colour, background, border colour/width, corner radius — and for an input, its label's size, weight and colour.
66
67
  - With nothing selected, the panel shows the **screen**: the table it saves to, its width, and the font and colours every control inherits.
67
68
  - **No Save button.** Edits are saved (as a draft) a moment after you stop, whenever the screen is valid; problems are marked on the controls that have them until they are fixed.
68
- - **Publish** (when `onPublish` is given) makes the draft the version everyone sees. It may be refused — e.g. the table has no column for a new field — and the reason, with the migration to run, is shown.
69
+ - **Publish** (when `onPublish` is given) makes the draft the version everyone sees and changes its table to match. If removed fields would drop columns, it asks first — "Publishing removes `phone` — 1,240 saved values" — with **Remove and publish** / **Cancel**. Unsafe changes (narrowing a field) are refused with the reason.
69
70
  - **`lockedNames`**: fields that are already columns of the table. Their names are read-only, because renaming one would leave its data behind.
70
71
 
71
72
  ## The screen
@@ -78,6 +79,7 @@ import { FactoryScreen } from '@xeplr/ui-factory'
78
79
  record={employee} // optional: open an existing record
79
80
  onSave={async (values, { id, source }) => api.save(source, id, values)} // returns the saved record, with its id
80
81
  fetchRecords={async ({ source }) => api.records(source)}
82
+ fetchRecord={async ({ screen, id }) => api.record(screen, id)} // optional: Edit loads the record fresh
81
83
  onDelete={async ({ id, source }) => api.remove(source, id)}
82
84
  fetchOptions={async ({ table }) => api.options(table)}
83
85
  screens={{ employee_edit: editScreen }} // what a list's Edit / New open in a popup
@@ -86,6 +88,8 @@ import { FactoryScreen } from '@xeplr/ui-factory'
86
88
 
87
89
  **There is no submit.** The screen saves itself in the background (one AJAX call to your `onSave`) a moment after a change, once the entered values are acceptable. Until then it says what is missing — "Fill in the required fields to save" — and a field's message appears once the person has been in it. The first save of a new record creates it: return the saved record and its `id` makes every later save an update.
88
90
 
91
+ If `onSave` throws an error with `fields: [{ field, message }]` (what `createFactoryApi` does with the server's 422 — a rule, or a hook's `reject`), each message shows on its field until that field is changed.
92
+
89
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).
90
94
 
91
95
  ### Lists of saved records
@@ -156,7 +160,7 @@ It lays the fields out as a tidy one- or two-column form and returns a checked d
156
160
 
157
161
  ```sh
158
162
  npx xeplr-factory screens entity.json -o dir # an entity → list + edit screens and pages
159
- npx xeplr-factory migration edit.screen.json -o migrations # the entity's table, as a migration
163
+ npx xeplr-factory migration edit.screen.json # preview the SQL Publish would run
160
164
  npx xeplr-factory generate spec.json -o screen.json # one screen from a spec
161
165
  npx xeplr-factory validate screen.json # every problem, with its path
162
166
  npx xeplr-factory controls # controls, props and rules
@@ -207,8 +211,8 @@ src/
207
211
  validateDocument.js ─ the document checker
208
212
  values.js ─ values, validation, autosave readiness, records and list columns, formSchema
209
213
  generate.js ─ screenFromSpec, screensFromSpec (list + edit), entity names
210
- scaffold.js ─ an entity → its screen JSON and .jsx pages
211
- tableSchema.js ─ a form → its table; CREATE / ALTER TABLE migrations, safe changes only
214
+ scaffold.js ─ an entity → its screen JSON, .jsx pages and hooks stub
215
+ tableSchema.js ─ a form → its table; the publish plan (create / add / widen / confirmed drops)
212
216
  remote.js ─ createFactoryApi: the calls to @xeplr/factory's routes
213
217
  model.js ─ everything above, React-free
214
218
  useFactoryBuilder.js ─ builder controller
@@ -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 and their .jsx pages
8
+ // xeplr-factory screens entity.json [-o dir] an entity → list + edit screens, their .jsx pages, a hooks stub
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
@@ -81,13 +81,18 @@ switch (cmd) {
81
81
  } catch (err) {
82
82
  fail(err.message)
83
83
  }
84
- const targets = Object.keys(result.files).map((f) => path.join(dir, f))
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)
88
+ const targets = writing.map((f) => path.join(dir, f))
85
89
  // Never overwrite a screen someone has since refined in the designer.
86
90
  const existing = targets.filter((t) => existsSync(t))
87
91
  if (existing.length && !force) fail(`would overwrite ${existing.join(', ')} — pass --force to replace them`)
88
92
  mkdirSync(dir, { recursive: true })
89
- Object.entries(result.files).forEach(([f, text]) => writeFileSync(path.join(dir, f), text))
93
+ writing.forEach((f) => writeFileSync(path.join(dir, f), result.files[f]))
90
94
  process.stderr.write(`wrote ${targets.join(', ')}\n`)
95
+ if (keptHooks) process.stderr.write(`kept ${path.join(dir, keptHooks)} — it is your code\n`)
91
96
  process.stderr.write(`list screen "${result.list.id}" opens "${result.edit.id}" for Edit / New; both use table "${result.edit.source}"\n`)
92
97
  break
93
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-factory",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
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",
@@ -72,13 +72,33 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
72
72
  </button>
73
73
  )}
74
74
  {ctrl.canPublish && (
75
- <button type="button" className="xeplr-factory-primary" onClick={ctrl.publish} disabled={ctrl.publishing}>
75
+ <button type="button" className="xeplr-factory-primary" onClick={() => ctrl.publish()} disabled={ctrl.publishing}>
76
76
  {ctrl.publishing ? 'Publishing…' : 'Publish'}
77
77
  </button>
78
78
  )}
79
79
  </header>
80
80
 
81
- {ctrl.publishResult && (
81
+ {ctrl.publishResult && ctrl.publishResult.confirm && (
82
+ <div className="xeplr-factory-publish is-confirm" role="alertdialog" aria-label="Confirm removing columns">
83
+ <div className="xeplr-factory-confirm-text">
84
+ <strong>Publishing removes {ctrl.publishResult.confirm.length === 1 ? 'a column' : `${ctrl.publishResult.confirm.length} columns`} and all the data in {ctrl.publishResult.confirm.length === 1 ? 'it' : 'them'}.</strong>
85
+ <ul>
86
+ {ctrl.publishResult.confirm.map((c) => (
87
+ <li key={c.column}><code>{c.column}</code> — {c.records} saved value{c.records === 1 ? '' : 's'}, for every company using this table</li>
88
+ ))}
89
+ </ul>
90
+ This cannot be undone. To keep the data, put the field back before publishing.
91
+ </div>
92
+ <div className="xeplr-factory-confirm-actions">
93
+ <button type="button" className="xeplr-factory-danger" disabled={ctrl.publishing}
94
+ onClick={() => ctrl.publish(ctrl.publishResult.confirm.map((c) => c.column))}>
95
+ {ctrl.publishing ? 'Publishing…' : 'Remove and publish'}
96
+ </button>
97
+ <button type="button" className="xeplr-factory-secondary" onClick={ctrl.clearPublishResult}>Cancel</button>
98
+ </div>
99
+ </div>
100
+ )}
101
+ {ctrl.publishResult && !ctrl.publishResult.confirm && (
82
102
  <div className={`xeplr-factory-publish${ctrl.publishResult.ok ? ' is-ok' : ' is-error'}`} role="status">
83
103
  <span>{ctrl.publishResult.message}</span>
84
104
  {ctrl.publishResult.detail && <pre className="xeplr-factory-publish-detail">{ctrl.publishResult.detail}</pre>}
@@ -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; }
@@ -223,3 +226,7 @@
223
226
  .xeplr-factory-publish.is-ok { color: var(--xeplr-status-good, #1d7a4c); background: color-mix(in srgb, var(--xeplr-status-good, #1d7a4c) 7%, transparent); }
224
227
  .xeplr-factory-publish.is-error { color: var(--xf-danger); background: color-mix(in srgb, var(--xf-danger) 7%, transparent); flex-wrap: wrap; }
225
228
  .xeplr-factory-publish-detail { flex-basis: 100%; order: 3; margin: 4px 0 0; padding: 8px; max-height: 220px; overflow: auto; font-size: 11.5px; color: var(--xeplr-text, #1a1a19); background: var(--xeplr-surface, #fff); border: 1px solid var(--xf-border); border-radius: 6px; white-space: pre; }
229
+ .xeplr-factory-publish.is-confirm { align-items: center; color: var(--xeplr-text, #1a1a19); background: color-mix(in srgb, var(--xeplr-status-warn, #a86a00) 10%, transparent); border-bottom-color: color-mix(in srgb, var(--xeplr-status-warn, #a86a00) 40%, transparent); }
230
+ .xeplr-factory-confirm-text { flex: 1; line-height: 1.5; }
231
+ .xeplr-factory-confirm-text ul { margin: 4px 0; padding-left: 18px; }
232
+ .xeplr-factory-confirm-actions { display: flex; gap: 8px; flex-shrink: 0; }
package/src/model.js CHANGED
@@ -17,5 +17,6 @@ export { getAtPath, setAtPath } from './propertyPath.js'
17
17
  export { createFactoryApi } from './remote.js'
18
18
  export {
19
19
  STANDARD_COLUMNS, RESERVED_COLUMNS, MAX_IDENTIFIER,
20
- columnForField, tableForScreen, columnSql, widening, diffTables, migrationFor, nextMigrationName
20
+ columnForField, tableForScreen, columnSql, widening, diffTables, migrationFor, nextMigrationName,
21
+ createStatements, addStatements, alterStatement, dropStatement, columnFromDatabase, planTableChange
21
22
  } from './tableSchema.js'
package/src/pages.jsx CHANGED
@@ -47,6 +47,7 @@ export function FactoryBuilder(props) {
47
47
  * @prop onSave async (values, { id, source, document }) → the saved record, with its id
48
48
  * @prop fetchOptions async ({ table }) → [{ id, name }] — rows for table dropdowns
49
49
  * @prop fetchRecords async ({ source }) → records — rows for lists
50
+ * @prop fetchRecord async ({ screen, id }) → record — Edit loads the record fresh
50
51
  * @prop onDelete async ({ id, source, record }) → void
51
52
  * @prop onChange (values) → void
52
53
  * @prop screens { id → document } — the screens a list's Edit / New open in a popup
@@ -78,6 +79,7 @@ export function FactoryScreen(props) {
78
79
  recordKey={props.recordKey}
79
80
  onSave={ctrl.popupSave}
80
81
  fetchOptions={props.fetchOptions}
82
+ fetchRecord={props.fetchRecord}
81
83
  fetchRecords={props.fetchRecords}
82
84
  onDelete={props.onDelete}
83
85
  screens={props.screens}
package/src/remote.js CHANGED
@@ -16,22 +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
- err.detail = json && json.dataArray && json.dataArray[0] && json.dataArray[0].migration
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)
32
35
  throw err
33
36
  }
34
- 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
35
57
  }
36
58
  const one = (rows) => (Array.isArray(rows) ? rows[0] : rows)
37
59
  const enc = encodeURIComponent
@@ -42,16 +64,20 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
42
64
  loadScreen: async (key) => one(await call('GET', `/screens/${enc(key)}`)).document,
43
65
  loadDraft: async (key) => one(await call('GET', `/screens/${enc(key)}?draft=true`)),
44
66
  saveDraft: (doc) => call('PUT', `/screens/${enc(doc.id)}/draft`, { document: doc }),
45
- publish: async (doc) => one(await call('POST', `/screens/${enc(doc.id)}/publish`)),
67
+ // Changes the screen's table directly. Rejects with err.confirm = [{ column, records }]
68
+ // when a removed field would drop a column; call again with { confirmDrop: [names] }.
69
+ publish: async (doc, options) => one(await call('POST', `/screens/${enc(doc.id)}/publish`, { confirmDrop: (options && options.confirmDrop) || [] })),
46
70
  listTables: () => call('GET', '/tables'),
47
71
 
48
72
  // records
49
73
  fetchRecords: ({ screen }) => call('GET', `/records/${enc(screen)}`),
74
+ // One record, through the screen's get hooks — what Edit opens.
75
+ fetchRecord: async ({ screen, id }) => one(await call('GET', `/records/${enc(screen)}/${enc(id)}`)),
50
76
  onSave: async (values, { id, screen }) => one(await call('POST', `/records/${enc(screen)}/save`, { id, values })),
51
77
  onDelete: ({ id, screen }) => call('POST', `/records/${enc(screen)}/delete`, { id }),
52
78
  fetchOptions: ({ table }) => call('GET', `/options/${enc(table)}`)
53
79
  }
54
- api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, onDelete: api.onDelete, fetchOptions: api.fetchOptions }
80
+ api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions }
55
81
  api.builderProps = { onSave: api.saveDraft, onPublish: api.publish, listTables: api.listTables, fetchOptions: api.fetchOptions, fetchRecords: api.fetchRecords }
56
82
  return api
57
83
  }
package/src/scaffold.js CHANGED
@@ -5,6 +5,7 @@
5
5
  //
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
+ // employee.hooks.js — the server's hooks for its records (all empty)
8
9
  //
9
10
  // The pages are thin on purpose: a screen document and the app's data calls,
10
11
  // nothing else. Changing what a screen shows is editing its JSON in the
@@ -28,7 +29,8 @@ export function scaffoldEntity(spec, controls) {
28
29
  [listJson]: JSON.stringify(list, null, 2) + '\n',
29
30
  [editJson]: JSON.stringify(edit, null, 2) + '\n',
30
31
  [`${names.listComponent}.jsx`]: listPage(names, listJson, editJson, list, edit),
31
- [`${names.editComponent}.jsx`]: editPage(names, editJson, edit)
32
+ [`${names.editComponent}.jsx`]: editPage(names, editJson, edit),
33
+ [`${names.file}.hooks.js`]: hooksFile(names, edit)
32
34
  }
33
35
  return { files, names, list, edit }
34
36
  }
@@ -58,6 +60,47 @@ export default function ${n.listComponent}({ api }) {
58
60
  `
59
61
  }
60
62
 
63
+ function hooksFile(n, edit) {
64
+ return `// Server hooks for ${n.singular} records — screen "${edit.id}" (and "${n.key}_list", which edits in it).
65
+ //
66
+ // Register with @xeplr/factory:
67
+ // factory.init({ knex, hooks: { ${edit.id}: require('./${n.file}.hooks') } })
68
+ //
69
+ // Each operation has four hooks. Delete the ones you do not need.
70
+ // before(ctx) first — save: return new values to replace them (they are then checked
71
+ // against the screen's rules); get: narrow ctx.query; any: ctx.reject(message, { field })
72
+ // after(ctx) once it succeeded — ctx.id, ctx.result; return a value to replace the result.
73
+ // If it throws, the operation still happened (and error is told).
74
+ // error(ctx, err) when the operation failed
75
+ // override(ctx) does the WHOLE operation instead — no rules, no before / after / error,
76
+ // no generic query. What it returns is the response.
77
+ //
78
+ // ctx: op, screenKey, screen, table, id, isNew (save), many (get), input (what the UI sent, read-only),
79
+ // values (save), previous (save / delete: the row before), result, user, tenant, knex, query (get), reject
80
+
81
+ module.exports = {
82
+ save: {
83
+ // before: async function(ctx) { return ctx.values },
84
+ // after: async function(ctx) {},
85
+ // error: async function(ctx, err) {},
86
+ // override: async function(ctx) { return savedRecord },
87
+ },
88
+ get: {
89
+ // before: async function(ctx) { /* ctx.query.where(...) */ },
90
+ // after: async function(ctx) { return ctx.result },
91
+ // error: async function(ctx, err) {},
92
+ // override: async function(ctx) { return ctx.id ? record : records },
93
+ },
94
+ delete: {
95
+ // before: async function(ctx) {},
96
+ // after: async function(ctx) {},
97
+ // error: async function(ctx, err) {},
98
+ // override: async function(ctx) { return { id: ctx.id } },
99
+ }
100
+ }
101
+ `
102
+ }
103
+
61
104
  function editPage(n, editJson, edit) {
62
105
  return `import { FactoryScreen } from '@xeplr/ui-factory'
63
106
  import editScreen from './${editJson}'
@@ -181,32 +181,136 @@ export function migrationFor(previous, next, controls = CONTROLS) {
181
181
  lines.push(...header, '')
182
182
 
183
183
  if (diff.create) {
184
- const cols = [...STANDARD_COLUMNS.slice(0, 1), ...after.columns.map((c) => ({ name: c.name, sql: columnSql(c), label: c.label })), ...STANDARD_COLUMNS.slice(1)]
185
- const width = Math.max(...cols.map((c) => c.name.length)) + 2
186
- lines.push(`CREATE TABLE IF NOT EXISTS ${q(t)} (`)
187
- cols.forEach((c, i) => {
188
- const comma = i < cols.length - 1 ? ',' : ''
189
- const note = c.label ? ` -- ${c.label}` : ''
190
- lines.push(` ${q(c.name).padEnd(width)} ${c.sql}${comma}${note}`)
191
- })
192
- lines.push(');', '')
193
- lines.push(`CREATE INDEX IF NOT EXISTS ${q(`${t}_mt_index`)} ON ${q(t)} ("mtId1", "mtId2");`)
194
- after.columns.filter((c) => c.references).forEach((c) => {
195
- lines.push(`CREATE INDEX IF NOT EXISTS ${q(`${t}_${c.name}_index`)} ON ${q(t)} (${q(c.name)});`)
196
- })
184
+ lines.push(...createStatements(after, { annotate: true }))
197
185
  } else {
198
- diff.add.forEach((c) => {
199
- lines.push(`ALTER TABLE ${q(t)} ADD COLUMN IF NOT EXISTS ${q(c.name)} ${columnSql(c)}; -- ${c.label}`)
200
- if (c.references) lines.push(`CREATE INDEX IF NOT EXISTS ${q(`${t}_${c.name}_index`)} ON ${q(t)} (${q(c.name)});`)
201
- })
202
- diff.alter.forEach(({ from, to }) => {
203
- const type = to.type === 'varchar' ? `varchar(${to.length})` : to.type
204
- lines.push(`ALTER TABLE ${q(t)} ALTER COLUMN ${q(to.name)} TYPE ${type}; -- was ${describe(from)}`)
205
- })
186
+ diff.add.forEach((c) => lines.push(...addStatements(t, c, { annotate: true })))
187
+ diff.alter.forEach(({ from, to }) => lines.push(alterStatement(t, from, to, { annotate: true })))
206
188
  }
207
189
  return { sql: lines.join('\n') + '\n', diff, table: t, empty: false }
208
190
  }
209
191
 
192
+ // ── statements ───────────────────────────────────────────────────────────
193
+
194
+ /** CREATE TABLE with the fields and the standard columns, and its indexes. */
195
+ export function createStatements(after, { annotate } = {}) {
196
+ const t = after.table
197
+ const cols = [...STANDARD_COLUMNS.slice(0, 1), ...after.columns.map((c) => ({ name: c.name, sql: columnSql(c), label: c.label })), ...STANDARD_COLUMNS.slice(1)]
198
+ const width = Math.max(...cols.map((c) => c.name.length)) + 2
199
+ const body = cols.map((c, i) => {
200
+ const comma = i < cols.length - 1 ? ',' : ''
201
+ const note = annotate && c.label ? ` -- ${c.label}` : ''
202
+ return ` ${q(c.name).padEnd(width)} ${c.sql}${comma}${note}`
203
+ })
204
+ const out = [`CREATE TABLE IF NOT EXISTS ${q(t)} (\n${body.join('\n')}\n);`]
205
+ out.push(`CREATE INDEX IF NOT EXISTS ${q(`${t}_mt_index`)} ON ${q(t)} ("mtId1", "mtId2");`)
206
+ after.columns.filter((c) => c.references).forEach((c) => {
207
+ out.push(`CREATE INDEX IF NOT EXISTS ${q(`${t}_${c.name}_index`)} ON ${q(t)} (${q(c.name)});`)
208
+ })
209
+ return out
210
+ }
211
+
212
+ export function addStatements(t, c, { annotate } = {}) {
213
+ const out = [`ALTER TABLE ${q(t)} ADD COLUMN IF NOT EXISTS ${q(c.name)} ${columnSql(c)};${annotate && c.label ? ` -- ${c.label}` : ''}`]
214
+ if (c.references) out.push(`CREATE INDEX IF NOT EXISTS ${q(`${t}_${c.name}_index`)} ON ${q(t)} (${q(c.name)});`)
215
+ return out
216
+ }
217
+
218
+ export function alterStatement(t, from, to, { annotate } = {}) {
219
+ const type = to.type === 'varchar' ? `varchar(${to.length})` : to.type
220
+ return `ALTER TABLE ${q(t)} ALTER COLUMN ${q(to.name)} TYPE ${type};${annotate ? ` -- was ${describe(from)}` : ''}`
221
+ }
222
+
223
+ /** Drops the column and every value in it. Its foreign key and index go with it. */
224
+ export function dropStatement(t, name) {
225
+ return `ALTER TABLE ${q(t)} DROP COLUMN IF EXISTS ${q(name)};`
226
+ }
227
+
228
+ // ── publish: apply directly ──────────────────────────────────────────────
229
+ //
230
+ // Publishing a screen changes its table straight away — no migration files.
231
+ // It compares the screen with the table AS IT IS in the database (not with
232
+ // the previous version), so a table changed by hand, or a publish that failed
233
+ // halfway, is still seen for what it is.
234
+ //
235
+ // A REMOVED FIELD DROPS ITS COLUMN AND ITS DATA — only after the person
236
+ // confirms, and never for a column this factory did not create:
237
+ // managed names that were fields of a published version of a screen on this
238
+ // table. A column somebody added by hand is not in it, and is left alone.
239
+ // inUse names another published screen on the same table still has —
240
+ // another company's screen, say. The column is shared; it stays.
241
+
242
+ /** A column as Postgres describes it → the shape columnForField returns. */
243
+ export function columnFromDatabase(c) {
244
+ const base = { name: c.name }
245
+ if (c.references) base.references = c.references
246
+ switch (c.udtName) {
247
+ case 'varchar': return { ...base, type: 'varchar', length: c.maxLength }
248
+ case 'text': return { ...base, type: 'text' }
249
+ case 'int2':
250
+ case 'int4': return { ...base, type: 'integer' }
251
+ case 'numeric': return { ...base, type: 'numeric' }
252
+ case 'date': return { ...base, type: 'date' }
253
+ case 'bool': return { ...base, type: 'boolean' }
254
+ // Anything else (a timestamp, a bigint someone chose) is not a type a field
255
+ // makes — so any change to it is refused rather than guessed at.
256
+ default: return { ...base, type: c.udtName }
257
+ }
258
+ }
259
+
260
+ /**
261
+ * What publishing `next` does to its table.
262
+ *
263
+ * @param current { table, columns: [{ name, udtName, maxLength, references }] }
264
+ * as the database describes it, or null when the table does not exist
265
+ * @param options.managed names ever published as fields on this table
266
+ * @param options.inUse names other published screens on this table still use
267
+ * @param options.confirmDrop names the person has confirmed dropping
268
+ * @returns {{
269
+ * table, create,
270
+ * add, alter, what will change
271
+ * drop, columns to drop: [{ name }]
272
+ * unconfirmed, of those, the ones not yet confirmed — nothing runs until this is empty
273
+ * keep, columns left in place, with why: [{ name, reason }]
274
+ * refused, changes that cannot be made safely: [{ column, reason }] — nothing runs
275
+ * statements the SQL, in order — empty while anything is refused or unconfirmed
276
+ * }}
277
+ */
278
+ export function planTableChange(current, next, options = {}, controls = CONTROLS) {
279
+ const after = tableForScreen(next, controls)
280
+ const managed = new Set(options.managed || [])
281
+ const inUse = new Set(options.inUse || [])
282
+ const confirmed = new Set(options.confirmDrop || [])
283
+ const plan = { table: after.table, create: false, add: [], alter: [], drop: [], unconfirmed: [], keep: [], refused: [], statements: [] }
284
+
285
+ if (!current) {
286
+ plan.create = true
287
+ plan.add = after.columns
288
+ plan.statements = createStatements(after)
289
+ return plan
290
+ }
291
+
292
+ const existing = current.columns
293
+ .filter((c) => !RESERVED_COLUMNS.includes(c.name))
294
+ .map(columnFromDatabase)
295
+ const diff = diffTables({ table: after.table, columns: existing }, after)
296
+ plan.add = diff.add
297
+ plan.alter = diff.alter
298
+ plan.refused = diff.refused
299
+
300
+ diff.unused.forEach((c) => {
301
+ if (!managed.has(c.name)) plan.keep.push({ name: c.name, reason: 'not created by a screen — left as it is' })
302
+ else if (inUse.has(c.name)) plan.keep.push({ name: c.name, reason: 'another published screen on this table still uses it' })
303
+ else plan.drop.push({ name: c.name })
304
+ })
305
+ plan.unconfirmed = plan.drop.filter((d) => !confirmed.has(d.name)).map((d) => d.name)
306
+
307
+ if (plan.refused.length || plan.unconfirmed.length) return plan
308
+ plan.add.forEach((c) => plan.statements.push(...addStatements(after.table, c)))
309
+ plan.alter.forEach(({ from, to }) => plan.statements.push(alterStatement(after.table, from, to)))
310
+ plan.drop.forEach((d) => plan.statements.push(dropStatement(after.table, d.name)))
311
+ return plan
312
+ }
313
+
210
314
  /** Next migration file name in a folder of NNNN_*.sql files. */
211
315
  export function nextMigrationName(existingFiles, table, create) {
212
316
  const top = (existingFiles || []).reduce((m, f) => {
@@ -29,8 +29,10 @@ import { validateDocument } from './validateDocument.js'
29
29
  * @param screens [{ id, name, document? }] — other screens, offered for a list's "Edit in";
30
30
  * with its document, a list screen can pick columns from its fields
31
31
  * @param lockedNames field names that are already columns of the table — they cannot be renamed
32
- * @param onPublish async (document) → { version } — makes the saved draft the version everyone sees.
33
- * It may refuse (e.g. the table is missing columns) by throwing; the message is shown.
32
+ * @param onPublish async (document, { confirmDrop }) → { version } — makes the saved draft the version
33
+ * everyone sees, and changes its table to match. Throw to refuse (the message is
34
+ * shown); throw with `confirm: [{ column, records }]` when removed fields would drop
35
+ * columns — the builder asks, and calls again with confirmDrop naming them.
34
36
  * @param controls the control registry (default: the built-ins)
35
37
  */
36
38
  export function useFactoryBuilder({ document: given, name, onSave, onChange, onPublish, listTables, screens, lockedNames, controls = CONTROLS, autosaveDelay = 800 } = {}) {
@@ -196,7 +198,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
196
198
  const onPublishRef = useRef(onPublish); onPublishRef.current = onPublish
197
199
  const [publishing, setPublishing] = useState(false)
198
200
  const [publishResult, setPublishResult] = useState(null) // { ok, message, version?, detail? }
199
- const publish = useCallback(async () => {
201
+ const publish = useCallback(async (confirmDrop) => {
200
202
  if (!onPublishRef.current) return { ok: false }
201
203
  const check = validateDocument(docRef.current, controls)
202
204
  if (!check.ok) {
@@ -209,12 +211,21 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
209
211
  try {
210
212
  const saved = await save()
211
213
  if (!saved.ok && !saved.busy) throw new Error((saved.errors && saved.errors[0] && saved.errors[0].message) || 'Could not save the draft')
212
- const res = await onPublishRef.current(docRef.current)
213
- const r = { ok: true, version: res && res.version, message: res && res.version ? `Published version ${res.version}` : 'Published' }
214
+ const res = await onPublishRef.current(docRef.current, { confirmDrop: Array.isArray(confirmDrop) ? confirmDrop : [] })
215
+ const kept = (res && res.keep) || []
216
+ const r = {
217
+ ok: true,
218
+ version: res && res.version,
219
+ message: (res && res.version ? `Published version ${res.version}` : 'Published') +
220
+ (kept.length ? ` — kept ${kept.map((k) => k.name).join(', ')} (${kept[0].reason})` : '')
221
+ }
214
222
  setPublishResult(r)
215
223
  return r
216
224
  } catch (err) {
217
- const r = { ok: false, message: err.message || 'Could not publish', detail: err.detail }
225
+ // A removed field would drop a column: not an error, a question.
226
+ const r = Array.isArray(err.confirm) && err.confirm.length
227
+ ? { ok: false, confirm: err.confirm, message: err.message }
228
+ : { ok: false, message: err.message || 'Could not publish', detail: err.detail }
218
229
  setPublishResult(r)
219
230
  return r
220
231
  } finally {
@@ -36,13 +36,14 @@ export const AUTOSAVE_DELAY = 700
36
36
  * @param fetchRecords async ({ source, node }) → records, for lists
37
37
  * @param onDelete async ({ id, source, record }) → void
38
38
  * @param onChange (values) → void — every change, before any save
39
+ * @param fetchRecord async ({ screen, id }) → record — Edit loads the record fresh (through the server's get hooks)
39
40
  * @param screens { id → document } — screens a list's Edit / New can open
40
41
  * @param loadScreen async (id) → document — for screens not in `screens`
41
42
  * @param autosaveDelay ms (default 700)
42
43
  * @param controls the control registry (default: the built-ins)
43
44
  */
44
45
  export function useFactoryScreen({
45
- document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, onDelete, onChange, screens, loadScreen,
46
+ document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen,
46
47
  autosaveDelay = AUTOSAVE_DELAY, controls = CONTROLS
47
48
  } = {}) {
48
49
  const check = useMemo(() => validateDocument(doc, controls), [doc, controls])
@@ -52,11 +53,32 @@ export function useFactoryScreen({
52
53
  // `department` is a dropdown whose 2 reads "Finance" — lives on the screen it
53
54
  // edits in. Those fields are borrowed for display, and their table options
54
55
  // loaded alongside this screen's own.
56
+ //
57
+ // An edit screen not passed in `screens` is fetched with loadScreen as soon
58
+ // as the list shows — not when Edit is first pressed — or the list's columns
59
+ // would show stored values ("in_progress") until then.
60
+ const [loadedScreens, setLoadedScreens] = useState({})
61
+ const knownScreens = useMemo(() => ({ ...loadedScreens, ...(screens || {}) }), [loadedScreens, screens])
62
+ const editScreenIds = check.ok && !inputs.length
63
+ ? [...new Set(doc.nodes.filter((n) => n.type === 'list' && n.props.editScreen).map((n) => n.props.editScreen))].join('|')
64
+ : ''
65
+ useEffect(() => {
66
+ if (!editScreenIds || !loadScreen) return undefined
67
+ let cancelled = false
68
+ editScreenIds.split('|').filter((id) => !(screens && screens[id])).forEach((id) => {
69
+ loadScreen(id).then(
70
+ (found) => { if (!cancelled && found) setLoadedScreens((m) => (m[id] ? m : { ...m, [id]: found })) },
71
+ () => { /* shown when Edit is pressed, where the person is looking */ }
72
+ )
73
+ })
74
+ return () => { cancelled = true }
75
+ }, [editScreenIds]) // eslint-disable-line react-hooks/exhaustive-deps
76
+
55
77
  const fieldsFor = useCallback((node) => {
56
78
  if (inputs.length || !node || !node.props.editScreen) return inputs
57
- const editDoc = screens && screens[node.props.editScreen]
79
+ const editDoc = knownScreens[node.props.editScreen]
58
80
  return editDoc && validateDocument(editDoc, controls).ok ? inputNodes(editDoc, controls) : []
59
- }, [inputs, screens, controls])
81
+ }, [inputs, knownScreens, controls])
60
82
  const optionNodes = useMemo(() => {
61
83
  if (!check.ok) return []
62
84
  const borrowed = doc.nodes.filter((n) => n.type === 'list').flatMap((n) => (inputs.length ? [] : fieldsFor(n)))
@@ -76,6 +98,9 @@ export function useFactoryScreen({
76
98
  const [saveError, setSaveError] = useState(null)
77
99
  const [savedAt, setSavedAt] = useState(null)
78
100
  const [listVersion, setListVersion] = useState(0)
101
+ // Messages the SERVER put on fields — a hook's reject, a rule only it knows.
102
+ // Shown like the form's own, and cleared as soon as that field is changed.
103
+ const [serverErrors, setServerErrors] = useState({})
79
104
 
80
105
  // Latest of everything a delayed save needs, read at the moment it runs.
81
106
  const live = useRef({})
@@ -155,8 +180,15 @@ export function useFactoryScreen({
155
180
  setStatus('saved')
156
181
  return { ok: true, record: saved }
157
182
  } catch (err) {
158
- setSaveError(err.message || 'Could not save')
159
- setStatus('error')
183
+ const onFields = Array.isArray(err.fields) ? err.fields.filter((f) => f && f.field && f.message) : []
184
+ if (onFields.length) {
185
+ setServerErrors(Object.fromEntries(onFields.map((f) => [f.field, f.message])))
186
+ setStatus('invalid')
187
+ setSaveError(null)
188
+ } else {
189
+ setSaveError(err.message || 'Could not save')
190
+ setStatus('error')
191
+ }
160
192
  return { ok: false, error: err }
161
193
  } finally {
162
194
  inFlight.current = null
@@ -193,6 +225,12 @@ export function useFactoryScreen({
193
225
  else next[name] = value
194
226
  live.current.values = next
195
227
  setValues(next)
228
+ setServerErrors((e) => {
229
+ if (!(name in e)) return e
230
+ const rest = { ...e }
231
+ delete rest[name]
232
+ return rest
233
+ })
196
234
  if (!live.current.touched.has(name)) {
197
235
  const t = new Set(live.current.touched); t.add(name)
198
236
  live.current.touched = t
@@ -222,6 +260,7 @@ export function useFactoryScreen({
222
260
  setRecordId(live.current.recordId)
223
261
  setTouched(new Set())
224
262
  setSaveError(null)
263
+ setServerErrors({})
225
264
  setStatus('idle')
226
265
  }, [flush, startValues, recordKey])
227
266
 
@@ -272,7 +311,8 @@ export function useFactoryScreen({
272
311
 
273
312
  // ── popup: a list's Edit / New in its edit screen ─────────────────────
274
313
  const [popup, setPopup] = useState(null) // { node, screenId, record, document, loading, error }
275
- const screensRef = useRef(screens); screensRef.current = screens
314
+ const screensRef = useRef(knownScreens); screensRef.current = knownScreens
315
+ const fetchRecordRef = useRef(fetchRecord); fetchRecordRef.current = fetchRecord
276
316
  const loadScreenRef = useRef(loadScreen); loadScreenRef.current = loadScreen
277
317
 
278
318
  /** Edit (a record) or New (null) in the list's edit screen. */
@@ -286,7 +326,13 @@ export function useFactoryScreen({
286
326
  if (!found) throw new Error(`Screen "${screenId}" was not provided — pass it in \`screens\` or supply loadScreen`)
287
327
  const checked = validateDocument(found, live.current.controls)
288
328
  if (!checked.ok) throw new Error(`Screen "${screenId}" is not valid: ${checked.errors[0].path} ${checked.errors[0].message}`)
289
- setPopup((p) => (p && p.screenId === screenId ? { ...p, document: found, loading: false } : p))
329
+ // Edit opens the record as it is NOW, through the server's get hooks —
330
+ // not the list's copy, which may be stale or shaped for the list.
331
+ let fresh = rec || null
332
+ if (rec && fetchRecordRef.current && rec[live.current.recordKey] !== undefined) {
333
+ fresh = await fetchRecordRef.current({ screen: screenId, id: rec[live.current.recordKey] })
334
+ }
335
+ setPopup((p) => (p && p.screenId === screenId ? { ...p, document: found, record: fresh, loading: false } : p))
290
336
  } catch (err) {
291
337
  setPopup((p) => (p && p.screenId === screenId ? { ...p, loading: false, error: err.message } : p))
292
338
  }
@@ -315,7 +361,7 @@ export function useFactoryScreen({
315
361
  popupSave,
316
362
  values,
317
363
  recordId,
318
- errors: state.shown,
364
+ errors: { ...state.shown, ...serverErrors },
319
365
  allErrors: state.errors,
320
366
  status,
321
367
  saveError,