@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
package/src/useFactoryScreen.js
CHANGED
|
@@ -4,14 +4,18 @@ import { inputNodes, nodesForSteps, steppers, stepsOf, stepOf, resolveStep, reac
|
|
|
4
4
|
import { validateDocument } from './validateDocument.js'
|
|
5
5
|
import { initialValues, parseInput, saveState, fieldError, optionValue, recordValues, listSource } from './values.js'
|
|
6
6
|
import { hookMethod } from './hooks.js'
|
|
7
|
+
import { useUrlSteps } from './useUrlPlaces.js'
|
|
7
8
|
|
|
8
9
|
// A saved screen, running. No JSX — designs/ScreenSample.jsx draws it.
|
|
9
10
|
//
|
|
10
|
-
// ──
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
11
|
+
// ── SAVED BY A BUTTON, OVER AJAX — NEVER A FORM SUBMIT ───────────────────
|
|
12
|
+
// Nothing is written while the person types. Changes are held in the form
|
|
13
|
+
// ("Unsaved changes") until they press Save, which makes ONE AJAX call to the
|
|
14
|
+
// host's onSave. There is no <form> post and no page reload — and no
|
|
15
|
+
// background save either: a half-typed record never lands in the table. The
|
|
16
|
+
// first save of a new record creates it (the host hands back its id); every
|
|
17
|
+
// save after that updates the same record. Leaving with unsaved changes asks
|
|
18
|
+
// first (Cancel, the popup's ×, closing the tab).
|
|
15
19
|
//
|
|
16
20
|
// ── THE DATABASE IS THE HOST'S ───────────────────────────────────────────
|
|
17
21
|
// Everything that reads or writes data is a call the host supplies:
|
|
@@ -26,9 +30,6 @@ import { hookMethod } from './hooks.js'
|
|
|
26
30
|
// for Edit and New. The screen comes from `screens` ({ id → document }) or the
|
|
27
31
|
// host's `loadScreen(id)`. The list refreshes as the popup saves.
|
|
28
32
|
|
|
29
|
-
/** Quiet time after the last change before a save goes out. */
|
|
30
|
-
export const AUTOSAVE_DELAY = 700
|
|
31
|
-
|
|
32
33
|
/**
|
|
33
34
|
* @param document a valid screen document
|
|
34
35
|
* @param record the record to open, e.g. { id: 7, firstName: 'Ada' } — omit for a new one
|
|
@@ -45,14 +46,16 @@ export const AUTOSAVE_DELAY = 700
|
|
|
45
46
|
* @param fetchRecord async ({ screen, id }) → record — Edit loads the record fresh (through the server's get hooks)
|
|
46
47
|
* @param screens { id → document } — screens a list's Edit / New can open
|
|
47
48
|
* @param loadScreen async (id) → document — for screens not in `screens`
|
|
48
|
-
* @param
|
|
49
|
+
* @param onDirtyChange (dirty) → void — whether there are unsaved changes, as that changes
|
|
49
50
|
* @param controls the control registry (default: the built-ins)
|
|
50
51
|
* @param hooks a FactoryHooks (or an object with some of its methods) — get / save / delete / actions
|
|
52
|
+
* @param urlKey where the open step of each stepper lives in the URL (`<urlKey>Steps`),
|
|
53
|
+
* so a refresh or a shared link lands on the same step. Omitted → in memory.
|
|
51
54
|
*/
|
|
52
55
|
export function useFactoryScreen({
|
|
53
56
|
document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl,
|
|
54
|
-
onOpenRecord, onDone,
|
|
55
|
-
|
|
57
|
+
onOpenRecord, onDone, onDirtyChange, urlKey,
|
|
58
|
+
controls = CONTROLS
|
|
56
59
|
} = {}) {
|
|
57
60
|
const check = useMemo(() => validateDocument(doc, controls), [doc, controls])
|
|
58
61
|
// Read at call time, so a new hooks object never re-runs loads by itself.
|
|
@@ -105,7 +108,7 @@ export function useFactoryScreen({
|
|
|
105
108
|
const [values, setValues] = useState(() => (check.ok ? startValues(record) : {}))
|
|
106
109
|
const [recordId, setRecordId] = useState(() => (record ? record[recordKey] ?? null : null))
|
|
107
110
|
const [touched, setTouched] = useState(() => new Set())
|
|
108
|
-
const [status, setStatus] = useState('idle') // idle | pending | saving | saved | incomplete | invalid | error
|
|
111
|
+
const [status, setStatus] = useState('idle') // idle | pending (unsaved changes) | saving | saved | incomplete | invalid | error
|
|
109
112
|
const [saveError, setSaveError] = useState(null)
|
|
110
113
|
const [savedAt, setSavedAt] = useState(null)
|
|
111
114
|
const [listVersion, setListVersion] = useState(0)
|
|
@@ -113,7 +116,17 @@ export function useFactoryScreen({
|
|
|
113
116
|
// Shown like the form's own, and cleared as soon as that field is changed.
|
|
114
117
|
const [serverErrors, setServerErrors] = useState({})
|
|
115
118
|
|
|
116
|
-
//
|
|
119
|
+
// UNSAVED CHANGES: every change bumps `changes`; a save records which count
|
|
120
|
+
// it wrote. A change made while a save was on the wire stays unsaved.
|
|
121
|
+
const changes = useRef(0)
|
|
122
|
+
const savedChanges = useRef(0)
|
|
123
|
+
const [dirty, setDirtyState] = useState(false)
|
|
124
|
+
const onDirtyRef = useRef(onDirtyChange); onDirtyRef.current = onDirtyChange
|
|
125
|
+
const setDirty = useCallback((d) => {
|
|
126
|
+
setDirtyState((was) => { if (was !== d) onDirtyRef.current?.(d); return d })
|
|
127
|
+
}, [])
|
|
128
|
+
|
|
129
|
+
// Latest of everything a save needs, read at the moment it runs.
|
|
117
130
|
const live = useRef({})
|
|
118
131
|
live.current = { doc, values, recordId, touched, source, onSave, controls, recordKey }
|
|
119
132
|
|
|
@@ -151,7 +164,7 @@ export function useFactoryScreen({
|
|
|
151
164
|
// A stepper shows one step at a time. Which step each is open at lives here;
|
|
152
165
|
// the fields of the steps not showing are still filled in, still checked and
|
|
153
166
|
// still saved — a step is what the person sees, not a separate form.
|
|
154
|
-
const [openSteps, setOpenSteps] =
|
|
167
|
+
const [openSteps, setOpenSteps] = useUrlSteps(urlKey)
|
|
155
168
|
// Steps the app has switched off — { stepper id: [index, …] }. A screen may
|
|
156
169
|
// have branches; which of them apply is the app's to say, from its hooks.
|
|
157
170
|
const [disabledSteps, setDisabledSteps] = useState({})
|
|
@@ -159,20 +172,20 @@ export function useFactoryScreen({
|
|
|
159
172
|
const visibleNodes = useMemo(() => (check.ok ? nodesForSteps(doc, openSteps, controls) : []), [check.ok, doc, openSteps, controls])
|
|
160
173
|
|
|
161
174
|
// A screen whose steps changed under it (a field moved to another step, a
|
|
162
|
-
// step removed) must not stay open at a step that is gone.
|
|
175
|
+
// step removed) must not stay open at a step that is gone. Steppers this
|
|
176
|
+
// document does not have are left alone: a screen still loading has none,
|
|
177
|
+
// and dropping them would lose the step a link was opened at.
|
|
163
178
|
useEffect(() => {
|
|
164
179
|
setOpenSteps((open) => {
|
|
165
|
-
|
|
166
|
-
const next = {}
|
|
180
|
+
const next = { ...open }
|
|
167
181
|
stepperNodes.forEach((n) => {
|
|
168
182
|
const count = stepsOf(n).length
|
|
169
183
|
const at = Number.isInteger(open[n.id]) ? open[n.id] : 0
|
|
170
184
|
next[n.id] = Math.min(Math.max(at, 0), Math.max(count - 1, 0))
|
|
171
|
-
if (next[n.id] !== open[n.id]) changed = true
|
|
172
185
|
})
|
|
173
|
-
return
|
|
186
|
+
return next
|
|
174
187
|
})
|
|
175
|
-
}, [stepperNodes])
|
|
188
|
+
}, [stepperNodes, setOpenSteps])
|
|
176
189
|
|
|
177
190
|
const stepControl = useMemo(() => {
|
|
178
191
|
const at = (id) => (Number.isInteger(openSteps[id]) ? openSteps[id] : 0)
|
|
@@ -264,7 +277,7 @@ export function useFactoryScreen({
|
|
|
264
277
|
})
|
|
265
278
|
}
|
|
266
279
|
return control
|
|
267
|
-
}, [stepperNodes, openSteps, disabledSteps, doc, controls])
|
|
280
|
+
}, [stepperNodes, openSteps, setOpenSteps, disabledSteps, doc, controls])
|
|
268
281
|
|
|
269
282
|
live.current.steps = stepControl
|
|
270
283
|
|
|
@@ -300,19 +313,25 @@ export function useFactoryScreen({
|
|
|
300
313
|
[check.ok, doc, values, touched, loadedItems, controls])
|
|
301
314
|
|
|
302
315
|
// ── saving ────────────────────────────────────────────────────────────
|
|
303
|
-
const timer = useRef(null)
|
|
304
316
|
const inFlight = useRef(null) // the promise of the save running now
|
|
305
|
-
const again = useRef(false) // a change arrived while it ran
|
|
306
317
|
|
|
318
|
+
/** Save — what the Save button does. One AJAX call; never on its own. */
|
|
307
319
|
const runSave = useCallback(async () => {
|
|
308
|
-
|
|
309
|
-
timer.current = null
|
|
310
|
-
if (inFlight.current) { again.current = true; return inFlight.current }
|
|
320
|
+
if (inFlight.current) return inFlight.current
|
|
311
321
|
const l = live.current
|
|
312
322
|
const st = saveState(l.doc, l.values, l.touched, l.loadedItems, l.controls)
|
|
313
|
-
if (!st.canSave) {
|
|
323
|
+
if (!st.canSave) {
|
|
324
|
+
// Pressing Save is the moment to say what is missing — on every field,
|
|
325
|
+
// including the ones nobody has been in yet.
|
|
326
|
+
const t = new Set([...l.touched, ...Object.keys(st.errors)])
|
|
327
|
+
live.current.touched = t
|
|
328
|
+
setTouched(t)
|
|
329
|
+
setStatus('invalid')
|
|
330
|
+
return { ok: false, errors: st.errors }
|
|
331
|
+
}
|
|
314
332
|
const customSave = hooksRef.current && typeof hooksRef.current.save === 'function'
|
|
315
|
-
|
|
333
|
+
const writing = changes.current
|
|
334
|
+
if (!l.onSave && !customSave) { savedChanges.current = writing; setDirty(false); setStatus('saved'); return { ok: true } }
|
|
316
335
|
|
|
317
336
|
setStatus('saving')
|
|
318
337
|
setSaveError(null)
|
|
@@ -332,9 +351,12 @@ export function useFactoryScreen({
|
|
|
332
351
|
setRecordId(saved[l.recordKey])
|
|
333
352
|
live.current.recordId = saved[l.recordKey]
|
|
334
353
|
}
|
|
354
|
+
savedChanges.current = writing
|
|
355
|
+
const still = changes.current !== writing
|
|
356
|
+
setDirty(still)
|
|
335
357
|
setSavedAt(new Date())
|
|
336
358
|
setListVersion((v) => v + 1)
|
|
337
|
-
setStatus('saved')
|
|
359
|
+
setStatus(still ? 'pending' : 'saved')
|
|
338
360
|
return { ok: true, record: saved }
|
|
339
361
|
} catch (err) {
|
|
340
362
|
const onFields = Array.isArray(err.fields) ? err.fields.filter((f) => f && f.field && f.message) : []
|
|
@@ -349,27 +371,36 @@ export function useFactoryScreen({
|
|
|
349
371
|
return { ok: false, error: err }
|
|
350
372
|
} finally {
|
|
351
373
|
inFlight.current = null
|
|
352
|
-
if (again.current) { again.current = false; schedule() } // eslint-disable-line no-use-before-define
|
|
353
374
|
}
|
|
354
375
|
})()
|
|
355
376
|
inFlight.current = task
|
|
356
377
|
return task
|
|
357
|
-
}, [])
|
|
378
|
+
}, [setDirty])
|
|
358
379
|
|
|
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. */
|
|
380
|
+
/** Save if there is anything unsaved — what a flow's Next does before moving on. */
|
|
366
381
|
const flush = useCallback(async () => {
|
|
367
|
-
if (
|
|
368
|
-
if (
|
|
382
|
+
if (inFlight.current) await inFlight.current
|
|
383
|
+
if (changes.current !== savedChanges.current) return runSave()
|
|
369
384
|
return { ok: true }
|
|
370
385
|
}, [runSave])
|
|
371
386
|
|
|
372
|
-
|
|
387
|
+
/**
|
|
388
|
+
* May the person leave? True when nothing is unsaved, or when they agree to
|
|
389
|
+
* lose it. Cancel, the popup's × and opening another record all ask this.
|
|
390
|
+
*/
|
|
391
|
+
const confirmDiscard = useCallback(() => {
|
|
392
|
+
if (changes.current === savedChanges.current) return true
|
|
393
|
+
// eslint-disable-next-line no-alert
|
|
394
|
+
return typeof window === 'undefined' || window.confirm('Discard your unsaved changes?')
|
|
395
|
+
}, [])
|
|
396
|
+
|
|
397
|
+
// Closing the tab or reloading with unsaved changes: the browser's own prompt.
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
if (!dirty || typeof window === 'undefined') return undefined
|
|
400
|
+
const warn = (e) => { e.preventDefault(); e.returnValue = '' }
|
|
401
|
+
window.addEventListener('beforeunload', warn)
|
|
402
|
+
return () => window.removeEventListener('beforeunload', warn)
|
|
403
|
+
}, [dirty])
|
|
373
404
|
|
|
374
405
|
// ── editing ───────────────────────────────────────────────────────────
|
|
375
406
|
const onChangeRef = useRef(onChange); onChangeRef.current = onChange
|
|
@@ -395,8 +426,10 @@ export function useFactoryScreen({
|
|
|
395
426
|
setTouched(t)
|
|
396
427
|
}
|
|
397
428
|
onChangeRef.current?.(next)
|
|
398
|
-
|
|
399
|
-
|
|
429
|
+
changes.current += 1
|
|
430
|
+
setDirty(true)
|
|
431
|
+
setStatus('pending')
|
|
432
|
+
}, [optionsFor, setDirty])
|
|
400
433
|
|
|
401
434
|
/** Leaving a field shows its message, even if nothing was typed. */
|
|
402
435
|
const touch = useCallback((node) => {
|
|
@@ -407,9 +440,12 @@ export function useFactoryScreen({
|
|
|
407
440
|
setTouched(t)
|
|
408
441
|
}, [])
|
|
409
442
|
|
|
410
|
-
/** Open a record:
|
|
443
|
+
/** Open a record (null: a new one). Unsaved changes are dropped — ask confirmDiscard first. */
|
|
411
444
|
const openRecord = useCallback(async (rec) => {
|
|
412
|
-
|
|
445
|
+
if (inFlight.current) await inFlight.current
|
|
446
|
+
changes.current = 0
|
|
447
|
+
savedChanges.current = 0
|
|
448
|
+
setDirty(false)
|
|
413
449
|
const next = startValues(rec)
|
|
414
450
|
live.current.values = next
|
|
415
451
|
live.current.recordId = rec ? rec[recordKey] ?? null : null
|
|
@@ -420,7 +456,7 @@ export function useFactoryScreen({
|
|
|
420
456
|
setSaveError(null)
|
|
421
457
|
setServerErrors({})
|
|
422
458
|
setStatus('idle')
|
|
423
|
-
}, [
|
|
459
|
+
}, [startValues, recordKey, setDirty])
|
|
424
460
|
|
|
425
461
|
const newRecord = useCallback(() => openRecord(null), [openRecord])
|
|
426
462
|
|
|
@@ -490,7 +526,6 @@ export function useFactoryScreen({
|
|
|
490
526
|
})
|
|
491
527
|
// Deleting the record that is open leaves nothing to save into.
|
|
492
528
|
if (id !== undefined && id === live.current.recordId) {
|
|
493
|
-
clearTimeout(timer.current); timer.current = null
|
|
494
529
|
await openRecord(null)
|
|
495
530
|
}
|
|
496
531
|
setListVersion((v) => v + 1)
|
|
@@ -576,7 +611,10 @@ export function useFactoryScreen({
|
|
|
576
611
|
upload,
|
|
577
612
|
fileUrl,
|
|
578
613
|
flush,
|
|
614
|
+
save: runSave,
|
|
579
615
|
saveNow: runSave,
|
|
616
|
+
dirty,
|
|
617
|
+
confirmDiscard,
|
|
580
618
|
openRecord,
|
|
581
619
|
newRecord,
|
|
582
620
|
listFor,
|
package/src/useFlowBuilder.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
addTransition, setTransition, removeTransition, validateFlow, firstStep, layoutFlow, FLOW_END
|
|
5
5
|
} from './flow.js'
|
|
6
6
|
import { inputNodes } from './document.js'
|
|
7
|
+
import { useUrlSelection } from './useUrlPlaces.js'
|
|
7
8
|
|
|
8
9
|
// DESIGNING a flow: which screens, in what order, and what decides the order.
|
|
9
10
|
//
|
|
@@ -23,10 +24,12 @@ export const FLOW_AUTOSAVE_DELAY = 800
|
|
|
23
24
|
* @param onSave async (flow) → void — called after edits, only with a flow that makes sense
|
|
24
25
|
* @param onPublish async (key) → void — shows a Publish button
|
|
25
26
|
* @param onChange (flow) → void — every edit
|
|
27
|
+
* @param urlKey where the selected step lives in the URL (`<urlKey>Selected`), so a
|
|
28
|
+
* refresh or a shared link opens the same step. Omitted → in memory.
|
|
26
29
|
*/
|
|
27
|
-
export function useFlowBuilder({ flow: given, name, screens = [], loadScreen, onSave, onPublish, onChange } = {}) {
|
|
30
|
+
export function useFlowBuilder({ flow: given, name, screens = [], loadScreen, onSave, onPublish, onChange, urlKey } = {}) {
|
|
28
31
|
const [flow, setFlow] = useState(() => given || createFlow({ name }))
|
|
29
|
-
const [selected, setSelected] =
|
|
32
|
+
const [selected, setSelected] = useUrlSelection(urlKey) // a step key
|
|
30
33
|
const [dirty, setDirty] = useState(false)
|
|
31
34
|
const [saving, setSaving] = useState(false)
|
|
32
35
|
const [saveError, setSaveError] = useState(null)
|
|
@@ -102,13 +105,13 @@ export function useFlowBuilder({ flow: given, name, screens = [], loadScreen, on
|
|
|
102
105
|
})
|
|
103
106
|
if (added) setSelected(added.stepKey)
|
|
104
107
|
return added
|
|
105
|
-
}, [update])
|
|
108
|
+
}, [update, setSelected])
|
|
106
109
|
const move = useCallback((stepKey, at) => update((f) => moveStep(f, stepKey, at)), [update])
|
|
107
110
|
const change = useCallback((stepKey, patch) => update((f) => setStep(f, stepKey, patch)), [update])
|
|
108
111
|
const remove = useCallback((stepKey) => {
|
|
109
112
|
update((f) => removeStep(f, stepKey))
|
|
110
113
|
setSelected((s) => (s === stepKey ? null : s))
|
|
111
|
-
}, [update])
|
|
114
|
+
}, [update, setSelected])
|
|
112
115
|
const connect = useCallback((fromKey, target, when = null) => update((f) => addTransition(f, fromKey, target, when)), [update])
|
|
113
116
|
const changeTransition = useCallback((fromKey, index, patch) => update((f) => setTransition(f, fromKey, index, patch)), [update])
|
|
114
117
|
const disconnect = useCallback((fromKey, index) => update((f) => removeTransition(f, fromKey, index)), [update])
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef } from 'react'
|
|
2
|
+
import { useUrlState } from '@xeplr/ui-utils/src/url/useUrlState.js'
|
|
3
|
+
import { factoryUrlKeys, encodeSteps, decodeSteps, replacesHistory } from './urlState.js'
|
|
4
|
+
|
|
5
|
+
// The two places the factory's controllers keep, each in useState's shape so
|
|
6
|
+
// a controller swaps one line. In the URL under the host's `urlKey`; without
|
|
7
|
+
// one, in memory with the same rules (see urlState.js).
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* [openSteps, setOpenSteps] — which step each stepper is open at,
|
|
11
|
+
* { stepper id: index }. Moving between steps is a switch, so it replaces the
|
|
12
|
+
* history entry rather than adding one.
|
|
13
|
+
*/
|
|
14
|
+
export function useUrlSteps(urlKey) {
|
|
15
|
+
const keys = factoryUrlKeys(urlKey)
|
|
16
|
+
const [value, setValue] = useUrlState(keys ? keys.steps : 'steps', { inUrl: Boolean(keys), replace: true })
|
|
17
|
+
const open = useMemo(() => decodeSteps(value), [value])
|
|
18
|
+
// Two moves in one event build on each other, as useState's updater would.
|
|
19
|
+
const openRef = useRef(open); openRef.current = open
|
|
20
|
+
const setOpen = useCallback((next) => {
|
|
21
|
+
const resolved = typeof next === 'function' ? next(openRef.current) : next
|
|
22
|
+
const encoded = encodeSteps(resolved)
|
|
23
|
+
if (encoded === encodeSteps(openRef.current)) return
|
|
24
|
+
openRef.current = decodeSteps(encoded)
|
|
25
|
+
setValue(encoded)
|
|
26
|
+
}, [setValue])
|
|
27
|
+
return [open, setOpen]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* [selected, setSelected] — the one thing whose properties are open (a
|
|
32
|
+
* control's id, a flow step's key), or null. Opening or closing the
|
|
33
|
+
* properties adds a history entry; moving to another thing replaces it.
|
|
34
|
+
* Read back from the URL it is a string.
|
|
35
|
+
*/
|
|
36
|
+
export function useUrlSelection(urlKey) {
|
|
37
|
+
const keys = factoryUrlKeys(urlKey)
|
|
38
|
+
const [value, setValue] = useUrlState(keys ? keys.selected : 'selected', { inUrl: Boolean(keys) })
|
|
39
|
+
const valueRef = useRef(value); valueRef.current = value
|
|
40
|
+
const select = useCallback((next) => {
|
|
41
|
+
const resolved = typeof next === 'function' ? next(valueRef.current) : next
|
|
42
|
+
const v = resolved === undefined || resolved === '' ? null : resolved
|
|
43
|
+
if ((v === null ? null : String(v)) === valueRef.current) return
|
|
44
|
+
setValue(v, { replace: replacesHistory(valueRef.current, v) })
|
|
45
|
+
valueRef.current = v === null ? null : String(v)
|
|
46
|
+
}, [setValue])
|
|
47
|
+
return [value, select]
|
|
48
|
+
}
|
package/src/validateDocument.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { CONTROLS, LABEL_VARIANTS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, LAYOUTS, MULTI_SEPARATOR, MAX_FILE_MB, STEP_LIMIT, acceptList } from './controls.js'
|
|
14
14
|
import { DOCUMENT_KIND, DOCUMENT_VERSION } from './document.js'
|
|
15
15
|
import { RESERVED_COLUMNS, MAX_IDENTIFIER } from './tableSchema.js'
|
|
16
|
+
import { FIELD_PRESETS, INPUT_TYPES, presetFor } from './presets.js'
|
|
16
17
|
|
|
17
18
|
const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
18
19
|
const TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_.$-]*$/
|
|
@@ -88,6 +89,9 @@ export function validateDocument(doc, controls = CONTROLS) {
|
|
|
88
89
|
if (!nonEmptyString(props.label)) err(`${at}.props.label`, 'is required — the text shown above the field')
|
|
89
90
|
if (props.required !== undefined && typeof props.required !== 'boolean') err(`${at}.props.required`, 'must be true or false')
|
|
90
91
|
if (props.placeholder !== undefined && typeof props.placeholder !== 'string') err(`${at}.props.placeholder`, 'must be a string')
|
|
92
|
+
// Where the field came from (a ready-made field's key), or false: plain on purpose.
|
|
93
|
+
if (props.preset !== undefined && props.preset !== false && !presetFor(props.preset)) err(`${at}.props.preset`, `"${props.preset}" is not a ready-made field — one of: ${FIELD_PRESETS.map((p) => p.key).join(', ')}, or false`)
|
|
94
|
+
if (props.inputType !== undefined && !INPUT_TYPES.includes(props.inputType)) err(`${at}.props.inputType`, `must be one of: ${INPUT_TYPES.join(', ')}`)
|
|
91
95
|
checkDefault(node, def, at, err)
|
|
92
96
|
checkValidation(node, def, at, err)
|
|
93
97
|
}
|
|
@@ -234,7 +238,7 @@ function checkValidation(node, def, at, err) {
|
|
|
234
238
|
const val = v[key]
|
|
235
239
|
if (key === 'minLength' || key === 'maxLength') {
|
|
236
240
|
if (!(Number.isInteger(val) && val >= 0)) err(path, 'must be a whole number, 0 or more')
|
|
237
|
-
} else if (key === 'integer') {
|
|
241
|
+
} else if (key === 'integer' || key === 'notFuture') {
|
|
238
242
|
if (typeof val !== 'boolean') err(path, 'must be true or false')
|
|
239
243
|
} else if (key === 'pattern') {
|
|
240
244
|
try { new RegExp(val) } catch (_) { err(path, `"${val}" is not a valid regular expression`) }
|
package/src/values.js
CHANGED
|
@@ -32,6 +32,13 @@ export function formSchema(doc, controls = CONTROLS) {
|
|
|
32
32
|
const v = p.validation && { ...p.validation }
|
|
33
33
|
if (v) {
|
|
34
34
|
delete v.patternMessage
|
|
35
|
+
// "Not in the future" is today's date AT THE MOMENT OF CHECKING — the
|
|
36
|
+
// server builds this schema per save, so it never goes stale.
|
|
37
|
+
if (v.notFuture) {
|
|
38
|
+
const today = localDay()
|
|
39
|
+
if (!v.max || v.max > today) v.max = today
|
|
40
|
+
}
|
|
41
|
+
delete v.notFuture
|
|
35
42
|
if (Object.keys(v).length) field.validation = v
|
|
36
43
|
}
|
|
37
44
|
return field
|
|
@@ -130,6 +137,7 @@ export function fieldError(node, value, loadedOptions) {
|
|
|
130
137
|
const day = value.slice(0, 10)
|
|
131
138
|
if (v.min && day < v.min) return `${label} must be on or after ${v.min}`
|
|
132
139
|
if (v.max && day > v.max) return `${label} must be on or before ${v.max}`
|
|
140
|
+
if (v.notFuture && day > localDay()) return `${label} cannot be in the future`
|
|
133
141
|
return null
|
|
134
142
|
}
|
|
135
143
|
case 'datetime': {
|
|
@@ -173,15 +181,20 @@ export function optionValue(options, raw) {
|
|
|
173
181
|
return hit ? hit.id : raw
|
|
174
182
|
}
|
|
175
183
|
|
|
184
|
+
/** Today as YYYY-MM-DD, in the local timezone — the day a person means by "today". */
|
|
185
|
+
function localDay() {
|
|
186
|
+
const d = new Date()
|
|
187
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
188
|
+
}
|
|
189
|
+
|
|
176
190
|
function safeRegExp(pattern) {
|
|
177
191
|
try { return new RegExp(pattern) } catch (_) { return { test: () => true } }
|
|
178
192
|
}
|
|
179
193
|
|
|
180
|
-
// ──
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// empty — it only holds the save back.
|
|
194
|
+
// ── can it save? ─────────────────────────────────────────────────────────
|
|
195
|
+
// Save (a button, one AJAX call) goes out only when what is entered is
|
|
196
|
+
// acceptable. This decides whether it can, and which messages to SHOW: a field
|
|
197
|
+
// nobody has touched is not shouted at for being empty until Save is pressed.
|
|
185
198
|
|
|
186
199
|
/**
|
|
187
200
|
* @param touched Set of field names the person has changed or left
|