@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.
@@ -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
- // ── NO SUBMIT ────────────────────────────────────────────────────────────
11
- // The screen saves ITSELF, in the background, a moment after the person stops
12
- // changing something — once what is entered is acceptable. There is no button
13
- // to press and no page to post. The first save of a new record creates it
14
- // (the host hands back its id); every save after that updates the same record.
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 autosaveDelay ms (default 700)
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
- autosaveDelay = AUTOSAVE_DELAY, controls = CONTROLS
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
- // Latest of everything a delayed save needs, read at the moment it runs.
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] = useState({})
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
- let changed = false
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 changed || Object.keys(next).length !== Object.keys(open).length ? next : open
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
- clearTimeout(timer.current)
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) { setStatus(st.status); return { ok: false, errors: st.errors } }
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
- if (!l.onSave && !customSave) { setStatus('saved'); return { ok: true } }
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
- const schedule = useCallback(() => {
360
- clearTimeout(timer.current)
361
- setStatus('pending')
362
- timer.current = setTimeout(runSave, autosaveDelay)
363
- }, [runSave, autosaveDelay])
364
-
365
- /** Save any pending change right away — before opening another record, say. */
380
+ /** Save if there is anything unsaved — what a flow's Next does before moving on. */
366
381
  const flush = useCallback(async () => {
367
- if (timer.current || again.current) return runSave()
368
- if (inFlight.current) return inFlight.current
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
- useEffect(() => () => clearTimeout(timer.current), [])
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
- schedule()
399
- }, [optionsFor, schedule])
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: whatever was pending is saved first. */
443
+ /** Open a record (null: a new one). Unsaved changes are dropped — ask confirmDiscard first. */
411
444
  const openRecord = useCallback(async (rec) => {
412
- await flush()
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
- }, [flush, startValues, recordKey])
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,
@@ -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] = useState(null) // a step key
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
+ }
@@ -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
- // ── autosave ─────────────────────────────────────────────────────────────
181
- // There is no submit. A screen saves itself a moment after a change, as long
182
- // as what is entered is acceptable. This decides whether it can, and which
183
- // messages to SHOW: a field nobody has touched is not shouted at for being
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