@xeplr/ui-factory 1.8.3 → 1.8.5

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/src/model.js CHANGED
@@ -13,7 +13,14 @@ export {
13
13
  } from './document.js'
14
14
  export { validateDocument, assertValidDocument } from './validateDocument.js'
15
15
  // A screen's events: functions picked by name, run on the server.
16
- export { SCREEN_EVENTS, SCREEN_EVENT_KEYS, DECIDE_EVENT, FUNCTION_NAME, screenFunctions, missingFunctions, setEvent, functionsFor } from './events.js'
16
+ export { SCREEN_EVENTS, SCREEN_EVENT_KEYS, DECIDE_EVENT, FUNCTION_NAME, screenFunctions, missingFunctions, functionProblems, setEvent, functionsFor } from './events.js'
17
+ // Buttons and lists whose rows a function works out — names and values only.
18
+ export {
19
+ BUTTON_EVENT, LIST_ROWS_EVENT, ROW_DETAILS_EVENT, BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, NOTE_INPUT, CONDITION_OPERATORS,
20
+ conditionMet, buttonShown, buttonDisabled, buttonPressed, screenButtons, buttonById, buttonInput, nodeFunctions, paramProblems, optionValues
21
+ } from './buttons.js'
22
+ // Which pages are drawn from documents and which are web only (ui/pages.json).
23
+ export { checkInventory, routesIn, pageWarnings, webOnlyMenus } from './pagesInventory.js'
17
24
  export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, chooseable, saveState, recordValues, displayValue, fileLabel, toDbValue, fromDbValue, listColumns, listSource } from './values.js'
18
25
  export { screenFromSpec, screensFromSpec, entityNames, SPEC_HEIGHTS, ROW_GAP } from './generate.js'
19
26
  export { scaffoldEntity } from './scaffold.js'
@@ -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
  /**
@@ -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,
@@ -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, buttonPressed } 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, pressed: buttonPressed(b, buttonRecord), 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', 'pressedWhen', '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', 'pressedWhen'].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