@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.
@@ -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 [showErrors, setShowErrors] = useState(false)
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. Compared by identity: the
37
- // host re-rendering with the same object must not throw away edits.
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 setProperty = useCallback((id, path, value) => update((d) => setNodeProperty(d, id, path, value, controls)), [update, controls])
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
- setShowErrors(true)
149
- setSaveError(null)
150
- if (!validation.ok) return { ok: false, errors: validation.errors }
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(doc)
155
- setDirty(false)
156
- setShowErrors(false)
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
- }, [doc, validation])
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
- save
285
+ savedAt,
286
+ status,
287
+ save,
288
+ canPublish: Boolean(onPublish),
289
+ publish,
290
+ publishing,
291
+ publishResult,
292
+ clearPublishResult: () => setPublishResult(null)
208
293
  }
209
294
  }
@@ -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, validateValues, fieldError, optionValue } from './values.js'
5
+ import { initialValues, parseInput, saveState, fieldError, optionValue, recordValues, listSource } from './values.js'
6
6
 
7
- // A saved screen, running: its values, its dropdown options, validation and
8
- // submit. No JSX — designs/ScreenSample.jsx draws it.
7
+ // A saved screen, running. No JSX — designs/ScreenSample.jsx draws it.
9
8
  //
10
- // The DATABASE IS THE HOST'S. A dropdown reading a table asks the host for its
11
- // rows — `fetchOptions({ table })` → [{ id, name }] — and a submit hands the
12
- // values to `onSubmit`. Nothing here knows where either goes.
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 fetchOptions async ({ table, node }) → [{ id, name }] — for table dropdowns
17
- * @param onSubmit async (values, { document }) → void
18
- * @param values initial values, e.g. the record being edited
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({ document: doc, fetchOptions, onSubmit, values: provided, controls = CONTROLS } = {}) {
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
- const [values, setValues] = useState(() => (check.ok ? initialValues(doc, provided, controls) : {}))
26
- const [errors, setErrors] = useState({})
27
- const [submitted, setSubmitted] = useState(false)
28
- const [submitting, setSubmitting] = useState(false)
29
- const [submitError, setSubmitError] = useState(null)
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
- // A different document or record starts the form over.
32
- const providedRef = useRef(provided); providedRef.current = provided
33
- const firstRun = useRef(true)
34
- useEffect(() => {
35
- if (firstRun.current) { firstRun.current = false; return }
36
- if (!check.ok) return
37
- setValues(initialValues(doc, providedRef.current, controls))
38
- setErrors({})
39
- setSubmitted(false)
40
- setSubmitError(null)
41
- }, [doc, provided, controls, check.ok])
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 fetchRef = useRef(fetchOptions); fetchRef.current = fetchOptions
90
+ const fetchOptionsRef = useRef(fetchOptions); fetchOptionsRef.current = fetchOptions
46
91
  useEffect(() => {
47
92
  let cancelled = false
48
- const tableNodes = inputs.filter((n) => n.type === 'dropdown' && n.props.data?.source === 'table')
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 = fetchRef.current
59
- ? Promise.resolve().then(() => fetchRef.current({ table, node: nodes[0] }))
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
- }, [inputs])
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
- setValues((v) => {
90
- const next = { ...v }
91
- if (value === undefined) delete next[node.props.name]
92
- else next[node.props.name] = value
93
- return next
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
- // Once a submit has failed, errors follow the typing — the message goes
96
- // away the moment the field is fixed, not on the next click of Save.
97
- if (submitted) {
98
- setErrors((e) => {
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
- }, [submitted, optionsFor, loadedItems])
107
-
108
- const reset = useCallback(() => {
109
- setValues(initialValues(doc, providedRef.current, controls))
110
- setErrors({})
111
- setSubmitted(false)
112
- setSubmitError(null)
113
- }, [doc, controls])
114
-
115
- const onSubmitRef = useRef(onSubmit); onSubmitRef.current = onSubmit
116
- const submit = useCallback(async (e) => {
117
- e?.preventDefault?.()
118
- setSubmitted(true)
119
- setSubmitError(null)
120
- const found = validateValues(doc, values, controls, loadedItems)
121
- setErrors(found)
122
- if (Object.keys(found).length) return { ok: false, errors: found }
123
- if (!onSubmitRef.current) return { ok: true, values }
124
- setSubmitting(true)
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
- await onSubmitRef.current(values, { document: doc })
127
- return { ok: true, values }
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
- setSubmitError(err.message || 'Could not submit')
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
- }, [doc, values, controls, loadedItems])
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
- errors,
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
- submit,
144
- reset,
145
- submitting,
146
- submitError
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