@xeplr/ui-factory 1.8.3 → 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.
- package/AUTHORING.md +21 -1
- package/README.md +169 -6
- package/bin/xeplr-factory.js +27 -1
- package/package.json +3 -1
- package/src/buttons.js +142 -0
- package/src/controls.js +48 -1
- package/src/designs/BuilderSample.jsx +1 -0
- package/src/designs/ButtonsView.jsx +107 -0
- package/src/designs/ControlView.jsx +4 -1
- package/src/designs/FedList.jsx +248 -0
- package/src/designs/ListView.jsx +6 -1
- package/src/designs/PropertyPanel.jsx +336 -4
- package/src/designs/ScreenSample.jsx +12 -1
- package/src/designs/factory.css +139 -0
- package/src/events.js +21 -0
- package/src/model.js +8 -1
- package/src/pagesInventory.js +91 -0
- package/src/remote.js +17 -2
- package/src/urlState.js +2 -2
- package/src/useFactoryBuilder.js +9 -1
- package/src/useFactoryScreen.js +103 -8
- package/src/useUrlPlaces.js +13 -0
- package/src/validateDocument.js +147 -3
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// WHICH PAGES WORK EVERYWHERE, AND WHICH ARE WEB ONLY.
|
|
2
|
+
//
|
|
3
|
+
// A page drawn from screen documents (a form's list, a flow's screens, a list
|
|
4
|
+
// fed by a function, a panel of buttons) can be drawn by anything that reads
|
|
5
|
+
// a document — a phone app included. A page written by hand in React cannot:
|
|
6
|
+
// it is the browser's alone.
|
|
7
|
+
//
|
|
8
|
+
// Writing one is allowed. Doing it without anyone knowing is what this file
|
|
9
|
+
// is for. Each app keeps an inventory, ui/pages.json:
|
|
10
|
+
//
|
|
11
|
+
// {
|
|
12
|
+
// "routes": ["src/App.jsx"],
|
|
13
|
+
// "documents": [{ "path": "/orders", "menu": "Orders", "screen": "orders_board" }],
|
|
14
|
+
// "webOnly": [{ "path": "/home", "menu": "Home", "reason": "The dashboard's cards are hand-written" }]
|
|
15
|
+
// }
|
|
16
|
+
//
|
|
17
|
+
// and `xeplr-factory pages` WARNS — it never refuses — about every web-only
|
|
18
|
+
// page, and about every route the inventory does not mention. The menu key
|
|
19
|
+
// lets a settings screen tag that menu item "Web only".
|
|
20
|
+
//
|
|
21
|
+
// Pure: no React, no file system. The CLI reads the files.
|
|
22
|
+
|
|
23
|
+
/** Whatever is wrong with the inventory itself, as [{ path, message }]. */
|
|
24
|
+
export function checkInventory(inv) {
|
|
25
|
+
const errors = []
|
|
26
|
+
const err = (path, message) => errors.push({ path, message })
|
|
27
|
+
if (!inv || typeof inv !== 'object' || Array.isArray(inv)) { err('', 'must be an object: { routes, documents, webOnly }'); return errors }
|
|
28
|
+
if (inv.routes !== undefined && !(Array.isArray(inv.routes) && inv.routes.every((r) => typeof r === 'string' && r))) err('routes', 'must be the files that declare the routes, e.g. ["src/App.jsx"]')
|
|
29
|
+
;['documents', 'webOnly'].forEach((list) => {
|
|
30
|
+
if (inv[list] === undefined) return
|
|
31
|
+
if (!Array.isArray(inv[list])) { err(list, 'must be an array of { path, … }'); return }
|
|
32
|
+
inv[list].forEach((p, i) => {
|
|
33
|
+
const at = `${list}[${i}]`
|
|
34
|
+
if (!p || typeof p !== 'object') { err(at, 'must be { path, … }'); return }
|
|
35
|
+
if (typeof p.path !== 'string' || !p.path.startsWith('/')) err(`${at}.path`, 'must be a route path, e.g. "/orders/:orderId"')
|
|
36
|
+
if (p.menu !== undefined && (typeof p.menu !== 'string' || !p.menu)) err(`${at}.menu`, 'must be the menu key it is under, e.g. "Orders"')
|
|
37
|
+
if (list === 'webOnly' && !(typeof p.reason === 'string' && p.reason.trim())) err(`${at}.reason`, 'is required — say why it is hand-written, so the next person knows what it would take to make it a document')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
const seen = new Map()
|
|
41
|
+
;[...(inv.documents || []), ...(inv.webOnly || [])].forEach((p) => {
|
|
42
|
+
if (!p || typeof p.path !== 'string') return
|
|
43
|
+
if (seen.has(p.path)) err('', `"${p.path}" is listed twice`)
|
|
44
|
+
seen.set(p.path, true)
|
|
45
|
+
})
|
|
46
|
+
return errors
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The route paths a routes file declares: every <Route path="…">, except
|
|
51
|
+
* redirects (an element that is a <Navigate>) and the catch-all "*".
|
|
52
|
+
*/
|
|
53
|
+
export function routesIn(source) {
|
|
54
|
+
const out = []
|
|
55
|
+
const re = /<Route\b[^>]*?\bpath=(?:"([^"]+)"|'([^']+)'|\{\s*["'`]([^"'`]+)["'`]\s*\})[^>]*>/g
|
|
56
|
+
let m
|
|
57
|
+
while ((m = re.exec(String(source || '')))) {
|
|
58
|
+
const path = m[1] || m[2] || m[3]
|
|
59
|
+
if (!path || path === '*') continue
|
|
60
|
+
if (/element=\{\s*<Navigate\b/.test(m[0])) continue
|
|
61
|
+
out.push(path)
|
|
62
|
+
}
|
|
63
|
+
return [...new Set(out)]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The warnings for an app: each web-only page, and each route nobody
|
|
68
|
+
* listed. [{ kind: 'webOnly' | 'unlisted' | 'gone' | 'inventory', path, message }]
|
|
69
|
+
*/
|
|
70
|
+
export function pageWarnings(inv, routes) {
|
|
71
|
+
const warnings = checkInventory(inv).map((e) => ({ kind: 'inventory', path: e.path, message: `pages.json ${e.path ? e.path + ' ' : ''}${e.message}` }))
|
|
72
|
+
const docs = new Set(((inv && inv.documents) || []).map((p) => p && p.path))
|
|
73
|
+
const web = ((inv && inv.webOnly) || []).filter((p) => p && p.path)
|
|
74
|
+
web.forEach((p) => warnings.push({ kind: 'webOnly', path: p.path, message: `${p.path} is web only — it will not be in a phone app. ${p.reason || ''}`.trim() }))
|
|
75
|
+
const listed = new Set([...docs, ...web.map((p) => p.path)])
|
|
76
|
+
;(routes || []).forEach((r) => {
|
|
77
|
+
if (!listed.has(r)) warnings.push({ kind: 'unlisted', path: r, message: `${r} is not in pages.json — say whether it is drawn from documents ("documents") or hand-written ("webOnly", with the reason)` })
|
|
78
|
+
})
|
|
79
|
+
// A listed page the routes no longer have: the inventory is out of date.
|
|
80
|
+
if (routes) listed.forEach((p) => { if (!routes.includes(p)) warnings.push({ kind: 'gone', path: p, message: `${p} is in pages.json but no route declares it any more — remove it` }) })
|
|
81
|
+
return warnings
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** { menu key → why } for the menu items whose page is web only — a settings screen tags them. */
|
|
85
|
+
export function webOnlyMenus(inv) {
|
|
86
|
+
const out = {}
|
|
87
|
+
;((inv && inv.webOnly) || []).forEach((p) => {
|
|
88
|
+
if (p && p.menu && !out[p.menu]) out[p.menu] = p.reason || 'Hand-written for the browser'
|
|
89
|
+
})
|
|
90
|
+
return out
|
|
91
|
+
}
|
package/src/remote.js
CHANGED
|
@@ -83,6 +83,10 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
83
83
|
}
|
|
84
84
|
const one = (rows) => (Array.isArray(rows) ? rows[0] : rows)
|
|
85
85
|
const enc = encodeURIComponent
|
|
86
|
+
const query = (params) => {
|
|
87
|
+
const pairs = Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
88
|
+
return pairs.length ? '?' + pairs.map(([k, v]) => `${enc(k)}=${enc(v)}`).join('&') : ''
|
|
89
|
+
}
|
|
86
90
|
|
|
87
91
|
const api = {
|
|
88
92
|
// screens
|
|
@@ -123,6 +127,16 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
123
127
|
onDelete: ({ id, screen }) => call('POST', `/records/${enc(screen)}/delete`, { id }),
|
|
124
128
|
fetchOptions: ({ table }) => call('GET', `/options/${enc(table)}`),
|
|
125
129
|
|
|
130
|
+
// ROWS A FUNCTION WORKS OUT, for a list with rowsFrom: { rows, counts }.
|
|
131
|
+
// Only what the person typed goes with it — the function is the screen's.
|
|
132
|
+
fetchRows: async ({ screen, node, q, filter }) => one(await call('GET', `/rows/${enc(screen)}${query({ node, q, filter })}`)),
|
|
133
|
+
// An activity cell's hover box for one row.
|
|
134
|
+
fetchRowDetails: ({ screen, node, column, id }) => call('GET', `/rows/${enc(screen)}/details${query({ node, column, id })}`),
|
|
135
|
+
// A button's press on one record → { button, message, output }. The
|
|
136
|
+
// server reads the button — its function and parameters — from the
|
|
137
|
+
// published screen; the request names it and carries the note.
|
|
138
|
+
pressButton: async ({ screen, button, id, note }) => one(await call('POST', `/buttons/${enc(screen)}/${enc(button)}`, note ? { id, note } : { id })),
|
|
139
|
+
|
|
126
140
|
// A file field's upload. The answer's `path` is what the record stores;
|
|
127
141
|
// the file itself is read back through the same route, with auth.
|
|
128
142
|
uploadFile: async (file, { screen, field }) => {
|
|
@@ -132,11 +146,12 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
132
146
|
},
|
|
133
147
|
fileUrl: (path) => `${base}/factory/files/${String(path).split('/').map(enc).join('/')}`
|
|
134
148
|
}
|
|
135
|
-
api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions, uploadFile: api.uploadFile, fileUrl: api.fileUrl
|
|
149
|
+
api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions, uploadFile: api.uploadFile, fileUrl: api.fileUrl,
|
|
150
|
+
fetchRows: api.fetchRows, fetchRowDetails: api.fetchRowDetails, pressButton: api.pressButton }
|
|
136
151
|
// makeChildTable: a list on a form can make the table it is about to point
|
|
137
152
|
// at — rows belonging to this record — without leaving the designer. A
|
|
138
153
|
// server act, which is why the builder is given it rather than doing it.
|
|
139
|
-
api.builderProps = { onSave: api.saveDraft, onPublish: api.publish, listTables: api.listTables, fetchOptions: api.fetchOptions, fetchRecords: api.fetchRecords, makeChildTable: function (spec) { return createChildTable(api, spec) },
|
|
154
|
+
api.builderProps = { onSave: api.saveDraft, onPublish: api.publish, listTables: api.listTables, listFunctions: api.listFunctions, fetchOptions: api.fetchOptions, fetchRecords: api.fetchRecords, makeChildTable: function (spec) { return createChildTable(api, spec) },
|
|
140
155
|
// The builder designs a child table in place, which needs the same calls
|
|
141
156
|
// this api already makes — handed over rather than rebuilt.
|
|
142
157
|
screenApi: api }
|
package/src/urlState.js
CHANGED
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* The query keys under a host's `urlKey`, or null (in memory).
|
|
15
|
-
* factoryUrlKeys('form') → { steps: 'formSteps', selected: 'formSelected', tab: 'formTab' }
|
|
15
|
+
* factoryUrlKeys('form') → { steps: 'formSteps', selected: 'formSelected', tab: 'formTab', q: 'formQ', filter: 'formFilter' }
|
|
16
16
|
*/
|
|
17
17
|
export function factoryUrlKeys(urlKey) {
|
|
18
18
|
if (!urlKey) return null
|
|
19
|
-
return { steps: `${urlKey}Steps`, selected: `${urlKey}Selected`, tab: `${urlKey}Tab` }
|
|
19
|
+
return { steps: `${urlKey}Steps`, selected: `${urlKey}Selected`, tab: `${urlKey}Tab`, q: `${urlKey}Q`, filter: `${urlKey}Filter` }
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
package/src/useFactoryBuilder.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { useFunctionCatalog } from './useFunctionCatalog.js'
|
|
2
3
|
import { CONTROLS, controlGroups } from './controls.js'
|
|
3
4
|
import {
|
|
4
5
|
createScreen, renameScreen, setScreenProperty, addControl as addControlTo, moveNode, setNodeProperty, setNodeStep,
|
|
@@ -28,6 +29,9 @@ import { selectionPlace } from './urlState.js'
|
|
|
28
29
|
* @param onSave async (document) → void; called automatically, only with a valid document
|
|
29
30
|
* @param autosaveDelay ms of quiet after the last edit before saving (default 800)
|
|
30
31
|
* @param onChange (document) → void; every edit, for hosts that autosave or preview
|
|
32
|
+
* @param listFunctions async () → [{ name, event, description, inputSchema }] — the app's
|
|
33
|
+
* registered functions, offered to a list's "Rows from", a column's
|
|
34
|
+
* hover box and each button (with the parameters it takes)
|
|
31
35
|
* @param listTables async () → [{ id, name }] | string[]; offered in a dropdown's
|
|
32
36
|
* "from a table" picker. Omitted → table sources can still be typed.
|
|
33
37
|
* @param screens [{ id, name, document? }] — other screens, offered for a list's "Edit in";
|
|
@@ -57,7 +61,7 @@ function linkNameFor(doc) {
|
|
|
57
61
|
return singular.replace(/_([a-z])/g, function (_, c) { return c.toUpperCase() }) + 'Id'
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
export function useFactoryBuilder({ document: given, name, onSave, onChange, onPublish, listTables, makeChildTable, screens, lockedNames, controls = CONTROLS, autosaveDelay = 800, urlKey } = {}) {
|
|
64
|
+
export function useFactoryBuilder({ document: given, name, onSave, onChange, onPublish, listTables, listFunctions, makeChildTable, screens, lockedNames, controls = CONTROLS, autosaveDelay = 800, urlKey } = {}) {
|
|
61
65
|
const [doc, setDoc] = useState(() => given || createScreen({ name }))
|
|
62
66
|
|
|
63
67
|
// ── selection ─────────────────────────────────────────────────────────
|
|
@@ -139,6 +143,9 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
139
143
|
return () => { cancelled = true }
|
|
140
144
|
}, [hasListTables, tableRound])
|
|
141
145
|
|
|
146
|
+
// ── the app's functions, for what a list's rows and a button run ──────
|
|
147
|
+
const functions = useFunctionCatalog({ listFunctions })
|
|
148
|
+
|
|
142
149
|
// ── edits ─────────────────────────────────────────────────────────────
|
|
143
150
|
const rename = useCallback((value) => update((d) => renameScreen(d, value)), [update])
|
|
144
151
|
/** A screen property by path — `source`, `width`, `style.fontSize`. */
|
|
@@ -485,6 +492,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
485
492
|
removeSelected,
|
|
486
493
|
duplicateSelected,
|
|
487
494
|
tables,
|
|
495
|
+
functions,
|
|
488
496
|
screens: screens || [],
|
|
489
497
|
lockedNames: lockedNames || [],
|
|
490
498
|
validation,
|
package/src/useFactoryScreen.js
CHANGED
|
@@ -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
|
-
|
|
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((
|
|
624
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'),
|
package/src/useUrlPlaces.js
CHANGED
|
@@ -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
|
+
}
|
package/src/validateDocument.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
433
|
-
|
|
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
|