@xeplr/ui-factory 1.1.0 → 1.3.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/README.md +189 -2
- package/bin/xeplr-factory.js +2 -1
- package/package.json +7 -2
- package/src/controls.js +62 -2
- package/src/designs/BuilderSample.jsx +47 -1
- package/src/designs/ControlView.jsx +36 -0
- package/src/designs/ListView.jsx +3 -1
- package/src/designs/PropertyPanel.jsx +218 -7
- package/src/designs/factory.css +50 -0
- package/src/designs/styles.js +1 -1
- package/src/document.js +51 -0
- package/src/index.js +3 -1
- package/src/masters.js +193 -0
- package/src/model.js +5 -0
- package/src/pages.jsx +14 -1
- package/src/remote.js +22 -2
- package/src/screenSource.js +85 -2
- package/src/tableSchema.js +16 -0
- package/src/urlState.js +71 -0
- package/src/useFactoryBuilder.js +134 -10
- package/src/useFactoryScreen.js +45 -14
- package/src/useFlowBuilder.js +7 -4
- package/src/useFlowRun.js +6 -3
- package/src/useScreenSource.js +8 -4
- package/src/useUrlPlaces.js +48 -0
- package/src/validateDocument.js +67 -1
- package/src/values.js +118 -2
package/src/useFactoryScreen.js
CHANGED
|
@@ -4,6 +4,7 @@ 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
|
//
|
|
@@ -48,10 +49,19 @@ import { hookMethod } from './hooks.js'
|
|
|
48
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.
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* @param params what this screen was CALLED WITH — { name: value } for the
|
|
57
|
+
* inputs it declares. A flow's Screen step supplies them; somebody opening
|
|
58
|
+
* the form from a list supplies nothing. A field whose default is
|
|
59
|
+
* `{params.x}` is filled from here, and left empty (for the person to fill
|
|
60
|
+
* in) when nobody said.
|
|
51
61
|
*/
|
|
52
62
|
export function useFactoryScreen({
|
|
53
63
|
document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl,
|
|
54
|
-
onOpenRecord, onDone, onDirtyChange,
|
|
64
|
+
onOpenRecord, onDone, onDirtyChange, params, urlKey,
|
|
55
65
|
controls = CONTROLS
|
|
56
66
|
} = {}) {
|
|
57
67
|
const check = useMemo(() => validateDocument(doc, controls), [doc, controls])
|
|
@@ -97,9 +107,13 @@ export function useFactoryScreen({
|
|
|
97
107
|
}, [check.ok, doc, inputs, fieldsFor])
|
|
98
108
|
const source = check.ok ? doc.source || null : null
|
|
99
109
|
|
|
110
|
+
// WHAT IT WAS CALLED WITH, then what was actually saved: a record being
|
|
111
|
+
// reopened wins, because the row is the truth once there is one.
|
|
100
112
|
const startValues = useCallback(
|
|
101
|
-
(rec) => (rec
|
|
102
|
-
|
|
113
|
+
(rec) => (rec
|
|
114
|
+
? { ...initialValues(doc, params || {}, controls), ...recordValues(doc, rec, controls) }
|
|
115
|
+
: initialValues(doc, params || {}, controls)),
|
|
116
|
+
[doc, controls, params]
|
|
103
117
|
)
|
|
104
118
|
|
|
105
119
|
const [values, setValues] = useState(() => (check.ok ? startValues(record) : {}))
|
|
@@ -161,7 +175,7 @@ export function useFactoryScreen({
|
|
|
161
175
|
// A stepper shows one step at a time. Which step each is open at lives here;
|
|
162
176
|
// the fields of the steps not showing are still filled in, still checked and
|
|
163
177
|
// still saved — a step is what the person sees, not a separate form.
|
|
164
|
-
const [openSteps, setOpenSteps] =
|
|
178
|
+
const [openSteps, setOpenSteps] = useUrlSteps(urlKey)
|
|
165
179
|
// Steps the app has switched off — { stepper id: [index, …] }. A screen may
|
|
166
180
|
// have branches; which of them apply is the app's to say, from its hooks.
|
|
167
181
|
const [disabledSteps, setDisabledSteps] = useState({})
|
|
@@ -169,20 +183,20 @@ export function useFactoryScreen({
|
|
|
169
183
|
const visibleNodes = useMemo(() => (check.ok ? nodesForSteps(doc, openSteps, controls) : []), [check.ok, doc, openSteps, controls])
|
|
170
184
|
|
|
171
185
|
// A screen whose steps changed under it (a field moved to another step, a
|
|
172
|
-
// step removed) must not stay open at a step that is gone.
|
|
186
|
+
// step removed) must not stay open at a step that is gone. Steppers this
|
|
187
|
+
// document does not have are left alone: a screen still loading has none,
|
|
188
|
+
// and dropping them would lose the step a link was opened at.
|
|
173
189
|
useEffect(() => {
|
|
174
190
|
setOpenSteps((open) => {
|
|
175
|
-
|
|
176
|
-
const next = {}
|
|
191
|
+
const next = { ...open }
|
|
177
192
|
stepperNodes.forEach((n) => {
|
|
178
193
|
const count = stepsOf(n).length
|
|
179
194
|
const at = Number.isInteger(open[n.id]) ? open[n.id] : 0
|
|
180
195
|
next[n.id] = Math.min(Math.max(at, 0), Math.max(count - 1, 0))
|
|
181
|
-
if (next[n.id] !== open[n.id]) changed = true
|
|
182
196
|
})
|
|
183
|
-
return
|
|
197
|
+
return next
|
|
184
198
|
})
|
|
185
|
-
}, [stepperNodes])
|
|
199
|
+
}, [stepperNodes, setOpenSteps])
|
|
186
200
|
|
|
187
201
|
const stepControl = useMemo(() => {
|
|
188
202
|
const at = (id) => (Number.isInteger(openSteps[id]) ? openSteps[id] : 0)
|
|
@@ -274,7 +288,7 @@ export function useFactoryScreen({
|
|
|
274
288
|
})
|
|
275
289
|
}
|
|
276
290
|
return control
|
|
277
|
-
}, [stepperNodes, openSteps, disabledSteps, doc, controls])
|
|
291
|
+
}, [stepperNodes, openSteps, setOpenSteps, disabledSteps, doc, controls])
|
|
278
292
|
|
|
279
293
|
live.current.steps = stepControl
|
|
280
294
|
|
|
@@ -481,7 +495,16 @@ export function useFactoryScreen({
|
|
|
481
495
|
defaults: {
|
|
482
496
|
get: () => {
|
|
483
497
|
if (!fetchRecordsRef.current) throw new Error(`No fetchRecords was provided to list "${src}"`)
|
|
484
|
-
|
|
498
|
+
// A SUB-TABLE IS THIS RECORD'S. `belongsTo` names the column on
|
|
499
|
+
// those rows that points back here, and the id is this record's
|
|
500
|
+
// — which is nothing until it has been saved, and the server
|
|
501
|
+
// answers with no rows rather than with everybody's.
|
|
502
|
+
var link = node.props && node.props.belongsTo
|
|
503
|
+
return fetchRecordsRef.current({
|
|
504
|
+
source: src, screen: doc.id, node,
|
|
505
|
+
belongsTo: link || undefined,
|
|
506
|
+
belongsToId: link ? live.current.recordId : undefined
|
|
507
|
+
})
|
|
485
508
|
}
|
|
486
509
|
}
|
|
487
510
|
}))
|
|
@@ -542,12 +565,20 @@ export function useFactoryScreen({
|
|
|
542
565
|
const openRecordRef = useRef(onOpenRecord); openRecordRef.current = onOpenRecord
|
|
543
566
|
const openEditor = useCallback(async (node, rec) => {
|
|
544
567
|
const screenId = node.props.editScreen
|
|
568
|
+
|
|
569
|
+
// WHAT THE CHILD IS CALLED WITH. A sub-table's row belongs to THIS
|
|
570
|
+
// record, so the link column is handed to the screen that adds one — the
|
|
571
|
+
// same `params` a flow's Screen step passes. The person never sees it,
|
|
572
|
+
// and never has to know which material they are adding a line to.
|
|
573
|
+
const link = node.props.belongsTo
|
|
574
|
+
const given = link && live.current.recordId ? { [link]: live.current.recordId } : null
|
|
575
|
+
|
|
545
576
|
if (node.props.openIn === 'page') {
|
|
546
577
|
if (!openRecordRef.current) throw new Error('This list opens records on a page, but no onOpenRecord was provided to go there')
|
|
547
578
|
const id = rec ? rec[live.current.recordKey] : null
|
|
548
|
-
return openRecordRef.current({ screen: screenId, id, record: rec || null, source: listSource(doc, node), node })
|
|
579
|
+
return openRecordRef.current({ screen: screenId, id, record: rec || null, source: listSource(doc, node), node, params: given })
|
|
549
580
|
}
|
|
550
|
-
const base = { node, screenId, record: rec || null, document: null, loading: true, error: null }
|
|
581
|
+
const base = { node, screenId, record: rec || null, document: null, loading: true, error: null, params: given }
|
|
551
582
|
setPopup(base)
|
|
552
583
|
try {
|
|
553
584
|
let found = screensRef.current && screensRef.current[screenId]
|
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])
|
package/src/useFlowRun.js
CHANGED
|
@@ -15,14 +15,17 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
15
15
|
* @param flowKey which flow to run (starting one, or picking up a run)
|
|
16
16
|
* @param runId an existing run to continue — omit to start one
|
|
17
17
|
* @param startRun async (flowKey) → { runId, status, stepKey, screen }
|
|
18
|
-
* @param loadRun async (runId) → { runId, status, stepKey, screen, values }
|
|
18
|
+
* @param loadRun async (runId) → { runId, status, stepKey, screen, values, given }
|
|
19
19
|
* @param submitRun async (runId, values, recordId) → { status, stepKey, screen }
|
|
20
20
|
* @param loadScreen async (key) → the screen document
|
|
21
21
|
* @param onStep (step) → void — told each time the run moves, for the address bar
|
|
22
22
|
* @param onFinish (run) → void — the journey is over
|
|
23
23
|
*/
|
|
24
24
|
export function useFlowRun({ flowKey, runId, startRun, loadRun, submitRun, loadScreen, onStep, onFinish } = {}) {
|
|
25
|
-
|
|
25
|
+
// `given` is what the flow called this screen with — the step's own input,
|
|
26
|
+
// resolved by the engine (see @xeplr/workflow's screen-show). A field bound
|
|
27
|
+
// to {params.x} is filled from it.
|
|
28
|
+
const [state, setState] = useState({ loading: true, error: null, runId: runId || null, status: null, stepKey: null, screen: null, document: null, values: null, given: null })
|
|
26
29
|
const [sending, setSending] = useState(false)
|
|
27
30
|
const live = useRef({})
|
|
28
31
|
live.current = { startRun, loadRun, submitRun, loadScreen, onStep, onFinish, flowKey }
|
|
@@ -40,7 +43,7 @@ export function useFlowRun({ flowKey, runId, startRun, loadRun, submitRun, loadS
|
|
|
40
43
|
setState({
|
|
41
44
|
loading: false, error: null,
|
|
42
45
|
runId: run.runId, status: run.status || 'running', stepKey: run.stepKey, screen: run.screen,
|
|
43
|
-
document: doc, values: run.values || null
|
|
46
|
+
document: doc, values: run.values || null, given: run.given || null
|
|
44
47
|
})
|
|
45
48
|
if (live.current.onStep) live.current.onStep(run)
|
|
46
49
|
}, [])
|
package/src/useScreenSource.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// createElement so the file stays .js.
|
|
22
22
|
|
|
23
23
|
import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
|
|
24
|
-
import { loadScreens } from './screenSource.js'
|
|
24
|
+
import { loadScreens, masterForms } from './screenSource.js'
|
|
25
25
|
import { ScreenEditorSample } from './designs/ScreenEditorSample.jsx'
|
|
26
26
|
|
|
27
27
|
/**
|
|
@@ -29,7 +29,7 @@ import { ScreenEditorSample } from './designs/ScreenEditorSample.jsx'
|
|
|
29
29
|
* @param opts { Builder, labels } — the builder component to draw, normally
|
|
30
30
|
* FactoryBuilder. Passed in rather than imported so this hook does
|
|
31
31
|
* not pull the whole builder into a bundle that never opens it.
|
|
32
|
-
* @returns { screens, screenEditor, reload, loading, error }
|
|
32
|
+
* @returns { screens, masters, screenEditor, reload, loading, error }
|
|
33
33
|
*/
|
|
34
34
|
export function useScreenSource(api, opts) {
|
|
35
35
|
opts = opts || {}
|
|
@@ -74,8 +74,12 @@ export function useScreenSource(api, opts) {
|
|
|
74
74
|
})
|
|
75
75
|
}, [api, Builder, labels, reload])
|
|
76
76
|
|
|
77
|
+
// The lists other forms point at, one entry per FORM — what the admin
|
|
78
|
+
// section shows. Derived here so no application groups screens by hand.
|
|
79
|
+
const masters = useMemo(() => masterForms(screens), [screens])
|
|
80
|
+
|
|
77
81
|
return useMemo(
|
|
78
|
-
() => ({ screens, screenEditor, reload, loading, error }),
|
|
79
|
-
[screens, screenEditor, reload, loading, error]
|
|
82
|
+
() => ({ screens, masters, screenEditor, reload, loading, error }),
|
|
83
|
+
[screens, masters, screenEditor, reload, loading, error]
|
|
80
84
|
)
|
|
81
85
|
}
|
|
@@ -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
|
@@ -18,7 +18,7 @@ import { FIELD_PRESETS, INPUT_TYPES, presetFor } from './presets.js'
|
|
|
18
18
|
const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
19
19
|
const TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_.$-]*$/
|
|
20
20
|
const SCREEN_ID = /^[A-Za-z0-9_-]+$/
|
|
21
|
-
const DOCUMENT_KEYS = ['kind', 'version', 'id', 'name', 'source', 'units', 'aspect', 'width', 'style', 'nodes']
|
|
21
|
+
const DOCUMENT_KEYS = ['kind', 'version', 'id', 'name', 'source', 'units', 'aspect', 'width', 'style', 'description', 'master', 'labelField', 'params', 'nodes']
|
|
22
22
|
const COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i
|
|
23
23
|
|
|
24
24
|
/**
|
|
@@ -41,6 +41,49 @@ export function validateDocument(doc, controls = CONTROLS) {
|
|
|
41
41
|
if (!(typeof doc.aspect === 'number' && doc.aspect > 0)) err('aspect', 'must be a number greater than 0 (1 = page as tall as it is wide)')
|
|
42
42
|
if (!(Number.isInteger(doc.width) && doc.width >= 320 && doc.width <= 1920)) err('width', 'must be a whole number of pixels from 320 to 1920 — the width the screen is designed at')
|
|
43
43
|
if (doc.source !== undefined && !(nonEmptyString(doc.source) && TABLE_NAME.test(doc.source))) err('source', 'must name the table records are saved to, e.g. "employees"')
|
|
44
|
+
|
|
45
|
+
// WHAT THIS FORM IS FOR, in the maker's own words. Three forms called
|
|
46
|
+
// "Inspection" are told apart by this and by who made them — never by their
|
|
47
|
+
// fields, which say what a form holds and not what it was for.
|
|
48
|
+
if (doc.description !== undefined && doc.description !== null && doc.description !== '') {
|
|
49
|
+
if (typeof doc.description !== 'string') err('description', 'must be a sentence about what this form is for')
|
|
50
|
+
else if (doc.description.length > 500) err('description', 'must be under 500 characters — a sentence, not the manual')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A MASTER is a form whose records other forms point at — departments,
|
|
54
|
+
// statuses, countries. It is a FLAG on an ordinary form, not a kind of its
|
|
55
|
+
// own: a master has a table, a list, an edit screen and permissions like
|
|
56
|
+
// anything else, and a separate type would be two things that behave the
|
|
57
|
+
// same until they do not, with every later feature asking which it has.
|
|
58
|
+
if (doc.master !== undefined && typeof doc.master !== 'boolean') {
|
|
59
|
+
err('master', 'must be true or false — whether other forms pick from this one')
|
|
60
|
+
}
|
|
61
|
+
// What a picker SHOWS for a record. Said once here rather than on every
|
|
62
|
+
// dropdown that points at this form.
|
|
63
|
+
if (doc.labelField !== undefined) {
|
|
64
|
+
if (!nonEmptyString(doc.labelField)) err('labelField', 'must name a field of this screen, e.g. "name"')
|
|
65
|
+
else if (!(doc.nodes || []).some((n) => n.props && n.props.name === doc.labelField)) {
|
|
66
|
+
err('labelField', `"${doc.labelField}" is not a field on this screen`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// WHAT THIS SCREEN IS CALLED WITH. Names only — a field binds to one with
|
|
71
|
+
// {params.<name>} as its default, so a name that is not a plain identifier
|
|
72
|
+
// could never be referenced, and a duplicate would make the reference mean
|
|
73
|
+
// two things.
|
|
74
|
+
if (doc.params !== undefined) {
|
|
75
|
+
if (!Array.isArray(doc.params)) err('params', 'must be a list of inputs this screen is called with')
|
|
76
|
+
else {
|
|
77
|
+
const seen = new Set()
|
|
78
|
+
doc.params.forEach((p, i) => {
|
|
79
|
+
const name = p && p.name
|
|
80
|
+
if (!nonEmptyString(name)) err(`params.${i}.name`, 'must be a name, e.g. "complaintId"')
|
|
81
|
+
else if (!/^[A-Za-z_][\w]*$/.test(name)) err(`params.${i}.name`, `"${name}" must be letters, digits and _, starting with a letter`)
|
|
82
|
+
else if (seen.has(name)) err(`params.${i}.name`, `"${name}" is declared twice — a field binding to it would mean two things`)
|
|
83
|
+
else seen.add(name)
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
}
|
|
44
87
|
checkStyle(doc.style, SCREEN_STYLE_KEYS, 'style', err, 'the screen')
|
|
45
88
|
Object.keys(doc).forEach((k) => {
|
|
46
89
|
if (!DOCUMENT_KEYS.includes(k)) err(k, `is not a screen property — allowed: ${DOCUMENT_KEYS.join(', ')}`)
|
|
@@ -92,6 +135,18 @@ export function validateDocument(doc, controls = CONTROLS) {
|
|
|
92
135
|
// Where the field came from (a ready-made field's key), or false: plain on purpose.
|
|
93
136
|
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
137
|
if (props.inputType !== undefined && !INPUT_TYPES.includes(props.inputType)) err(`${at}.props.inputType`, `must be one of: ${INPUT_TYPES.join(', ')}`)
|
|
138
|
+
|
|
139
|
+
// AN IDENTIFIER — the number or code this record is known by. How it is
|
|
140
|
+
// made decides the column, so it is checked here rather than discovered
|
|
141
|
+
// at publish time.
|
|
142
|
+
if (props.idKind !== undefined && props.idKind !== 'code' && props.idKind !== 'auto') {
|
|
143
|
+
err(`${at}.props.idKind`, 'must be "code" (CMP-7K3QF9, assigned on save) or "auto" (a number the database counts)')
|
|
144
|
+
}
|
|
145
|
+
if (props.codePrefix !== undefined && props.codePrefix !== '') {
|
|
146
|
+
if (typeof props.codePrefix !== 'string' || !/^[A-Za-z0-9]{1,8}$/.test(props.codePrefix)) {
|
|
147
|
+
err(`${at}.props.codePrefix`, 'must be up to 8 letters or digits, e.g. "CMP" — it is what the code starts with')
|
|
148
|
+
}
|
|
149
|
+
}
|
|
95
150
|
checkDefault(node, def, at, err)
|
|
96
151
|
checkValidation(node, def, at, err)
|
|
97
152
|
}
|
|
@@ -330,6 +385,17 @@ function checkList(props, doc, at, err) {
|
|
|
330
385
|
if (props.source !== undefined && !(nonEmptyString(props.source) && TABLE_NAME.test(props.source))) err(`${at}.props.source`, 'must name the table to list records from')
|
|
331
386
|
if (props.source === undefined && doc.source === undefined) err(`${at}.props.source`, 'is required when the screen has no "source" — which table should the list read?')
|
|
332
387
|
if (props.editScreen !== undefined && !(nonEmptyString(props.editScreen) && SCREEN_ID.test(props.editScreen))) err(`${at}.props.editScreen`, 'must be the id of the screen that edits a record, e.g. "employee_edit"')
|
|
388
|
+
|
|
389
|
+
// THE LINK BACK, for a sub-table on a form: this material's
|
|
390
|
+
// characteristics. A column name, checked as one — a value that could never
|
|
391
|
+
// be a column is a list that is always empty and nobody knows why.
|
|
392
|
+
if (props.belongsTo !== undefined && props.belongsTo !== '') {
|
|
393
|
+
if (!(nonEmptyString(props.belongsTo) && /^[A-Za-z_][\w]*$/.test(props.belongsTo))) {
|
|
394
|
+
err(`${at}.props.belongsTo`, 'must be a column name, e.g. "materialId"')
|
|
395
|
+
} else if (!props.source) {
|
|
396
|
+
err(`${at}.props.belongsTo`, 'only means something with "Records from" set — a list of this screen\'s own table already belongs to this record')
|
|
397
|
+
}
|
|
398
|
+
}
|
|
333
399
|
if (props.pageSize !== undefined && !(Number.isInteger(props.pageSize) && props.pageSize >= 1 && props.pageSize <= 200)) err(`${at}.props.pageSize`, 'must be a whole number from 1 to 200')
|
|
334
400
|
if (props.actions !== undefined) {
|
|
335
401
|
if (!Array.isArray(props.actions)) err(`${at}.props.actions`, `must be an array of: ${LIST_ACTIONS.join(', ')}`)
|
package/src/values.js
CHANGED
|
@@ -46,12 +46,46 @@ export function formSchema(doc, controls = CONTROLS) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/** Each input's starting value: the caller's, else the control's default. */
|
|
49
|
+
/**
|
|
50
|
+
* A VALUE WITH A TOKEN IS A BINDING; a plain value is a literal.
|
|
51
|
+
*
|
|
52
|
+
* `{params.complaintId}` in a field's default means "whatever this screen was
|
|
53
|
+
* called with" — the same brace this product uses in a workflow step, a
|
|
54
|
+
* condition and a report. Nothing is marked on the field: the brace says it.
|
|
55
|
+
*
|
|
56
|
+
* Unknown, or called with nothing, resolves to undefined — the field is then
|
|
57
|
+
* empty and the person fills it in, which is what the same form does when
|
|
58
|
+
* somebody opens it on its own. A form that will not open is worse than a
|
|
59
|
+
* question nobody expected.
|
|
60
|
+
*
|
|
61
|
+
* Whole-value only. `{params.a}-{params.b}` is not a value a form field takes,
|
|
62
|
+
* and splicing text is a formula's job, not a form's.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveBinding(value, params) {
|
|
65
|
+
if (typeof value !== 'string') return value
|
|
66
|
+
const m = /^\{params\.([A-Za-z_][\w]*)\}$/.exec(value.trim())
|
|
67
|
+
if (!m) return value
|
|
68
|
+
return params ? params[m[1]] : undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Is this what somebody typed, or a reference to an input? */
|
|
72
|
+
export function isBinding(value) {
|
|
73
|
+
return typeof value === 'string' && /^\{params\.[A-Za-z_][\w]*\}$/.test(value.trim())
|
|
74
|
+
}
|
|
75
|
+
|
|
49
76
|
export function initialValues(doc, provided = {}, controls = CONTROLS) {
|
|
50
77
|
const out = {}
|
|
51
78
|
inputNodes(doc, controls).forEach((node) => {
|
|
52
79
|
const name = node.props.name
|
|
53
80
|
if (provided[name] !== undefined) out[name] = provided[name]
|
|
54
|
-
else if (node.props.default !== undefined)
|
|
81
|
+
else if (node.props.default !== undefined) {
|
|
82
|
+
// A default may be a binding to one of the screen's own inputs.
|
|
83
|
+
const resolved = resolveBinding(node.props.default, provided)
|
|
84
|
+
if (resolved !== undefined) out[name] = resolved
|
|
85
|
+
else if (!isBinding(node.props.default)) out[name] = node.props.default
|
|
86
|
+
else if (node.type === 'checkbox') out[name] = false
|
|
87
|
+
else if (node.type === 'multiselect') out[name] = []
|
|
88
|
+
}
|
|
55
89
|
else if (node.type === 'checkbox') out[name] = false
|
|
56
90
|
else if (node.type === 'multiselect') out[name] = []
|
|
57
91
|
})
|
|
@@ -98,6 +132,11 @@ export function fieldError(node, value, loadedOptions) {
|
|
|
98
132
|
const label = p.label || p.name
|
|
99
133
|
const v = p.validation || {}
|
|
100
134
|
|
|
135
|
+
// AN IDENTIFIER IS NOT SOMEBODY'S TO FILL IN. The server assigns a code on
|
|
136
|
+
// first save and the database counts a number, so a rule here would refuse
|
|
137
|
+
// the very save that produces the value.
|
|
138
|
+
if (node.type === 'id') return null
|
|
139
|
+
|
|
101
140
|
if (node.type === 'checkbox') {
|
|
102
141
|
return p.required && value !== true ? `${label} must be ticked` : null
|
|
103
142
|
}
|
|
@@ -234,8 +273,28 @@ export function listSource(doc, node) {
|
|
|
234
273
|
}
|
|
235
274
|
|
|
236
275
|
/** A list's columns: the ones chosen, else the screen's fields in reading order. */
|
|
237
|
-
export function listColumns(doc, node, controls = CONTROLS) {
|
|
276
|
+
export function listColumns(doc, node, controls = CONTROLS, rows) {
|
|
238
277
|
if (node && Array.isArray(node.props?.columns) && node.props.columns.length) return node.props.columns
|
|
278
|
+
|
|
279
|
+
// A LIST OF ANOTHER TABLE HAS NOTHING TO DO WITH THIS SCREEN'S FIELDS.
|
|
280
|
+
//
|
|
281
|
+
// A sub-table — this material's characteristics — holds rows of a different
|
|
282
|
+
// shape entirely, so falling back to this screen's fields shows four empty
|
|
283
|
+
// columns named after the material. The rows themselves are the only
|
|
284
|
+
// description to hand, so they are what it reads.
|
|
285
|
+
const foreign = node && node.props && node.props.source && doc && node.props.source !== doc.source
|
|
286
|
+
if (foreign) {
|
|
287
|
+
const first = (rows || []).find((r) => r && typeof r === 'object')
|
|
288
|
+
if (first) {
|
|
289
|
+
return Object.keys(first)
|
|
290
|
+
// Neither is a column anybody asked to see: one is the row's handle,
|
|
291
|
+
// the other is the record it belongs to, which is the list itself.
|
|
292
|
+
.filter((k) => k !== 'id' && k !== (node.props.belongsTo || ''))
|
|
293
|
+
.map((k) => ({ field: k, label: k }))
|
|
294
|
+
}
|
|
295
|
+
return []
|
|
296
|
+
}
|
|
297
|
+
|
|
239
298
|
return inputNodes(doc, controls).map((n) => ({ field: n.props.name, label: n.props.label || n.props.name }))
|
|
240
299
|
}
|
|
241
300
|
|
|
@@ -299,3 +358,60 @@ export function fromDbValue(node, value) {
|
|
|
299
358
|
}
|
|
300
359
|
return value
|
|
301
360
|
}
|
|
361
|
+
|
|
362
|
+
// ── a code people can read out ──────────────────────────────────────────
|
|
363
|
+
//
|
|
364
|
+
// The record's own id is a 25-character key made on save. It is unique,
|
|
365
|
+
// stable and completely unquotable: nobody reads one over the phone, writes
|
|
366
|
+
// one on a form, or finds a complaint by it. So a field can be marked
|
|
367
|
+
// GENERATED, and gets a short code instead — CMP-7K3QF9.
|
|
368
|
+
//
|
|
369
|
+
// RANDOM, not sequential. A counted number (CMP-0001) needs a counter bumped
|
|
370
|
+
// in the same transaction as the insert, or two people saving at once take
|
|
371
|
+
// the same one; and it still shows gaps whenever a save fails after the
|
|
372
|
+
// number was taken, which somebody then has to explain. A random code has
|
|
373
|
+
// neither problem, at the cost of not telling you the fifth from the five
|
|
374
|
+
// hundredth — which is what the record's date is for.
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The alphabet, minus the characters people mistake for each other.
|
|
378
|
+
*
|
|
379
|
+
* ONE OF EACH PAIR SURVIVES, which is the whole trick: O and I and S are
|
|
380
|
+
* gone, so 0, 1 and 5 are unambiguous and can stay. Dropping both halves
|
|
381
|
+
* would cost a third of the alphabet to solve a problem already solved.
|
|
382
|
+
*
|
|
383
|
+
* The point of this code is that it survives being read aloud, written down
|
|
384
|
+
* and typed back in.
|
|
385
|
+
*/
|
|
386
|
+
var CODE_ALPHABET = '23456789ABCDEFGHJKLMNPQRTUVWXYZ'
|
|
387
|
+
|
|
388
|
+
/** The fields a screen generates a code for. */
|
|
389
|
+
export function codeFields(doc, controls = CONTROLS) {
|
|
390
|
+
return inputNodes(doc, controls).filter((n) => n.type === 'id' && (n.props || {}).idKind !== 'auto')
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The identifiers the DATABASE fills in — a counted number. They are never
|
|
395
|
+
* sent on an insert: the column is an identity, so mentioning it at all would
|
|
396
|
+
* be claiming a number nobody chose.
|
|
397
|
+
*/
|
|
398
|
+
export function countedFields(doc, controls = CONTROLS) {
|
|
399
|
+
return inputNodes(doc, controls).filter((n) => n.type === 'id' && (n.props || {}).idKind === 'auto')
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* One code. `prefix` is what makes it recognisable at a glance — a complaint
|
|
404
|
+
* is CMP-…, a purchase order is PO-… — and is optional.
|
|
405
|
+
*
|
|
406
|
+
* Six characters from 31 is about 900 million codes, which is not a
|
|
407
|
+
* guarantee: whoever writes one is expected to check it is free and try
|
|
408
|
+
* again, which is cheap and has to be done in the database anyway.
|
|
409
|
+
*/
|
|
410
|
+
export function generateCode(prefix, length = 6) {
|
|
411
|
+
let out = ''
|
|
412
|
+
for (let i = 0; i < length; i++) {
|
|
413
|
+
out += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)]
|
|
414
|
+
}
|
|
415
|
+
const head = String(prefix || '').trim().toUpperCase().replace(/[^A-Z0-9]+/g, '')
|
|
416
|
+
return head ? head + '-' + out : out
|
|
417
|
+
}
|