@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.
- package/AUTHORING.md +29 -5
- package/README.md +167 -20
- package/package.json +7 -2
- package/src/controls.js +19 -13
- package/src/designs/BuilderSample.jsx +77 -19
- package/src/designs/ControlView.jsx +10 -1
- package/src/designs/FlowRunnerSample.jsx +6 -4
- package/src/designs/ListView.jsx +40 -21
- package/src/designs/Palette.jsx +2 -1
- package/src/designs/PropertyPanel.jsx +57 -1
- package/src/designs/ScreenEditorSample.jsx +97 -0
- package/src/designs/ScreenModal.jsx +2 -2
- package/src/designs/ScreenSample.jsx +92 -41
- package/src/designs/factory.css +170 -76
- package/src/designs/index.js +4 -0
- package/src/designs/styles.js +11 -1
- package/src/generate.js +35 -10
- package/src/hooks.js +1 -1
- package/src/index.js +6 -2
- package/src/model.js +2 -1
- package/src/pages.jsx +18 -3
- package/src/presets.js +324 -0
- package/src/remote.js +7 -1
- package/src/scaffold.js +2 -2
- package/src/screenSource.js +91 -0
- package/src/tableSchema.js +109 -6
- package/src/urlState.js +71 -0
- package/src/useFactoryBuilder.js +97 -25
- package/src/useFactoryScreen.js +85 -47
- package/src/useFlowBuilder.js +7 -4
- package/src/useScreenSource.js +81 -0
- package/src/useUrlPlaces.js +48 -0
- package/src/validateDocument.js +5 -1
- package/src/values.js +18 -5
|
@@ -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
|
+
}
|
package/src/tableSchema.js
CHANGED
|
@@ -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
|
|
22
|
-
// lost or rejected; write that
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/urlState.js
ADDED
|
@@ -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
|
+
}
|
package/src/useFactoryBuilder.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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] =
|
|
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
|
-
/**
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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 ? '
|
|
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
|
-
|
|
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,
|