@xeplr/ui-factory 1.0.14 → 1.2.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.
@@ -0,0 +1,91 @@
1
+ // WHAT A FLOW NEEDS TO KNOW ABOUT THIS APP'S SCREENS.
2
+ //
3
+ // @xeplr/ui-workflow's designer offers a Screen step, and asks its host two
4
+ // things: which screens exist, and what each one hands back when somebody
5
+ // submits it. It cannot answer either itself — it carries a screen key and
6
+ // knows nothing else about forms, on purpose, so that an app with a different
7
+ // screen system is not locked out.
8
+ //
9
+ // This is @xeplr/factory's answer, ONCE. It used to be written out in the app:
10
+ // list the screens, load each document, filter the nodes that hold a value,
11
+ // map them to names and types. That is the same twenty lines in every app the
12
+ // CLI scaffolds, hand-copied, drifting apart the first time a control type is
13
+ // added here — which is exactly the kind of code that should not be in an
14
+ // application at all.
15
+ //
16
+ // Pure: no React. The hook around it is useScreenSource.js.
17
+
18
+ import { inputNodes } from './document.js'
19
+
20
+ /**
21
+ * A screen document → what the designer needs.
22
+ *
23
+ * `inputNodes` is this package's own answer to "which nodes hold a value", so
24
+ * a label, a stepper or a divider is never offered as a field — and a control
25
+ * type added later is included here without anybody editing an app.
26
+ */
27
+ export function describeScreen(doc) {
28
+ if (!doc || !doc.id) return null
29
+ return {
30
+ key: doc.id,
31
+ name: doc.name || doc.id,
32
+ fields: inputNodes(doc).map(function (n) {
33
+ return { name: n.id, type: n.type }
34
+ })
35
+ }
36
+ }
37
+
38
+ /** A listing row → the same shape, before its document has been read. */
39
+ export function screenStub(row) {
40
+ if (!row) return null
41
+ var key = row.screenKey || row.id || row.key
42
+ if (!key) return null
43
+ return { key: key, name: row.name || key }
44
+ }
45
+
46
+ /**
47
+ * Every screen, with its fields.
48
+ *
49
+ * TWO PASSES, because the second is slow and the first is what makes the menu
50
+ * usable: `onList` is called with the names as soon as they arrive, and the
51
+ * result resolves once each document has been read. A document that will not
52
+ * load leaves its screen in the list WITHOUT fields — choosable, with its
53
+ * output typed by hand, rather than missing.
54
+ *
55
+ * @param api a createFactoryApi() instance
56
+ * @param onList optional, called with the names-only list first
57
+ */
58
+ export async function loadScreens(api, onList) {
59
+ var rows = await api.listScreens()
60
+ var stubs = (rows || []).map(screenStub).filter(Boolean)
61
+ if (onList) onList(stubs)
62
+
63
+ return Promise.all(stubs.map(async function (stub) {
64
+ try {
65
+ return describeScreen(await api.loadScreen(stub.key)) || stub
66
+ } catch (err) {
67
+ return stub
68
+ }
69
+ }))
70
+ }
71
+
72
+ /** "Leave request" → "leave_request": what a new form is keyed by. */
73
+ export function keyFromName(label) {
74
+ return String(label == null ? '' : label)
75
+ .toLowerCase()
76
+ .replace(/[^a-z0-9]+/g, '_')
77
+ .replace(/^_+|_+$/g, '')
78
+ }
79
+
80
+ /**
81
+ * The screen a new entity's FORM half is, from what createEntity answered.
82
+ *
83
+ * Read off the response rather than spelled out here: the naming is this
84
+ * package's and may not stay "<key>_edit", and an app guessing it would break
85
+ * quietly the day it changes.
86
+ */
87
+ export function editScreenOf(made, fallbackKey) {
88
+ var edit = made && made.edit
89
+ if (edit && typeof edit === 'object') return edit.id || edit.screenKey || fallbackKey
90
+ return edit || fallbackKey
91
+ }
@@ -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
  }
@@ -0,0 +1,71 @@
1
+ // WHERE YOU ARE LIVES IN THE URL — the pure half, no React.
2
+ //
3
+ // The step a stepper is open at, and the control (or flow step) whose
4
+ // properties are open, are places a person navigates to: a refresh or a
5
+ // shared link must land on them again. This file is how they are named in the
6
+ // URL's query; useUrlPlaces.js reads and writes them.
7
+ //
8
+ // THE KEYS ARE THE HOST'S. A builder, a running screen and the preview inside
9
+ // the builder can share one page, and the host app has keys of its own, so a
10
+ // hook writes the URL only when given a `urlKey`. Without one it keeps the
11
+ // same values in memory, exactly as before.
12
+
13
+ /**
14
+ * The query keys under a host's `urlKey`, or null (in memory).
15
+ * factoryUrlKeys('form') → { steps: 'formSteps', selected: 'formSelected' }
16
+ */
17
+ export function factoryUrlKeys(urlKey) {
18
+ if (!urlKey) return null
19
+ return { steps: `${urlKey}Steps`, selected: `${urlKey}Selected` }
20
+ }
21
+
22
+ /**
23
+ * Which step each stepper is open at → one query value. Step 0 is where a
24
+ * stepper starts, so it is left out — a screen on its first steps has a
25
+ * clean URL. Ids are URI-encoded, so one holding ':' or ',' splits back.
26
+ * { s1: 2, s2: 0 } → 's1:2'
27
+ */
28
+ export function encodeSteps(open) {
29
+ return Object.keys(open || {})
30
+ .filter((id) => Number.isInteger(open[id]) && open[id] > 0)
31
+ .map((id) => `${encodeURIComponent(id)}:${open[id]}`)
32
+ .join(',') || null
33
+ }
34
+
35
+ /** The query value back to { stepper id: index }; anything unreadable is dropped. */
36
+ export function decodeSteps(value) {
37
+ const open = {}
38
+ if (!value || typeof value !== 'string') return open
39
+ value.split(',').forEach((pair) => {
40
+ const at = pair.lastIndexOf(':')
41
+ if (at <= 0) return
42
+ if (!/^\d+$/.test(pair.slice(at + 1))) return
43
+ const index = Number(pair.slice(at + 1))
44
+ let id
45
+ try { id = decodeURIComponent(pair.slice(0, at)) } catch { return }
46
+ open[id] = index
47
+ })
48
+ return open
49
+ }
50
+
51
+ /**
52
+ * Selecting adds a history entry when it opens or closes the properties
53
+ * (nothing → something, something → nothing), so Back closes them; moving
54
+ * from one selected thing to another is a switch and replaces the entry.
55
+ */
56
+ export function replacesHistory(previous, next) {
57
+ const had = previous !== null && previous !== undefined && previous !== ''
58
+ const has = next !== null && next !== undefined && next !== ''
59
+ return had && has
60
+ }
61
+
62
+ /**
63
+ * A multi-selection is NOT a place: it is a working set for Delete or
64
+ * Duplicate, not somewhere a person goes. Only a single selection — the one
65
+ * whose properties are open — is named in the URL.
66
+ * one id → that id none, or several → null
67
+ */
68
+ export function selectionPlace(ids) {
69
+ const list = ids ? [...ids] : []
70
+ return list.length === 1 ? list[0] : null
71
+ }
@@ -6,6 +6,9 @@ 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'
10
+ import { useUrlSteps, useUrlSelection } from './useUrlPlaces.js'
11
+ import { selectionPlace } from './urlState.js'
9
12
 
10
13
  // The builder's controller: the document being edited, what is selected, and
11
14
  // every change the builder can make. No JSX — designs/BuilderSample.jsx draws
@@ -35,10 +38,29 @@ import { validateDocument } from './validateDocument.js'
35
38
  * shown); throw with `confirm: [{ column, records }]` when removed fields would drop
36
39
  * columns — the builder asks, and calls again with confirmDrop naming them.
37
40
  * @param controls the control registry (default: the built-ins)
41
+ * @param urlKey where the selected control and the open steps live in the URL:
42
+ * `<urlKey>Selected` and `<urlKey>Steps`, so a refresh or a shared
43
+ * link lands on the same control. Omitted → kept in memory.
38
44
  */
39
- export function useFactoryBuilder({ document: given, name, onSave, onChange, onPublish, listTables, screens, lockedNames, controls = CONTROLS, autosaveDelay = 800 } = {}) {
45
+ export function useFactoryBuilder({ document: given, name, onSave, onChange, onPublish, listTables, screens, lockedNames, controls = CONTROLS, autosaveDelay = 800, urlKey } = {}) {
40
46
  const [doc, setDoc] = useState(() => given || createScreen({ name }))
41
- const [selected, setSelected] = useState(() => new Set())
47
+
48
+ // ── selection ─────────────────────────────────────────────────────────
49
+ // ONE selected control is a place — its properties are open — so it is
50
+ // named in the URL (by id, found again in the document). Several selected
51
+ // is a working set for Delete or Duplicate, not a place: it stays here, and
52
+ // the URL then names nothing.
53
+ const [placeId, selectPlace] = useUrlSelection(urlKey)
54
+ const [several, setSeveral] = useState(null)
55
+ const placeNode = placeId === null ? null : doc.nodes.find((n) => String(n.id) === placeId) || null
56
+ const placeNodeId = placeNode ? placeNode.id : null
57
+ const selected = useMemo(() => several || new Set(placeNodeId === null ? [] : [placeNodeId]), [several, placeNodeId])
58
+ const selectedRef = useRef(selected); selectedRef.current = selected
59
+ const setSelected = useCallback((next) => {
60
+ const ids = [...((typeof next === 'function' ? next(selectedRef.current) : next) || [])]
61
+ setSeveral(ids.length > 1 ? new Set(ids) : null)
62
+ selectPlace(selectionPlace(ids))
63
+ }, [selectPlace])
42
64
  const [dirty, setDirty] = useState(false)
43
65
  const [saving, setSaving] = useState(false)
44
66
  const [saveError, setSaveError] = useState(null)
@@ -56,7 +78,10 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
56
78
  if (JSON.stringify(given) === JSON.stringify(docRef.current)) return
57
79
  docRef.current = given
58
80
  setDoc(given)
59
- setSelected(new Set())
81
+ // A multi-selection belongs to the old document. The URL's control is
82
+ // kept: a host that loads the screen after mounting hands it in here, and
83
+ // the link's control is found in it — or, if it is not there, nothing is.
84
+ setSeveral(null)
60
85
  setDirty(false)
61
86
  }, [given])
62
87
 
@@ -103,7 +128,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
103
128
  // Which step of each stepper is open on the canvas. Everything dropped while
104
129
  // a step is open belongs to that step, and the canvas shows only that step —
105
130
  // so one screen holds as many little screens as it has steps.
106
- const [openSteps, setOpenSteps] = useState({})
131
+ const [openSteps, setOpenSteps] = useUrlSteps(urlKey)
107
132
  const stepperNodes = useMemo(() => steppers(doc, controls), [doc, controls])
108
133
  const [building, setBuilding] = useState(null) // the stepper being built
109
134
  const activeStepper = stepperNodes.find((n) => n.id === building) || stepperNodes[stepperNodes.length - 1] || null
@@ -112,7 +137,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
112
137
  const goToStep = useCallback((id, index) => {
113
138
  setBuilding(id)
114
139
  setOpenSteps((o) => ({ ...o, [id]: index }))
115
- }, [])
140
+ }, [setOpenSteps])
116
141
 
117
142
  /** The step a new control lands on: the one open on the stepper being built. */
118
143
  const dropStep = useCallback(() => (
@@ -124,30 +149,51 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
124
149
 
125
150
  const visibleNodes = useMemo(() => nodesForSteps(doc, openSteps, controls), [doc, openSteps, controls])
126
151
 
127
- /** Adds a control and selects it, so its properties are open straight away. */
152
+ /**
153
+ * Adds a control and selects it, so its properties are open straight away.
154
+ * `type` may be a ready-made field — "preset:email" — which adds its control
155
+ * with the preset's settings already in it.
156
+ */
128
157
  const addControl = useCallback((type, at) => {
129
158
  let added = null
159
+ const preset = presetFor(type)
130
160
  update((d) => {
131
- const r = addControlTo(d, type, { at, step: dropStep() }, controls)
161
+ const r = preset
162
+ ? addControlTo(d, preset.control, { at, step: dropStep(), props: presetProps(preset), size: preset.size }, controls)
163
+ : addControlTo(d, type, { at, step: dropStep() }, controls)
132
164
  added = r.node
133
165
  return r.document
134
166
  })
135
167
  if (added) setSelected(new Set([added.id]))
136
168
  return added
137
- }, [update, controls, dropStep])
169
+ }, [update, controls, dropStep, setSelected])
138
170
 
139
171
  const moveControl = useCallback((id, patch) => update((d) => moveNode(d, id, patch)), [update])
140
172
 
141
173
  const lockedRef = useRef(lockedNames); lockedRef.current = lockedNames
142
174
  const setProperty = useCallback((id, path, value) => update((d) => setNodeProperty(d, id, path, value, controls, { lockedNames: lockedRef.current })), [update, controls])
143
175
 
144
- const selectedRef = useRef(selected); selectedRef.current = selected
176
+ /**
177
+ * A field becomes another kind — a control type ('number') or a ready-made
178
+ * field ('email'). Returns what did not carry over, in words, for the panel
179
+ * to say ("the pattern", "the options").
180
+ */
181
+ const convertControl = useCallback((id, target) => {
182
+ let dropped = []
183
+ update((d) => {
184
+ const r = convertField(d, id, target, controls, { lockedNames: lockedRef.current })
185
+ dropped = r.dropped
186
+ return r.document
187
+ })
188
+ return dropped
189
+ }, [update, controls])
190
+
145
191
  const removeSelected = useCallback(() => {
146
192
  const ids = [...selectedRef.current]
147
193
  if (!ids.length) return
148
194
  update((d) => removeNodes(d, ids))
149
195
  setSelected(new Set())
150
- }, [update])
196
+ }, [update, setSelected])
151
197
 
152
198
  /** Everything selected moves to a copy, just below the original. */
153
199
  const duplicateSelected = useCallback(() => {
@@ -168,10 +214,12 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
168
214
  return next
169
215
  })
170
216
  setSelected(new Set(created))
171
- }, [update, controls])
217
+ }, [update, controls, setSelected])
172
218
 
173
219
  // ── validation + save ─────────────────────────────────────────────────
174
220
  const validation = useMemo(() => validateDocument(doc, controls), [doc, controls])
221
+ /** Fields that read like a ready-made one they are not. Never block anything. */
222
+ const hints = useMemo(() => presetHints(doc, controls), [doc, controls])
175
223
 
176
224
  /** Errors grouped by node id, for the canvas and the property panel. */
177
225
  const errorsByNode = useMemo(() => {
@@ -211,12 +259,18 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
211
259
  }
212
260
  }, [controls])
213
261
 
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])
262
+ // NOTHING SAVES ITSELF. Saving is an act, and the person designing decides
263
+ // when they have finished — a screen that writes itself to the server a
264
+ // moment after every drag means there is no such thing as trying something
265
+ // out, and no moment at which somebody said "this is right".
266
+ //
267
+ // It also made every half-made field an error: a field is invalid while it
268
+ // is being built, and an autosave meets it mid-build and reports it.
269
+ //
270
+ // `save` is called by the Design's Save control (see BuilderSample), and by
271
+ // publish, which saves first because publishing what is on screen is the
272
+ // one thing it must never get wrong. `autosaveDelay` is kept as a prop and
273
+ // ignored, so a host passing it is not broken by this; it does nothing.
220
274
 
221
275
  // PUBLISH: the draft becomes the next version — what screens actually show.
222
276
  // Deliberate, unlike saving: a half-finished design must not reach everyone
@@ -224,7 +278,11 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
224
278
  const onPublishRef = useRef(onPublish); onPublishRef.current = onPublish
225
279
  const [publishing, setPublishing] = useState(false)
226
280
  const [publishResult, setPublishResult] = useState(null) // { ok, message, version?, detail? }
227
- const publish = useCallback(async (confirmDrop) => {
281
+ /**
282
+ * @param confirmDrop column names the person agreed to remove
283
+ * @param confirmConvert column names the person agreed to convert to their field's new kind
284
+ */
285
+ const publish = useCallback(async (confirmDrop, confirmConvert) => {
228
286
  if (!onPublishRef.current) return { ok: false }
229
287
  const check = validateDocument(docRef.current, controls)
230
288
  if (!check.ok) {
@@ -237,7 +295,10 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
237
295
  try {
238
296
  const saved = await save()
239
297
  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 : [] })
298
+ const res = await onPublishRef.current(docRef.current, {
299
+ confirmDrop: Array.isArray(confirmDrop) ? confirmDrop : [],
300
+ confirmConvert: Array.isArray(confirmConvert) ? confirmConvert : []
301
+ })
241
302
  const kept = (res && res.keep) || []
242
303
  const r = {
243
304
  ok: true,
@@ -248,10 +309,12 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
248
309
  setPublishResult(r)
249
310
  return r
250
311
  } 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 }
312
+ // A removed field would drop a column, or a field that changed kind would
313
+ // convert one: not an error, a question.
314
+ const asks = (Array.isArray(err.confirm) && err.confirm.length) || (Array.isArray(err.convert) && err.convert.length)
315
+ const r = asks
316
+ ? { ok: false, confirm: err.confirm || [], convert: err.convert || [], message: err.message }
317
+ : { ok: false, message: err.message || 'Could not publish', detail: err.detail, wontFit: err.wontFit }
255
318
  setPublishResult(r)
256
319
  return r
257
320
  } finally {
@@ -259,10 +322,12 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
259
322
  }
260
323
  }, [controls, save])
261
324
 
325
+ // `pending` now means "there are changes you have not saved", which is a
326
+ // thing to act on rather than a thing about to happen by itself.
262
327
  const status = saveError ? 'error'
263
328
  : saving ? 'saving'
264
329
  : !validation.ok ? 'invalid'
265
- : dirty ? 'pending'
330
+ : dirty ? 'unsaved'
266
331
  : savedAt ? 'saved' : 'idle'
267
332
 
268
333
  // ── keyboard: Delete / Backspace removes, Cmd/Ctrl+D duplicates ────────
@@ -288,7 +353,12 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
288
353
  return {
289
354
  document: doc,
290
355
  controls,
291
- palette: useMemo(() => controlGroups(controls), [controls]),
356
+ // The controls, then the ready-made fields made of the ones this registry has.
357
+ palette: useMemo(() => {
358
+ const ready = presetGroup()
359
+ ready.controls = ready.controls.filter((c) => controls[c.icon] && controls[c.icon].input)
360
+ return ready.controls.length ? [...controlGroups(controls), ready] : controlGroups(controls)
361
+ }, [controls]),
292
362
  selected,
293
363
  setSelected,
294
364
  selectedNode,
@@ -309,6 +379,8 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
309
379
  },
310
380
  visibleNodes,
311
381
  setProperty,
382
+ convertControl,
383
+ hints,
312
384
  removeSelected,
313
385
  duplicateSelected,
314
386
  tables,