@xeplr/ui-factory 1.0.2 → 1.0.3
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 +12 -11
- package/README.md +9 -5
- package/bin/xeplr-factory.js +8 -3
- package/package.json +1 -1
- package/src/designs/BuilderSample.jsx +22 -2
- package/src/designs/factory.css +4 -0
- package/src/model.js +2 -1
- package/src/pages.jsx +2 -0
- package/src/remote.js +12 -3
- package/src/scaffold.js +44 -1
- package/src/tableSchema.js +125 -21
- package/src/useFactoryBuilder.js +17 -6
- package/src/useFactoryScreen.js +30 -5
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 `
|
|
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. **
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
32
|
-
6. **
|
|
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>]
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
211
|
-
tableSchema.js ─ a form → its table;
|
|
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
|
package/bin/xeplr-factory.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// turn a short spec into a laid-out document, and check a document before
|
|
6
6
|
// handing it to an app, with errors precise enough to fix without guessing.
|
|
7
7
|
//
|
|
8
|
-
// xeplr-factory screens entity.json [-o dir] an entity → list + edit screens
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "1.0.3",
|
|
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>}
|
package/src/designs/factory.css
CHANGED
|
@@ -223,3 +223,7 @@
|
|
|
223
223
|
.xeplr-factory-publish.is-ok { color: var(--xeplr-status-good, #1d7a4c); background: color-mix(in srgb, var(--xeplr-status-good, #1d7a4c) 7%, transparent); }
|
|
224
224
|
.xeplr-factory-publish.is-error { color: var(--xf-danger); background: color-mix(in srgb, var(--xf-danger) 7%, transparent); flex-wrap: wrap; }
|
|
225
225
|
.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; }
|
|
226
|
+
.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); }
|
|
227
|
+
.xeplr-factory-confirm-text { flex: 1; line-height: 1.5; }
|
|
228
|
+
.xeplr-factory-confirm-text ul { margin: 4px 0; padding-left: 18px; }
|
|
229
|
+
.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
|
@@ -28,7 +28,12 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
28
28
|
const err = new Error((json && json.message) || `${method} ${path} failed (${res.status})`)
|
|
29
29
|
err.status = res.status
|
|
30
30
|
err.fields = json && json.error && json.error.fields
|
|
31
|
-
|
|
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')
|
|
32
37
|
throw err
|
|
33
38
|
}
|
|
34
39
|
return json ? json.dataArray : []
|
|
@@ -42,16 +47,20 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
42
47
|
loadScreen: async (key) => one(await call('GET', `/screens/${enc(key)}`)).document,
|
|
43
48
|
loadDraft: async (key) => one(await call('GET', `/screens/${enc(key)}?draft=true`)),
|
|
44
49
|
saveDraft: (doc) => call('PUT', `/screens/${enc(doc.id)}/draft`, { document: doc }),
|
|
45
|
-
|
|
50
|
+
// Changes the screen's table directly. Rejects with err.confirm = [{ column, records }]
|
|
51
|
+
// when a removed field would drop a column; call again with { confirmDrop: [names] }.
|
|
52
|
+
publish: async (doc, options) => one(await call('POST', `/screens/${enc(doc.id)}/publish`, { confirmDrop: (options && options.confirmDrop) || [] })),
|
|
46
53
|
listTables: () => call('GET', '/tables'),
|
|
47
54
|
|
|
48
55
|
// records
|
|
49
56
|
fetchRecords: ({ screen }) => call('GET', `/records/${enc(screen)}`),
|
|
57
|
+
// One record, through the screen's get hooks — what Edit opens.
|
|
58
|
+
fetchRecord: async ({ screen, id }) => one(await call('GET', `/records/${enc(screen)}/${enc(id)}`)),
|
|
50
59
|
onSave: async (values, { id, screen }) => one(await call('POST', `/records/${enc(screen)}/save`, { id, values })),
|
|
51
60
|
onDelete: ({ id, screen }) => call('POST', `/records/${enc(screen)}/delete`, { id }),
|
|
52
61
|
fetchOptions: ({ table }) => call('GET', `/options/${enc(table)}`)
|
|
53
62
|
}
|
|
54
|
-
api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, onDelete: api.onDelete, fetchOptions: api.fetchOptions }
|
|
63
|
+
api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions }
|
|
55
64
|
api.builderProps = { onSave: api.saveDraft, onPublish: api.publish, listTables: api.listTables, fetchOptions: api.fetchOptions, fetchRecords: api.fetchRecords }
|
|
56
65
|
return api
|
|
57
66
|
}
|
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}'
|
package/src/tableSchema.js
CHANGED
|
@@ -181,32 +181,136 @@ export function migrationFor(previous, next, controls = CONTROLS) {
|
|
|
181
181
|
lines.push(...header, '')
|
|
182
182
|
|
|
183
183
|
if (diff.create) {
|
|
184
|
-
|
|
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
|
-
|
|
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) => {
|
package/src/useFactoryBuilder.js
CHANGED
|
@@ -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
|
|
33
|
-
*
|
|
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
|
|
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
|
-
|
|
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 {
|
package/src/useFactoryScreen.js
CHANGED
|
@@ -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])
|
|
@@ -76,6 +77,9 @@ export function useFactoryScreen({
|
|
|
76
77
|
const [saveError, setSaveError] = useState(null)
|
|
77
78
|
const [savedAt, setSavedAt] = useState(null)
|
|
78
79
|
const [listVersion, setListVersion] = useState(0)
|
|
80
|
+
// Messages the SERVER put on fields — a hook's reject, a rule only it knows.
|
|
81
|
+
// Shown like the form's own, and cleared as soon as that field is changed.
|
|
82
|
+
const [serverErrors, setServerErrors] = useState({})
|
|
79
83
|
|
|
80
84
|
// Latest of everything a delayed save needs, read at the moment it runs.
|
|
81
85
|
const live = useRef({})
|
|
@@ -155,8 +159,15 @@ export function useFactoryScreen({
|
|
|
155
159
|
setStatus('saved')
|
|
156
160
|
return { ok: true, record: saved }
|
|
157
161
|
} catch (err) {
|
|
158
|
-
|
|
159
|
-
|
|
162
|
+
const onFields = Array.isArray(err.fields) ? err.fields.filter((f) => f && f.field && f.message) : []
|
|
163
|
+
if (onFields.length) {
|
|
164
|
+
setServerErrors(Object.fromEntries(onFields.map((f) => [f.field, f.message])))
|
|
165
|
+
setStatus('invalid')
|
|
166
|
+
setSaveError(null)
|
|
167
|
+
} else {
|
|
168
|
+
setSaveError(err.message || 'Could not save')
|
|
169
|
+
setStatus('error')
|
|
170
|
+
}
|
|
160
171
|
return { ok: false, error: err }
|
|
161
172
|
} finally {
|
|
162
173
|
inFlight.current = null
|
|
@@ -193,6 +204,12 @@ export function useFactoryScreen({
|
|
|
193
204
|
else next[name] = value
|
|
194
205
|
live.current.values = next
|
|
195
206
|
setValues(next)
|
|
207
|
+
setServerErrors((e) => {
|
|
208
|
+
if (!(name in e)) return e
|
|
209
|
+
const rest = { ...e }
|
|
210
|
+
delete rest[name]
|
|
211
|
+
return rest
|
|
212
|
+
})
|
|
196
213
|
if (!live.current.touched.has(name)) {
|
|
197
214
|
const t = new Set(live.current.touched); t.add(name)
|
|
198
215
|
live.current.touched = t
|
|
@@ -222,6 +239,7 @@ export function useFactoryScreen({
|
|
|
222
239
|
setRecordId(live.current.recordId)
|
|
223
240
|
setTouched(new Set())
|
|
224
241
|
setSaveError(null)
|
|
242
|
+
setServerErrors({})
|
|
225
243
|
setStatus('idle')
|
|
226
244
|
}, [flush, startValues, recordKey])
|
|
227
245
|
|
|
@@ -273,6 +291,7 @@ export function useFactoryScreen({
|
|
|
273
291
|
// ── popup: a list's Edit / New in its edit screen ─────────────────────
|
|
274
292
|
const [popup, setPopup] = useState(null) // { node, screenId, record, document, loading, error }
|
|
275
293
|
const screensRef = useRef(screens); screensRef.current = screens
|
|
294
|
+
const fetchRecordRef = useRef(fetchRecord); fetchRecordRef.current = fetchRecord
|
|
276
295
|
const loadScreenRef = useRef(loadScreen); loadScreenRef.current = loadScreen
|
|
277
296
|
|
|
278
297
|
/** Edit (a record) or New (null) in the list's edit screen. */
|
|
@@ -286,7 +305,13 @@ export function useFactoryScreen({
|
|
|
286
305
|
if (!found) throw new Error(`Screen "${screenId}" was not provided — pass it in \`screens\` or supply loadScreen`)
|
|
287
306
|
const checked = validateDocument(found, live.current.controls)
|
|
288
307
|
if (!checked.ok) throw new Error(`Screen "${screenId}" is not valid: ${checked.errors[0].path} ${checked.errors[0].message}`)
|
|
289
|
-
|
|
308
|
+
// Edit opens the record as it is NOW, through the server's get hooks —
|
|
309
|
+
// not the list's copy, which may be stale or shaped for the list.
|
|
310
|
+
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] })
|
|
313
|
+
}
|
|
314
|
+
setPopup((p) => (p && p.screenId === screenId ? { ...p, document: found, record: fresh, loading: false } : p))
|
|
290
315
|
} catch (err) {
|
|
291
316
|
setPopup((p) => (p && p.screenId === screenId ? { ...p, loading: false, error: err.message } : p))
|
|
292
317
|
}
|
|
@@ -315,7 +340,7 @@ export function useFactoryScreen({
|
|
|
315
340
|
popupSave,
|
|
316
341
|
values,
|
|
317
342
|
recordId,
|
|
318
|
-
errors: state.shown,
|
|
343
|
+
errors: { ...state.shown, ...serverErrors },
|
|
319
344
|
allErrors: state.errors,
|
|
320
345
|
status,
|
|
321
346
|
saveError,
|