@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.
- package/AUTHORING.md +29 -5
- package/README.md +140 -18
- package/package.json +1 -1
- 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 +100 -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 +15 -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/useFactoryBuilder.js +65 -16
- package/src/useFactoryScreen.js +74 -39
- package/src/useScreenSource.js +81 -0
- package/src/validateDocument.js +5 -1
- package/src/values.js +18 -5
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/useFactoryBuilder.js
CHANGED
|
@@ -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
|
-
/**
|
|
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 =
|
|
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
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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 ? '
|
|
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
|
-
|
|
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,
|
package/src/useFactoryScreen.js
CHANGED
|
@@ -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
|
-
// ──
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
368
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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:
|
|
440
|
+
/** Open a record (null: a new one). Unsaved changes are dropped — ask confirmDiscard first. */
|
|
411
441
|
const openRecord = useCallback(async (rec) => {
|
|
412
|
-
|
|
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
|
-
}, [
|
|
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
|
+
}
|