@xeplr/ui-factory 1.8.2 → 1.8.4

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,7 +4,8 @@ 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, resolveBinding } from './values.js'
6
6
  import { hookMethod } from './hooks.js'
7
- import { useUrlSteps } from './useUrlPlaces.js'
7
+ import { useUrlSteps, useUrlListQuery } from './useUrlPlaces.js'
8
+ import { buttonShown, buttonDisabled } from './buttons.js'
8
9
 
9
10
  // A saved screen, running. No JSX — designs/ScreenSample.jsx draws it.
10
11
  //
@@ -58,6 +59,11 @@ import { useUrlSteps } from './useUrlPlaces.js'
58
59
  * @param onDirtyChange (dirty) → void — whether there are unsaved changes, as that changes
59
60
  * @param controls the control registry (default: the built-ins)
60
61
  * @param hooks a FactoryHooks (or an object with some of its methods) — get / save / delete / actions
62
+ * @param fetchRows async ({ screen, node, q, filter }) → { rows, counts } — a list whose rows a function works out (rowsFrom)
63
+ * @param fetchRowDetails async ({ screen, node, column, id }) → [{ text, sub?, at?, open? }] — an activity cell's hover box
64
+ * @param pressButton async ({ screen, button, id, note }) → { message } — a button's press, run on the server
65
+ * @param onButtonDone ({ button, result, record }) → void — after a press: the host reloads what it shows
66
+ * @param reloadKey anything — when it changes, every list reloads (the host heard something changed)
61
67
  * @param urlKey where the open step of each stepper lives in the URL (`<urlKey>Steps`),
62
68
  * so a refresh or a shared link lands on the same step. Omitted → in memory.
63
69
  */
@@ -72,9 +78,13 @@ import { useUrlSteps } from './useUrlPlaces.js'
72
78
  * whose default is `{previous.department_edit.values.name}` is filled from
73
79
  * here — empty when that screen was not on this run's path.
74
80
  */
81
+ /** How long typing in a list's search pauses before it asks. */
82
+ const SEARCH_PAUSE_MS = 250
83
+
75
84
  export function useFactoryScreen({
76
85
  document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl,
77
86
  onOpenRecord, onDone, onDirtyChange, params, urlKey, previous,
87
+ fetchRows, fetchRowDetails, pressButton, onButtonDone, reloadKey,
78
88
  link, onReady,
79
89
  controls = CONTROLS
80
90
  } = {}) {
@@ -591,20 +601,47 @@ export function useFactoryScreen({
591
601
  }, [doc, record]) // eslint-disable-line react-hooks/exhaustive-deps
592
602
 
593
603
  // ── lists ─────────────────────────────────────────────────────────────
594
- const [lists, setLists] = useState({}) // node id → { loading, rows, error }
604
+ const [lists, setLists] = useState({}) // node id → { loading, rows, error, counts }
595
605
  const fetchRecordsRef = useRef(fetchRecords); fetchRecordsRef.current = fetchRecords
606
+ const fetchRowsRef = useRef(fetchRows); fetchRowsRef.current = fetchRows
596
607
  const listNodes = useMemo(() => (check.ok ? doc.nodes.filter((n) => n.type === 'list') : []), [doc, check.ok])
608
+ // WHAT A FUNCTION-FED LIST IS NARROWED TO — the search typed and the chip
609
+ // chosen. In the URL under the host's urlKey, so a refresh lands on the same
610
+ // rows. The search waits for a pause in typing before it asks.
611
+ const [listQ, setListQ] = useUrlListQuery(urlKey, 'q')
612
+ const [listFilter, setListFilter] = useUrlListQuery(urlKey, 'filter')
613
+ const [askedQ, setAskedQ] = useState(listQ)
614
+ useEffect(() => {
615
+ if (askedQ === listQ) return undefined
616
+ const t = setTimeout(() => setAskedQ(listQ), SEARCH_PAUSE_MS)
617
+ return () => clearTimeout(t)
618
+ }, [listQ, askedQ])
619
+ // A list whose text changes with time ("2h ago") asks again every so often.
620
+ const refreshEvery = useMemo(() => Math.min(...listNodes.map((n) => n.props.refreshEvery || Infinity)), [listNodes])
621
+ useEffect(() => {
622
+ if (!Number.isFinite(refreshEvery)) return undefined
623
+ const t = setInterval(() => setListVersion((v) => v + 1), refreshEvery * 1000)
624
+ return () => clearInterval(t)
625
+ }, [refreshEvery])
597
626
  useEffect(() => {
598
627
  if (!listNodes.length) return undefined
599
628
  let cancelled = false
600
629
  listNodes.forEach((node) => {
601
630
  const src = listSource(doc, node)
602
- setLists((l) => ({ ...l, [node.id]: { rows: l[node.id]?.rows || [], loading: true, error: null } }))
631
+ const fed = Boolean(node.props.rowsFrom)
632
+ setLists((l) => ({ ...l, [node.id]: { rows: l[node.id]?.rows || [], counts: l[node.id]?.counts || {}, loading: true, error: null } }))
603
633
  const run = Promise.resolve().then(() => hook('get')({
604
634
  many: true, id: null, screen: doc.id, source: src, node, document: doc,
605
635
  steps: live.current.steps,
636
+ query: fed ? { q: askedQ, filter: listFilter } : undefined,
606
637
  defaults: {
607
638
  get: () => {
639
+ // ROWS A FUNCTION WORKS OUT: the server runs the list's rowsFrom
640
+ // with the search and the chip, and answers { rows, counts }.
641
+ if (fed) {
642
+ if (!fetchRowsRef.current) throw new Error(`No fetchRows was provided — the list's rows come from "${node.props.rowsFrom}"`)
643
+ return fetchRowsRef.current({ screen: doc.id, node: node.id, q: askedQ, filter: listFilter || undefined })
644
+ }
608
645
  if (!fetchRecordsRef.current) throw new Error(`No fetchRecords was provided to list "${src}"`)
609
646
  // A SUB-TABLE IS THIS RECORD'S. `belongsTo` names the column on
610
647
  // those rows that points back here, and the id is this record's
@@ -620,11 +657,26 @@ export function useFactoryScreen({
620
657
  }
621
658
  }))
622
659
  run
623
- .then((rows) => { if (!cancelled) setLists((l) => ({ ...l, [node.id]: { rows: Array.isArray(rows) ? rows : [], loading: false, error: null } })) })
624
- .catch((err) => { if (!cancelled) setLists((l) => ({ ...l, [node.id]: { rows: l[node.id]?.rows || [], loading: false, error: err.message || 'Could not load records' } })) })
660
+ .then((out) => {
661
+ if (cancelled) return
662
+ const rows = Array.isArray(out) ? out : out && Array.isArray(out.rows) ? out.rows : []
663
+ const counts = out && !Array.isArray(out) && out.counts ? out.counts : {}
664
+ setLists((l) => ({ ...l, [node.id]: { rows, counts, loading: false, error: null } }))
665
+ })
666
+ .catch((err) => { if (!cancelled) setLists((l) => ({ ...l, [node.id]: { rows: l[node.id]?.rows || [], counts: l[node.id]?.counts || {}, loading: false, error: err.message || 'Could not load records' } })) })
625
667
  })
626
668
  return () => { cancelled = true }
627
- }, [listNodes, listVersion, doc])
669
+ }, [listNodes, listVersion, doc, askedQ, listFilter, reloadKey]) // eslint-disable-line react-hooks/exhaustive-deps
670
+
671
+ /** What a function-fed list is narrowed to, and how to change it. */
672
+ const listQuery = useMemo(() => ({ q: listQ, filter: listFilter, setQ: setListQ, setFilter: setListFilter }), [listQ, listFilter, setListQ, setListFilter])
673
+
674
+ /** An activity cell's hover box: the column's function, for one row. */
675
+ const fetchRowDetailsRef = useRef(fetchRowDetails); fetchRowDetailsRef.current = fetchRowDetails
676
+ const rowDetails = useCallback((node, column, id) => {
677
+ if (!fetchRowDetailsRef.current) return Promise.reject(new Error('No fetchRowDetails was provided'))
678
+ return Promise.resolve(fetchRowDetailsRef.current({ screen: doc.id, node: node.id, column: column.field, id }))
679
+ }, [doc])
628
680
 
629
681
  const listFor = useCallback((node) => lists[node.id] || { rows: [], loading: false, error: null }, [lists])
630
682
 
@@ -674,7 +726,9 @@ export function useFactoryScreen({
674
726
  * open: the factory has no router, so it asks.
675
727
  */
676
728
  const openRecordRef = useRef(onOpenRecord); openRecordRef.current = onOpenRecord
677
- const openEditor = useCallback(async (node, rec) => {
729
+ // `where`: a page opened at one part of the record — what a hover box's
730
+ // entry points at ({ task: 'tk_3' }), handed to onOpenRecord as params.
731
+ const openEditor = useCallback(async (node, rec, where) => {
678
732
  const screenId = node.props.editScreen
679
733
 
680
734
  // WHAT THE CHILD IS CALLED WITH. A sub-table's row belongs to THIS
@@ -687,7 +741,8 @@ export function useFactoryScreen({
687
741
  if (node.props.openIn === 'page') {
688
742
  if (!openRecordRef.current) throw new Error('This list opens records on a page, but no onOpenRecord was provided to go there')
689
743
  const id = rec ? rec[live.current.recordKey] : null
690
- return openRecordRef.current({ screen: screenId, id, record: rec || null, source: listSource(doc, node), node, params: given })
744
+ const at = where && typeof where === 'object' ? where : null
745
+ return openRecordRef.current({ screen: screenId, id, record: rec || null, source: listSource(doc, node), node, params: given || at ? { ...(given || {}), ...(at || {}) } : given })
691
746
  }
692
747
  const base = { node, screenId, record: rec || null, document: null, loading: true, error: null, params: given }
693
748
  setPopup(base)
@@ -769,6 +824,43 @@ export function useFactoryScreen({
769
824
  return saved
770
825
  }, [])
771
826
 
827
+ // ── buttons ───────────────────────────────────────────────────────────
828
+ // A button acts on the record this screen is about: the one the host
829
+ // handed in, with whatever the form has changed on top. Whether each shows
830
+ // and is greyed out is read from it; the press runs on the server, which
831
+ // checks the same again on the saved record.
832
+ const pressRef = useRef(pressButton); pressRef.current = pressButton
833
+ const buttonDoneRef = useRef(onButtonDone); buttonDoneRef.current = onButtonDone
834
+ const [pressing, setPressing] = useState(null) // button id on the wire
835
+ const [buttonNote, setButtonNote] = useState(null) // { kind: 'done' | 'error', text, button }
836
+ const buttonRecord = useMemo(() => ({ ...(record || {}), ...values, [recordKey]: recordId }), [record, values, recordKey, recordId])
837
+ const buttonsFor = useCallback((node) => ({
838
+ record: buttonRecord,
839
+ buttons: ((node.props && node.props.buttons) || [])
840
+ .filter((b) => buttonShown(b, buttonRecord))
841
+ .map((b) => ({ ...b, disabled: buttonDisabled(b, buttonRecord) || recordId === null || recordId === undefined })),
842
+ pressing,
843
+ note: buttonNote,
844
+ clearNote: () => setButtonNote(null),
845
+ press: async (b, typed) => {
846
+ if (!pressRef.current) { setButtonNote({ kind: 'error', text: 'No pressButton was provided', button: b.id }); return false }
847
+ setPressing(b.id)
848
+ setButtonNote(null)
849
+ try {
850
+ const result = await pressRef.current({ screen: doc.id, button: b.id, id: recordId, note: typed })
851
+ setButtonNote({ kind: 'done', text: (result && result.message) || `${b.label} — done`, button: b.id })
852
+ setListVersion((v) => v + 1)
853
+ if (buttonDoneRef.current) buttonDoneRef.current({ button: b, result, record: buttonRecord })
854
+ return true
855
+ } catch (err) {
856
+ setButtonNote({ kind: 'error', text: err.message || `Could not ${b.label}`, button: b.id })
857
+ return false
858
+ } finally {
859
+ setPressing(null)
860
+ }
861
+ }
862
+ }), [buttonRecord, pressing, buttonNote, doc, recordId])
863
+
772
864
  return {
773
865
  document: doc,
774
866
  documentErrors: linkError ? [...check.errors, linkError] : check.errors,
@@ -805,6 +897,9 @@ export function useFactoryScreen({
805
897
  openRecord,
806
898
  newRecord,
807
899
  listFor,
900
+ listQuery,
901
+ rowDetails,
902
+ buttonsFor,
808
903
  deleteRecord,
809
904
  recordKey,
810
905
  canDelete: Boolean(onDelete) || Boolean(hooks && typeof hooks.delete === 'function'),
@@ -58,3 +58,16 @@ export function useUrlTab(urlKey, first) {
58
58
  const setTab = useCallback((next) => setValue(next === first ? null : next), [setValue, first])
59
59
  return [value || first, setTab]
60
60
  }
61
+
62
+ /**
63
+ * [value, setValue] — a list's search, or the filter chip it is on: what the
64
+ * person narrowed the list to, so a refresh or a shared link shows the same
65
+ * rows. One of each per screen. Typing and switching chips replace the
66
+ * history entry rather than adding one per letter.
67
+ */
68
+ export function useUrlListQuery(urlKey, which) {
69
+ const keys = factoryUrlKeys(urlKey)
70
+ const [value, setValue] = useUrlState(keys ? keys[which] : which, { inUrl: Boolean(keys), replace: true })
71
+ const set = useCallback((next) => setValue(next === undefined || next === null || next === '' ? null : String(next)), [setValue])
72
+ return [value || '', set]
73
+ }
@@ -16,6 +16,7 @@ import { DOCUMENT_KIND, DOCUMENT_VERSION } from './document.js'
16
16
  import { RESERVED_COLUMNS, MAX_IDENTIFIER } from './tableSchema.js'
17
17
  import { FIELD_PRESETS, INPUT_TYPES, presetFor } from './presets.js'
18
18
  import { isBinding } from './values.js'
19
+ import { BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, CONDITION_OPERATORS } from './buttons.js'
19
20
 
20
21
  const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
21
22
  const TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_.$-]*$/
@@ -108,6 +109,8 @@ export function validateDocument(doc, controls = CONTROLS) {
108
109
 
109
110
  const ids = new Map()
110
111
  const names = new Map()
112
+ // A press names its button by id, so an id is unique on the whole screen.
113
+ const buttonIds = new Map()
111
114
 
112
115
  doc.nodes.forEach((node, i) => {
113
116
  const at = `nodes[${i}]`
@@ -175,6 +178,7 @@ export function validateDocument(doc, controls = CONTROLS) {
175
178
  if (node.type === 'stepper') checkSteps(props, at, err)
176
179
  checkStep(node, doc, at, err)
177
180
  if (node.type === 'list') checkList(props, doc, at, err)
181
+ if (node.type === 'buttons') checkButtons(props, at, err, buttonIds)
178
182
  if (node.type === 'subform') checkSubform(props, doc, at, err)
179
183
  if (node.type === 'label') {
180
184
  if (typeof props.text !== 'string') err(`${at}.props.text`, 'must be a string')
@@ -401,7 +405,16 @@ function checkList(props, doc, at, err) {
401
405
  }
402
406
  if (props.title !== undefined && typeof props.title !== 'string') err(`${at}.props.title`, 'must be a string')
403
407
  if (props.source !== undefined && !(nonEmptyString(props.source) && TABLE_NAME.test(props.source))) err(`${at}.props.source`, 'must name the table to list records from')
404
- 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?')
408
+ // ROWS A FUNCTION WORKS OUT need no table — but everything that only a
409
+ // function can answer (a search, chips and their counts, a hover box)
410
+ // needs the function.
411
+ const fed = props.rowsFrom !== undefined
412
+ if (fed && !(nonEmptyString(props.rowsFrom) && FUNCTION_NAME.test(props.rowsFrom))) err(`${at}.props.rowsFrom`, 'must be the name of a registered function (event "listRows"), e.g. "orderList"')
413
+ if (!fed && props.source === undefined && doc.source === undefined) err(`${at}.props.source`, 'is required when the screen has no "source" — which table should the list read? (or "rowsFrom": a function that works the rows out)')
414
+ checkFedList(props, fed, at, err)
415
+ if (props.grow !== undefined && typeof props.grow !== 'boolean') err(`${at}.props.grow`, 'must be true or false')
416
+ // The page grows with it, so nothing can be placed beside or under it.
417
+ if (props.grow && doc.nodes.length !== 1) err(`${at}.props.grow`, 'only for a list that is the screen\'s one control — the page grows with it, so nothing else has a place')
405
418
  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"')
406
419
 
407
420
  // THE LINK BACK, for a sub-table on a form: this material's
@@ -429,12 +442,143 @@ function checkList(props, doc, at, err) {
429
442
  else if (seen.has(c.field)) err(`${p}.field`, `"${c.field}" appears twice`)
430
443
  else seen.add(c.field)
431
444
  if (!nonEmptyString(c.label)) err(`${p}.label`, 'must be a non-empty string — the column heading')
432
- const extra = Object.keys(c).filter((k) => k !== 'field' && k !== 'label')
433
- if (extra.length) err(p, `only "field" and "label" are used — remove: ${extra.join(', ')}`)
445
+ const allowed = fed ? FED_COLUMN_KEYS : ['field', 'label']
446
+ const extra = Object.keys(c).filter((k) => !allowed.includes(k))
447
+ if (extra.length) err(p, fed ? `has keys a column does not use — remove: ${extra.join(', ')}` : `only "field" and "label" are used — remove: ${extra.join(', ')} (the rest need "rowsFrom")`)
448
+ if (fed) checkFedColumn(c, p, err)
434
449
  })
435
450
  }
436
451
  }
437
452
 
453
+ // What a column of a function-fed list may say: the value it sorts by
454
+ // (`field`), the text shown for it (`text`, else the value), a second line,
455
+ // how it is drawn, badges after it, how many more pictures, and the function
456
+ // its hover box lists from.
457
+ const FED_COLUMN_KEYS = ['field', 'label', 'text', 'sub', 'display', 'badges', 'more', 'details', 'sortable']
458
+
459
+ /** The settings only a function-fed list has. */
460
+ function checkFedList(props, fed, at, err) {
461
+ const need = (key, what) => { if (props[key] !== undefined && !fed) err(`${at}.props.${key}`, `${what} needs "rowsFrom" — a table's records have no function to ask`) }
462
+ need('search', 'a search box')
463
+ need('filters', 'filter chips')
464
+ need('refreshEvery', 'refreshing')
465
+ if (props.search !== undefined) {
466
+ const sp = props.search
467
+ if (!sp || typeof sp !== 'object' || Array.isArray(sp)) err(`${at}.props.search`, 'must be { placeholder } — the hint in the search box')
468
+ else if (sp.placeholder !== undefined && typeof sp.placeholder !== 'string') err(`${at}.props.search.placeholder`, 'must be a string')
469
+ }
470
+ if (props.filters !== undefined) {
471
+ if (!Array.isArray(props.filters)) err(`${at}.props.filters`, 'must be an array of { key, label }')
472
+ else {
473
+ const keys = new Set()
474
+ props.filters.forEach((f, j) => {
475
+ const p = `${at}.props.filters[${j}]`
476
+ if (!f || typeof f !== 'object') { err(p, 'must be { key, label }'); return }
477
+ if (!(nonEmptyString(f.key) && /^[A-Za-z0-9_-]+$/.test(f.key))) err(`${p}.key`, 'must be letters, digits, _ or - — what the function is asked for, e.g. "packed"')
478
+ else if (keys.has(f.key)) err(`${p}.key`, `"${f.key}" appears twice`)
479
+ else keys.add(f.key)
480
+ if (!nonEmptyString(f.label)) err(`${p}.label`, 'must be the chip\'s text, e.g. "Packed"')
481
+ const extra = Object.keys(f).filter((k) => k !== 'key' && k !== 'label')
482
+ if (extra.length) err(p, `only "key" and "label" are used — remove: ${extra.join(', ')}`)
483
+ })
484
+ }
485
+ }
486
+ if (props.refreshEvery !== undefined && !(Number.isInteger(props.refreshEvery) && props.refreshEvery >= 10 && props.refreshEvery <= 3600)) {
487
+ err(`${at}.props.refreshEvery`, 'must be a whole number of seconds from 10 to 3600')
488
+ }
489
+ if (props.rowOpen !== undefined && typeof props.rowOpen !== 'boolean') err(`${at}.props.rowOpen`, 'must be true or false')
490
+ if (props.rowOpen && !props.editScreen) err(`${at}.props.rowOpen`, 'needs "Edit in" (editScreen) — the screen a row opens in')
491
+ // A worked-out row is not a record of one table: nothing to delete it from,
492
+ // and Edit or New only with a screen to do it in.
493
+ if (fed && Array.isArray(props.actions)) {
494
+ if (props.actions.includes('delete')) err(`${at}.props.actions`, 'cannot include "delete" — rows from a function are not one table\'s records')
495
+ if ((props.actions.includes('edit') || props.actions.includes('new')) && !props.editScreen) err(`${at}.props.actions`, 'Edit and New need "Edit in" (editScreen) — the screen they open')
496
+ }
497
+ if (props.highlight !== undefined && !(nonEmptyString(props.highlight) && FIELD_NAME.test(props.highlight))) err(`${at}.props.highlight`, 'must be a field of the row, e.g. "isNew"')
498
+ }
499
+
500
+ function checkFedColumn(c, p, err) {
501
+ ;['text', 'sub', 'more'].forEach((k) => {
502
+ if (c[k] !== undefined && !(nonEmptyString(c[k]) && FIELD_NAME.test(c[k]))) err(`${p}.${k}`, 'must be a field of the row — letters, digits and _')
503
+ })
504
+ if (c.display !== undefined && !COLUMN_DISPLAYS.includes(c.display)) err(`${p}.display`, `must be one of: ${COLUMN_DISPLAYS.join(', ')}`)
505
+ if (c.more !== undefined && c.display !== 'images') err(`${p}.more`, 'only means something on an "images" column')
506
+ if (c.details !== undefined) {
507
+ if (!(nonEmptyString(c.details) && FUNCTION_NAME.test(c.details))) err(`${p}.details`, 'must be the name of a registered function (event "rowDetails")')
508
+ else if (c.display !== 'activity') err(`${p}.details`, 'is what an "activity" column\'s hover box lists — set display to "activity"')
509
+ }
510
+ if (c.sortable !== undefined && typeof c.sortable !== 'boolean') err(`${p}.sortable`, 'must be true or false')
511
+ if (c.badges !== undefined) {
512
+ if (!Array.isArray(c.badges)) { err(`${p}.badges`, 'must be an array of { field, label, tone? }'); return }
513
+ c.badges.forEach((b, k) => {
514
+ const bp = `${p}.badges[${k}]`
515
+ if (!b || typeof b !== 'object') { err(bp, 'must be { field, label, tone? }'); return }
516
+ if (!(nonEmptyString(b.field) && FIELD_NAME.test(b.field))) err(`${bp}.field`, 'must be a field of the row — the badge shows where it is true')
517
+ if (!nonEmptyString(b.label)) err(`${bp}.label`, 'must be the badge\'s text, e.g. "New"')
518
+ if (b.tone !== undefined && !BADGE_TONES.includes(b.tone)) err(`${bp}.tone`, `must be one of: ${BADGE_TONES.join(', ')}`)
519
+ const extra = Object.keys(b).filter((x) => !['field', 'label', 'tone'].includes(x))
520
+ if (extra.length) err(bp, `only "field", "label" and "tone" are used — remove: ${extra.join(', ')}`)
521
+ })
522
+ }
523
+ }
524
+
525
+ const BUTTON_KEYS = ['id', 'label', 'action', 'params', 'ask', 'when', 'disabledWhen', 'variant']
526
+ const ASK_KEYS = ['title', 'message', 'note']
527
+
528
+ /**
529
+ * A panel of buttons. Each names a function and the values it is given; the
530
+ * server checks the names against what is registered when the screen is
531
+ * published (functionProblems). Here: the shape.
532
+ */
533
+ function checkButtons(props, at, err, ids) {
534
+ if (props.title !== undefined && typeof props.title !== 'string') err(`${at}.props.title`, 'must be a string')
535
+ if (!Array.isArray(props.buttons)) { err(`${at}.props.buttons`, 'must be an array of buttons — { id, label, action, params? }'); return }
536
+ props.buttons.forEach((b, j) => {
537
+ const p = `${at}.props.buttons[${j}]`
538
+ if (!b || typeof b !== 'object' || Array.isArray(b)) { err(p, 'must be { id, label, action, params? }'); return }
539
+ const extra = Object.keys(b).filter((k) => !BUTTON_KEYS.includes(k))
540
+ if (extra.length) err(p, `has keys a button does not use — remove: ${extra.join(', ')}`)
541
+ if (!(nonEmptyString(b.id) && /^[A-Za-z0-9_-]{1,60}$/.test(b.id))) err(`${p}.id`, 'must be letters, digits, _ or - — what a press names this button by, e.g. "packed"')
542
+ else if (ids.has(b.id)) err(`${p}.id`, `"${b.id}" is already the id of ${ids.get(b.id)} — every button on a screen needs its own`)
543
+ else ids.set(b.id, p)
544
+ if (!nonEmptyString(b.label)) err(`${p}.label`, 'must be the button\'s text, e.g. "Packed"')
545
+ if (!(nonEmptyString(b.action) && FUNCTION_NAME.test(b.action))) err(`${p}.action`, 'must be the name of a registered function (event "button"), e.g. "setOrderStatus"')
546
+ if (b.params !== undefined) {
547
+ if (!b.params || typeof b.params !== 'object' || Array.isArray(b.params)) err(`${p}.params`, 'must be { name: value } — what the function is given')
548
+ else Object.keys(b.params).forEach((k) => {
549
+ const v = b.params[k]
550
+ if (!(v === null || ['string', 'number', 'boolean'].includes(typeof v))) err(`${p}.params.${k}`, 'must be a plain value — text, a number, true or false')
551
+ })
552
+ }
553
+ if (b.ask !== undefined) {
554
+ if (!b.ask || typeof b.ask !== 'object' || Array.isArray(b.ask)) err(`${p}.ask`, 'must be { title?, message?, note? } — what the person is asked before it runs')
555
+ else {
556
+ const bad = Object.keys(b.ask).filter((k) => !ASK_KEYS.includes(k))
557
+ if (bad.length) err(`${p}.ask`, `only ${ASK_KEYS.map((k) => `"${k}"`).join(', ')} are used — remove: ${bad.join(', ')}`)
558
+ ASK_KEYS.forEach((k) => { if (b.ask[k] !== undefined && typeof b.ask[k] !== 'string') err(`${p}.ask.${k}`, 'must be a string') })
559
+ }
560
+ }
561
+ if (b.variant !== undefined && !BUTTON_VARIANTS.includes(b.variant)) err(`${p}.variant`, `must be one of: ${BUTTON_VARIANTS.join(', ')}`)
562
+ ;['when', 'disabledWhen'].forEach((k) => { if (b[k] !== undefined) checkRules(b[k], `${p}.${k}`, err) })
563
+ })
564
+ }
565
+
566
+ /** Conditions on the record: @xeplr/rules rules, every one of which must match. */
567
+ function checkRules(rules, at, err) {
568
+ if (!Array.isArray(rules)) { err(at, 'must be an array of { field, op, value } — every one must match'); return }
569
+ rules.forEach((r, k) => {
570
+ const p = `${at}[${k}]`
571
+ if (!r || typeof r !== 'object') { err(p, 'must be { field, op, value }'); return }
572
+ if (!(nonEmptyString(r.field) && FIELD_NAME.test(r.field))) err(`${p}.field`, 'must be a field of the record, e.g. "customStatus"')
573
+ const op = CONDITION_OPERATORS.find((o) => o.key === r.op)
574
+ if (!op) { err(`${p}.op`, `must be one of: ${CONDITION_OPERATORS.map((o) => o.key).join(', ')}`); return }
575
+ if (!op.noValue && (r.value === undefined || r.value === null || r.value === '')) err(`${p}.value`, 'needs a value to compare with')
576
+ if (op.second && (r.value2 === undefined || r.value2 === null || r.value2 === '')) err(`${p}.value2`, 'needs the other end of the range')
577
+ const extra = Object.keys(r).filter((x) => !['field', 'op', 'value', 'value2'].includes(x))
578
+ if (extra.length) err(p, `only "field", "op", "value" and "value2" are used — remove: ${extra.join(', ')}`)
579
+ })
580
+ }
581
+
438
582
  /**
439
583
  * A form inside this one. Both halves are required: a form with no link saves
440
584
  * a record belonging to nothing, and a link with no form is nothing. Itself is