@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.
@@ -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 ? { ...initialValues(doc, {}, controls), ...recordValues(doc, rec, controls) } : initialValues(doc, {}, controls)),
102
- [doc, controls]
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] = useState({})
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
- let changed = false
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 changed || Object.keys(next).length !== Object.keys(open).length ? next : open
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
- return fetchRecordsRef.current({ source: src, screen: doc.id, node })
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]
@@ -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])
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
- const [state, setState] = useState({ loading: true, error: null, runId: runId || null, status: null, stepKey: null, screen: null, document: null, values: null })
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
  }, [])
@@ -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
+ }
@@ -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) out[name] = node.props.default
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
+ }