@xeplr/ui-factory 1.0.13 → 1.1.0

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.
@@ -18,8 +18,12 @@
18
18
  // ── CHANGES THAT ARE SAFE, AND CHANGES THAT ARE REFUSED ──────────────────
19
19
  // new field ADD COLUMN
20
20
  // wider (varchar 80 → 120, varchar → text, integer → numeric) ALTER TYPE
21
- // narrower, or a different type (text → number) REFUSED — data would be
22
- // lost or rejected; write that migration by hand
21
+ // narrower, or a different type (text → number) REFUSED in a migration file —
22
+ // data would be lost or rejected; write that by hand.
23
+ // On PUBLISH, a CONVERSION instead (below): the saved
24
+ // values are checked first, and the column changes
25
+ // only if every one of them fits, and only once the
26
+ // person has confirmed
23
27
  // field removed column KEPT, reported as unused — dropping data
24
28
  // is a deliberate migration, never a side effect
25
29
  // field renamed seen as a NEW column beside an unused old one, with
@@ -130,6 +134,11 @@ export function widening(from, to) {
130
134
  if (from.type === 'integer' && to.type === 'numeric') return { ok: true }
131
135
  // A date becomes that day at midnight; the other way round would lose the time.
132
136
  if (from.type === 'date' && to.type === 'timestamp') return { ok: true }
137
+ // Not safe for ANY data, but safe for data that fits: the value checks in
138
+ // conversionFor() say whether this table's does.
139
+ if (convertible(from, to)) {
140
+ return { ok: false, convert: true, reason: `${describe(from)} → ${describe(to)} changes the kind of value — every saved value has to fit` }
141
+ }
133
142
  return { ok: false, reason: `${describe(from)} → ${describe(to)} is a different kind of value` }
134
143
  }
135
144
 
@@ -153,7 +162,7 @@ export function diffTables(before, after) {
153
162
  const old = was[c.name]
154
163
  if (!old) { add.push(c); return }
155
164
  const w = widening(old, c)
156
- if (!w.ok) refused.push({ column: c.name, reason: w.reason })
165
+ if (!w.ok) refused.push(w.convert ? { column: c.name, reason: w.reason, convert: { from: old, to: c } } : { column: c.name, reason: w.reason })
157
166
  else if (!w.same) alter.push({ from: old, to: c })
158
167
  })
159
168
  const unused = before.columns.filter((c) => !now[c.name])
@@ -237,6 +246,85 @@ export function dropStatement(t, name) {
237
246
  return `ALTER TABLE ${q(t)} DROP COLUMN IF EXISTS ${q(name)};`
238
247
  }
239
248
 
249
+ // ── conversions: a field that became another kind ────────────────────────
250
+ //
251
+ // "LinkedIn URL" typed as text and later made a number, a date, a yes/no…
252
+ // The column can follow ONLY IF every value already saved in it fits the new
253
+ // kind. For each conversion there are three pieces of SQL, all read-only but
254
+ // the last:
255
+ //
256
+ // check how many saved values would NOT fit — publish refuses unless 0
257
+ // sample up to five of them, to show the person what is in the way
258
+ // statement the ALTER, with a USING that turns each value into the new kind
259
+ //
260
+ // Empty text becomes NULL rather than failing: a blank box saved as '' is "no
261
+ // value", in the new kind as in the old.
262
+ //
263
+ // NO "?" ANYWHERE IN THIS SQL. Hosts run it through knex.raw, which reads every
264
+ // ? as a binding placeholder — an optional regex group spelled [+-]? silently
265
+ // became a different pattern. Optional is spelled {0,1}.
266
+
267
+ const TEXTUAL = ['varchar', 'text']
268
+ const CONVERT_TO_FROM_TEXT = ['integer', 'numeric', 'date', 'timestamp', 'boolean']
269
+ const TRUE_WORDS = "'true','t','yes','y','1'"
270
+ const FALSE_WORDS = "'false','f','no','n','0'"
271
+
272
+ function convertible(from, to) {
273
+ if (from.references || to.references) return false
274
+ if (TEXTUAL.includes(from.type) && CONVERT_TO_FROM_TEXT.includes(to.type)) return true
275
+ if (TEXTUAL.includes(to.type) && ['integer', 'numeric', 'date', 'timestamp', 'boolean', 'varchar'].includes(from.type)) return true
276
+ if (from.type === 'numeric' && to.type === 'integer') return true
277
+ return false
278
+ }
279
+
280
+ /**
281
+ * @returns {{ check, sample, statement }} — see above; null when the change is
282
+ * not a conversion this knows how to make
283
+ */
284
+ export function conversionFor(table, from, to) {
285
+ if (!convertible(from, to)) return null
286
+ const t = q(table)
287
+ const c = q(to.name)
288
+ const txt = `btrim(${c}::text)`
289
+ let bad
290
+ let using
291
+ if (TEXTUAL.includes(to.type)) {
292
+ // Anything becomes text; a varchar only if it is long enough.
293
+ bad = to.type === 'varchar' ? `length(${c}::text) > ${to.length}` : 'false'
294
+ using = `${c}::text`
295
+ } else if (from.type === 'numeric' && to.type === 'integer') {
296
+ bad = `${c} <> trunc(${c})`
297
+ using = `${c}::integer`
298
+ } else if (to.type === 'integer') {
299
+ bad = `${txt} <> '' AND NOT (${txt} ~ '^[+-]{0,1}[0-9]{1,9}$')`
300
+ using = `NULLIF(${txt}, '')::integer`
301
+ } else if (to.type === 'numeric') {
302
+ bad = `${txt} <> '' AND NOT (${txt} ~ '^[+-]{0,1}([0-9]+[.]{0,1}[0-9]*|[.][0-9]+)([eE][+-]{0,1}[0-9]+){0,1}$')`
303
+ using = `NULLIF(${txt}, '')::numeric`
304
+ } else if (to.type === 'date') {
305
+ bad = `${txt} <> '' AND NOT (${txt} ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$')`
306
+ using = `NULLIF(${txt}, '')::date`
307
+ } else if (to.type === 'timestamp') {
308
+ bad = `${txt} <> '' AND NOT (${txt} ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}([ T][0-9]{2}:[0-9]{2}(:[0-9]{2}([.][0-9]+){0,1}){0,1}){0,1}$')`
309
+ using = `NULLIF(${txt}, '')::timestamp`
310
+ } else if (to.type === 'boolean') {
311
+ bad = `lower(${txt}) NOT IN (${TRUE_WORDS}, ${FALSE_WORDS}, '')`
312
+ using = `CASE WHEN lower(${txt}) IN (${TRUE_WORDS}) THEN true WHEN lower(${txt}) IN (${FALSE_WORDS}) THEN false ELSE NULL END`
313
+ }
314
+ const where = `${c} IS NOT NULL AND (${bad})`
315
+ const type = to.type === 'varchar' ? `varchar(${to.length})` : to.type
316
+ // A boolean field is NOT NULL DEFAULT false (columnForField): blanks become false.
317
+ const fill = to.notNull && to.default !== undefined
318
+ ? [`UPDATE ${t} SET ${c} = ${to.default} WHERE ${c} IS NULL;`, `ALTER TABLE ${t} ALTER COLUMN ${c} SET DEFAULT ${to.default};`, `ALTER TABLE ${t} ALTER COLUMN ${c} SET NOT NULL;`]
319
+ : []
320
+ return {
321
+ check: `SELECT count(*)::int AS n FROM ${t} WHERE ${where}`,
322
+ sample: `SELECT ${c}::text AS v FROM ${t} WHERE ${where} LIMIT 5`,
323
+ count: `SELECT count(*)::int AS n FROM ${t} WHERE ${c} IS NOT NULL`,
324
+ statements: [`ALTER TABLE ${t} ALTER COLUMN ${c} TYPE ${type} USING ${using};`, ...fill]
325
+ }
326
+ }
327
+
240
328
  // ── publish: apply directly ──────────────────────────────────────────────
241
329
  //
242
330
  // Publishing a screen changes its table straight away — no migration files.
@@ -279,11 +367,19 @@ export function columnFromDatabase(c) {
279
367
  * @param options.managed names ever published as fields on this table
280
368
  * @param options.inUse names other published screens on this table still use
281
369
  * @param options.confirmDrop names the person has confirmed dropping
370
+ * @param options.convert true: plan conversions (the caller runs their checks).
371
+ * OFF by default — a host that does not know about them
372
+ * must see a refusal, not a plan it would publish
373
+ * without running (that would skip the ALTER)
374
+ * @param options.confirmConvert names the person has confirmed converting
282
375
  * @returns {{
283
376
  * table, create,
284
377
  * add, alter, what will change
285
378
  * drop, columns to drop: [{ name }]
286
379
  * unconfirmed, of those, the ones not yet confirmed — nothing runs until this is empty
380
+ * convert, columns changing kind: [{ column, from, to, check, sample, count }] —
381
+ * the host runs `check` and refuses unless it is 0 for each
382
+ * unconfirmedConvert, of those, the ones not yet confirmed — nothing runs until this is empty
287
383
  * keep, columns left in place, with why: [{ name, reason }]
288
384
  * refused, changes that cannot be made safely: [{ column, reason }] — nothing runs
289
385
  * statements the SQL, in order — empty while anything is refused or unconfirmed
@@ -294,7 +390,8 @@ export function planTableChange(current, next, options = {}, controls = CONTROLS
294
390
  const managed = new Set(options.managed || [])
295
391
  const inUse = new Set(options.inUse || [])
296
392
  const confirmed = new Set(options.confirmDrop || [])
297
- const plan = { table: after.table, create: false, add: [], alter: [], drop: [], unconfirmed: [], keep: [], refused: [], statements: [] }
393
+ const converting = new Set(options.confirmConvert || [])
394
+ const plan = { table: after.table, create: false, add: [], alter: [], drop: [], unconfirmed: [], convert: [], unconfirmedConvert: [], keep: [], refused: [], statements: [] }
298
395
 
299
396
  if (!current) {
300
397
  plan.create = true
@@ -309,7 +406,12 @@ export function planTableChange(current, next, options = {}, controls = CONTROLS
309
406
  const diff = diffTables({ table: after.table, columns: existing }, after)
310
407
  plan.add = diff.add
311
408
  plan.alter = diff.alter
312
- plan.refused = diff.refused
409
+ diff.refused.forEach((r) => {
410
+ const how = options.convert && r.convert && conversionFor(after.table, r.convert.from, r.convert.to)
411
+ if (!how) { plan.refused.push({ column: r.column, reason: r.reason }); return }
412
+ plan.convert.push({ column: r.column, from: describe(r.convert.from), to: describe(r.convert.to), check: how.check, sample: how.sample, count: how.count, statements: how.statements })
413
+ })
414
+ plan.unconfirmedConvert = plan.convert.filter((c) => !converting.has(c.column)).map((c) => c.column)
313
415
 
314
416
  diff.unused.forEach((c) => {
315
417
  if (!managed.has(c.name)) plan.keep.push({ name: c.name, reason: 'not created by a screen — left as it is' })
@@ -318,9 +420,10 @@ export function planTableChange(current, next, options = {}, controls = CONTROLS
318
420
  })
319
421
  plan.unconfirmed = plan.drop.filter((d) => !confirmed.has(d.name)).map((d) => d.name)
320
422
 
321
- if (plan.refused.length || plan.unconfirmed.length) return plan
423
+ if (plan.refused.length || plan.unconfirmed.length || plan.unconfirmedConvert.length) return plan
322
424
  plan.add.forEach((c) => plan.statements.push(...addStatements(after.table, c)))
323
425
  plan.alter.forEach(({ from, to }) => plan.statements.push(alterStatement(after.table, from, to)))
426
+ plan.convert.forEach((c) => plan.statements.push(...c.statements))
324
427
  plan.drop.forEach((d) => plan.statements.push(dropStatement(after.table, d.name)))
325
428
  return plan
326
429
  }
@@ -6,6 +6,7 @@ import {
6
6
  removeNodes
7
7
  } from './document.js'
8
8
  import { validateDocument } from './validateDocument.js'
9
+ import { presetFor, presetProps, presetGroup, presetHints, convertField } from './presets.js'
9
10
 
10
11
  // The builder's controller: the document being edited, what is selected, and
11
12
  // every change the builder can make. No JSX — designs/BuilderSample.jsx draws
@@ -124,11 +125,18 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
124
125
 
125
126
  const visibleNodes = useMemo(() => nodesForSteps(doc, openSteps, controls), [doc, openSteps, controls])
126
127
 
127
- /** Adds a control and selects it, so its properties are open straight away. */
128
+ /**
129
+ * Adds a control and selects it, so its properties are open straight away.
130
+ * `type` may be a ready-made field — "preset:email" — which adds its control
131
+ * with the preset's settings already in it.
132
+ */
128
133
  const addControl = useCallback((type, at) => {
129
134
  let added = null
135
+ const preset = presetFor(type)
130
136
  update((d) => {
131
- const r = addControlTo(d, type, { at, step: dropStep() }, controls)
137
+ const r = preset
138
+ ? addControlTo(d, preset.control, { at, step: dropStep(), props: presetProps(preset), size: preset.size }, controls)
139
+ : addControlTo(d, type, { at, step: dropStep() }, controls)
132
140
  added = r.node
133
141
  return r.document
134
142
  })
@@ -141,6 +149,21 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
141
149
  const lockedRef = useRef(lockedNames); lockedRef.current = lockedNames
142
150
  const setProperty = useCallback((id, path, value) => update((d) => setNodeProperty(d, id, path, value, controls, { lockedNames: lockedRef.current })), [update, controls])
143
151
 
152
+ /**
153
+ * A field becomes another kind — a control type ('number') or a ready-made
154
+ * field ('email'). Returns what did not carry over, in words, for the panel
155
+ * to say ("the pattern", "the options").
156
+ */
157
+ const convertControl = useCallback((id, target) => {
158
+ let dropped = []
159
+ update((d) => {
160
+ const r = convertField(d, id, target, controls, { lockedNames: lockedRef.current })
161
+ dropped = r.dropped
162
+ return r.document
163
+ })
164
+ return dropped
165
+ }, [update, controls])
166
+
144
167
  const selectedRef = useRef(selected); selectedRef.current = selected
145
168
  const removeSelected = useCallback(() => {
146
169
  const ids = [...selectedRef.current]
@@ -172,6 +195,8 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
172
195
 
173
196
  // ── validation + save ─────────────────────────────────────────────────
174
197
  const validation = useMemo(() => validateDocument(doc, controls), [doc, controls])
198
+ /** Fields that read like a ready-made one they are not. Never block anything. */
199
+ const hints = useMemo(() => presetHints(doc, controls), [doc, controls])
175
200
 
176
201
  /** Errors grouped by node id, for the canvas and the property panel. */
177
202
  const errorsByNode = useMemo(() => {
@@ -211,12 +236,18 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
211
236
  }
212
237
  }, [controls])
213
238
 
214
- // AUTOSAVE: a quiet moment after the last edit, if the screen is valid.
215
- useEffect(() => {
216
- if (!dirty || !validation.ok || !onSaveRef.current) return undefined
217
- const t = setTimeout(save, autosaveDelay)
218
- return () => clearTimeout(t)
219
- }, [doc, dirty, validation.ok, save, autosaveDelay, saving])
239
+ // NOTHING SAVES ITSELF. Saving is an act, and the person designing decides
240
+ // when they have finished — a screen that writes itself to the server a
241
+ // moment after every drag means there is no such thing as trying something
242
+ // out, and no moment at which somebody said "this is right".
243
+ //
244
+ // It also made every half-made field an error: a field is invalid while it
245
+ // is being built, and an autosave meets it mid-build and reports it.
246
+ //
247
+ // `save` is called by the Design's Save control (see BuilderSample), and by
248
+ // publish, which saves first because publishing what is on screen is the
249
+ // one thing it must never get wrong. `autosaveDelay` is kept as a prop and
250
+ // ignored, so a host passing it is not broken by this; it does nothing.
220
251
 
221
252
  // PUBLISH: the draft becomes the next version — what screens actually show.
222
253
  // Deliberate, unlike saving: a half-finished design must not reach everyone
@@ -224,7 +255,11 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
224
255
  const onPublishRef = useRef(onPublish); onPublishRef.current = onPublish
225
256
  const [publishing, setPublishing] = useState(false)
226
257
  const [publishResult, setPublishResult] = useState(null) // { ok, message, version?, detail? }
227
- const publish = useCallback(async (confirmDrop) => {
258
+ /**
259
+ * @param confirmDrop column names the person agreed to remove
260
+ * @param confirmConvert column names the person agreed to convert to their field's new kind
261
+ */
262
+ const publish = useCallback(async (confirmDrop, confirmConvert) => {
228
263
  if (!onPublishRef.current) return { ok: false }
229
264
  const check = validateDocument(docRef.current, controls)
230
265
  if (!check.ok) {
@@ -237,7 +272,10 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
237
272
  try {
238
273
  const saved = await save()
239
274
  if (!saved.ok && !saved.busy) throw new Error((saved.errors && saved.errors[0] && saved.errors[0].message) || 'Could not save the draft')
240
- const res = await onPublishRef.current(docRef.current, { confirmDrop: Array.isArray(confirmDrop) ? confirmDrop : [] })
275
+ const res = await onPublishRef.current(docRef.current, {
276
+ confirmDrop: Array.isArray(confirmDrop) ? confirmDrop : [],
277
+ confirmConvert: Array.isArray(confirmConvert) ? confirmConvert : []
278
+ })
241
279
  const kept = (res && res.keep) || []
242
280
  const r = {
243
281
  ok: true,
@@ -248,10 +286,12 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
248
286
  setPublishResult(r)
249
287
  return r
250
288
  } catch (err) {
251
- // A removed field would drop a column: not an error, a question.
252
- const r = Array.isArray(err.confirm) && err.confirm.length
253
- ? { ok: false, confirm: err.confirm, message: err.message }
254
- : { ok: false, message: err.message || 'Could not publish', detail: err.detail }
289
+ // A removed field would drop a column, or a field that changed kind would
290
+ // convert one: not an error, a question.
291
+ const asks = (Array.isArray(err.confirm) && err.confirm.length) || (Array.isArray(err.convert) && err.convert.length)
292
+ const r = asks
293
+ ? { ok: false, confirm: err.confirm || [], convert: err.convert || [], message: err.message }
294
+ : { ok: false, message: err.message || 'Could not publish', detail: err.detail, wontFit: err.wontFit }
255
295
  setPublishResult(r)
256
296
  return r
257
297
  } finally {
@@ -259,10 +299,12 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
259
299
  }
260
300
  }, [controls, save])
261
301
 
302
+ // `pending` now means "there are changes you have not saved", which is a
303
+ // thing to act on rather than a thing about to happen by itself.
262
304
  const status = saveError ? 'error'
263
305
  : saving ? 'saving'
264
306
  : !validation.ok ? 'invalid'
265
- : dirty ? 'pending'
307
+ : dirty ? 'unsaved'
266
308
  : savedAt ? 'saved' : 'idle'
267
309
 
268
310
  // ── keyboard: Delete / Backspace removes, Cmd/Ctrl+D duplicates ────────
@@ -288,7 +330,12 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
288
330
  return {
289
331
  document: doc,
290
332
  controls,
291
- palette: useMemo(() => controlGroups(controls), [controls]),
333
+ // The controls, then the ready-made fields made of the ones this registry has.
334
+ palette: useMemo(() => {
335
+ const ready = presetGroup()
336
+ ready.controls = ready.controls.filter((c) => controls[c.icon] && controls[c.icon].input)
337
+ return ready.controls.length ? [...controlGroups(controls), ready] : controlGroups(controls)
338
+ }, [controls]),
292
339
  selected,
293
340
  setSelected,
294
341
  selectedNode,
@@ -309,6 +356,8 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
309
356
  },
310
357
  visibleNodes,
311
358
  setProperty,
359
+ convertControl,
360
+ hints,
312
361
  removeSelected,
313
362
  duplicateSelected,
314
363
  tables,
@@ -7,11 +7,14 @@ import { hookMethod } from './hooks.js'
7
7
 
8
8
  // A saved screen, running. No JSX — designs/ScreenSample.jsx draws it.
9
9
  //
10
- // ── NO SUBMIT ────────────────────────────────────────────────────────────
11
- // The screen saves ITSELF, in the background, a moment after the person stops
12
- // changing something — once what is entered is acceptable. There is no button
13
- // to press and no page to post. The first save of a new record creates it
14
- // (the host hands back its id); every save after that updates the same record.
10
+ // ── SAVED BY A BUTTON, OVER AJAX — NEVER A FORM SUBMIT ───────────────────
11
+ // Nothing is written while the person types. Changes are held in the form
12
+ // ("Unsaved changes") until they press Save, which makes ONE AJAX call to the
13
+ // host's onSave. There is no <form> post and no page reload — and no
14
+ // background save either: a half-typed record never lands in the table. The
15
+ // first save of a new record creates it (the host hands back its id); every
16
+ // save after that updates the same record. Leaving with unsaved changes asks
17
+ // first (Cancel, the popup's ×, closing the tab).
15
18
  //
16
19
  // ── THE DATABASE IS THE HOST'S ───────────────────────────────────────────
17
20
  // Everything that reads or writes data is a call the host supplies:
@@ -26,9 +29,6 @@ import { hookMethod } from './hooks.js'
26
29
  // for Edit and New. The screen comes from `screens` ({ id → document }) or the
27
30
  // host's `loadScreen(id)`. The list refreshes as the popup saves.
28
31
 
29
- /** Quiet time after the last change before a save goes out. */
30
- export const AUTOSAVE_DELAY = 700
31
-
32
32
  /**
33
33
  * @param document a valid screen document
34
34
  * @param record the record to open, e.g. { id: 7, firstName: 'Ada' } — omit for a new one
@@ -45,14 +45,14 @@ export const AUTOSAVE_DELAY = 700
45
45
  * @param fetchRecord async ({ screen, id }) → record — Edit loads the record fresh (through the server's get hooks)
46
46
  * @param screens { id → document } — screens a list's Edit / New can open
47
47
  * @param loadScreen async (id) → document — for screens not in `screens`
48
- * @param autosaveDelay ms (default 700)
48
+ * @param onDirtyChange (dirty) → void — whether there are unsaved changes, as that changes
49
49
  * @param controls the control registry (default: the built-ins)
50
50
  * @param hooks a FactoryHooks (or an object with some of its methods) — get / save / delete / actions
51
51
  */
52
52
  export function useFactoryScreen({
53
53
  document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl,
54
- onOpenRecord, onDone,
55
- autosaveDelay = AUTOSAVE_DELAY, controls = CONTROLS
54
+ onOpenRecord, onDone, onDirtyChange,
55
+ controls = CONTROLS
56
56
  } = {}) {
57
57
  const check = useMemo(() => validateDocument(doc, controls), [doc, controls])
58
58
  // Read at call time, so a new hooks object never re-runs loads by itself.
@@ -105,7 +105,7 @@ export function useFactoryScreen({
105
105
  const [values, setValues] = useState(() => (check.ok ? startValues(record) : {}))
106
106
  const [recordId, setRecordId] = useState(() => (record ? record[recordKey] ?? null : null))
107
107
  const [touched, setTouched] = useState(() => new Set())
108
- const [status, setStatus] = useState('idle') // idle | pending | saving | saved | incomplete | invalid | error
108
+ const [status, setStatus] = useState('idle') // idle | pending (unsaved changes) | saving | saved | incomplete | invalid | error
109
109
  const [saveError, setSaveError] = useState(null)
110
110
  const [savedAt, setSavedAt] = useState(null)
111
111
  const [listVersion, setListVersion] = useState(0)
@@ -113,7 +113,17 @@ export function useFactoryScreen({
113
113
  // Shown like the form's own, and cleared as soon as that field is changed.
114
114
  const [serverErrors, setServerErrors] = useState({})
115
115
 
116
- // Latest of everything a delayed save needs, read at the moment it runs.
116
+ // UNSAVED CHANGES: every change bumps `changes`; a save records which count
117
+ // it wrote. A change made while a save was on the wire stays unsaved.
118
+ const changes = useRef(0)
119
+ const savedChanges = useRef(0)
120
+ const [dirty, setDirtyState] = useState(false)
121
+ const onDirtyRef = useRef(onDirtyChange); onDirtyRef.current = onDirtyChange
122
+ const setDirty = useCallback((d) => {
123
+ setDirtyState((was) => { if (was !== d) onDirtyRef.current?.(d); return d })
124
+ }, [])
125
+
126
+ // Latest of everything a save needs, read at the moment it runs.
117
127
  const live = useRef({})
118
128
  live.current = { doc, values, recordId, touched, source, onSave, controls, recordKey }
119
129
 
@@ -300,19 +310,25 @@ export function useFactoryScreen({
300
310
  [check.ok, doc, values, touched, loadedItems, controls])
301
311
 
302
312
  // ── saving ────────────────────────────────────────────────────────────
303
- const timer = useRef(null)
304
313
  const inFlight = useRef(null) // the promise of the save running now
305
- const again = useRef(false) // a change arrived while it ran
306
314
 
315
+ /** Save — what the Save button does. One AJAX call; never on its own. */
307
316
  const runSave = useCallback(async () => {
308
- clearTimeout(timer.current)
309
- timer.current = null
310
- if (inFlight.current) { again.current = true; return inFlight.current }
317
+ if (inFlight.current) return inFlight.current
311
318
  const l = live.current
312
319
  const st = saveState(l.doc, l.values, l.touched, l.loadedItems, l.controls)
313
- if (!st.canSave) { setStatus(st.status); return { ok: false, errors: st.errors } }
320
+ if (!st.canSave) {
321
+ // Pressing Save is the moment to say what is missing — on every field,
322
+ // including the ones nobody has been in yet.
323
+ const t = new Set([...l.touched, ...Object.keys(st.errors)])
324
+ live.current.touched = t
325
+ setTouched(t)
326
+ setStatus('invalid')
327
+ return { ok: false, errors: st.errors }
328
+ }
314
329
  const customSave = hooksRef.current && typeof hooksRef.current.save === 'function'
315
- if (!l.onSave && !customSave) { setStatus('saved'); return { ok: true } }
330
+ const writing = changes.current
331
+ if (!l.onSave && !customSave) { savedChanges.current = writing; setDirty(false); setStatus('saved'); return { ok: true } }
316
332
 
317
333
  setStatus('saving')
318
334
  setSaveError(null)
@@ -332,9 +348,12 @@ export function useFactoryScreen({
332
348
  setRecordId(saved[l.recordKey])
333
349
  live.current.recordId = saved[l.recordKey]
334
350
  }
351
+ savedChanges.current = writing
352
+ const still = changes.current !== writing
353
+ setDirty(still)
335
354
  setSavedAt(new Date())
336
355
  setListVersion((v) => v + 1)
337
- setStatus('saved')
356
+ setStatus(still ? 'pending' : 'saved')
338
357
  return { ok: true, record: saved }
339
358
  } catch (err) {
340
359
  const onFields = Array.isArray(err.fields) ? err.fields.filter((f) => f && f.field && f.message) : []
@@ -349,27 +368,36 @@ export function useFactoryScreen({
349
368
  return { ok: false, error: err }
350
369
  } finally {
351
370
  inFlight.current = null
352
- if (again.current) { again.current = false; schedule() } // eslint-disable-line no-use-before-define
353
371
  }
354
372
  })()
355
373
  inFlight.current = task
356
374
  return task
357
- }, [])
375
+ }, [setDirty])
358
376
 
359
- const schedule = useCallback(() => {
360
- clearTimeout(timer.current)
361
- setStatus('pending')
362
- timer.current = setTimeout(runSave, autosaveDelay)
363
- }, [runSave, autosaveDelay])
364
-
365
- /** Save any pending change right away — before opening another record, say. */
377
+ /** Save if there is anything unsaved — what a flow's Next does before moving on. */
366
378
  const flush = useCallback(async () => {
367
- if (timer.current || again.current) return runSave()
368
- if (inFlight.current) return inFlight.current
379
+ if (inFlight.current) await inFlight.current
380
+ if (changes.current !== savedChanges.current) return runSave()
369
381
  return { ok: true }
370
382
  }, [runSave])
371
383
 
372
- useEffect(() => () => clearTimeout(timer.current), [])
384
+ /**
385
+ * May the person leave? True when nothing is unsaved, or when they agree to
386
+ * lose it. Cancel, the popup's × and opening another record all ask this.
387
+ */
388
+ const confirmDiscard = useCallback(() => {
389
+ if (changes.current === savedChanges.current) return true
390
+ // eslint-disable-next-line no-alert
391
+ return typeof window === 'undefined' || window.confirm('Discard your unsaved changes?')
392
+ }, [])
393
+
394
+ // Closing the tab or reloading with unsaved changes: the browser's own prompt.
395
+ useEffect(() => {
396
+ if (!dirty || typeof window === 'undefined') return undefined
397
+ const warn = (e) => { e.preventDefault(); e.returnValue = '' }
398
+ window.addEventListener('beforeunload', warn)
399
+ return () => window.removeEventListener('beforeunload', warn)
400
+ }, [dirty])
373
401
 
374
402
  // ── editing ───────────────────────────────────────────────────────────
375
403
  const onChangeRef = useRef(onChange); onChangeRef.current = onChange
@@ -395,8 +423,10 @@ export function useFactoryScreen({
395
423
  setTouched(t)
396
424
  }
397
425
  onChangeRef.current?.(next)
398
- schedule()
399
- }, [optionsFor, schedule])
426
+ changes.current += 1
427
+ setDirty(true)
428
+ setStatus('pending')
429
+ }, [optionsFor, setDirty])
400
430
 
401
431
  /** Leaving a field shows its message, even if nothing was typed. */
402
432
  const touch = useCallback((node) => {
@@ -407,9 +437,12 @@ export function useFactoryScreen({
407
437
  setTouched(t)
408
438
  }, [])
409
439
 
410
- /** Open a record: whatever was pending is saved first. */
440
+ /** Open a record (null: a new one). Unsaved changes are dropped — ask confirmDiscard first. */
411
441
  const openRecord = useCallback(async (rec) => {
412
- await flush()
442
+ if (inFlight.current) await inFlight.current
443
+ changes.current = 0
444
+ savedChanges.current = 0
445
+ setDirty(false)
413
446
  const next = startValues(rec)
414
447
  live.current.values = next
415
448
  live.current.recordId = rec ? rec[recordKey] ?? null : null
@@ -420,7 +453,7 @@ export function useFactoryScreen({
420
453
  setSaveError(null)
421
454
  setServerErrors({})
422
455
  setStatus('idle')
423
- }, [flush, startValues, recordKey])
456
+ }, [startValues, recordKey, setDirty])
424
457
 
425
458
  const newRecord = useCallback(() => openRecord(null), [openRecord])
426
459
 
@@ -490,7 +523,6 @@ export function useFactoryScreen({
490
523
  })
491
524
  // Deleting the record that is open leaves nothing to save into.
492
525
  if (id !== undefined && id === live.current.recordId) {
493
- clearTimeout(timer.current); timer.current = null
494
526
  await openRecord(null)
495
527
  }
496
528
  setListVersion((v) => v + 1)
@@ -576,7 +608,10 @@ export function useFactoryScreen({
576
608
  upload,
577
609
  fileUrl,
578
610
  flush,
611
+ save: runSave,
579
612
  saveNow: runSave,
613
+ dirty,
614
+ confirmDiscard,
580
615
  openRecord,
581
616
  newRecord,
582
617
  listFor,
@@ -0,0 +1,81 @@
1
+ // THIS APP'S SCREENS, AS ANOTHER PACKAGE'S DESIGNER WANTS THEM.
2
+ //
3
+ // One hook, so that using @xeplr/factory's forms inside @xeplr/ui-workflow's
4
+ // flow designer is two props rather than a page of glue in every application:
5
+ //
6
+ // const { screens, screenEditor } = useScreenSource(factory, { Builder: FactoryBuilder })
7
+ // <WorkflowDesigner screens={screens} screenEditor={screenEditor} … />
8
+ //
9
+ // It answers both questions that designer asks its host — which screens exist
10
+ // and what each one holds, and how to design one without leaving the flow —
11
+ // and it reloads itself when a form is published from in there, so a form
12
+ // made inside a flow is immediately choosable in it.
13
+ //
14
+ // WHY HERE. The alternative was the same twenty lines copied into every app
15
+ // the CLI scaffolds: list the screens, read each document, filter the nodes
16
+ // that hold a value, wire a builder to publish. Copied code cannot be fixed
17
+ // once — an app generated last month keeps that copy forever, and a control
18
+ // type added to this package never reaches any of them.
19
+ //
20
+ // No JSX, like every other hook here: the element is built with
21
+ // createElement so the file stays .js.
22
+
23
+ import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
24
+ import { loadScreens } from './screenSource.js'
25
+ import { ScreenEditorSample } from './designs/ScreenEditorSample.jsx'
26
+
27
+ /**
28
+ * @param api a createFactoryApi() instance
29
+ * @param opts { Builder, labels } — the builder component to draw, normally
30
+ * FactoryBuilder. Passed in rather than imported so this hook does
31
+ * not pull the whole builder into a bundle that never opens it.
32
+ * @returns { screens, screenEditor, reload, loading, error }
33
+ */
34
+ export function useScreenSource(api, opts) {
35
+ opts = opts || {}
36
+ var Builder = opts.Builder
37
+ var labels = opts.labels
38
+
39
+ const [screens, setScreens] = useState([])
40
+ const [loading, setLoading] = useState(true)
41
+ const [error, setError] = useState(null)
42
+ const [round, setRound] = useState(0)
43
+
44
+ const reload = useCallback(() => setRound((n) => n + 1), [])
45
+
46
+ useEffect(() => {
47
+ let cancelled = false
48
+ setLoading(true)
49
+ // The names arrive first so the menu works while the documents are still
50
+ // being read — see loadScreens.
51
+ loadScreens(api, (stubs) => { if (!cancelled) setScreens(stubs) })
52
+ .then((full) => { if (!cancelled) { setScreens(full); setError(null) } })
53
+ // A designer that still works, minus the menu: a key can be typed.
54
+ .catch((e) => { if (!cancelled) { setScreens([]); setError(e.message) } })
55
+ .then(() => { if (!cancelled) setLoading(false) })
56
+ return () => { cancelled = true }
57
+ }, [api, round])
58
+
59
+ // The render prop @xeplr/ui-workflow calls inside its modal. Stable, so
60
+ // typing in the flow does not remount the builder.
61
+ const screenEditor = useCallback((props) => {
62
+ if (!Builder) return null
63
+ return createElement(ScreenEditorSample, {
64
+ api: api,
65
+ Builder: Builder,
66
+ screenKey: props.screenKey,
67
+ labels: labels,
68
+ onDone: function (screen) {
69
+ // Reload, so a form made in there joins the menu. The designer is
70
+ // also told directly, so the step is right before this lands.
71
+ reload()
72
+ if (props.onDone) props.onDone(screen)
73
+ }
74
+ })
75
+ }, [api, Builder, labels, reload])
76
+
77
+ return useMemo(
78
+ () => ({ screens, screenEditor, reload, loading, error }),
79
+ [screens, screenEditor, reload, loading, error]
80
+ )
81
+ }