@xeplr/ui-factory 1.8.6 → 1.8.7

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 CHANGED
@@ -241,6 +241,8 @@ A button is **a registered function and its parameters** — never code. Several
241
241
  ] } }
242
242
  ```
243
243
 
244
+ A button that needs values typed first (a parcel's size before Packed) gets `ask.fields` — `[{ name, label, type: "number" | "text", unit, required, min, max, fill }]` — and its function takes each `name`. A list with KPI cards over it gets `kpis: [{ key, label, tone? }]` and `dateRange: { label, default: "last30" }`; its `listRows` function returns `kpis: { key: count }` and reads `ctx.query.kpi`, `from`, `to`.
245
+
244
246
  Register the function with `event: 'button'` and an `inputSchema` (with `options` for the values allowed, a `note` input if a button asks for one, and a `files` input if its `ask.file` takes photos or documents — `{ "accept": ".png,.jpg,.jpeg", "multiple": true, "source": "any" }`, the same rules as a file field). `when` / `disabledWhen` are `@xeplr/rules` rules on the record. Publishing refuses parameters the function does not take. See the README's "Buttons".
245
247
 
246
248
  ## When the document cannot say it
package/README.md CHANGED
@@ -434,7 +434,7 @@ actions.register({
434
434
  | `label` | the button's text |
435
435
  | `action` | the registered function (event `button`) |
436
436
  | `params` | the values it is given — plain values, checked against its `inputSchema` (`options` = the values allowed) at publish |
437
- | `ask` | asked first: `title`, `message`, and `note` — the label of a note box, whose text reaches the function as `note` (it must take one) |
437
+ | `ask` | asked first: `title`, `message`, `note` — the label of a note box, whose text reaches the function as `note` (it must take one) — `fields` (values to type, below) and `file` (photos or documents, below) |
438
438
  | `when` | shown only when every rule matches the record |
439
439
  | `disabledWhen` | greyed out when every rule matches |
440
440
  | `pressedWhen` | drawn as the **current choice** (filled, `aria-pressed`) when every rule matches — the status an order is at. Give it the same rules as `disabledWhen` when choosing it again would change nothing |
@@ -447,6 +447,30 @@ Rules are [`@xeplr/rules`](https://www.npmjs.com/package/@xeplr/rules) rules —
447
447
  - **What happens next is the host's.** The panel shows the function's `message`; `onButtonDone({ button, result, record })` lets the page reload what it shows. Lists on the same screen reload by themselves.
448
448
  - **In the builder**, picking a function lists the parameters it takes — a dropdown where it names the values allowed — so nobody types a status by hand. Functions come from `listFunctions` (`createFactoryApi`'s `builderProps` has it).
449
449
 
450
+ ### A button that asks for values — `ask.fields`
451
+
452
+ Packed asks for the parcel's size, for the courier:
453
+
454
+ ```json
455
+ { "id": "packed", "label": "Packed", "action": "setOrderStatus", "params": { "status": "packed" },
456
+ "ask": { "title": "Change status to “Packed”?", "fields": [
457
+ { "name": "lengthIn", "label": "Length", "type": "number", "unit": "in", "required": true, "min": 0.1, "fill": "parcelLengthIn" },
458
+ { "name": "weightKg", "label": "Weight", "type": "number", "unit": "kg", "required": true, "min": 0.01, "fill": "parcelWeightKg" }
459
+ ] } }
460
+ ```
461
+
462
+ | field key | |
463
+ |---|---|
464
+ | `name` | what the function receives it as — one of its `inputSchema` inputs, never one of the button's `params`, `note` or `files` |
465
+ | `label`, `unit` | what the person sees: "Length", "in" |
466
+ | `type` | `number` (a number keyboard on a phone; the value arrives as a number) or `text` |
467
+ | `required`, `min`, `max` | checked in the dialog **and on the server**, by the same `askFieldValues` |
468
+ | `fill` | a field of the record the dialog starts from — asked again, it shows what was given last time |
469
+
470
+ - The press sends them as `values`; the server checks them against the **published** button and hands them to the function with its `params` — which always win, so a value typed cannot change what the screen says the button does.
471
+ - Publishing refuses a field the function does not take, or takes as another type. A required input the button asks for is not "missing" from its `params`.
472
+ - In the builder: **Asks for** under the button's Ask first, listing the function's inputs the button does not already give.
473
+
450
474
  ### A button that takes files — `ask.file`
451
475
 
452
476
  A press can carry photos or documents: the button's `ask` gets a file picker,
@@ -517,9 +541,10 @@ filter chips and the count on each:
517
541
 
518
542
  ```js
519
543
  actions.register({ name: 'orderList', event: 'listRows', execute: async (ctx) => {
520
- // ctx.query = { q, filter } — what was typed, and the chip (always one of the list's own)
544
+ // ctx.query = { q, filter, kpi, from, to } — what was typed, the chip (always one of the list's own),
545
+ // the KPI card clicked (one of the list's own, or ''), and the date range's days ('YYYY-MM-DD', '' = open)
521
546
  // ctx.user, ctx.tenant, ctx.db(table), ctx.knex
522
- return { rows: [...], counts: { all: 41, packed: 6 } }
547
+ return { rows: [...], counts: { all: 41, packed: 6 }, kpis: { ordered: 5, open: 12, delivered: 30, total: 47 } }
523
548
  } })
524
549
  actions.register({ name: 'orderActivity', event: 'rowDetails', execute: async (ctx) =>
525
550
  // ctx.id — the row; ctx.column
@@ -538,11 +563,18 @@ actions.register({ name: 'orderActivity', event: 'rowDetails', execute: async (c
538
563
  | `sortable` | `false` for a column whose header should not sort |
539
564
 
540
565
  - **The search and the chip live in the URL** under the screen's `urlKey` (`<urlKey>Q`, `<urlKey>Filter`), so a refresh shows the same rows. The search waits for a pause in typing.
566
+ - **A dashboard over the rows** — `dateRange` and `kpis`:
567
+ ```json
568
+ "dateRange": { "label": "Placed", "default": "last30" },
569
+ "kpis": [{ "key": "ordered", "label": "Not started", "tone": "warning" }, { "key": "open", "label": "Open" },
570
+ { "key": "delivered", "label": "Delivered", "tone": "success" }, { "key": "total", "label": "Total orders", "tone": "muted" }]
571
+ ```
572
+ A From–To range (presets: `today`, `yesterday`, `last7`, `last30`, `thisMonth`, `lastMonth`, `all`, or two days typed) and a card per KPI showing the count the function returned in `kpis`. **Clicking a card shows its rows** (`ctx.query.kpi`); clicking it again lets go. The range and the card live in the URL (`<urlKey>Dates` — `last30` or `2026-10-01..2026-10-31` — and `<urlKey>Kpi`), and a preset is turned into days on the day it is asked, so "last 30 days" stays the last 30 days. What a card counts, and which date the range is about, are the function's to decide; every count should be for the range. On a phone the cards are two to a row. Both need `rowsFrom`.
541
573
  - **`refreshEvery`** (seconds, 10–3600) asks again — for text that changes with time. **`reloadKey`** on `<FactoryScreen>` reloads every list when it changes (the host heard something happen).
542
574
  - **`grow`** (a list that is its screen's **one** control) lets the page grow with the rows on a desktop, instead of the list scrolling inside the box it was placed in — a full-page list like Orders. Phones always flow.
543
575
  - **`rowOpen`** makes a click anywhere on a row open it (the first cell is also a button, for the keyboard); **`highlight`** marks rows whose field is true.
544
576
  - **Rows that are worked out are not a table's records**: no Delete, and Edit / New only with an `editScreen`.
545
- - Routes: `GET /factory/rows/:screen` (`?node=&q=&filter=`) and `/factory/rows/:screen/details` (`?node=&column=&id=`) — see `@xeplr/factory`.
577
+ - Routes: `GET /factory/rows/:screen` (`?node=&q=&filter=&kpi=&from=&to=`) and `/factory/rows/:screen/details` (`?node=&column=&id=`) — see `@xeplr/factory`.
546
578
 
547
579
  ## Web-only pages — `ui/pages.json`
548
580
 
@@ -1086,7 +1118,8 @@ src/
1086
1118
  useFactoryBuilder.js ─ builder controller
1087
1119
  useFactoryScreen.js ─ screen controller
1088
1120
  useFlowBuilder.js ─ flow designer controller
1089
- buttons.js ─ buttons and function-fed lists: conditions, what a press sends, functionProblems' checks (React-free)
1121
+ buttons.js ─ buttons and function-fed lists: conditions, what a press sends, asked values (askFieldValues), functionProblems' checks (React-free)
1122
+ listRange.js ─ a list's date range: presets → days (resolveRange) (React-free)
1090
1123
  pagesInventory.js ─ ui/pages.json: routesIn, pageWarnings, webOnlyMenus (React-free)
1091
1124
  urlState.js ─ the URL keys and values for selection and steps (React-free)
1092
1125
  useUrlPlaces.js ─ useUrlSteps, useUrlSelection: those places, in the URL or in memory
@@ -1105,7 +1138,7 @@ dev/ ─ the stand-in app for `npm run dev` (not published)
1105
1138
  npm test
1106
1139
  ```
1107
1140
 
1108
- `test/buttons.test.js` covers buttons and function-fed lists in the document: what the checker refuses, when a button shows or is greyed out, what a press sends (the document's parameters, never the request's), and `functionProblems` against a registry. `test/cli.test.js` includes `pages`. `test/files.test.js` covers files: several in one field (the value, its column, its checks), camera only, and a button whose press takes files.
1141
+ `test/buttons.test.js` covers buttons and function-fed lists in the document: what the checker refuses, when a button shows or is greyed out, what a press sends (the document's parameters, never the request's), and `functionProblems` against a registry. `test/cli.test.js` includes `pages`. `test/dashboard.test.js` covers a list's date range and KPI cards, and a button that asks for values. `test/files.test.js` covers files: several in one field (the value, its column, its checks), camera only, and a button whose press takes files.
1109
1142
 
1110
1143
  `test/urlState.test.js` covers the URL state: the key format, and the three controllers reading their places back from a real `MemoryRouter` and being clicked through in memory, rendered with `react-dom/server`.
1111
1144
 
@@ -1114,7 +1147,7 @@ npx playwright install chromium-headless-shell # once
1114
1147
  npm run test:phone
1115
1148
  ```
1116
1149
 
1117
- `test/phone.check.js` opens the dev app (`dev/`) in a real browser at 360, 390 and 430px wide and holds every screen to the phone rules: the page never scrolls sideways; fields are one per row, full width, in reading order, none overlapping; inputs are 16px or larger and 44px tall; the footer's buttons are 44px and on screen; a list's row buttons are on screen without scrolling the table; a popup fills the screen. At 1280px it checks that the designed layout is untouched. On the Orders page it checks the function-fed list and buttons at phone sizes (16px / 44px search, chips on screen, an "ask first" that fills the screen) and, at 1280px, that they work: search and chips narrow the rows and the counts, the hover box lists entries, a row opens, a press with a note changes the order and greys its button out, and Confirm COD gives way to Undo. On the Photos page it checks the pickers (several images; the back camera for camera only), the thumbnails, Remove, and a button whose press takes a photo end to end. It is not part of `npm test` — it needs a browser and starts a dev server — so run it before releasing anything that changes how a screen is drawn. The list checks use the `@xeplr/ui-table` installed here.
1150
+ `test/phone.check.js` opens the dev app (`dev/`) in a real browser at 360, 390 and 430px wide and holds every screen to the phone rules: the page never scrolls sideways; fields are one per row, full width, in reading order, none overlapping; inputs are 16px or larger and 44px tall; the footer's buttons are 44px and on screen; a list's row buttons are on screen without scrolling the table; a popup fills the screen. At 1280px it checks that the designed layout is untouched. On the Orders page it checks the function-fed list and buttons at phone sizes (16px / 44px search, chips on screen, an "ask first" that fills the screen) and, at 1280px, that they work: search and chips narrow the rows and the counts, the hover box lists entries, a row opens, a press with a note changes the order and greys its button out, and Confirm COD gives way to Undo. It checks the dashboard (KPI cards two to a row on a phone, a card showing its rows and letting go, a range narrowing the counts) and Packed asking for the parcel's size (nothing sent without it, numbers reaching the function). On the Photos page it checks the pickers (several images; the back camera for camera only), the thumbnails, Remove, and a button whose press takes a photo end to end. It is not part of `npm test` — it needs a browser and starts a dev server — so run it before releasing anything that changes how a screen is drawn. The list checks use the `@xeplr/ui-table` installed here.
1118
1151
 
1119
1152
  ## License
1120
1153
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-factory",
3
- "version": "1.8.6",
3
+ "version": "1.8.7",
4
4
  "description": "Design data-entry screens on @xeplr/ui-canvas, save them as JSON, run them as auto-saving forms with lists of saved records — built for Claude to draft and people to refine",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/buttons.js CHANGED
@@ -104,10 +104,12 @@ export function askFile(button) {
104
104
 
105
105
  /**
106
106
  * What a press sends the function: the document's parameters, the person's
107
- * note when it asks for one, and the stored files when it takes them.
107
+ * note when it asks for one, the stored files when it takes them, and the
108
+ * values its `ask.fields` asked for (already checked: askFieldValues).
108
109
  */
109
- export function buttonInput(button, note, files) {
110
- const input = { ...((button && button.params) || {}) }
110
+ export function buttonInput(button, note, files, values) {
111
+ // The person's answers first: the document's own parameters always win.
112
+ const input = { ...((values && typeof values === 'object') ? values : {}), ...((button && button.params) || {}) }
111
113
  if (button && button.ask && button.ask.note && note !== undefined && note !== null && String(note).trim() !== '') {
112
114
  input[NOTE_INPUT] = String(note).trim()
113
115
  }
@@ -115,6 +117,68 @@ export function buttonInput(button, note, files) {
115
117
  return input
116
118
  }
117
119
 
120
+ /** The kinds of value a button's `ask.fields` can ask for. */
121
+ export const ASK_FIELD_TYPES = ['number', 'text']
122
+
123
+ /** Longest text an ask field takes. */
124
+ export const MAX_ASK_TEXT = 500
125
+
126
+ /**
127
+ * The values a button's press asks the person for (`ask.fields`) — a parcel's
128
+ * length, width, height and weight before Packed:
129
+ *
130
+ * "ask": { "fields": [
131
+ * { "name": "lengthIn", "label": "Length", "type": "number", "unit": "in", "required": true, "min": 0.1, "fill": "parcelLengthIn" } ] }
132
+ *
133
+ * Each reaches the function under its `name`, which its inputSchema must take.
134
+ * `fill` names a field of the record the dialog starts from, so asking again
135
+ * shows what was given last time.
136
+ */
137
+ export function askFields(button) {
138
+ const f = button && button.ask && button.ask.fields
139
+ return Array.isArray(f) ? f.filter((x) => x && typeof x === 'object' && x.name) : []
140
+ }
141
+
142
+ /** What the dialog starts each field with: the record's `fill` field, or empty. */
143
+ export function askFieldStart(field, record) {
144
+ const v = field.fill && record ? record[field.fill] : undefined
145
+ return v === undefined || v === null ? '' : String(v)
146
+ }
147
+
148
+ /**
149
+ * The values given for a button's `ask.fields`, checked and typed:
150
+ * { values: { name: value }, errors: [{ field, message }] }. Typed text is
151
+ * what both sides receive — "12.5" from a box — and a number field's value is
152
+ * a number. Anything not asked for is dropped. Both the dialog and the server
153
+ * use it, so a value refused in one is refused in the other.
154
+ */
155
+ export function askFieldValues(button, given) {
156
+ const values = {}
157
+ const errors = []
158
+ const raw = given && typeof given === 'object' && !Array.isArray(given) ? given : {}
159
+ askFields(button).forEach((f) => {
160
+ const label = f.label || f.name
161
+ const v = raw[f.name]
162
+ const empty = v === undefined || v === null || String(v).trim() === ''
163
+ if (empty) {
164
+ if (f.required) errors.push({ field: f.name, message: `${label} is required` })
165
+ return
166
+ }
167
+ if (f.type === 'number') {
168
+ const n = typeof v === 'number' ? v : Number(String(v).trim())
169
+ if (!Number.isFinite(n)) { errors.push({ field: f.name, message: `${label} must be a number` }); return }
170
+ if (f.min !== undefined && n < f.min) { errors.push({ field: f.name, message: `${label} must be at least ${f.min}` }); return }
171
+ if (f.max !== undefined && n > f.max) { errors.push({ field: f.name, message: `${label} must be at most ${f.max}` }); return }
172
+ values[f.name] = n
173
+ return
174
+ }
175
+ const t = String(v).trim()
176
+ if (t.length > MAX_ASK_TEXT) { errors.push({ field: f.name, message: `${label} can be at most ${MAX_ASK_TEXT} characters` }); return }
177
+ values[f.name] = t
178
+ })
179
+ return { values, errors }
180
+ }
181
+
118
182
  /** Why the files given to a press are not what its `ask.file` takes — or null. */
119
183
  export function askFileProblem(button, files) {
120
184
  const f = askFile(button)
@@ -171,10 +235,17 @@ export function paramProblems(button, inputSchema) {
171
235
  if (!f || !f.name || !f.required || f.default !== undefined) return
172
236
  if (f.name === NOTE_INPUT && button && button.ask && button.ask.note) return
173
237
  if (f.name === FILES_INPUT && askFile(button)) return
238
+ if (askFields(button).some((a) => a.name === f.name && a.required)) return
174
239
  if (params[f.name] === undefined || params[f.name] === null || params[f.name] === '') problems.push(`needs "${f.name}"`)
175
240
  })
176
241
  if (button && button.ask && button.ask.note && !byName.has(NOTE_INPUT)) problems.push(`asks for a note, but takes no "${NOTE_INPUT}"`)
177
242
  if (askFile(button) && !byName.has(FILES_INPUT)) problems.push(`asks for a file, but takes no "${FILES_INPUT}"`)
243
+ askFields(button).forEach((a) => {
244
+ const f = byName.get(a.name)
245
+ if (!f) { problems.push(`asks for "${a.name}", but takes no "${a.name}"`); return }
246
+ const want = a.type === 'number' ? 'number' : 'string'
247
+ if (f.type && f.type !== want) problems.push(`asks for "${a.name}" as ${a.type === 'number' ? 'a number' : 'text'}, but it takes ${f.type}`)
248
+ })
178
249
  return problems
179
250
  }
180
251
 
package/src/controls.js CHANGED
@@ -12,6 +12,8 @@
12
12
  // (width × the screen's aspect). See document.js.
13
13
 
14
14
  /** A dropdown binds `id` and shows `name` — always. */
15
+ import { DATE_PRESETS } from './listRange.js'
16
+
15
17
  export const OPTION_ID = 'id'
16
18
  export const OPTION_LABEL = 'name'
17
19
 
@@ -591,7 +593,7 @@ CONTROLS.list = {
591
593
  // rowsFrom: rows a registered function works out (event "listRows") —
592
594
  // with its search box, filter chips and their counts — instead of one
593
595
  // table's records. See buttons.js.
594
- props: ['title', 'source', 'belongsTo', 'editScreen', 'openIn', 'columns', 'pageSize', 'actions', 'rowsFrom', 'search', 'filters', 'refreshEvery', 'rowOpen', 'highlight', 'grow', 'style'],
596
+ props: ['title', 'source', 'belongsTo', 'editScreen', 'openIn', 'columns', 'pageSize', 'actions', 'rowsFrom', 'search', 'filters', 'kpis', 'dateRange', 'refreshEvery', 'rowOpen', 'highlight', 'grow', 'style'],
595
597
  validation: [],
596
598
  styles: ['fontFamily', 'fontSize', 'color', 'background', 'borderColor', 'borderWidth', 'borderRadius'],
597
599
  properties: [
@@ -624,6 +626,9 @@ CONTROLS.list = {
624
626
  help: 'A function of the app that works the rows out — instead of reading one table. Blank → the table above.' },
625
627
  { path: 'props.search.placeholder', label: 'Search box hint', type: 'text', help: 'Blank → no search box. Needs "Rows from".' },
626
628
  { path: 'props.filters', label: 'Filter chips', type: 'filters', help: 'The first is where the list starts. The function counts each.' },
629
+ { path: 'props.kpis', label: 'KPI cards', type: 'filters', help: 'Counts above the table, for the date range. A click shows that card\'s rows; another click lets go. The function counts each.' },
630
+ { path: 'props.dateRange', label: 'Date range', type: 'dateRange', presets: DATE_PRESETS,
631
+ help: 'A From–To range above the table, starting at the one chosen here. The function is asked for its rows and counts in it.' },
627
632
  { path: 'props.refreshEvery', label: 'Refresh every (seconds)', type: 'number', help: 'For rows whose text changes with time, like "2h ago". Blank → never.' },
628
633
  { path: 'props.rowOpen', label: 'A click on a row opens it', type: 'toggle' },
629
634
  { path: 'props.highlight', label: 'Mark rows where', type: 'text', help: 'A field of the row; a row where it is true is marked — "isNew".' },
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { boxStyle, fieldStyle } from './styles.js'
4
4
  import FileField from './FileField.jsx'
5
+ import { askFields, askFieldStart, askFieldValues } from '../buttons.js'
5
6
 
6
7
  // A PANEL OF BUTTONS. Each runs a function of the app on the server, with the
7
8
  // parameters the screen gives it; which ones show, and which are greyed out,
@@ -63,19 +64,30 @@ export default function ButtonsView({ node, design, panel }) {
63
64
  onCancel={() => { setAsking(null); panel.clearNote() }}
64
65
  upload={(file) => panel.upload(file, asking)}
65
66
  readFile={panel.readFile}
66
- onConfirm={async (typed, files) => { if (await panel.press(asking, typed, files)) setAsking(null) }}
67
+ record={panel.record}
68
+ onConfirm={async (typed, files, values) => { if (await panel.press(asking, typed, files, values)) setAsking(null) }}
67
69
  />
68
70
  )}
69
71
  </div>
70
72
  )
71
73
  }
72
74
 
73
- /** "Change status to Packed?" — the button's message, a note and files when it takes them. */
74
- function AskDialog({ button, busy, error, onCancel, onConfirm, upload, readFile }) {
75
+ /** "Change status to Packed?" — the button's message, its fields, a note and files when it takes them. */
76
+ function AskDialog({ button, busy, error, onCancel, onConfirm, upload, readFile, record }) {
75
77
  const ask = button.ask || {}
76
78
  const file = ask.file && typeof ask.file === 'object' ? ask.file : null
79
+ const fields = askFields(button)
77
80
  const [typed, setTyped] = useState('')
78
81
  const [files, setFiles] = useState([])
82
+ // Each asked value starts from the record (`fill`): asked again, it shows what was given last.
83
+ const [given, setGiven] = useState(() => Object.fromEntries(fields.map((f) => [f.name, askFieldStart(f, record)])))
84
+ const [shown, setShown] = useState({}) // field → message, after a try
85
+ const confirm = () => {
86
+ const out = askFieldValues(button, given)
87
+ if (out.errors.length) { setShown(Object.fromEntries(out.errors.map((e) => [e.field, e.message]))); return }
88
+ setShown({})
89
+ onConfirm(typed, files, out.values)
90
+ }
79
91
  const missing = Boolean(file && file.required && !files.length)
80
92
  const ref = useRef(null)
81
93
  useEffect(() => {
@@ -95,6 +107,34 @@ function AskDialog({ button, busy, error, onCancel, onConfirm, upload, readFile
95
107
  </header>
96
108
  <div className="xeplr-factory-modal-body">
97
109
  {ask.message && <p className="xeplr-factory-ask-message">{ask.message}</p>}
110
+ {fields.length > 0 && (
111
+ <div className="xeplr-factory-ask-fields">
112
+ {fields.map((f) => {
113
+ const fid = `${titleId}-${f.name}`
114
+ return (
115
+ <div key={f.name} className="xeplr-factory-ask-field">
116
+ <label className="xeplr-factory-ask-label" htmlFor={fid}>{f.label}{f.required ? ' *' : ''}</label>
117
+ <span className="xeplr-factory-ask-value">
118
+ <input
119
+ id={fid}
120
+ className="xeplr-factory-ask-input"
121
+ type={f.type === 'number' ? 'number' : 'text'}
122
+ inputMode={f.type === 'number' ? 'decimal' : undefined}
123
+ step={f.type === 'number' ? 'any' : undefined}
124
+ min={f.min} max={f.max}
125
+ value={given[f.name] ?? ''}
126
+ aria-invalid={shown[f.name] ? true : undefined}
127
+ aria-describedby={shown[f.name] ? `${fid}-error` : undefined}
128
+ onChange={(e) => { const v = e.target.value; setGiven((g) => ({ ...g, [f.name]: v })); setShown((m) => ({ ...m, [f.name]: undefined })) }}
129
+ />
130
+ {f.unit && <span className="xeplr-factory-ask-unit">{f.unit}</span>}
131
+ </span>
132
+ {shown[f.name] && <span className="xeplr-factory-ask-field-error" id={`${fid}-error`} role="alert">{shown[f.name]}</span>}
133
+ </div>
134
+ )
135
+ })}
136
+ </div>
137
+ )}
98
138
  {ask.note && (
99
139
  <label className="xeplr-factory-ask-field" htmlFor={`${titleId}-note`}>
100
140
  <span className="xeplr-factory-ask-label">{ask.note}</span>
@@ -120,7 +160,7 @@ function AskDialog({ button, busy, error, onCancel, onConfirm, upload, readFile
120
160
  </div>
121
161
  <footer className="xeplr-factory-ask-foot">
122
162
  <button type="button" className="xeplr-factory-secondary" onClick={onCancel} disabled={busy}>Cancel</button>
123
- <button type="button" className={button.variant === 'danger' ? 'xeplr-factory-danger' : 'xeplr-factory-primary'} onClick={() => onConfirm(typed, files)} disabled={busy || missing}>
163
+ <button type="button" className={button.variant === 'danger' ? 'xeplr-factory-danger' : 'xeplr-factory-primary'} onClick={confirm} disabled={busy || missing}>
124
164
  {busy ? 'Working…' : button.label}
125
165
  </button>
126
166
  </footer>
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { XeplrTable } from '@xeplr/ui-table'
4
4
  import { boxStyle, fieldStyle } from './styles.js'
5
+ import { DATE_PRESETS, customRange, isDay } from '../listRange.js'
5
6
 
6
7
  // A LIST WHOSE ROWS A FUNCTION WORKS OUT (rowsFrom) — live, in @xeplr/ui-table.
7
8
  //
@@ -11,9 +12,10 @@ import { boxStyle, fieldStyle } from './styles.js'
11
12
  // renderer. What this file draws is only how: a second line, badges, the
12
13
  // pictures, an activity line with its hover box.
13
14
  //
14
- // Above the rows: the search box and the filter chips, each chip with the
15
- // count the function gave it. Presentation only — the query, the rows and
16
- // what a click does arrive as props.
15
+ // Above the rows: a date range and KPI cards (a dashboard), then the search
16
+ // box and the filter chips, each card and chip with the count the function
17
+ // gave it. A card is a filter too: a click shows its rows, another lets go.
18
+ // Presentation only — the query, the rows and what a click does arrive as props.
17
19
 
18
20
  const OPEN_DELAY_MS = 250
19
21
  const CLOSE_DELAY_MS = 200
@@ -66,8 +68,37 @@ export default function FedList({ node, list, query, onOpen, rowDetails, recordK
66
68
  if (marker) open(rows[Number(marker.getAttribute('data-xf-row'))])
67
69
  }
68
70
 
71
+ const kpis = Array.isArray(p.kpis) ? p.kpis : []
72
+ const kpiCounts = list.kpis || {}
73
+ const range = query.rangeFor ? query.rangeFor(node) : null
74
+
69
75
  return (
70
76
  <div className="xeplr-factory-list xeplr-factory-list--fed" style={style}>
77
+ {(range || kpis.length > 0) && (
78
+ <div className="xeplr-factory-dash">
79
+ {range && <RangePicker label={p.dateRange.label} range={range} onChange={query.setDates} />}
80
+ {kpis.length > 0 && (
81
+ <div className="xeplr-factory-kpis" role="group" aria-label={p.title ? `${p.title} at a glance` : 'At a glance'}>
82
+ {kpis.map((k) => {
83
+ const on = query.kpi === k.key
84
+ const n = kpiCounts[k.key]
85
+ return (
86
+ <button
87
+ key={k.key}
88
+ type="button"
89
+ aria-pressed={on}
90
+ className={`xeplr-factory-kpi xeplr-factory-kpi--${k.tone || 'accent'}${on ? ' is-on' : ''}`}
91
+ onClick={() => query.setKpi(on ? '' : k.key)}
92
+ >
93
+ <span className="xeplr-factory-kpi-count">{n === undefined ? '—' : n}</span>
94
+ <span className="xeplr-factory-kpi-label">{k.label}</span>
95
+ </button>
96
+ )
97
+ })}
98
+ </div>
99
+ )}
100
+ </div>
101
+ )}
71
102
  <div className="xeplr-factory-list-tools">
72
103
  {p.title && <span className="xeplr-factory-list-title">{p.title}</span>}
73
104
  {p.search && p.search.placeholder && (
@@ -102,7 +133,7 @@ export default function FedList({ node, list, query, onOpen, rowDetails, recordK
102
133
  {list.error && <span className="xeplr-factory-error-inline" role="alert">{list.error}</span>}
103
134
  </div>
104
135
  {rows.length === 0
105
- ? !list.loading && !list.error && <div className="xeplr-factory-list-empty">{query.q ? 'Nothing matches that search.' : 'Nothing here yet.'}</div>
136
+ ? !list.loading && !list.error && <div className="xeplr-factory-list-empty">{query.q || query.kpi || (range && range.from) ? 'Nothing matches.' : 'Nothing here yet.'}</div>
106
137
  : (
107
138
  <div className={'xeplr-factory-fed-table' + (open ? ' is-openable' : '')} onClick={openFromRow}>
108
139
  <XeplrTable
@@ -121,6 +152,41 @@ export default function FedList({ node, list, query, onOpen, rowDetails, recordK
121
152
  )
122
153
  }
123
154
 
155
+ /** From–To: a preset, or two days typed. Whatever is chosen is kept as one value. */
156
+ function RangePicker({ label, range, onChange }) {
157
+ const custom = !range.preset
158
+ const [from, setFrom] = useState(range.from)
159
+ const [to, setTo] = useState(range.to)
160
+ useEffect(() => { setFrom(range.from); setTo(range.to) }, [range.from, range.to])
161
+ const typed = (f, t) => {
162
+ setFrom(f); setTo(t)
163
+ if ((f === '' || isDay(f)) && (t === '' || isDay(t)) && !(f && t && f > t)) onChange(customRange(f, t))
164
+ }
165
+ return (
166
+ <div className="xeplr-factory-range" role="group" aria-label={label ? `${label} between` : 'Dates'}>
167
+ {label && <span className="xeplr-factory-range-label">{label}</span>}
168
+ <select
169
+ className="xeplr-factory-range-preset"
170
+ aria-label={label ? `${label}: range` : 'Range'}
171
+ value={custom ? 'custom' : range.preset}
172
+ onChange={(e) => onChange(e.target.value === 'custom' ? customRange(range.from, range.to) : e.target.value)}
173
+ >
174
+ {DATE_PRESETS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
175
+ <option value="custom">Between…</option>
176
+ </select>
177
+ {(custom || range.from || range.to) && (
178
+ <span className="xeplr-factory-range-days">
179
+ <input type="date" className="xeplr-factory-range-day" aria-label="From" value={from} max={to || undefined}
180
+ onChange={(e) => typed(e.target.value, to)} />
181
+ <span aria-hidden="true">–</span>
182
+ <input type="date" className="xeplr-factory-range-day" aria-label="To" value={to} min={from || undefined}
183
+ onChange={(e) => typed(from, e.target.value)} />
184
+ </span>
185
+ )}
186
+ </div>
187
+ )
188
+ }
189
+
124
190
  /** One cell: the server's text, and how this column draws it. */
125
191
  function Cell({ column: c, row, index, first, open, details, onOpenAt }) {
126
192
  const value = row[c.field]
@@ -644,6 +644,26 @@ function ToggleEditor({ id, field, value, onChange }) {
644
644
  )
645
645
  }
646
646
 
647
+ /** A list's date range: none, or where it starts and what the dates are. */
648
+ function DateRangeEditor({ id, field, value, onChange }) {
649
+ const r = value && typeof value === 'object' ? value : null
650
+ return (
651
+ <div className="xeplr-factory-prop-stack">
652
+ <select id={id} className="xeplr-factory-prop-input" value={r ? (r.default || 'all') : ''} onChange={(e) => {
653
+ const v = e.target.value
654
+ onChange(v === '' ? undefined : { ...(r || {}), default: v })
655
+ }}>
656
+ <option value="">— none —</option>
657
+ {(field.presets || []).map((o) => <option key={o.value} value={o.value}>Starts at: {o.label}</option>)}
658
+ </select>
659
+ {r && (
660
+ <input className="xeplr-factory-prop-input" type="text" value={r.label || ''} placeholder='What the dates are, e.g. "Placed"'
661
+ onChange={(e) => { const next = { ...r, label: e.target.value }; if (!next.label) delete next.label; onChange(next) }} />
662
+ )}
663
+ </div>
664
+ )
665
+ }
666
+
647
667
  function SelectEditor({ id, field, value, onChange }) {
648
668
  // Style selects may be unset ("inherit"); numeric option values stay numbers.
649
669
  const optional = String(field.path || '').includes('.style.') || String(field.path || '').startsWith('style.')
@@ -982,6 +1002,11 @@ function ButtonsEditor({ value, onChange, functions }) {
982
1002
  {takesNote
983
1003
  ? <MiniField label="Note box label" help="Blank → no note box"><input className="xeplr-factory-prop-input" value={b.ask.note || ''} placeholder="Reason (optional)" onChange={(e) => edit(i, { ask: clean({ ...b.ask, note: e.target.value }) })} /></MiniField>
984
1004
  : fn && <div className="xeplr-factory-prop-help">{fn.name} takes no note.</div>}
1005
+ <AskFieldsEditor
1006
+ value={b.ask.fields}
1007
+ inputs={schema.filter((f) => f.name !== NOTE_INPUT && f.name !== FILES_INPUT && !(b.params && Object.prototype.hasOwnProperty.call(b.params, f.name)))}
1008
+ onChange={(fields) => edit(i, { ask: clean({ ...b.ask, fields }) })}
1009
+ />
985
1010
  {takesFiles
986
1011
  ? <AskFileEditor value={b.ask.file} onChange={(file) => edit(i, { ask: clean({ ...b.ask, file }) })} />
987
1012
  : b.ask.file && <div className="xeplr-factory-prop-help">{fn ? fn.name : 'This function'} takes no files — the picker will be refused when publishing.</div>}
@@ -1005,6 +1030,55 @@ function ButtonsEditor({ value, onChange, functions }) {
1005
1030
 
1006
1031
  const VARIANT_LABELS = { default: 'Ordinary', primary: 'Main', danger: 'Undo / remove' }
1007
1032
 
1033
+ /**
1034
+ * The values a button's press asks for (`ask.fields`) — a parcel's size before
1035
+ * Packed. Each is one of the function's inputs the screen does not give.
1036
+ */
1037
+ function AskFieldsEditor({ value, inputs, onChange }) {
1038
+ const fields = Array.isArray(value) ? value : []
1039
+ const set = (next) => onChange(next.length ? next : undefined)
1040
+ const edit = (j, patch) => set(fields.map((f, k) => {
1041
+ if (k !== j) return f
1042
+ const next = { ...f, ...patch }
1043
+ Object.keys(next).forEach((key) => { if (next[key] === undefined || next[key] === '' || next[key] === false) delete next[key] })
1044
+ return next
1045
+ }))
1046
+ const free = inputs.filter((f) => !fields.some((a) => a.name === f.name))
1047
+ const typeOf = (f) => (f && f.type === 'number' ? 'number' : 'text')
1048
+ if (!inputs.length && !fields.length) return null
1049
+ return (
1050
+ <div className="xeplr-factory-rows-editor">
1051
+ <div className="xeplr-factory-prop-label">Asks for</div>
1052
+ {fields.map((f, j) => (
1053
+ <fieldset key={f.name || j} className="xeplr-factory-rows-editor-card">
1054
+ <legend>{f.label || f.name}</legend>
1055
+ <RowTools i={j} count={fields.length} onMove={(to) => set(move(fields, j, to))} onRemove={() => set(fields.filter((_, k) => k !== j))} />
1056
+ <MiniField label="Input"><input className="xeplr-factory-prop-input" value={f.name} readOnly /></MiniField>
1057
+ <MiniField label="Label"><input className="xeplr-factory-prop-input" value={f.label || ''} onChange={(e) => edit(j, { label: e.target.value })} /></MiniField>
1058
+ <MiniField label="Unit" help='e.g. "in", "kg"'><input className="xeplr-factory-prop-input" value={f.unit || ''} maxLength={12} onChange={(e) => edit(j, { unit: e.target.value })} /></MiniField>
1059
+ {f.type === 'number' && (
1060
+ <MiniField label="At least"><input className="xeplr-factory-prop-input" type="number" value={f.min ?? ''} onChange={(e) => edit(j, { min: e.target.value === '' ? undefined : Number(e.target.value) })} /></MiniField>
1061
+ )}
1062
+ <MiniField label="Starts from" help="A field of the record — asked again, it shows the last value"><input className="xeplr-factory-prop-input" value={f.fill || ''} onChange={(e) => edit(j, { fill: e.target.value })} /></MiniField>
1063
+ <label className="xeplr-factory-toggle">
1064
+ <input type="checkbox" checked={Boolean(f.required)} onChange={(e) => edit(j, { required: e.target.checked })} />
1065
+ <span>Required</span>
1066
+ </label>
1067
+ </fieldset>
1068
+ ))}
1069
+ {free.length > 0 && (
1070
+ <select className="xeplr-factory-prop-input" value="" aria-label="Ask for another input" onChange={(e) => {
1071
+ const f = free.find((x) => x.name === e.target.value)
1072
+ if (f) set([...fields, { name: f.name, label: f.label || f.name, type: typeOf(f) }])
1073
+ }}>
1074
+ <option value="">+ Ask for…</option>
1075
+ {free.map((f) => <option key={f.name} value={f.name}>{f.label || f.name}</option>)}
1076
+ </select>
1077
+ )}
1078
+ </div>
1079
+ )
1080
+ }
1081
+
1008
1082
  /**
1009
1083
  * The file a button's press takes (`ask.file`): a file field's rules, set the
1010
1084
  * same way. Offered only when the function takes "files".
@@ -1168,6 +1242,7 @@ export const EDITORS = {
1168
1242
  toggle: ToggleEditor,
1169
1243
  toggleValue: ToggleValueEditor,
1170
1244
  select: SelectEditor,
1245
+ dateRange: DateRangeEditor,
1171
1246
  font: FontEditor,
1172
1247
  color: ColorEditor,
1173
1248
  table: TableEditor,
@@ -323,6 +323,34 @@
323
323
  Search and chips over the rows; every cell is text the server wrote. */
324
324
  .xeplr-factory-list--fed { overflow: hidden; }
325
325
  .xeplr-factory-list--fed .xeplr-table-container { border: none; border-radius: 0; background: transparent; }
326
+ /* A dashboard over the rows: the date range, then a card per KPI — a count,
327
+ and a filter (pressed: its rows). */
328
+ .xeplr-factory-dash { display: flex; flex-direction: column; gap: 12px; padding: 14px; border-bottom: 1px solid var(--xf-border); }
329
+ .xeplr-factory-range { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--xf-ink); }
330
+ .xeplr-factory-range-label { font-weight: 600; }
331
+ .xeplr-factory-range-days { display: inline-flex; align-items: center; gap: 6px; color: var(--xf-muted); }
332
+ .xeplr-factory-range-preset, .xeplr-factory-range-day {
333
+ font: inherit; color: var(--xf-ink); background: var(--xf-field-bg); border: 1px solid var(--xf-field-border);
334
+ border-radius: 8px; padding: 6px 10px; min-height: 36px; box-sizing: border-box;
335
+ }
336
+ .xeplr-factory-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
337
+ .xeplr-factory-kpi {
338
+ --xf-kpi: var(--xf-accent);
339
+ display: flex; flex-direction: column; align-items: flex-start; gap: 2px; text-align: left; cursor: pointer;
340
+ font: inherit; color: var(--xf-ink); background: var(--xf-field-bg);
341
+ border: 1px solid var(--xf-border); border-left: 4px solid var(--xf-kpi); border-radius: 10px; padding: 12px 14px;
342
+ }
343
+ .xeplr-factory-kpi:hover { background: var(--xf-hover); }
344
+ .xeplr-factory-kpi:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--xf-kpi) 35%, transparent); }
345
+ .xeplr-factory-kpi.is-on { background: color-mix(in srgb, var(--xf-kpi) 12%, var(--xf-field-bg)); border-color: var(--xf-kpi); }
346
+ .xeplr-factory-kpi--success { --xf-kpi: var(--xf-good); }
347
+ .xeplr-factory-kpi--warning { --xf-kpi: var(--xf-warn); }
348
+ .xeplr-factory-kpi--danger { --xf-kpi: var(--xf-danger); }
349
+ .xeplr-factory-kpi--muted { --xf-kpi: var(--xf-muted); }
350
+ .xeplr-factory-kpi-count { font-size: 1.9em; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
351
+ .xeplr-factory-kpi-label { color: var(--xf-muted); font-size: .92em; }
352
+ .xeplr-factory-kpi.is-on .xeplr-factory-kpi-label { color: var(--xf-ink); }
353
+
326
354
  .xeplr-factory-list-tools {
327
355
  display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 12px 14px;
328
356
  border-bottom: 1px solid var(--xf-border);
@@ -417,6 +445,15 @@ button.xeplr-factory-activity-item:hover, button.xeplr-factory-activity-item:foc
417
445
  color: var(--xf-ink); background: var(--xf-field-bg); border: 1px solid var(--xf-field-border);
418
446
  }
419
447
  .xeplr-factory-ask-note { resize: vertical; min-height: 80px; }
448
+ .xeplr-factory-ask-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px 12px; }
449
+ .xeplr-factory-ask-value { display: flex; align-items: center; gap: 6px; }
450
+ .xeplr-factory-ask-input {
451
+ flex: 1 1 auto; min-width: 0; font: inherit; color: var(--xf-ink); background: var(--xf-field-bg);
452
+ border: 1px solid var(--xf-field-border); border-radius: 8px; padding: 7px 10px; box-sizing: border-box;
453
+ }
454
+ .xeplr-factory-ask-input[aria-invalid] { border-color: var(--xf-danger); }
455
+ .xeplr-factory-ask-unit { color: var(--xf-muted); white-space: nowrap; min-width: 1.6em; }
456
+ .xeplr-factory-ask-field-error { color: var(--xf-danger); font-size: .88em; }
420
457
  .xeplr-factory-ask-note:focus, .xeplr-factory-list-search:focus { outline: none; border-color: var(--xf-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--xf-accent) 25%, transparent); }
421
458
  .xeplr-factory-ask-error { margin: 0; color: var(--xf-danger); }
422
459
  .xeplr-factory-ask-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px 16px; }
@@ -863,11 +900,18 @@ button.xeplr-factory-activity-item:hover, button.xeplr-factory-activity-item:foc
863
900
  /* Function-fed lists and buttons on a phone: the search takes the width, chips
864
901
  scroll in a row, and every button is a thumb-sized target. */
865
902
  @media (max-width: 640px) {
866
- .xeplr-factory-list-search, .xeplr-factory-ask-note { font-size: max(16px, 1em); min-height: 44px; }
903
+ .xeplr-factory-list-search, .xeplr-factory-ask-note, .xeplr-factory-ask-input { font-size: max(16px, 1em); min-height: 44px; }
904
+ .xeplr-factory-ask-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
867
905
  .xeplr-factory-list-search { flex-basis: 100%; max-width: none; }
868
906
  .xeplr-factory-ask-foot button { flex: 1; min-height: 44px; }
869
907
  .xeplr-factory-chips { flex-wrap: nowrap; overflow-x: auto; max-width: 100%; padding-bottom: 2px; }
870
908
  .xeplr-factory-chip { min-height: 36px; }
909
+ .xeplr-factory-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
910
+ .xeplr-factory-range-preset, .xeplr-factory-range-day { font-size: max(16px, 1em); min-height: 44px; }
911
+ .xeplr-factory-range-preset { flex: 1 1 100%; }
912
+ .xeplr-factory-range-days { flex: 1 1 100%; min-width: 0; }
913
+ /* A date box's own width is the browser's; let the row decide instead. */
914
+ .xeplr-factory-range-day { flex: 1 1 0; min-width: 0; width: 0; }
871
915
  .xeplr-factory-button { min-height: 44px; flex: 1 1 auto; }
872
916
  .xeplr-factory-buttons-note { flex-basis: 100%; }
873
917
  .xeplr-factory-ask .xeplr-factory-modal-body { padding: 4px 16px 8px; }
@@ -0,0 +1,85 @@
1
+ // A FUNCTION-FED LIST'S DATE RANGE AND KPI CARDS — the parts of a dashboard.
2
+ //
3
+ // "dateRange": { "label": "Placed", "default": "last30" },
4
+ // "kpis": [{ "key": "ordered", "label": "Not started" }, { "key": "open", "label": "Open" }, …]
5
+ //
6
+ // The range is one value, kept in the URL as it was chosen — a preset
7
+ // ("last30") or two days ("2026-10-01..2026-10-31") — and turned into the
8
+ // two days the function is asked for (`from`, `to`, both included) on the day
9
+ // it is asked: "last 30 days" opened tomorrow is tomorrow's last 30 days.
10
+ //
11
+ // A KPI card is a count the function works out for the range, and a filter:
12
+ // clicking one asks for its rows (`kpi`), clicking it again lets go. What
13
+ // each card counts is the function's to decide — the document names them.
14
+ //
15
+ // Pure: no React, no requests.
16
+
17
+ /** The ranges a person can pick without typing two dates. */
18
+ export const DATE_PRESETS = [
19
+ { value: 'today', label: 'Today' },
20
+ { value: 'yesterday', label: 'Yesterday' },
21
+ { value: 'last7', label: 'Last 7 days' },
22
+ { value: 'last30', label: 'Last 30 days' },
23
+ { value: 'thisMonth', label: 'This month' },
24
+ { value: 'lastMonth', label: 'Last month' },
25
+ { value: 'all', label: 'All time' }
26
+ ]
27
+
28
+ export const DATE_PRESET_VALUES = DATE_PRESETS.map((p) => p.value)
29
+
30
+ const DAY = /^\d{4}-\d{2}-\d{2}$/
31
+
32
+ /** Whether a string is a real day, "YYYY-MM-DD". */
33
+ export function isDay(s) {
34
+ if (typeof s !== 'string' || !DAY.test(s)) return false
35
+ const d = new Date(s + 'T00:00:00Z')
36
+ return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s
37
+ }
38
+
39
+ /** A Date's own calendar day where it is — "YYYY-MM-DD". */
40
+ export function dayOf(date) {
41
+ const d = date instanceof Date ? date : new Date(date)
42
+ const pad = (n) => String(n).padStart(2, '0')
43
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
44
+ }
45
+
46
+ function addDays(day, n) {
47
+ const d = new Date(day + 'T00:00:00Z')
48
+ d.setUTCDate(d.getUTCDate() + n)
49
+ return d.toISOString().slice(0, 10)
50
+ }
51
+
52
+ /**
53
+ * A range as it is kept → the days it means: { from, to, preset } — from/to
54
+ * empty for "all time". `today` is "YYYY-MM-DD" (the caller's own day).
55
+ * Anything unreadable falls back to `fallback` (the list's default), so an old
56
+ * or mistyped link opens the usual range rather than an error.
57
+ */
58
+ export function resolveRange(value, today, fallback = 'all') {
59
+ const v = typeof value === 'string' ? value : ''
60
+ if (v.includes('..')) {
61
+ const [from, to] = v.split('..')
62
+ if ((from === '' || isDay(from)) && (to === '' || isDay(to)) && !(from && to && from > to)) return { from, to, preset: null }
63
+ return resolveRange(fallback, today, 'all')
64
+ }
65
+ const month = today.slice(0, 7)
66
+ switch (v) {
67
+ case 'today': return { from: today, to: today, preset: v }
68
+ case 'yesterday': { const y = addDays(today, -1); return { from: y, to: y, preset: v } }
69
+ case 'last7': return { from: addDays(today, -6), to: today, preset: v }
70
+ case 'last30': return { from: addDays(today, -29), to: today, preset: v }
71
+ case 'thisMonth': return { from: month + '-01', to: today, preset: v }
72
+ case 'lastMonth': {
73
+ const firstThis = month + '-01'
74
+ const lastPrev = addDays(firstThis, -1)
75
+ return { from: lastPrev.slice(0, 7) + '-01', to: lastPrev, preset: v }
76
+ }
77
+ case 'all': return { from: '', to: '', preset: 'all' }
78
+ default: return v === fallback ? { from: '', to: '', preset: 'all' } : resolveRange(fallback, today, 'all')
79
+ }
80
+ }
81
+
82
+ /** Two days typed → how the range is kept. */
83
+ export function customRange(from, to) {
84
+ return `${from || ''}..${to || ''}`
85
+ }
package/src/model.js CHANGED
@@ -17,7 +17,7 @@ export { SCREEN_EVENTS, SCREEN_EVENT_KEYS, DECIDE_EVENT, FUNCTION_NAME, screenFu
17
17
  // Buttons and lists whose rows a function works out — names and values only.
18
18
  export {
19
19
  BUTTON_EVENT, LIST_ROWS_EVENT, ROW_DETAILS_EVENT, BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, NOTE_INPUT, FILES_INPUT, CONDITION_OPERATORS,
20
- conditionMet, buttonShown, buttonDisabled, buttonPressed, screenButtons, buttonById, buttonInput, askFile, askFileProblem, nodeFunctions, paramProblems, optionValues
20
+ conditionMet, buttonShown, buttonDisabled, buttonPressed, screenButtons, buttonById, buttonInput, askFile, askFileProblem, askFields, askFieldStart, askFieldValues, ASK_FIELD_TYPES, nodeFunctions, paramProblems, optionValues
21
21
  } from './buttons.js'
22
22
  // Which pages are drawn from documents and which are web only (ui/pages.json).
23
23
  export { checkInventory, routesIn, pageWarnings, webOnlyMenus } from './pagesInventory.js'
@@ -43,3 +43,5 @@ export {
43
43
  addTransition, setTransition, removeTransition, firstStep, reachableSteps, layoutFlow, validateFlow, describeWhen,
44
44
  arrowTargets, navigationOf, setNavigation
45
45
  } from './flow.js'
46
+ // A function-fed list's date range: presets → days, and what a day is.
47
+ export { DATE_PRESETS, DATE_PRESET_VALUES, resolveRange, customRange, isDay, dayOf } from './listRange.js'
package/src/remote.js CHANGED
@@ -132,14 +132,15 @@ export function createFactoryApi({ fetch: doFetch, fetchFile, base = '' } = {})
132
132
 
133
133
  // ROWS A FUNCTION WORKS OUT, for a list with rowsFrom: { rows, counts }.
134
134
  // Only what the person typed goes with it — the function is the screen's.
135
- fetchRows: async ({ screen, node, q, filter }) => one(await call('GET', `/rows/${enc(screen)}${query({ node, q, filter })}`)),
135
+ fetchRows: async ({ screen, node, q, filter, kpi, from, to }) => one(await call('GET', `/rows/${enc(screen)}${query({ node, q, filter, kpi, from, to })}`)),
136
136
  // An activity cell's hover box for one row.
137
137
  fetchRowDetails: ({ screen, node, column, id }) => call('GET', `/rows/${enc(screen)}/details${query({ node, column, id })}`),
138
138
  // A button's press on one record → { button, message, output }. The
139
139
  // server reads the button — its function and parameters — from the
140
140
  // published screen; the request names it and carries the note.
141
- pressButton: async ({ screen, button, id, note, files }) => one(await call('POST', `/buttons/${enc(screen)}/${enc(button)}`, {
142
- id, ...(note ? { note } : {}), ...(Array.isArray(files) && files.length ? { files } : {})
141
+ pressButton: async ({ screen, button, id, note, files, values }) => one(await call('POST', `/buttons/${enc(screen)}/${enc(button)}`, {
142
+ id, ...(note ? { note } : {}), ...(Array.isArray(files) && files.length ? { files } : {}),
143
+ ...(values && Object.keys(values).length ? { values } : {})
143
144
  })),
144
145
  // A file a button's ask.file takes, stored before the press; the press
145
146
  // then names it by the path this answers with.
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', q: 'formQ', filter: 'formFilter' }
15
+ * factoryUrlKeys('form') → { steps: 'formSteps', selected: 'formSelected', tab: 'formTab', q: 'formQ', filter: 'formFilter', kpi: 'formKpi', dates: 'formDates' }
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`, q: `${urlKey}Q`, filter: `${urlKey}Filter` }
19
+ return { steps: `${urlKey}Steps`, selected: `${urlKey}Selected`, tab: `${urlKey}Tab`, q: `${urlKey}Q`, filter: `${urlKey}Filter`, kpi: `${urlKey}Kpi`, dates: `${urlKey}Dates` }
20
20
  }
21
21
 
22
22
  /**
@@ -6,6 +6,7 @@ import { initialValues, parseInput, saveState, fieldError, optionValue, recordVa
6
6
  import { hookMethod } from './hooks.js'
7
7
  import { useUrlSteps, useUrlListQuery } from './useUrlPlaces.js'
8
8
  import { buttonShown, buttonDisabled, buttonPressed } from './buttons.js'
9
+ import { resolveRange, dayOf } from './listRange.js'
9
10
 
10
11
  // A saved screen, running. No JSX — designs/ScreenSample.jsx draws it.
11
12
  //
@@ -59,9 +60,9 @@ import { buttonShown, buttonDisabled, buttonPressed } from './buttons.js'
59
60
  * @param onDirtyChange (dirty) → void — whether there are unsaved changes, as that changes
60
61
  * @param controls the control registry (default: the built-ins)
61
62
  * @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 fetchRows async ({ screen, node, q, filter, kpi, from, to }) → { rows, counts, kpis } — a list whose rows a function works out (rowsFrom)
63
64
  * @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, files }) → { message } — a button's press, run on the server
65
+ * @param pressButton async ({ screen, button, id, note, files, values }) → { message } — a button's press, run on the server
65
66
  * @param uploadButtonFile async (file, { screen, button }) → { path } — a file a button's `ask.file` takes, stored before the press
66
67
  * @param readFile async (path) → Blob — an attached file's bytes: a picture's preview (no preview without it)
67
68
  * @param onButtonDone ({ button, result, record }) → void — after a press: the host reloads what it shows
@@ -612,6 +613,15 @@ export function useFactoryScreen({
612
613
  // rows. The search waits for a pause in typing before it asks.
613
614
  const [listQ, setListQ] = useUrlListQuery(urlKey, 'q')
614
615
  const [listFilter, setListFilter] = useUrlListQuery(urlKey, 'filter')
616
+ // A dashboard's KPI card (clicked: its rows) and date range (a preset, or
617
+ // "from..to"), in the URL too. The range is turned into days when asked.
618
+ const [listKpi, setListKpi] = useUrlListQuery(urlKey, 'kpi')
619
+ const [listDates, setListDates] = useUrlListQuery(urlKey, 'dates')
620
+ const rangeFor = useCallback((node) => {
621
+ const r = node && node.props && node.props.dateRange
622
+ if (!r) return null
623
+ return resolveRange(listDates || r.default || 'all', dayOf(new Date()), r.default || 'all')
624
+ }, [listDates])
615
625
  const [askedQ, setAskedQ] = useState(listQ)
616
626
  useEffect(() => {
617
627
  if (askedQ === listQ) return undefined
@@ -635,14 +645,21 @@ export function useFactoryScreen({
635
645
  const run = Promise.resolve().then(() => hook('get')({
636
646
  many: true, id: null, screen: doc.id, source: src, node, document: doc,
637
647
  steps: live.current.steps,
638
- query: fed ? { q: askedQ, filter: listFilter } : undefined,
648
+ query: fed ? { q: askedQ, filter: listFilter, kpi: listKpi, ...(rangeFor(node) || {}) } : undefined,
639
649
  defaults: {
640
650
  get: () => {
641
651
  // ROWS A FUNCTION WORKS OUT: the server runs the list's rowsFrom
642
652
  // with the search and the chip, and answers { rows, counts }.
643
653
  if (fed) {
644
654
  if (!fetchRowsRef.current) throw new Error(`No fetchRows was provided — the list's rows come from "${node.props.rowsFrom}"`)
645
- return fetchRowsRef.current({ screen: doc.id, node: node.id, q: askedQ, filter: listFilter || undefined })
655
+ const range = rangeFor(node)
656
+ const kpis = Array.isArray(node.props.kpis) ? node.props.kpis : []
657
+ return fetchRowsRef.current({
658
+ screen: doc.id, node: node.id, q: askedQ, filter: listFilter || undefined,
659
+ kpi: kpis.some((k) => k.key === listKpi) ? listKpi : undefined,
660
+ from: range && range.from ? range.from : undefined,
661
+ to: range && range.to ? range.to : undefined
662
+ })
646
663
  }
647
664
  if (!fetchRecordsRef.current) throw new Error(`No fetchRecords was provided to list "${src}"`)
648
665
  // A SUB-TABLE IS THIS RECORD'S. `belongsTo` names the column on
@@ -663,15 +680,21 @@ export function useFactoryScreen({
663
680
  if (cancelled) return
664
681
  const rows = Array.isArray(out) ? out : out && Array.isArray(out.rows) ? out.rows : []
665
682
  const counts = out && !Array.isArray(out) && out.counts ? out.counts : {}
666
- setLists((l) => ({ ...l, [node.id]: { rows, counts, loading: false, error: null } }))
683
+ const kpis = out && !Array.isArray(out) && out.kpis ? out.kpis : {}
684
+ setLists((l) => ({ ...l, [node.id]: { rows, counts, kpis, loading: false, error: null } }))
667
685
  })
668
686
  .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' } })) })
669
687
  })
670
688
  return () => { cancelled = true }
671
- }, [listNodes, listVersion, doc, askedQ, listFilter, reloadKey]) // eslint-disable-line react-hooks/exhaustive-deps
689
+ }, [listNodes, listVersion, doc, askedQ, listFilter, listKpi, rangeFor, reloadKey]) // eslint-disable-line react-hooks/exhaustive-deps
672
690
 
673
691
  /** What a function-fed list is narrowed to, and how to change it. */
674
- const listQuery = useMemo(() => ({ q: listQ, filter: listFilter, setQ: setListQ, setFilter: setListFilter }), [listQ, listFilter, setListQ, setListFilter])
692
+ const listQuery = useMemo(() => ({
693
+ q: listQ, filter: listFilter, setQ: setListQ, setFilter: setListFilter,
694
+ kpi: listKpi, setKpi: setListKpi,
695
+ // The range a list shows (its own default until one is chosen), and choosing one.
696
+ rangeFor, setDates: setListDates
697
+ }), [listQ, listFilter, setListQ, setListFilter, listKpi, setListKpi, rangeFor, setListDates])
675
698
 
676
699
  /** An activity cell's hover box: the column's function, for one row. */
677
700
  const fetchRowDetailsRef = useRef(fetchRowDetails); fetchRowDetailsRef.current = fetchRowDetails
@@ -853,12 +876,12 @@ export function useFactoryScreen({
853
876
  if (!out || !out.path) throw new Error('The upload did not answer with the stored file')
854
877
  return out
855
878
  },
856
- press: async (b, typed, files) => {
879
+ press: async (b, typed, files, values) => {
857
880
  if (!pressRef.current) { setButtonNote({ kind: 'error', text: 'No pressButton was provided', button: b.id }); return false }
858
881
  setPressing(b.id)
859
882
  setButtonNote(null)
860
883
  try {
861
- const result = await pressRef.current({ screen: doc.id, button: b.id, id: recordId, note: typed, files })
884
+ const result = await pressRef.current({ screen: doc.id, button: b.id, id: recordId, note: typed, files, values })
862
885
  setButtonNote({ kind: 'done', text: (result && result.message) || `${b.label} — done`, button: b.id })
863
886
  setListVersion((v) => v + 1)
864
887
  if (buttonDoneRef.current) buttonDoneRef.current({ button: b, result, record: buttonRecord })
@@ -13,10 +13,11 @@
13
13
  import { SCREEN_EVENT_KEYS, FUNCTION_NAME } from './events.js'
14
14
  import { CONTROLS, LABEL_VARIANTS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, LAYOUTS, MULTI_SEPARATOR, MAX_FILE_MB, STEP_LIMIT, acceptList, FILE_SOURCES, IMAGE_EXTENSIONS, MAX_FILES } from './controls.js'
15
15
  import { DOCUMENT_KIND, DOCUMENT_VERSION } from './document.js'
16
+ import { DATE_PRESET_VALUES } from './listRange.js'
16
17
  import { RESERVED_COLUMNS, MAX_IDENTIFIER } from './tableSchema.js'
17
18
  import { FIELD_PRESETS, INPUT_TYPES, presetFor } from './presets.js'
18
19
  import { isBinding } from './values.js'
19
- import { BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, CONDITION_OPERATORS } from './buttons.js'
20
+ import { BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, CONDITION_OPERATORS, ASK_FIELD_TYPES, NOTE_INPUT, FILES_INPUT } from './buttons.js'
20
21
 
21
22
  const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
22
23
  const TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_.$-]*$/
@@ -508,6 +509,35 @@ function checkFedList(props, fed, at, err) {
508
509
  })
509
510
  }
510
511
  }
512
+ need('kpis', 'KPI cards')
513
+ need('dateRange', 'a date range')
514
+ if (props.kpis !== undefined) {
515
+ if (!Array.isArray(props.kpis)) err(`${at}.props.kpis`, 'must be an array of { key, label, tone? }')
516
+ else {
517
+ const keys = new Set()
518
+ props.kpis.forEach((k, j) => {
519
+ const p = `${at}.props.kpis[${j}]`
520
+ if (!k || typeof k !== 'object') { err(p, 'must be { key, label, tone? }'); return }
521
+ if (!(nonEmptyString(k.key) && /^[A-Za-z0-9_-]+$/.test(k.key))) err(`${p}.key`, 'must be letters, digits, _ or - — what the function is asked for, e.g. "open"')
522
+ else if (keys.has(k.key)) err(`${p}.key`, `"${k.key}" appears twice`)
523
+ else keys.add(k.key)
524
+ if (!nonEmptyString(k.label)) err(`${p}.label`, 'must be the card\'s text, e.g. "Open"')
525
+ if (k.tone !== undefined && !BADGE_TONES.includes(k.tone)) err(`${p}.tone`, `must be one of: ${BADGE_TONES.join(', ')}`)
526
+ const extra = Object.keys(k).filter((x) => !['key', 'label', 'tone'].includes(x))
527
+ if (extra.length) err(p, `only "key", "label" and "tone" are used — remove: ${extra.join(', ')}`)
528
+ })
529
+ }
530
+ }
531
+ if (props.dateRange !== undefined) {
532
+ const d = props.dateRange
533
+ if (!d || typeof d !== 'object' || Array.isArray(d)) err(`${at}.props.dateRange`, 'must be { label?, default? } — the range the function is asked for')
534
+ else {
535
+ const extra = Object.keys(d).filter((x) => x !== 'label' && x !== 'default')
536
+ if (extra.length) err(`${at}.props.dateRange`, `only "label" and "default" are used — remove: ${extra.join(', ')}`)
537
+ if (d.label !== undefined && typeof d.label !== 'string') err(`${at}.props.dateRange.label`, 'must be a string, e.g. "Placed"')
538
+ if (d.default !== undefined && !DATE_PRESET_VALUES.includes(d.default)) err(`${at}.props.dateRange.default`, `must be one of: ${DATE_PRESET_VALUES.join(', ')}`)
539
+ }
540
+ }
511
541
  if (props.refreshEvery !== undefined && !(Number.isInteger(props.refreshEvery) && props.refreshEvery >= 10 && props.refreshEvery <= 3600)) {
512
542
  err(`${at}.props.refreshEvery`, 'must be a whole number of seconds from 10 to 3600')
513
543
  }
@@ -557,6 +587,36 @@ const ASK_FILE_KEYS = ['label', 'required', 'accept', 'maxSize', 'multiple', 'ma
557
587
  * server checks the names against what is registered when the screen is
558
588
  * published (functionProblems). Here: the shape.
559
589
  */
590
+ const ASK_FIELD_KEYS = ['name', 'label', 'type', 'unit', 'required', 'min', 'max', 'fill']
591
+
592
+ /** A button's `ask.fields`: the values a press asks for, each reaching the function under its name. */
593
+ function checkAskFields(fields, params, at, err) {
594
+ if (!Array.isArray(fields)) { err(at, 'must be a list of { name, label, type, unit?, required?, min?, max?, fill? }'); return }
595
+ const seen = new Set()
596
+ fields.forEach((f, i) => {
597
+ const p = `${at}[${i}]`
598
+ if (!f || typeof f !== 'object' || Array.isArray(f)) { err(p, 'must be { name, label, type, … }'); return }
599
+ const bad = Object.keys(f).filter((k) => !ASK_FIELD_KEYS.includes(k))
600
+ if (bad.length) err(p, `only ${ASK_FIELD_KEYS.map((k) => `"${k}"`).join(', ')} are used — remove: ${bad.join(', ')}`)
601
+ if (!(nonEmptyString(f.name) && /^[A-Za-z][A-Za-z0-9_]{0,62}$/.test(f.name))) err(`${p}.name`, 'must be letters, digits or _ — what the function receives it as, e.g. "lengthIn"')
602
+ else if (seen.has(f.name)) err(`${p}.name`, `"${f.name}" is asked twice`)
603
+ else if (f.name === NOTE_INPUT || f.name === FILES_INPUT) err(`${p}.name`, `"${f.name}" is the note's or the files' own name — use ask.note or ask.file`)
604
+ else if (params && Object.prototype.hasOwnProperty.call(params, f.name)) err(`${p}.name`, `"${f.name}" is already one of the button's parameters — the screen gives it, so nobody should be asked`)
605
+ else seen.add(f.name)
606
+ if (!nonEmptyString(f.label)) err(`${p}.label`, 'must be what the person sees, e.g. "Length"')
607
+ if (!ASK_FIELD_TYPES.includes(f.type)) err(`${p}.type`, `must be one of: ${ASK_FIELD_TYPES.join(', ')}`)
608
+ if (f.unit !== undefined && !(typeof f.unit === 'string' && f.unit.length <= 12)) err(`${p}.unit`, 'must be a short text, e.g. "in" or "kg"')
609
+ if (f.required !== undefined && typeof f.required !== 'boolean') err(`${p}.required`, 'must be true or false')
610
+ ;['min', 'max'].forEach((k) => {
611
+ if (f[k] === undefined) return
612
+ if (f.type !== 'number') err(`${p}.${k}`, 'is for a number field')
613
+ else if (!Number.isFinite(f[k])) err(`${p}.${k}`, 'must be a number')
614
+ })
615
+ if (Number.isFinite(f.min) && Number.isFinite(f.max) && f.min > f.max) err(`${p}.min`, 'is more than max')
616
+ if (f.fill !== undefined && !(nonEmptyString(f.fill) && /^[A-Za-z_][A-Za-z0-9_]*$/.test(f.fill))) err(`${p}.fill`, 'must be a field of the record, e.g. "parcelLengthIn"')
617
+ })
618
+ }
619
+
560
620
  function checkAskFile(f, at, err) {
561
621
  if (!f || typeof f !== 'object' || Array.isArray(f)) { err(at, 'must be { label?, accept, maxSize?, multiple?, maxFiles?, source?, required? } — the file a press takes'); return }
562
622
  const bad = Object.keys(f).filter((k) => !ASK_FILE_KEYS.includes(k))
@@ -591,10 +651,11 @@ function checkButtons(props, at, err, ids) {
591
651
  if (b.ask !== undefined) {
592
652
  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')
593
653
  else {
594
- const bad = Object.keys(b.ask).filter((k) => !ASK_KEYS.includes(k) && k !== 'file')
595
- if (bad.length) err(`${p}.ask`, `only ${[...ASK_KEYS, 'file'].map((k) => `"${k}"`).join(', ')} are used — remove: ${bad.join(', ')}`)
654
+ const bad = Object.keys(b.ask).filter((k) => !ASK_KEYS.includes(k) && k !== 'file' && k !== 'fields')
655
+ if (bad.length) err(`${p}.ask`, `only ${[...ASK_KEYS, 'file', 'fields'].map((k) => `"${k}"`).join(', ')} are used — remove: ${bad.join(', ')}`)
596
656
  ASK_KEYS.forEach((k) => { if (b.ask[k] !== undefined && typeof b.ask[k] !== 'string') err(`${p}.ask.${k}`, 'must be a string') })
597
657
  if (b.ask.file !== undefined) checkAskFile(b.ask.file, `${p}.ask.file`, err)
658
+ if (b.ask.fields !== undefined) checkAskFields(b.ask.fields, b.params, `${p}.ask.fields`, err)
598
659
  }
599
660
  }
600
661
  if (b.variant !== undefined && !BUTTON_VARIANTS.includes(b.variant)) err(`${p}.variant`, `must be one of: ${BUTTON_VARIANTS.join(', ')}`)