@xeplr/ui-factory 1.8.5 → 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,7 +241,9 @@ A button is **a registered function and its parameters** — never code. Several
241
241
  ] } }
242
242
  ```
243
243
 
244
- Register the function with `event: 'button'` and an `inputSchema` (with `options` for the values allowed, and a `note` input if a button asks for one). `when` / `disabledWhen` are `@xeplr/rules` rules on the record. Publishing refuses parameters the function does not take. See the README's "Buttons".
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
+
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
247
249
 
@@ -292,6 +294,8 @@ Rules you give merge over the ready-made ones (`"validation": { "max": 120 }`).
292
294
  | one of the app's records | `dropdown` with `table` |
293
295
  | several of a set — tags, skills, days | `multiselect` |
294
296
  | a document, a contract, a spreadsheet, a photo | `file` with `accept` (`".pdf,.docx"`) |
297
+ | several photos, several documents | `file` with `multiple: true` (and `maxFiles`) — the value is a list of paths |
298
+ | a photo taken now, at the door / on site | `file` with `source: "camera"` and images in `accept` — the phone opens its camera |
295
299
  | a section title between groups | `{ "type": "label", "text": "…", "variant": "subheading" }` |
296
300
  | a long form people work through in stages | a `stepper`, then `step` on each field |
297
301
 
package/README.md CHANGED
@@ -103,6 +103,7 @@ import { FactoryScreen } from '@xeplr/ui-factory'
103
103
  onDelete={async ({ id, source }) => api.remove(source, id)}
104
104
  fetchOptions={async ({ table }) => api.options(table)}
105
105
  uploadFile={async (file, { screen, field }) => api.upload(file, screen, field)} // a file field: → { path }
106
+ readFile={async (path) => api.fileBlob(path)} // optional: a stored picture's bytes, for its thumbnail
106
107
  screens={{ employee_edit: editScreen }} // what a list's Edit / New open in a popup
107
108
  />
108
109
  ```
@@ -115,7 +116,7 @@ import { FactoryScreen } from '@xeplr/ui-factory'
115
116
 
116
117
  If `onSave` throws an error with `fields: [{ field, message }]` (what `createFactoryApi` does with the server's 422 — a rule, or a hook's `reject`), each message shows on its field until that field is changed.
117
118
 
118
- Values arrive typed: numbers as numbers, checkboxes as booleans, dates as `"YYYY-MM-DD"`, a date and time as `"YYYY-MM-DDTHH:MM"`, a dropdown or radio group as the option's own `id` (a table's numeric id stays a number), a multi-select as an **array** of ids, and a file as the stored file's **path**.
119
+ Values arrive typed: numbers as numbers, checkboxes as booleans, dates as `"YYYY-MM-DD"`, a date and time as `"YYYY-MM-DDTHH:MM"`, a dropdown or radio group as the option's own `id` (a table's numeric id stays a number), a multi-select as an **array** of ids, and a file as the stored file's **path** (a field with `multiple`: an **array** of paths).
119
120
 
120
121
  ### On a phone
121
122
 
@@ -143,7 +144,10 @@ one scalar have a rule of their own:
143
144
  the server use them, so it is written once.
144
145
  * **File** — the column holds the path the upload answered with, not the file.
145
146
  The file itself is stored by `@xeplr/factory` and read back through its
146
- authenticated route; nothing is ever served without a check.
147
+ authenticated route; nothing is ever served without a check. A field with
148
+ `multiple` keeps its paths in one `text` column, joined by commas, like a
149
+ multi-select (a stored path has no comma in it); turning `multiple` on is a
150
+ widening, so a path already saved reads back as a list of one.
147
151
  * **Date and time** — a `timestamp` column, read back as text so no timezone can
148
152
  move the moment. A `date` column may be widened to `timestamp` on publish
149
153
  (the day becomes midnight); the reverse is refused, as it would lose the time.
@@ -430,7 +434,7 @@ actions.register({
430
434
  | `label` | the button's text |
431
435
  | `action` | the registered function (event `button`) |
432
436
  | `params` | the values it is given — plain values, checked against its `inputSchema` (`options` = the values allowed) at publish |
433
- | `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) |
434
438
  | `when` | shown only when every rule matches the record |
435
439
  | `disabledWhen` | greyed out when every rule matches |
436
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 |
@@ -443,6 +447,76 @@ Rules are [`@xeplr/rules`](https://www.npmjs.com/package/@xeplr/rules) rules —
443
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.
444
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).
445
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
+
474
+ ### A button that takes files — `ask.file`
475
+
476
+ A press can carry photos or documents: the button's `ask` gets a file picker,
477
+ with a file field's own rules.
478
+
479
+ ```json
480
+ { "id": "proof", "label": "Delivered", "action": "markDelivered", "params": { "status": "delivered" },
481
+ "ask": { "title": "Proof of delivery", "note": "Note (optional)",
482
+ "file": { "label": "Photos", "accept": ".png,.jpg,.jpeg", "maxSize": 10, "multiple": true, "maxFiles": 5, "source": "any", "required": true } } }
483
+ ```
484
+
485
+ ```js
486
+ actions.register({
487
+ name: 'markDelivered', event: 'button',
488
+ inputSchema: [{ name: 'status', type: 'string', required: true }, { name: 'files', type: 'array' }, { name: 'note', type: 'string' }],
489
+ execute: async ({ input, system }) => {
490
+ // input.files: the stored paths. system.files: [{ path, name, size, fullPath }] — read the bytes from fullPath.
491
+ }
492
+ })
493
+ ```
494
+
495
+ - **Each file is stored before the press** — `POST /factory/buttons/:screen/:button/files`, checked against `ask.file`'s `accept` and `maxSize` — and the press sends the stored paths as `files`.
496
+ - **The server takes only files stored for this button, by this person, in this company** — a path typed by hand, or another button's, is refused. It also checks `required`, `multiple` and `maxFiles` again.
497
+ - `accept` is required: the server must have something to check an upload against.
498
+ - The function must take `files` (in its `inputSchema`); publishing refuses a button whose function does not. The builder shows the file settings only for a function that does.
499
+ - Host: pass `uploadButtonFile` (`createFactoryApi`'s `screenProps` has it).
500
+
501
+ ## Files: gallery, camera, several
502
+
503
+ A file field — and a button's `ask.file` — says **what** it wants; the device
504
+ decides how to get it. The same document works in a desktop browser, a phone
505
+ browser, an installed web app and, later, a phone app.
506
+
507
+ | prop | |
508
+ |---|---|
509
+ | `accept` | the extensions it takes — the server refuses anything else |
510
+ | `maxSize` | megabytes, per file |
511
+ | `source` | `any` (default): the device's own picker — on a phone, one menu of **Camera, Photos and Files**. `camera`: the camera opens straight away, for a photo taken now (`capture="environment"`); `accept` must then be images only |
512
+ | `multiple` | holds several files — the value is an array of paths |
513
+ | `maxFiles` | with `multiple`: at most this many (up to 20) |
514
+
515
+ - **Pictures show as thumbnails.** An `<img>` cannot send the sign-in token, so a stored picture is read back through `readFile(path) → Blob` and shown from an object URL. `createFactoryApi({ fetch: authFetch, fetchFile: authFetchFile })` (both from `@xeplr/ui-account`) gives `screenProps.readFile`; without it a field shows names only.
516
+ - **Several files**: chosen together, they upload one by one and each is added as it lands, so a refusal of the third keeps the first two. Remove takes one away.
517
+ - `filePicker(props)` → `{ accept, multiple, capture }` is the one place that turns these props into a picker — a phone app's renderer reads the same.
518
+ - iPhone photos are HEIC. iOS hands a photo over as JPEG when the picker does not ask for HEIC, so `.heic` is not in the Images list — not yet checked on a real iPhone.
519
+
446
520
  ## A list whose rows a function works out — `rowsFrom`
447
521
 
448
522
  A list reads one table's records. When the rows are **worked out** — an
@@ -467,9 +541,10 @@ filter chips and the count on each:
467
541
 
468
542
  ```js
469
543
  actions.register({ name: 'orderList', event: 'listRows', execute: async (ctx) => {
470
- // 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)
471
546
  // ctx.user, ctx.tenant, ctx.db(table), ctx.knex
472
- return { rows: [...], counts: { all: 41, packed: 6 } }
547
+ return { rows: [...], counts: { all: 41, packed: 6 }, kpis: { ordered: 5, open: 12, delivered: 30, total: 47 } }
473
548
  } })
474
549
  actions.register({ name: 'orderActivity', event: 'rowDetails', execute: async (ctx) =>
475
550
  // ctx.id — the row; ctx.column
@@ -488,11 +563,18 @@ actions.register({ name: 'orderActivity', event: 'rowDetails', execute: async (c
488
563
  | `sortable` | `false` for a column whose header should not sort |
489
564
 
490
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`.
491
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).
492
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.
493
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.
494
576
  - **Rows that are worked out are not a table's records**: no Delete, and Edit / New only with an `editScreen`.
495
- - 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`.
496
578
 
497
579
  ## Web-only pages — `ui/pages.json`
498
580
 
@@ -786,7 +868,7 @@ Both walk someone through something, and they answer different questions:
786
868
  | `dropdown` | option id | — |
787
869
  | `radio` | option id | — |
788
870
  | `multiselect` | array of option ids | `minItems`, `maxItems` |
789
- | `file` | the stored file's path | `accept` (extensions), `maxSize` (MB) |
871
+ | `file` | the stored file's path — or, with `multiple`, an array of them | `accept` (extensions), `maxSize` (MB), `multiple`, `maxFiles`, `source` (`any` / `camera`); see [Files: gallery, camera, several](#files-gallery-camera-several) |
790
872
  | `stepper` | — | a journey across the top; every other control says which step it is on |
791
873
  | `label` | — | text with a heading, subheading or text preset |
792
874
  | `list` | — | saved records, with New / Edit / Delete — or rows a function works out (`rowsFrom`), with search, filter chips and counts; see above |
@@ -1030,21 +1112,23 @@ src/
1030
1112
  hooks.js ─ FactoryHooks: get / save / delete / actions — extend and call super
1031
1113
  presets.js ─ ready-made fields (Email, Age, Gender…), changing a field's kind, label suggestions
1032
1114
  tableSchema.js ─ a form → its table; the publish plan (create / add / widen / confirmed drops and conversions)
1033
- remote.js ─ createFactoryApi({ fetch }): the calls to @xeplr/factory's routes — fetch may be window.fetch or @xeplr/ui-account's authFetch (which returns parsed bodies); includes createEntity({ key, label })
1115
+ remote.js ─ createFactoryApi({ fetch, fetchFile }): the calls to @xeplr/factory's routes — fetch may be window.fetch or @xeplr/ui-account's authFetch (which returns parsed bodies); includes createEntity({ key, label })
1034
1116
  screenSource.js ─ this app's screens as another designer wants them: describeScreen, loadScreens, keyFromName, editScreenOf (React-free)
1035
1117
  model.js ─ everything above, React-free
1036
1118
  useFactoryBuilder.js ─ builder controller
1037
1119
  useFactoryScreen.js ─ screen controller
1038
1120
  useFlowBuilder.js ─ flow designer controller
1039
- 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)
1040
1123
  pagesInventory.js ─ ui/pages.json: routesIn, pageWarnings, webOnlyMenus (React-free)
1041
1124
  urlState.js ─ the URL keys and values for selection and steps (React-free)
1042
1125
  useUrlPlaces.js ─ useUrlSteps, useUrlSelection: those places, in the URL or in memory
1043
1126
  useScreenSource.js ─ { screens, screenEditor } for @xeplr/ui-workflow's Screen step
1044
- designs/ ─ BuilderSample, ScreenSample, ScreenEditorSample, ControlView, ListView, FedList, ButtonsView, Palette, PropertyPanel, styles.js, factory.css
1127
+ useFilePreview.js ─ a stored picture as an object URL, through readFile
1128
+ designs/ ─ BuilderSample, ScreenSample, ScreenEditorSample, ControlView, ListView, FedList, ButtonsView, FileField, Palette, PropertyPanel, styles.js, factory.css
1045
1129
  pages.jsx ─ FactoryBuilder, FactoryScreen
1046
1130
  bin/xeplr-factory.js ─ screens / generate / validate / controls / schema / pages
1047
- examples/ ─ employee.entity.json and what `screens` makes of it; orders-board and order-actions (a function-fed list, buttons)
1131
+ examples/ ─ employee.entity.json and what `screens` makes of it; orders-board and order-actions (a function-fed list, buttons — one takes photos); delivery-photos (several pictures, camera only)
1048
1132
  dev/ ─ the stand-in app for `npm run dev` (not published)
1049
1133
  ```
1050
1134
 
@@ -1054,7 +1138,7 @@ dev/ ─ the stand-in app for `npm run dev` (not published)
1054
1138
  npm test
1055
1139
  ```
1056
1140
 
1057
- `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`.
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.
1058
1142
 
1059
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`.
1060
1144
 
@@ -1063,7 +1147,7 @@ npx playwright install chromium-headless-shell # once
1063
1147
  npm run test:phone
1064
1148
  ```
1065
1149
 
1066
- `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 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.
1067
1151
 
1068
1152
  ## License
1069
1153
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-factory",
3
- "version": "1.8.5",
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
@@ -46,6 +46,13 @@ export const BADGE_TONES = ['accent', 'success', 'warning', 'danger', 'muted']
46
46
  /** The name a button's note reaches its function under. */
47
47
  export const NOTE_INPUT = 'note'
48
48
 
49
+ /**
50
+ * The name a button's files reach its function under: the stored paths, as a
51
+ * list, when the button's `ask.file` takes them. The function reads the bytes
52
+ * through `system.files` (each with its full path on disk).
53
+ */
54
+ export const FILES_INPUT = 'files'
55
+
49
56
  /** Operators a button's condition can use: every one that compares a single value. */
50
57
  export const CONDITION_OPERATORS = OPERATORS.filter((o) => !o.needsRows)
51
58
 
@@ -89,15 +96,101 @@ export function buttonById(doc, id) {
89
96
  return screenButtons(doc).find((b) => b.button && b.button.id === id) || null
90
97
  }
91
98
 
92
- /** What a press sends the function: the document's parameters, and the person's note when it asks for one. */
93
- export function buttonInput(button, note) {
94
- const input = { ...((button && button.params) || {}) }
99
+ /** The file a button's press takes (`ask.file`), or null when it takes none. */
100
+ export function askFile(button) {
101
+ const f = button && button.ask && button.ask.file
102
+ return f && typeof f === 'object' ? f : null
103
+ }
104
+
105
+ /**
106
+ * What a press sends the function: the document's parameters, the person's
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).
109
+ */
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) || {}) }
95
113
  if (button && button.ask && button.ask.note && note !== undefined && note !== null && String(note).trim() !== '') {
96
114
  input[NOTE_INPUT] = String(note).trim()
97
115
  }
116
+ if (askFile(button) && Array.isArray(files) && files.length) input[FILES_INPUT] = files.slice()
98
117
  return input
99
118
  }
100
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
+
182
+ /** Why the files given to a press are not what its `ask.file` takes — or null. */
183
+ export function askFileProblem(button, files) {
184
+ const f = askFile(button)
185
+ const list = Array.isArray(files) ? files : []
186
+ if (!f) return list.length ? 'This button takes no files' : null
187
+ const label = f.label || 'A file'
188
+ if (!list.length) return f.required ? `${label} is required` : null
189
+ if (!f.multiple && list.length > 1) return `${label}: one file only`
190
+ if (f.multiple && f.maxFiles && list.length > f.maxFiles) return `${label}: at most ${f.maxFiles} files`
191
+ return null
192
+ }
193
+
101
194
  /**
102
195
  * The functions a screen's CONTROLS name, as [{ event, name, at }] — a list's
103
196
  * rows, a column's hover box, each button. `at` is where in the document, so
@@ -141,9 +234,18 @@ export function paramProblems(button, inputSchema) {
141
234
  schema.forEach((f) => {
142
235
  if (!f || !f.name || !f.required || f.default !== undefined) return
143
236
  if (f.name === NOTE_INPUT && button && button.ask && button.ask.note) return
237
+ if (f.name === FILES_INPUT && askFile(button)) return
238
+ if (askFields(button).some((a) => a.name === f.name && a.required)) return
144
239
  if (params[f.name] === undefined || params[f.name] === null || params[f.name] === '') problems.push(`needs "${f.name}"`)
145
240
  })
146
241
  if (button && button.ask && button.ask.note && !byName.has(NOTE_INPUT)) problems.push(`asks for a note, but takes no "${NOTE_INPUT}"`)
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
+ })
147
249
  return problems
148
250
  }
149
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
 
@@ -139,6 +141,44 @@ export const FILE_KINDS = {
139
141
  any: { label: 'Any of the above', accept: '.pdf,.doc,.docx,.odt,.rtf,.txt,.xls,.xlsx,.csv,.ods,.png,.jpg,.jpeg,.gif,.webp,.svg' }
140
142
  }
141
143
 
144
+ /** The extensions a picture can have — what a preview is drawn for, and all a camera field takes. */
145
+ export const IMAGE_EXTENSIONS = acceptList(FILE_KINDS.image.accept)
146
+
147
+ /**
148
+ * Where a file field's files come from. `any` is the device's own choice —
149
+ * on a phone that is Camera, Photos and Files in one menu; `camera` opens the
150
+ * camera straight away (a photo taken now, not one from the gallery).
151
+ */
152
+ export const FILE_SOURCES = [
153
+ { value: 'any', label: 'Files, gallery or camera' },
154
+ { value: 'camera', label: 'Camera only' }
155
+ ]
156
+
157
+ /** Most files one field can hold. */
158
+ export const MAX_FILES = 20
159
+
160
+ /** Whether a stored path (or a file name) is a picture — by its extension, as the server checks. */
161
+ export function isImageFile(name) {
162
+ const s = String(name || '').toLowerCase()
163
+ const dot = s.lastIndexOf('.')
164
+ return dot !== -1 && IMAGE_EXTENSIONS.includes(s.slice(dot))
165
+ }
166
+
167
+ /**
168
+ * What a file picker is asked for, from a file field's props — the one place
169
+ * that turns `accept`, `multiple` and `source` into a picker. The web reads it
170
+ * as <input type="file"> attributes; a phone app reads the same three.
171
+ */
172
+ export function filePicker(props) {
173
+ const p = props || {}
174
+ const camera = p.source === 'camera'
175
+ return {
176
+ accept: p.accept || '',
177
+ multiple: Boolean(p.multiple) && !camera,
178
+ capture: camera ? 'environment' : undefined
179
+ }
180
+ }
181
+
142
182
  /** An extension list as one string → the same list, cleaned and lower-cased. */
143
183
  export function acceptList(accept) {
144
184
  return String(accept || '')
@@ -456,11 +496,13 @@ export const CONTROLS = {
456
496
  label: 'File upload',
457
497
  group: 'Inputs',
458
498
  input: true,
459
- // The value is the stored file's path, as the upload answered with it.
499
+ // The value is the stored file's path, as the upload answered with it —
500
+ // or, with `multiple`, a list of them (one text column, comma separated,
501
+ // like a multi-select: a stored path has no comma in it).
460
502
  valueType: 'string',
461
503
  defaultSize: { w: 0.44, h: 0.1 },
462
504
  defaults: { label: 'File', accept: FILE_KINDS.document.accept, maxSize: 10 },
463
- props: ['name', 'label', 'preset', 'required', 'accept', 'maxSize', 'style'],
505
+ props: ['name', 'label', 'preset', 'required', 'accept', 'maxSize', 'multiple', 'maxFiles', 'source', 'style'],
464
506
  validation: [],
465
507
  styles: INPUT_STYLES.filter((k) => k !== 'textAlign'),
466
508
  properties: [
@@ -470,7 +512,10 @@ export const CONTROLS = {
470
512
  title: 'File',
471
513
  fields: [
472
514
  { path: 'props.accept', label: 'Allowed types', type: 'accept', help: 'Extensions, comma separated — the server refuses anything else' },
473
- { path: 'props.maxSize', label: 'Largest size (MB)', type: 'number', min: 1, max: 200 }
515
+ { path: 'props.maxSize', label: 'Largest size (MB)', type: 'number', min: 1, max: 200 },
516
+ { path: 'props.source', label: 'Take from', type: 'select', options: FILE_SOURCES, help: 'Camera only: a photo taken now — the allowed types must be images' },
517
+ { path: 'props.multiple', label: 'Several files', type: 'toggle' },
518
+ { path: 'props.maxFiles', label: 'At most (files)', type: 'number', min: 1, max: MAX_FILES, help: 'With several files' }
474
519
  ]
475
520
  },
476
521
  ...styleGroups(INPUT_STYLES.filter((k) => k !== 'textAlign'))
@@ -548,7 +593,7 @@ CONTROLS.list = {
548
593
  // rowsFrom: rows a registered function works out (event "listRows") —
549
594
  // with its search box, filter chips and their counts — instead of one
550
595
  // table's records. See buttons.js.
551
- 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'],
552
597
  validation: [],
553
598
  styles: ['fontFamily', 'fontSize', 'color', 'background', 'borderColor', 'borderWidth', 'borderRadius'],
554
599
  properties: [
@@ -581,6 +626,9 @@ CONTROLS.list = {
581
626
  help: 'A function of the app that works the rows out — instead of reading one table. Blank → the table above.' },
582
627
  { path: 'props.search.placeholder', label: 'Search box hint', type: 'text', help: 'Blank → no search box. Needs "Rows from".' },
583
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.' },
584
632
  { path: 'props.refreshEvery', label: 'Refresh every (seconds)', type: 'number', help: 'For rows whose text changes with time, like "2h ago". Blank → never.' },
585
633
  { path: 'props.rowOpen', label: 'A click on a row opens it', type: 'toggle' },
586
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".' },
@@ -1,13 +1,17 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { boxStyle, fieldStyle } from './styles.js'
4
+ import FileField from './FileField.jsx'
5
+ import { askFields, askFieldStart, askFieldValues } from '../buttons.js'
4
6
 
5
7
  // A PANEL OF BUTTONS. Each runs a function of the app on the server, with the
6
8
  // parameters the screen gives it; which ones show, and which are greyed out,
7
9
  // the controller works out from the record (useFactoryScreen.buttonsFor).
8
10
  //
9
- // A button that asks first opens a small dialog: its message, and a note box
10
- // when it takes a note. Presentation only — the press is the controller's.
11
+ // A button that asks first opens a small dialog: its message, a note box when
12
+ // it takes a note, and a file picker when it takes files (`ask.file` — on a
13
+ // phone, the gallery or the camera). Presentation only — the press, and the
14
+ // upload, are the controller's.
11
15
  //
12
16
  // On the canvas (design) the buttons are drawn as they will look, inert.
13
17
 
@@ -58,17 +62,33 @@ export default function ButtonsView({ node, design, panel }) {
58
62
  busy={panel.pressing === asking.id}
59
63
  error={note && note.kind === 'error' && note.button === asking.id ? note.text : null}
60
64
  onCancel={() => { setAsking(null); panel.clearNote() }}
61
- onConfirm={async (typed) => { if (await panel.press(asking, typed)) setAsking(null) }}
65
+ upload={(file) => panel.upload(file, asking)}
66
+ readFile={panel.readFile}
67
+ record={panel.record}
68
+ onConfirm={async (typed, files, values) => { if (await panel.press(asking, typed, files, values)) setAsking(null) }}
62
69
  />
63
70
  )}
64
71
  </div>
65
72
  )
66
73
  }
67
74
 
68
- /** "Change status to Packed?" — the button's message, and a note when it takes one. */
69
- function AskDialog({ button, busy, error, onCancel, onConfirm }) {
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 }) {
70
77
  const ask = button.ask || {}
78
+ const file = ask.file && typeof ask.file === 'object' ? ask.file : null
79
+ const fields = askFields(button)
71
80
  const [typed, setTyped] = useState('')
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
+ }
91
+ const missing = Boolean(file && file.required && !files.length)
72
92
  const ref = useRef(null)
73
93
  useEffect(() => {
74
94
  ref.current?.focus()
@@ -87,17 +107,60 @@ function AskDialog({ button, busy, error, onCancel, onConfirm }) {
87
107
  </header>
88
108
  <div className="xeplr-factory-modal-body">
89
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
+ )}
90
138
  {ask.note && (
91
139
  <label className="xeplr-factory-ask-field" htmlFor={`${titleId}-note`}>
92
140
  <span className="xeplr-factory-ask-label">{ask.note}</span>
93
141
  <textarea id={`${titleId}-note`} className="xeplr-factory-ask-note" rows={3} maxLength={1000} value={typed} onChange={(e) => setTyped(e.target.value)} />
94
142
  </label>
95
143
  )}
144
+ {file && (
145
+ <div className="xeplr-factory-ask-field">
146
+ <span className="xeplr-factory-ask-label" id={`${titleId}-file`}>{file.label || (file.multiple ? 'Files' : 'File')}{file.required ? ' *' : ''}</span>
147
+ <FileField
148
+ id={`${titleId}-files`}
149
+ rules={file}
150
+ files={files}
151
+ onChange={setFiles}
152
+ upload={upload}
153
+ readFile={readFile}
154
+ disabled={busy}
155
+ inputProps={{ 'aria-labelledby': `${titleId}-file`, 'aria-required': file.required ? true : undefined }}
156
+ />
157
+ </div>
158
+ )}
96
159
  {error && <p className="xeplr-factory-ask-error" role="alert">{error}</p>}
97
160
  </div>
98
161
  <footer className="xeplr-factory-ask-foot">
99
162
  <button type="button" className="xeplr-factory-secondary" onClick={onCancel} disabled={busy}>Cancel</button>
100
- <button type="button" className={button.variant === 'danger' ? 'xeplr-factory-danger' : 'xeplr-factory-primary'} onClick={() => onConfirm(typed)} disabled={busy}>
163
+ <button type="button" className={button.variant === 'danger' ? 'xeplr-factory-danger' : 'xeplr-factory-primary'} onClick={confirm} disabled={busy || missing}>
101
164
  {busy ? 'Working…' : button.label}
102
165
  </button>
103
166
  </footer>