@xeplr/ui-factory 1.0.1 → 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 +101 -12
- package/README.md +73 -22
- package/bin/xeplr-factory.js +72 -1
- package/package.json +6 -2
- package/src/controls.js +142 -45
- package/src/designs/BuilderSample.jsx +80 -36
- package/src/designs/ControlView.jsx +43 -36
- package/src/designs/ListView.jsx +95 -0
- package/src/designs/PropertyPanel.jsx +187 -13
- package/src/designs/ScreenModal.jsx +42 -0
- package/src/designs/ScreenSample.jsx +49 -9
- package/src/designs/factory.css +72 -20
- package/src/designs/index.js +2 -0
- package/src/designs/styles.js +59 -0
- package/src/document.js +31 -4
- package/src/generate.js +136 -25
- package/src/index.js +2 -2
- package/src/model.js +12 -5
- package/src/pages.jsx +62 -10
- package/src/remote.js +66 -0
- package/src/scaffold.js +116 -0
- package/src/tableSchema.js +329 -0
- package/src/useFactoryBuilder.js +102 -17
- package/src/useFactoryScreen.js +290 -78
- package/src/validateDocument.js +73 -5
- package/src/values.js +61 -0
package/src/useFactoryBuilder.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { CONTROLS, controlGroups } from './controls.js'
|
|
3
3
|
import {
|
|
4
|
-
createScreen, renameScreen, addControl as addControlTo, moveNode, setNodeProperty,
|
|
4
|
+
createScreen, renameScreen, setScreenProperty, addControl as addControlTo, moveNode, setNodeProperty,
|
|
5
5
|
removeNodes
|
|
6
6
|
} from './document.js'
|
|
7
7
|
import { validateDocument } from './validateDocument.js'
|
|
@@ -14,36 +14,49 @@ import { validateDocument } from './validateDocument.js'
|
|
|
14
14
|
// the `document` it is handed and gives the result to `onSave`. A document
|
|
15
15
|
// handed in LATER (Claude regenerated the screen, another record was opened)
|
|
16
16
|
// replaces what is being edited.
|
|
17
|
+
//
|
|
18
|
+
// NO SAVE BUTTON. Every edit is saved a moment after the last one, whenever the
|
|
19
|
+
// screen is valid; while it is not, the problems are shown and nothing is sent.
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
22
|
* @param document the screen to edit; omitted → a new empty screen
|
|
20
23
|
* @param name the name for a new screen (when no document)
|
|
21
|
-
* @param onSave async (document) → void; called only with a valid document
|
|
24
|
+
* @param onSave async (document) → void; called automatically, only with a valid document
|
|
25
|
+
* @param autosaveDelay ms of quiet after the last edit before saving (default 800)
|
|
22
26
|
* @param onChange (document) → void; every edit, for hosts that autosave or preview
|
|
23
27
|
* @param listTables async () → [{ id, name }] | string[]; offered in a dropdown's
|
|
24
28
|
* "from a table" picker. Omitted → table sources can still be typed.
|
|
29
|
+
* @param screens [{ id, name, document? }] — other screens, offered for a list's "Edit in";
|
|
30
|
+
* with its document, a list screen can pick columns from its fields
|
|
31
|
+
* @param lockedNames field names that are already columns of the table — they cannot be renamed
|
|
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.
|
|
25
36
|
* @param controls the control registry (default: the built-ins)
|
|
26
37
|
*/
|
|
27
|
-
export function useFactoryBuilder({ document: given, name, onSave, onChange, listTables, controls = CONTROLS } = {}) {
|
|
38
|
+
export function useFactoryBuilder({ document: given, name, onSave, onChange, onPublish, listTables, screens, lockedNames, controls = CONTROLS, autosaveDelay = 800 } = {}) {
|
|
28
39
|
const [doc, setDoc] = useState(() => given || createScreen({ name }))
|
|
29
40
|
const [selected, setSelected] = useState(() => new Set())
|
|
30
41
|
const [dirty, setDirty] = useState(false)
|
|
31
42
|
const [saving, setSaving] = useState(false)
|
|
32
43
|
const [saveError, setSaveError] = useState(null)
|
|
33
|
-
const [
|
|
44
|
+
const [savedAt, setSavedAt] = useState(null)
|
|
34
45
|
const docRef = useRef(doc); docRef.current = doc
|
|
35
46
|
|
|
36
|
-
// A NEW document from outside replaces the draft.
|
|
37
|
-
//
|
|
47
|
+
// A NEW document from outside replaces the draft. Not a new OBJECT: a host
|
|
48
|
+
// that re-reads what it just saved hands back the same screen as a fresh
|
|
49
|
+
// object on every autosave, and replacing the draft with it would drop the
|
|
50
|
+
// selection each time. Only different content replaces what is being edited.
|
|
38
51
|
const lastGiven = useRef(given)
|
|
39
52
|
useEffect(() => {
|
|
40
53
|
if (!given || given === lastGiven.current) return
|
|
41
54
|
lastGiven.current = given
|
|
55
|
+
if (JSON.stringify(given) === JSON.stringify(docRef.current)) return
|
|
42
56
|
docRef.current = given
|
|
43
57
|
setDoc(given)
|
|
44
58
|
setSelected(new Set())
|
|
45
59
|
setDirty(false)
|
|
46
|
-
setShowErrors(false)
|
|
47
60
|
}, [given])
|
|
48
61
|
|
|
49
62
|
// Every edit goes through here. It works on a REF of the current document,
|
|
@@ -82,6 +95,8 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, lis
|
|
|
82
95
|
|
|
83
96
|
// ── edits ─────────────────────────────────────────────────────────────
|
|
84
97
|
const rename = useCallback((value) => update((d) => renameScreen(d, value)), [update])
|
|
98
|
+
/** A screen property by path — `source`, `width`, `style.fontSize`. */
|
|
99
|
+
const setScreen = useCallback((path, value) => update((d) => setScreenProperty(d, path, value)), [update])
|
|
85
100
|
|
|
86
101
|
/** Adds a control and selects it, so its properties are open straight away. */
|
|
87
102
|
const addControl = useCallback((type, at) => {
|
|
@@ -97,7 +112,8 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, lis
|
|
|
97
112
|
|
|
98
113
|
const moveControl = useCallback((id, patch) => update((d) => moveNode(d, id, patch)), [update])
|
|
99
114
|
|
|
100
|
-
const
|
|
115
|
+
const lockedRef = useRef(lockedNames); lockedRef.current = lockedNames
|
|
116
|
+
const setProperty = useCallback((id, path, value) => update((d) => setNodeProperty(d, id, path, value, controls, { lockedNames: lockedRef.current })), [update, controls])
|
|
101
117
|
|
|
102
118
|
const selectedRef = useRef(selected); selectedRef.current = selected
|
|
103
119
|
const removeSelected = useCallback(() => {
|
|
@@ -144,24 +160,84 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, lis
|
|
|
144
160
|
}, [validation, doc.nodes])
|
|
145
161
|
|
|
146
162
|
const onSaveRef = useRef(onSave); onSaveRef.current = onSave
|
|
163
|
+
const savingRef = useRef(false)
|
|
147
164
|
const save = useCallback(async () => {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (!
|
|
165
|
+
const current = docRef.current
|
|
166
|
+
const check = validateDocument(current, controls)
|
|
167
|
+
if (!check.ok) return { ok: false, errors: check.errors }
|
|
151
168
|
if (!onSaveRef.current) return { ok: false, errors: [{ path: '', message: 'No onSave was provided' }] }
|
|
169
|
+
if (savingRef.current) return { ok: false, busy: true }
|
|
170
|
+
savingRef.current = true
|
|
152
171
|
setSaving(true)
|
|
172
|
+
setSaveError(null)
|
|
153
173
|
try {
|
|
154
|
-
await onSaveRef.current(
|
|
155
|
-
|
|
156
|
-
|
|
174
|
+
await onSaveRef.current(current)
|
|
175
|
+
// Only clean if nothing changed while the save was out.
|
|
176
|
+
if (docRef.current === current) setDirty(false)
|
|
177
|
+
setSavedAt(new Date())
|
|
157
178
|
return { ok: true }
|
|
158
179
|
} catch (err) {
|
|
159
180
|
setSaveError(err.message || 'Could not save')
|
|
160
181
|
return { ok: false, errors: [{ path: '', message: err.message }] }
|
|
161
182
|
} finally {
|
|
183
|
+
savingRef.current = false
|
|
162
184
|
setSaving(false)
|
|
163
185
|
}
|
|
164
|
-
}, [
|
|
186
|
+
}, [controls])
|
|
187
|
+
|
|
188
|
+
// AUTOSAVE: a quiet moment after the last edit, if the screen is valid.
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (!dirty || !validation.ok || !onSaveRef.current) return undefined
|
|
191
|
+
const t = setTimeout(save, autosaveDelay)
|
|
192
|
+
return () => clearTimeout(t)
|
|
193
|
+
}, [doc, dirty, validation.ok, save, autosaveDelay, saving])
|
|
194
|
+
|
|
195
|
+
// PUBLISH: the draft becomes the next version — what screens actually show.
|
|
196
|
+
// Deliberate, unlike saving: a half-finished design must not reach everyone
|
|
197
|
+
// filling the form in.
|
|
198
|
+
const onPublishRef = useRef(onPublish); onPublishRef.current = onPublish
|
|
199
|
+
const [publishing, setPublishing] = useState(false)
|
|
200
|
+
const [publishResult, setPublishResult] = useState(null) // { ok, message, version?, detail? }
|
|
201
|
+
const publish = useCallback(async (confirmDrop) => {
|
|
202
|
+
if (!onPublishRef.current) return { ok: false }
|
|
203
|
+
const check = validateDocument(docRef.current, controls)
|
|
204
|
+
if (!check.ok) {
|
|
205
|
+
const r = { ok: false, message: `Fix ${check.errors.length} problem${check.errors.length === 1 ? '' : 's'} before publishing` }
|
|
206
|
+
setPublishResult(r)
|
|
207
|
+
return r
|
|
208
|
+
}
|
|
209
|
+
setPublishing(true)
|
|
210
|
+
setPublishResult(null)
|
|
211
|
+
try {
|
|
212
|
+
const saved = await save()
|
|
213
|
+
if (!saved.ok && !saved.busy) throw new Error((saved.errors && saved.errors[0] && saved.errors[0].message) || 'Could not save the draft')
|
|
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
|
+
}
|
|
222
|
+
setPublishResult(r)
|
|
223
|
+
return r
|
|
224
|
+
} catch (err) {
|
|
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 }
|
|
229
|
+
setPublishResult(r)
|
|
230
|
+
return r
|
|
231
|
+
} finally {
|
|
232
|
+
setPublishing(false)
|
|
233
|
+
}
|
|
234
|
+
}, [controls, save])
|
|
235
|
+
|
|
236
|
+
const status = saveError ? 'error'
|
|
237
|
+
: saving ? 'saving'
|
|
238
|
+
: !validation.ok ? 'invalid'
|
|
239
|
+
: dirty ? 'pending'
|
|
240
|
+
: savedAt ? 'saved' : 'idle'
|
|
165
241
|
|
|
166
242
|
// ── keyboard: Delete / Backspace removes, Cmd/Ctrl+D duplicates ────────
|
|
167
243
|
useEffect(() => {
|
|
@@ -192,18 +268,27 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, lis
|
|
|
192
268
|
selectedNode,
|
|
193
269
|
selectedControl: selectedNode ? controls[selectedNode.type] : null,
|
|
194
270
|
rename,
|
|
271
|
+
setScreen,
|
|
195
272
|
addControl,
|
|
196
273
|
moveControl,
|
|
197
274
|
setProperty,
|
|
198
275
|
removeSelected,
|
|
199
276
|
duplicateSelected,
|
|
200
277
|
tables,
|
|
278
|
+
screens: screens || [],
|
|
279
|
+
lockedNames: lockedNames || [],
|
|
201
280
|
validation,
|
|
202
281
|
errorsByNode,
|
|
203
|
-
showErrors,
|
|
204
282
|
dirty,
|
|
205
283
|
saving,
|
|
206
284
|
saveError,
|
|
207
|
-
|
|
285
|
+
savedAt,
|
|
286
|
+
status,
|
|
287
|
+
save,
|
|
288
|
+
canPublish: Boolean(onPublish),
|
|
289
|
+
publish,
|
|
290
|
+
publishing,
|
|
291
|
+
publishResult,
|
|
292
|
+
clearPublishResult: () => setPublishResult(null)
|
|
208
293
|
}
|
|
209
294
|
}
|
package/src/useFactoryScreen.js
CHANGED
|
@@ -2,50 +2,95 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
2
2
|
import { CONTROLS } from './controls.js'
|
|
3
3
|
import { inputNodes } from './document.js'
|
|
4
4
|
import { validateDocument } from './validateDocument.js'
|
|
5
|
-
import { initialValues, parseInput,
|
|
5
|
+
import { initialValues, parseInput, saveState, fieldError, optionValue, recordValues, listSource } from './values.js'
|
|
6
6
|
|
|
7
|
-
// A saved screen, running
|
|
8
|
-
// submit. No JSX — designs/ScreenSample.jsx draws it.
|
|
7
|
+
// A saved screen, running. No JSX — designs/ScreenSample.jsx draws it.
|
|
9
8
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// ── NO SUBMIT ────────────────────────────────────────────────────────────
|
|
10
|
+
// The screen saves ITSELF, in the background, a moment after the person stops
|
|
11
|
+
// changing something — once what is entered is acceptable. There is no button
|
|
12
|
+
// to press and no page to post. The first save of a new record creates it
|
|
13
|
+
// (the host hands back its id); every save after that updates the same record.
|
|
14
|
+
//
|
|
15
|
+
// ── THE DATABASE IS THE HOST'S ───────────────────────────────────────────
|
|
16
|
+
// Everything that reads or writes data is a call the host supplies:
|
|
17
|
+
// onSave(values, { id, source, screen, document }) → the saved record (with its id)
|
|
18
|
+
// fetchRecords({ source, screen, node }) → rows, for a list
|
|
19
|
+
// onDelete({ id, source, screen, record })
|
|
20
|
+
// fetchOptions({ table, node }) → [{ id, name }], for a table dropdown
|
|
21
|
+
//
|
|
22
|
+
// ── LIST → EDIT IN A POPUP ───────────────────────────────────────────────
|
|
23
|
+
// A list whose `editScreen` names another screen opens that screen in a popup
|
|
24
|
+
// for Edit and New. The screen comes from `screens` ({ id → document }) or the
|
|
25
|
+
// host's `loadScreen(id)`. The list refreshes as the popup saves.
|
|
26
|
+
|
|
27
|
+
/** Quiet time after the last change before a save goes out. */
|
|
28
|
+
export const AUTOSAVE_DELAY = 700
|
|
13
29
|
|
|
14
30
|
/**
|
|
15
31
|
* @param document a valid screen document
|
|
16
|
-
* @param
|
|
17
|
-
* @param
|
|
18
|
-
* @param
|
|
32
|
+
* @param record the record to open, e.g. { id: 7, firstName: 'Ada' } — omit for a new one
|
|
33
|
+
* @param recordKey the id field on a record (default 'id')
|
|
34
|
+
* @param onSave async (values, { id, source, document }) → saved record | void
|
|
35
|
+
* @param fetchOptions async ({ table, node }) → [{ id, name }]
|
|
36
|
+
* @param fetchRecords async ({ source, node }) → records, for lists
|
|
37
|
+
* @param onDelete async ({ id, source, record }) → void
|
|
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)
|
|
40
|
+
* @param screens { id → document } — screens a list's Edit / New can open
|
|
41
|
+
* @param loadScreen async (id) → document — for screens not in `screens`
|
|
42
|
+
* @param autosaveDelay ms (default 700)
|
|
19
43
|
* @param controls the control registry (default: the built-ins)
|
|
20
44
|
*/
|
|
21
|
-
export function useFactoryScreen({
|
|
45
|
+
export function useFactoryScreen({
|
|
46
|
+
document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen,
|
|
47
|
+
autosaveDelay = AUTOSAVE_DELAY, controls = CONTROLS
|
|
48
|
+
} = {}) {
|
|
22
49
|
const check = useMemo(() => validateDocument(doc, controls), [doc, controls])
|
|
23
50
|
const inputs = useMemo(() => (check.ok ? inputNodes(doc, controls) : []), [doc, controls, check.ok])
|
|
24
51
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
52
|
+
// A LIST SCREEN has no fields of its own; what its columns mean — that
|
|
53
|
+
// `department` is a dropdown whose 2 reads "Finance" — lives on the screen it
|
|
54
|
+
// edits in. Those fields are borrowed for display, and their table options
|
|
55
|
+
// loaded alongside this screen's own.
|
|
56
|
+
const fieldsFor = useCallback((node) => {
|
|
57
|
+
if (inputs.length || !node || !node.props.editScreen) return inputs
|
|
58
|
+
const editDoc = screens && screens[node.props.editScreen]
|
|
59
|
+
return editDoc && validateDocument(editDoc, controls).ok ? inputNodes(editDoc, controls) : []
|
|
60
|
+
}, [inputs, screens, controls])
|
|
61
|
+
const optionNodes = useMemo(() => {
|
|
62
|
+
if (!check.ok) return []
|
|
63
|
+
const borrowed = doc.nodes.filter((n) => n.type === 'list').flatMap((n) => (inputs.length ? [] : fieldsFor(n)))
|
|
64
|
+
return [...inputs, ...borrowed]
|
|
65
|
+
}, [check.ok, doc, inputs, fieldsFor])
|
|
66
|
+
const source = check.ok ? doc.source || null : null
|
|
30
67
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
68
|
+
const startValues = useCallback(
|
|
69
|
+
(rec) => (rec ? { ...initialValues(doc, {}, controls), ...recordValues(doc, rec, controls) } : initialValues(doc, {}, controls)),
|
|
70
|
+
[doc, controls]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const [values, setValues] = useState(() => (check.ok ? startValues(record) : {}))
|
|
74
|
+
const [recordId, setRecordId] = useState(() => (record ? record[recordKey] ?? null : null))
|
|
75
|
+
const [touched, setTouched] = useState(() => new Set())
|
|
76
|
+
const [status, setStatus] = useState('idle') // idle | pending | saving | saved | incomplete | invalid | error
|
|
77
|
+
const [saveError, setSaveError] = useState(null)
|
|
78
|
+
const [savedAt, setSavedAt] = useState(null)
|
|
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({})
|
|
83
|
+
|
|
84
|
+
// Latest of everything a delayed save needs, read at the moment it runs.
|
|
85
|
+
const live = useRef({})
|
|
86
|
+
live.current = { doc, values, recordId, touched, source, onSave, controls, recordKey }
|
|
42
87
|
|
|
43
88
|
// ── options for table dropdowns, fetched once per table ───────────────
|
|
44
89
|
const [options, setOptions] = useState({}) // node id → { loading, items, error }
|
|
45
|
-
const
|
|
90
|
+
const fetchOptionsRef = useRef(fetchOptions); fetchOptionsRef.current = fetchOptions
|
|
46
91
|
useEffect(() => {
|
|
47
92
|
let cancelled = false
|
|
48
|
-
const tableNodes =
|
|
93
|
+
const tableNodes = optionNodes.filter((n) => n.type === 'dropdown' && n.props.data?.source === 'table')
|
|
49
94
|
if (!tableNodes.length) { setOptions({}); return undefined }
|
|
50
95
|
setOptions(Object.fromEntries(tableNodes.map((n) => [n.id, { loading: true, items: [], error: null }])))
|
|
51
96
|
const byTable = new Map()
|
|
@@ -55,8 +100,8 @@ export function useFactoryScreen({ document: doc, fetchOptions, onSubmit, values
|
|
|
55
100
|
byTable.get(t).push(n)
|
|
56
101
|
})
|
|
57
102
|
byTable.forEach((nodes, table) => {
|
|
58
|
-
const run =
|
|
59
|
-
? Promise.resolve().then(() =>
|
|
103
|
+
const run = fetchOptionsRef.current
|
|
104
|
+
? Promise.resolve().then(() => fetchOptionsRef.current({ table, node: nodes[0] }))
|
|
60
105
|
: Promise.reject(new Error(`No fetchOptions was provided to read "${table}"`))
|
|
61
106
|
run
|
|
62
107
|
.then((rows) => {
|
|
@@ -68,9 +113,8 @@ export function useFactoryScreen({ document: doc, fetchOptions, onSubmit, values
|
|
|
68
113
|
})
|
|
69
114
|
})
|
|
70
115
|
return () => { cancelled = true }
|
|
71
|
-
}, [
|
|
116
|
+
}, [optionNodes])
|
|
72
117
|
|
|
73
|
-
/** The options a dropdown shows, whichever source they come from. */
|
|
74
118
|
const optionsFor = useCallback((node) => {
|
|
75
119
|
if (node.props.data?.source === 'static') return { loading: false, items: node.props.data.options, error: null }
|
|
76
120
|
return options[node.id] || { loading: false, items: [], error: null }
|
|
@@ -81,69 +125,237 @@ export function useFactoryScreen({ document: doc, fetchOptions, onSubmit, values
|
|
|
81
125
|
Object.entries(options).forEach(([id, o]) => { if (!o.loading && !o.error) map[id] = o.items })
|
|
82
126
|
return map
|
|
83
127
|
}, [options])
|
|
128
|
+
live.current.loadedItems = loadedItems
|
|
129
|
+
|
|
130
|
+
const state = useMemo(() => (check.ok ? saveState(doc, values, touched, loadedItems, controls) : { canSave: false, status: 'invalid', errors: {}, shown: {} }),
|
|
131
|
+
[check.ok, doc, values, touched, loadedItems, controls])
|
|
132
|
+
|
|
133
|
+
// ── saving ────────────────────────────────────────────────────────────
|
|
134
|
+
const timer = useRef(null)
|
|
135
|
+
const inFlight = useRef(null) // the promise of the save running now
|
|
136
|
+
const again = useRef(false) // a change arrived while it ran
|
|
137
|
+
|
|
138
|
+
const runSave = useCallback(async () => {
|
|
139
|
+
clearTimeout(timer.current)
|
|
140
|
+
timer.current = null
|
|
141
|
+
if (inFlight.current) { again.current = true; return inFlight.current }
|
|
142
|
+
const l = live.current
|
|
143
|
+
const st = saveState(l.doc, l.values, l.touched, l.loadedItems, l.controls)
|
|
144
|
+
if (!st.canSave) { setStatus(st.status); return { ok: false, errors: st.errors } }
|
|
145
|
+
if (!l.onSave) { setStatus('saved'); return { ok: true } }
|
|
146
|
+
|
|
147
|
+
setStatus('saving')
|
|
148
|
+
setSaveError(null)
|
|
149
|
+
const task = (async () => {
|
|
150
|
+
try {
|
|
151
|
+
const saved = await l.onSave(l.values, { id: l.recordId, source: l.source, screen: l.doc.id, document: l.doc })
|
|
152
|
+
// A new record's first save creates it; its id makes the next save an update.
|
|
153
|
+
if (saved && typeof saved === 'object' && saved[l.recordKey] !== undefined && saved[l.recordKey] !== null) {
|
|
154
|
+
setRecordId(saved[l.recordKey])
|
|
155
|
+
live.current.recordId = saved[l.recordKey]
|
|
156
|
+
}
|
|
157
|
+
setSavedAt(new Date())
|
|
158
|
+
setListVersion((v) => v + 1)
|
|
159
|
+
setStatus('saved')
|
|
160
|
+
return { ok: true, record: saved }
|
|
161
|
+
} catch (err) {
|
|
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
|
+
}
|
|
171
|
+
return { ok: false, error: err }
|
|
172
|
+
} finally {
|
|
173
|
+
inFlight.current = null
|
|
174
|
+
if (again.current) { again.current = false; schedule() } // eslint-disable-line no-use-before-define
|
|
175
|
+
}
|
|
176
|
+
})()
|
|
177
|
+
inFlight.current = task
|
|
178
|
+
return task
|
|
179
|
+
}, [])
|
|
180
|
+
|
|
181
|
+
const schedule = useCallback(() => {
|
|
182
|
+
clearTimeout(timer.current)
|
|
183
|
+
setStatus('pending')
|
|
184
|
+
timer.current = setTimeout(runSave, autosaveDelay)
|
|
185
|
+
}, [runSave, autosaveDelay])
|
|
186
|
+
|
|
187
|
+
/** Save any pending change right away — before opening another record, say. */
|
|
188
|
+
const flush = useCallback(async () => {
|
|
189
|
+
if (timer.current || again.current) return runSave()
|
|
190
|
+
if (inFlight.current) return inFlight.current
|
|
191
|
+
return { ok: true }
|
|
192
|
+
}, [runSave])
|
|
193
|
+
|
|
194
|
+
useEffect(() => () => clearTimeout(timer.current), [])
|
|
84
195
|
|
|
85
196
|
// ── editing ───────────────────────────────────────────────────────────
|
|
197
|
+
const onChangeRef = useRef(onChange); onChangeRef.current = onChange
|
|
86
198
|
const setValue = useCallback((node, raw) => {
|
|
87
199
|
let value = parseInput(node, raw)
|
|
88
200
|
if (node.type === 'dropdown' && value !== undefined) value = optionValue(optionsFor(node).items, value)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
201
|
+
const name = node.props.name
|
|
202
|
+
const next = { ...live.current.values }
|
|
203
|
+
if (value === undefined) delete next[name]
|
|
204
|
+
else next[name] = value
|
|
205
|
+
live.current.values = next
|
|
206
|
+
setValues(next)
|
|
207
|
+
setServerErrors((e) => {
|
|
208
|
+
if (!(name in e)) return e
|
|
209
|
+
const rest = { ...e }
|
|
210
|
+
delete rest[name]
|
|
211
|
+
return rest
|
|
94
212
|
})
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const msg = fieldError(node, value, loadedItems[node.id])
|
|
100
|
-
const next = { ...e }
|
|
101
|
-
if (msg) next[node.props.name] = msg
|
|
102
|
-
else delete next[node.props.name]
|
|
103
|
-
return next
|
|
104
|
-
})
|
|
213
|
+
if (!live.current.touched.has(name)) {
|
|
214
|
+
const t = new Set(live.current.touched); t.add(name)
|
|
215
|
+
live.current.touched = t
|
|
216
|
+
setTouched(t)
|
|
105
217
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
218
|
+
onChangeRef.current?.(next)
|
|
219
|
+
schedule()
|
|
220
|
+
}, [optionsFor, schedule])
|
|
221
|
+
|
|
222
|
+
/** Leaving a field shows its message, even if nothing was typed. */
|
|
223
|
+
const touch = useCallback((node) => {
|
|
224
|
+
const name = node.props.name
|
|
225
|
+
if (live.current.touched.has(name)) return
|
|
226
|
+
const t = new Set(live.current.touched); t.add(name)
|
|
227
|
+
live.current.touched = t
|
|
228
|
+
setTouched(t)
|
|
229
|
+
}, [])
|
|
230
|
+
|
|
231
|
+
/** Open a record: whatever was pending is saved first. */
|
|
232
|
+
const openRecord = useCallback(async (rec) => {
|
|
233
|
+
await flush()
|
|
234
|
+
const next = startValues(rec)
|
|
235
|
+
live.current.values = next
|
|
236
|
+
live.current.recordId = rec ? rec[recordKey] ?? null : null
|
|
237
|
+
live.current.touched = new Set()
|
|
238
|
+
setValues(next)
|
|
239
|
+
setRecordId(live.current.recordId)
|
|
240
|
+
setTouched(new Set())
|
|
241
|
+
setSaveError(null)
|
|
242
|
+
setServerErrors({})
|
|
243
|
+
setStatus('idle')
|
|
244
|
+
}, [flush, startValues, recordKey])
|
|
245
|
+
|
|
246
|
+
const newRecord = useCallback(() => openRecord(null), [openRecord])
|
|
247
|
+
|
|
248
|
+
// A different document, or a different record handed in, starts over.
|
|
249
|
+
const firstRun = useRef(true)
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
if (firstRun.current) { firstRun.current = false; return }
|
|
252
|
+
if (!check.ok) return
|
|
253
|
+
openRecord(record || null)
|
|
254
|
+
}, [doc, record]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
255
|
+
|
|
256
|
+
// ── lists ─────────────────────────────────────────────────────────────
|
|
257
|
+
const [lists, setLists] = useState({}) // node id → { loading, rows, error }
|
|
258
|
+
const fetchRecordsRef = useRef(fetchRecords); fetchRecordsRef.current = fetchRecords
|
|
259
|
+
const listNodes = useMemo(() => (check.ok ? doc.nodes.filter((n) => n.type === 'list') : []), [doc, check.ok])
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
if (!listNodes.length) return undefined
|
|
262
|
+
let cancelled = false
|
|
263
|
+
listNodes.forEach((node) => {
|
|
264
|
+
const src = listSource(doc, node)
|
|
265
|
+
setLists((l) => ({ ...l, [node.id]: { rows: l[node.id]?.rows || [], loading: true, error: null } }))
|
|
266
|
+
const run = fetchRecordsRef.current
|
|
267
|
+
? Promise.resolve().then(() => fetchRecordsRef.current({ source: src, screen: doc.id, node }))
|
|
268
|
+
: Promise.reject(new Error(`No fetchRecords was provided to list "${src}"`))
|
|
269
|
+
run
|
|
270
|
+
.then((rows) => { if (!cancelled) setLists((l) => ({ ...l, [node.id]: { rows: Array.isArray(rows) ? rows : [], loading: false, error: null } })) })
|
|
271
|
+
.catch((err) => { if (!cancelled) setLists((l) => ({ ...l, [node.id]: { rows: l[node.id]?.rows || [], loading: false, error: err.message || 'Could not load records' } })) })
|
|
272
|
+
})
|
|
273
|
+
return () => { cancelled = true }
|
|
274
|
+
}, [listNodes, listVersion, doc])
|
|
275
|
+
|
|
276
|
+
const listFor = useCallback((node) => lists[node.id] || { rows: [], loading: false, error: null }, [lists])
|
|
277
|
+
|
|
278
|
+
const onDeleteRef = useRef(onDelete); onDeleteRef.current = onDelete
|
|
279
|
+
const deleteRecord = useCallback(async (node, rec) => {
|
|
280
|
+
const id = rec ? rec[recordKey] : undefined
|
|
281
|
+
if (!onDeleteRef.current) throw new Error('No onDelete was provided')
|
|
282
|
+
await onDeleteRef.current({ id, source: listSource(doc, node), screen: doc.id, record: rec })
|
|
283
|
+
// Deleting the record that is open leaves nothing to save into.
|
|
284
|
+
if (id !== undefined && id === live.current.recordId) {
|
|
285
|
+
clearTimeout(timer.current); timer.current = null
|
|
286
|
+
await openRecord(null)
|
|
287
|
+
}
|
|
288
|
+
setListVersion((v) => v + 1)
|
|
289
|
+
}, [doc, recordKey, openRecord])
|
|
290
|
+
|
|
291
|
+
// ── popup: a list's Edit / New in its edit screen ─────────────────────
|
|
292
|
+
const [popup, setPopup] = useState(null) // { node, screenId, record, document, loading, error }
|
|
293
|
+
const screensRef = useRef(screens); screensRef.current = screens
|
|
294
|
+
const fetchRecordRef = useRef(fetchRecord); fetchRecordRef.current = fetchRecord
|
|
295
|
+
const loadScreenRef = useRef(loadScreen); loadScreenRef.current = loadScreen
|
|
296
|
+
|
|
297
|
+
/** Edit (a record) or New (null) in the list's edit screen. */
|
|
298
|
+
const openEditor = useCallback(async (node, rec) => {
|
|
299
|
+
const screenId = node.props.editScreen
|
|
300
|
+
const base = { node, screenId, record: rec || null, document: null, loading: true, error: null }
|
|
301
|
+
setPopup(base)
|
|
125
302
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
303
|
+
let found = screensRef.current && screensRef.current[screenId]
|
|
304
|
+
if (!found && loadScreenRef.current) found = await loadScreenRef.current(screenId)
|
|
305
|
+
if (!found) throw new Error(`Screen "${screenId}" was not provided — pass it in \`screens\` or supply loadScreen`)
|
|
306
|
+
const checked = validateDocument(found, live.current.controls)
|
|
307
|
+
if (!checked.ok) throw new Error(`Screen "${screenId}" is not valid: ${checked.errors[0].path} ${checked.errors[0].message}`)
|
|
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))
|
|
128
315
|
} catch (err) {
|
|
129
|
-
|
|
130
|
-
return { ok: false, error: err }
|
|
131
|
-
} finally {
|
|
132
|
-
setSubmitting(false)
|
|
316
|
+
setPopup((p) => (p && p.screenId === screenId ? { ...p, loading: false, error: err.message } : p))
|
|
133
317
|
}
|
|
134
|
-
}, [
|
|
318
|
+
}, [])
|
|
319
|
+
|
|
320
|
+
const closeEditor = useCallback(() => {
|
|
321
|
+
setPopup(null)
|
|
322
|
+
setListVersion((v) => v + 1)
|
|
323
|
+
}, [])
|
|
324
|
+
|
|
325
|
+
/** The popup's saves go to the host's onSave, and refresh the list behind it. */
|
|
326
|
+
const onSaveRef = useRef(onSave); onSaveRef.current = onSave
|
|
327
|
+
const popupSave = useCallback(async (vals, meta) => {
|
|
328
|
+
const saved = onSaveRef.current ? await onSaveRef.current(vals, meta) : undefined
|
|
329
|
+
setListVersion((v) => v + 1)
|
|
330
|
+
return saved
|
|
331
|
+
}, [])
|
|
135
332
|
|
|
136
333
|
return {
|
|
137
334
|
document: doc,
|
|
138
335
|
documentErrors: check.errors,
|
|
336
|
+
popup,
|
|
337
|
+
fieldsFor,
|
|
338
|
+
openEditor,
|
|
339
|
+
closeEditor,
|
|
340
|
+
popupSave,
|
|
139
341
|
values,
|
|
140
|
-
|
|
342
|
+
recordId,
|
|
343
|
+
errors: { ...state.shown, ...serverErrors },
|
|
344
|
+
allErrors: state.errors,
|
|
345
|
+
status,
|
|
346
|
+
saveError,
|
|
347
|
+
savedAt,
|
|
141
348
|
setValue,
|
|
349
|
+
touch,
|
|
142
350
|
optionsFor,
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
351
|
+
flush,
|
|
352
|
+
saveNow: runSave,
|
|
353
|
+
openRecord,
|
|
354
|
+
newRecord,
|
|
355
|
+
listFor,
|
|
356
|
+
deleteRecord,
|
|
357
|
+
recordKey,
|
|
358
|
+
canDelete: Boolean(onDelete)
|
|
147
359
|
}
|
|
148
360
|
}
|
|
149
361
|
|