@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 +2 -0
- package/README.md +40 -7
- package/package.json +1 -1
- package/src/buttons.js +74 -3
- package/src/controls.js +6 -1
- package/src/designs/ButtonsView.jsx +44 -4
- package/src/designs/FedList.jsx +70 -4
- package/src/designs/PropertyPanel.jsx +75 -0
- package/src/designs/factory.css +45 -1
- package/src/listRange.js +85 -0
- package/src/model.js +3 -1
- package/src/remote.js +4 -3
- package/src/urlState.js +2 -2
- package/src/useFactoryScreen.js +32 -9
- package/src/validateDocument.js +64 -3
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`,
|
|
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,
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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>
|
package/src/designs/FedList.jsx
CHANGED
|
@@ -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:
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
|
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,
|
package/src/designs/factory.css
CHANGED
|
@@ -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; }
|
package/src/listRange.js
ADDED
|
@@ -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
|
/**
|
package/src/useFactoryScreen.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(() => ({
|
|
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 })
|
package/src/validateDocument.js
CHANGED
|
@@ -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(', ')}`)
|