@xeplr/ui-factory 1.8.5 → 1.8.6
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 +3 -1
- package/README.md +59 -8
- package/package.json +1 -1
- package/src/buttons.js +33 -2
- package/src/controls.js +46 -3
- package/src/designs/ButtonsView.jsx +29 -6
- package/src/designs/ControlView.jsx +26 -44
- package/src/designs/FileField.jsx +99 -0
- package/src/designs/PropertyPanel.jsx +52 -3
- package/src/designs/ScreenSample.jsx +1 -0
- package/src/designs/factory.css +36 -9
- package/src/model.js +4 -4
- package/src/pages.jsx +7 -0
- package/src/remote.js +20 -6
- package/src/tableSchema.js +4 -2
- package/src/useFactoryScreen.js +18 -6
- package/src/useFilePreview.js +40 -0
- package/src/validateDocument.js +50 -11
- package/src/values.js +27 -8
package/AUTHORING.md
CHANGED
|
@@ -241,7 +241,7 @@ 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,
|
|
244
|
+
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
245
|
|
|
246
246
|
## When the document cannot say it
|
|
247
247
|
|
|
@@ -292,6 +292,8 @@ Rules you give merge over the ready-made ones (`"validation": { "max": 120 }`).
|
|
|
292
292
|
| one of the app's records | `dropdown` with `table` |
|
|
293
293
|
| several of a set — tags, skills, days | `multiselect` |
|
|
294
294
|
| a document, a contract, a spreadsheet, a photo | `file` with `accept` (`".pdf,.docx"`) |
|
|
295
|
+
| several photos, several documents | `file` with `multiple: true` (and `maxFiles`) — the value is a list of paths |
|
|
296
|
+
| a photo taken now, at the door / on site | `file` with `source: "camera"` and images in `accept` — the phone opens its camera |
|
|
295
297
|
| a section title between groups | `{ "type": "label", "text": "…", "variant": "subheading" }` |
|
|
296
298
|
| a long form people work through in stages | a `stepper`, then `step` on each field |
|
|
297
299
|
|
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.
|
|
@@ -443,6 +447,52 @@ 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 takes files — `ask.file`
|
|
451
|
+
|
|
452
|
+
A press can carry photos or documents: the button's `ask` gets a file picker,
|
|
453
|
+
with a file field's own rules.
|
|
454
|
+
|
|
455
|
+
```json
|
|
456
|
+
{ "id": "proof", "label": "Delivered", "action": "markDelivered", "params": { "status": "delivered" },
|
|
457
|
+
"ask": { "title": "Proof of delivery", "note": "Note (optional)",
|
|
458
|
+
"file": { "label": "Photos", "accept": ".png,.jpg,.jpeg", "maxSize": 10, "multiple": true, "maxFiles": 5, "source": "any", "required": true } } }
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
```js
|
|
462
|
+
actions.register({
|
|
463
|
+
name: 'markDelivered', event: 'button',
|
|
464
|
+
inputSchema: [{ name: 'status', type: 'string', required: true }, { name: 'files', type: 'array' }, { name: 'note', type: 'string' }],
|
|
465
|
+
execute: async ({ input, system }) => {
|
|
466
|
+
// input.files: the stored paths. system.files: [{ path, name, size, fullPath }] — read the bytes from fullPath.
|
|
467
|
+
}
|
|
468
|
+
})
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
- **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`.
|
|
472
|
+
- **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.
|
|
473
|
+
- `accept` is required: the server must have something to check an upload against.
|
|
474
|
+
- 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.
|
|
475
|
+
- Host: pass `uploadButtonFile` (`createFactoryApi`'s `screenProps` has it).
|
|
476
|
+
|
|
477
|
+
## Files: gallery, camera, several
|
|
478
|
+
|
|
479
|
+
A file field — and a button's `ask.file` — says **what** it wants; the device
|
|
480
|
+
decides how to get it. The same document works in a desktop browser, a phone
|
|
481
|
+
browser, an installed web app and, later, a phone app.
|
|
482
|
+
|
|
483
|
+
| prop | |
|
|
484
|
+
|---|---|
|
|
485
|
+
| `accept` | the extensions it takes — the server refuses anything else |
|
|
486
|
+
| `maxSize` | megabytes, per file |
|
|
487
|
+
| `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 |
|
|
488
|
+
| `multiple` | holds several files — the value is an array of paths |
|
|
489
|
+
| `maxFiles` | with `multiple`: at most this many (up to 20) |
|
|
490
|
+
|
|
491
|
+
- **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.
|
|
492
|
+
- **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.
|
|
493
|
+
- `filePicker(props)` → `{ accept, multiple, capture }` is the one place that turns these props into a picker — a phone app's renderer reads the same.
|
|
494
|
+
- 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.
|
|
495
|
+
|
|
446
496
|
## A list whose rows a function works out — `rowsFrom`
|
|
447
497
|
|
|
448
498
|
A list reads one table's records. When the rows are **worked out** — an
|
|
@@ -786,7 +836,7 @@ Both walk someone through something, and they answer different questions:
|
|
|
786
836
|
| `dropdown` | option id | — |
|
|
787
837
|
| `radio` | option id | — |
|
|
788
838
|
| `multiselect` | array of option ids | `minItems`, `maxItems` |
|
|
789
|
-
| `file` | the stored file's path | `accept` (extensions), `maxSize` (MB) |
|
|
839
|
+
| `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
840
|
| `stepper` | — | a journey across the top; every other control says which step it is on |
|
|
791
841
|
| `label` | — | text with a heading, subheading or text preset |
|
|
792
842
|
| `list` | — | saved records, with New / Edit / Delete — or rows a function works out (`rowsFrom`), with search, filter chips and counts; see above |
|
|
@@ -1030,7 +1080,7 @@ src/
|
|
|
1030
1080
|
hooks.js ─ FactoryHooks: get / save / delete / actions — extend and call super
|
|
1031
1081
|
presets.js ─ ready-made fields (Email, Age, Gender…), changing a field's kind, label suggestions
|
|
1032
1082
|
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 })
|
|
1083
|
+
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
1084
|
screenSource.js ─ this app's screens as another designer wants them: describeScreen, loadScreens, keyFromName, editScreenOf (React-free)
|
|
1035
1085
|
model.js ─ everything above, React-free
|
|
1036
1086
|
useFactoryBuilder.js ─ builder controller
|
|
@@ -1041,10 +1091,11 @@ src/
|
|
|
1041
1091
|
urlState.js ─ the URL keys and values for selection and steps (React-free)
|
|
1042
1092
|
useUrlPlaces.js ─ useUrlSteps, useUrlSelection: those places, in the URL or in memory
|
|
1043
1093
|
useScreenSource.js ─ { screens, screenEditor } for @xeplr/ui-workflow's Screen step
|
|
1044
|
-
|
|
1094
|
+
useFilePreview.js ─ a stored picture as an object URL, through readFile
|
|
1095
|
+
designs/ ─ BuilderSample, ScreenSample, ScreenEditorSample, ControlView, ListView, FedList, ButtonsView, FileField, Palette, PropertyPanel, styles.js, factory.css
|
|
1045
1096
|
pages.jsx ─ FactoryBuilder, FactoryScreen
|
|
1046
1097
|
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)
|
|
1098
|
+
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
1099
|
dev/ ─ the stand-in app for `npm run dev` (not published)
|
|
1049
1100
|
```
|
|
1050
1101
|
|
|
@@ -1054,7 +1105,7 @@ dev/ ─ the stand-in app for `npm run dev` (not published)
|
|
|
1054
1105
|
npm test
|
|
1055
1106
|
```
|
|
1056
1107
|
|
|
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`.
|
|
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.
|
|
1058
1109
|
|
|
1059
1110
|
`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
1111
|
|
|
@@ -1063,7 +1114,7 @@ npx playwright install chromium-headless-shell # once
|
|
|
1063
1114
|
npm run test:phone
|
|
1064
1115
|
```
|
|
1065
1116
|
|
|
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.
|
|
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.
|
|
1067
1118
|
|
|
1068
1119
|
## License
|
|
1069
1120
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/ui-factory",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.6",
|
|
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,37 @@ export function buttonById(doc, id) {
|
|
|
89
96
|
return screenButtons(doc).find((b) => b.button && b.button.id === id) || null
|
|
90
97
|
}
|
|
91
98
|
|
|
92
|
-
/**
|
|
93
|
-
export function
|
|
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, and the stored files when it takes them.
|
|
108
|
+
*/
|
|
109
|
+
export function buttonInput(button, note, files) {
|
|
94
110
|
const input = { ...((button && button.params) || {}) }
|
|
95
111
|
if (button && button.ask && button.ask.note && note !== undefined && note !== null && String(note).trim() !== '') {
|
|
96
112
|
input[NOTE_INPUT] = String(note).trim()
|
|
97
113
|
}
|
|
114
|
+
if (askFile(button) && Array.isArray(files) && files.length) input[FILES_INPUT] = files.slice()
|
|
98
115
|
return input
|
|
99
116
|
}
|
|
100
117
|
|
|
118
|
+
/** Why the files given to a press are not what its `ask.file` takes — or null. */
|
|
119
|
+
export function askFileProblem(button, files) {
|
|
120
|
+
const f = askFile(button)
|
|
121
|
+
const list = Array.isArray(files) ? files : []
|
|
122
|
+
if (!f) return list.length ? 'This button takes no files' : null
|
|
123
|
+
const label = f.label || 'A file'
|
|
124
|
+
if (!list.length) return f.required ? `${label} is required` : null
|
|
125
|
+
if (!f.multiple && list.length > 1) return `${label}: one file only`
|
|
126
|
+
if (f.multiple && f.maxFiles && list.length > f.maxFiles) return `${label}: at most ${f.maxFiles} files`
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
|
|
101
130
|
/**
|
|
102
131
|
* The functions a screen's CONTROLS name, as [{ event, name, at }] — a list's
|
|
103
132
|
* rows, a column's hover box, each button. `at` is where in the document, so
|
|
@@ -141,9 +170,11 @@ export function paramProblems(button, inputSchema) {
|
|
|
141
170
|
schema.forEach((f) => {
|
|
142
171
|
if (!f || !f.name || !f.required || f.default !== undefined) return
|
|
143
172
|
if (f.name === NOTE_INPUT && button && button.ask && button.ask.note) return
|
|
173
|
+
if (f.name === FILES_INPUT && askFile(button)) return
|
|
144
174
|
if (params[f.name] === undefined || params[f.name] === null || params[f.name] === '') problems.push(`needs "${f.name}"`)
|
|
145
175
|
})
|
|
146
176
|
if (button && button.ask && button.ask.note && !byName.has(NOTE_INPUT)) problems.push(`asks for a note, but takes no "${NOTE_INPUT}"`)
|
|
177
|
+
if (askFile(button) && !byName.has(FILES_INPUT)) problems.push(`asks for a file, but takes no "${FILES_INPUT}"`)
|
|
147
178
|
return problems
|
|
148
179
|
}
|
|
149
180
|
|
package/src/controls.js
CHANGED
|
@@ -139,6 +139,44 @@ export const FILE_KINDS = {
|
|
|
139
139
|
any: { label: 'Any of the above', accept: '.pdf,.doc,.docx,.odt,.rtf,.txt,.xls,.xlsx,.csv,.ods,.png,.jpg,.jpeg,.gif,.webp,.svg' }
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/** The extensions a picture can have — what a preview is drawn for, and all a camera field takes. */
|
|
143
|
+
export const IMAGE_EXTENSIONS = acceptList(FILE_KINDS.image.accept)
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Where a file field's files come from. `any` is the device's own choice —
|
|
147
|
+
* on a phone that is Camera, Photos and Files in one menu; `camera` opens the
|
|
148
|
+
* camera straight away (a photo taken now, not one from the gallery).
|
|
149
|
+
*/
|
|
150
|
+
export const FILE_SOURCES = [
|
|
151
|
+
{ value: 'any', label: 'Files, gallery or camera' },
|
|
152
|
+
{ value: 'camera', label: 'Camera only' }
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
/** Most files one field can hold. */
|
|
156
|
+
export const MAX_FILES = 20
|
|
157
|
+
|
|
158
|
+
/** Whether a stored path (or a file name) is a picture — by its extension, as the server checks. */
|
|
159
|
+
export function isImageFile(name) {
|
|
160
|
+
const s = String(name || '').toLowerCase()
|
|
161
|
+
const dot = s.lastIndexOf('.')
|
|
162
|
+
return dot !== -1 && IMAGE_EXTENSIONS.includes(s.slice(dot))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* What a file picker is asked for, from a file field's props — the one place
|
|
167
|
+
* that turns `accept`, `multiple` and `source` into a picker. The web reads it
|
|
168
|
+
* as <input type="file"> attributes; a phone app reads the same three.
|
|
169
|
+
*/
|
|
170
|
+
export function filePicker(props) {
|
|
171
|
+
const p = props || {}
|
|
172
|
+
const camera = p.source === 'camera'
|
|
173
|
+
return {
|
|
174
|
+
accept: p.accept || '',
|
|
175
|
+
multiple: Boolean(p.multiple) && !camera,
|
|
176
|
+
capture: camera ? 'environment' : undefined
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
142
180
|
/** An extension list as one string → the same list, cleaned and lower-cased. */
|
|
143
181
|
export function acceptList(accept) {
|
|
144
182
|
return String(accept || '')
|
|
@@ -456,11 +494,13 @@ export const CONTROLS = {
|
|
|
456
494
|
label: 'File upload',
|
|
457
495
|
group: 'Inputs',
|
|
458
496
|
input: true,
|
|
459
|
-
// The value is the stored file's path, as the upload answered with it
|
|
497
|
+
// The value is the stored file's path, as the upload answered with it —
|
|
498
|
+
// or, with `multiple`, a list of them (one text column, comma separated,
|
|
499
|
+
// like a multi-select: a stored path has no comma in it).
|
|
460
500
|
valueType: 'string',
|
|
461
501
|
defaultSize: { w: 0.44, h: 0.1 },
|
|
462
502
|
defaults: { label: 'File', accept: FILE_KINDS.document.accept, maxSize: 10 },
|
|
463
|
-
props: ['name', 'label', 'preset', 'required', 'accept', 'maxSize', 'style'],
|
|
503
|
+
props: ['name', 'label', 'preset', 'required', 'accept', 'maxSize', 'multiple', 'maxFiles', 'source', 'style'],
|
|
464
504
|
validation: [],
|
|
465
505
|
styles: INPUT_STYLES.filter((k) => k !== 'textAlign'),
|
|
466
506
|
properties: [
|
|
@@ -470,7 +510,10 @@ export const CONTROLS = {
|
|
|
470
510
|
title: 'File',
|
|
471
511
|
fields: [
|
|
472
512
|
{ 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 }
|
|
513
|
+
{ path: 'props.maxSize', label: 'Largest size (MB)', type: 'number', min: 1, max: 200 },
|
|
514
|
+
{ path: 'props.source', label: 'Take from', type: 'select', options: FILE_SOURCES, help: 'Camera only: a photo taken now — the allowed types must be images' },
|
|
515
|
+
{ path: 'props.multiple', label: 'Several files', type: 'toggle' },
|
|
516
|
+
{ path: 'props.maxFiles', label: 'At most (files)', type: 'number', min: 1, max: MAX_FILES, help: 'With several files' }
|
|
474
517
|
]
|
|
475
518
|
},
|
|
476
519
|
...styleGroups(INPUT_STYLES.filter((k) => k !== 'textAlign'))
|
|
@@ -1,13 +1,16 @@
|
|
|
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'
|
|
4
5
|
|
|
5
6
|
// A PANEL OF BUTTONS. Each runs a function of the app on the server, with the
|
|
6
7
|
// parameters the screen gives it; which ones show, and which are greyed out,
|
|
7
8
|
// the controller works out from the record (useFactoryScreen.buttonsFor).
|
|
8
9
|
//
|
|
9
|
-
// A button that asks first opens a small dialog: its message,
|
|
10
|
-
//
|
|
10
|
+
// A button that asks first opens a small dialog: its message, a note box when
|
|
11
|
+
// it takes a note, and a file picker when it takes files (`ask.file` — on a
|
|
12
|
+
// phone, the gallery or the camera). Presentation only — the press, and the
|
|
13
|
+
// upload, are the controller's.
|
|
11
14
|
//
|
|
12
15
|
// On the canvas (design) the buttons are drawn as they will look, inert.
|
|
13
16
|
|
|
@@ -58,17 +61,22 @@ export default function ButtonsView({ node, design, panel }) {
|
|
|
58
61
|
busy={panel.pressing === asking.id}
|
|
59
62
|
error={note && note.kind === 'error' && note.button === asking.id ? note.text : null}
|
|
60
63
|
onCancel={() => { setAsking(null); panel.clearNote() }}
|
|
61
|
-
|
|
64
|
+
upload={(file) => panel.upload(file, asking)}
|
|
65
|
+
readFile={panel.readFile}
|
|
66
|
+
onConfirm={async (typed, files) => { if (await panel.press(asking, typed, files)) setAsking(null) }}
|
|
62
67
|
/>
|
|
63
68
|
)}
|
|
64
69
|
</div>
|
|
65
70
|
)
|
|
66
71
|
}
|
|
67
72
|
|
|
68
|
-
/** "Change status to Packed?" — the button's message,
|
|
69
|
-
function AskDialog({ button, busy, error, onCancel, onConfirm }) {
|
|
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 }) {
|
|
70
75
|
const ask = button.ask || {}
|
|
76
|
+
const file = ask.file && typeof ask.file === 'object' ? ask.file : null
|
|
71
77
|
const [typed, setTyped] = useState('')
|
|
78
|
+
const [files, setFiles] = useState([])
|
|
79
|
+
const missing = Boolean(file && file.required && !files.length)
|
|
72
80
|
const ref = useRef(null)
|
|
73
81
|
useEffect(() => {
|
|
74
82
|
ref.current?.focus()
|
|
@@ -93,11 +101,26 @@ function AskDialog({ button, busy, error, onCancel, onConfirm }) {
|
|
|
93
101
|
<textarea id={`${titleId}-note`} className="xeplr-factory-ask-note" rows={3} maxLength={1000} value={typed} onChange={(e) => setTyped(e.target.value)} />
|
|
94
102
|
</label>
|
|
95
103
|
)}
|
|
104
|
+
{file && (
|
|
105
|
+
<div className="xeplr-factory-ask-field">
|
|
106
|
+
<span className="xeplr-factory-ask-label" id={`${titleId}-file`}>{file.label || (file.multiple ? 'Files' : 'File')}{file.required ? ' *' : ''}</span>
|
|
107
|
+
<FileField
|
|
108
|
+
id={`${titleId}-files`}
|
|
109
|
+
rules={file}
|
|
110
|
+
files={files}
|
|
111
|
+
onChange={setFiles}
|
|
112
|
+
upload={upload}
|
|
113
|
+
readFile={readFile}
|
|
114
|
+
disabled={busy}
|
|
115
|
+
inputProps={{ 'aria-labelledby': `${titleId}-file`, 'aria-required': file.required ? true : undefined }}
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
96
119
|
{error && <p className="xeplr-factory-ask-error" role="alert">{error}</p>}
|
|
97
120
|
</div>
|
|
98
121
|
<footer className="xeplr-factory-ask-foot">
|
|
99
122
|
<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}>
|
|
123
|
+
<button type="button" className={button.variant === 'danger' ? 'xeplr-factory-danger' : 'xeplr-factory-primary'} onClick={() => onConfirm(typed, files)} disabled={busy || missing}>
|
|
101
124
|
{busy ? 'Working…' : button.label}
|
|
102
125
|
</button>
|
|
103
126
|
</footer>
|
|
@@ -3,6 +3,7 @@ import { tableColumns, LABEL_PRESETS } from '../controls.js'
|
|
|
3
3
|
import { stepsOf } from '../document.js'
|
|
4
4
|
import ListView from './ListView.jsx'
|
|
5
5
|
import ButtonsView from './ButtonsView.jsx'
|
|
6
|
+
import FileField from './FileField.jsx'
|
|
6
7
|
import { fieldStyle, labelStyle, boxStyle } from './styles.js'
|
|
7
8
|
|
|
8
9
|
// How each control LOOKS — the one renderer, used both on the builder's
|
|
@@ -14,7 +15,7 @@ import { fieldStyle, labelStyle, boxStyle } from './styles.js'
|
|
|
14
15
|
|
|
15
16
|
const ids = (node) => `xeplr-factory-${node.id}`
|
|
16
17
|
|
|
17
|
-
export default function ControlView({ node, mode = 'live', value, error, options, onChange, onBlur, disabled, list, upload, stepper, subform, rows, buttons }) {
|
|
18
|
+
export default function ControlView({ node, mode = 'live', value, error, options, onChange, onBlur, disabled, list, upload, readFile, stepper, subform, rows, buttons }) {
|
|
18
19
|
const View = VIEWS[node.type]
|
|
19
20
|
if (!View) {
|
|
20
21
|
return <div className="xeplr-factory-unknown">Unknown control “{node.type}”</div>
|
|
@@ -33,6 +34,7 @@ export default function ControlView({ node, mode = 'live', value, error, options
|
|
|
33
34
|
disabled={design || disabled}
|
|
34
35
|
list={list}
|
|
35
36
|
upload={upload}
|
|
37
|
+
readFile={readFile}
|
|
36
38
|
stepper={stepper}
|
|
37
39
|
subform={subform}
|
|
38
40
|
rows={rows}
|
|
@@ -317,51 +319,31 @@ function DatetimeView({ node, p, value, error, onChange, onBlur, disabled }) {
|
|
|
317
319
|
}
|
|
318
320
|
|
|
319
321
|
/**
|
|
320
|
-
* A file field holds the stored file's PATH, not the file
|
|
321
|
-
*
|
|
322
|
-
* the
|
|
322
|
+
* A file field holds the stored file's PATH, not the file — or, with
|
|
323
|
+
* `multiple`, a list of them. Picking one uploads it straight away (`upload`,
|
|
324
|
+
* supplied by the screen) and what comes back is the value saved with the
|
|
325
|
+
* record. A stored picture shows as a thumbnail when the screen can read it
|
|
326
|
+
* back (`readFile`).
|
|
323
327
|
*/
|
|
324
|
-
function FileView({ node, p, design, value, error, onChange, onBlur, disabled, upload }) {
|
|
325
|
-
const
|
|
326
|
-
const [
|
|
327
|
-
const
|
|
328
|
-
const choose = async (file) => {
|
|
329
|
-
if (!file || !upload) return
|
|
330
|
-
setFailed(null)
|
|
331
|
-
setBusy(true)
|
|
332
|
-
try {
|
|
333
|
-
const out = await upload(file, node)
|
|
334
|
-
onChange(out && out.path ? out.path : undefined)
|
|
335
|
-
} catch (e) {
|
|
336
|
-
setFailed(e && e.message ? e.message : 'The file could not be uploaded')
|
|
337
|
-
} finally {
|
|
338
|
-
setBusy(false)
|
|
339
|
-
onBlur()
|
|
340
|
-
}
|
|
341
|
-
}
|
|
328
|
+
function FileView({ node, p, design, value, error, onChange, onBlur, disabled, upload, readFile }) {
|
|
329
|
+
const several = Boolean(p.multiple)
|
|
330
|
+
const files = several ? (Array.isArray(value) ? value : []) : value ? [value] : []
|
|
331
|
+
const { style, ...aria } = inputProps(node, p, error, disabled, onBlur)
|
|
342
332
|
return (
|
|
343
|
-
<Field node={node} p={p} error={error
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
)
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
onChange={(e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; choose(f) }}
|
|
358
|
-
{...inputProps(node, p, error, disabled || busy, onBlur)}
|
|
359
|
-
/>
|
|
360
|
-
)}
|
|
361
|
-
{busy && <div className="xeplr-factory-hint">Uploading…</div>}
|
|
362
|
-
{!busy && !value && (design || accept) && (
|
|
363
|
-
<div className="xeplr-factory-hint">{accept || 'any file'}{p.maxSize ? ` · up to ${p.maxSize} MB` : ''}</div>
|
|
364
|
-
)}
|
|
333
|
+
<Field node={node} p={p} error={error}>
|
|
334
|
+
<FileField
|
|
335
|
+
id={aria.id}
|
|
336
|
+
rules={p}
|
|
337
|
+
files={files}
|
|
338
|
+
onChange={(paths) => onChange(several ? paths : paths[0])}
|
|
339
|
+
onBlur={onBlur}
|
|
340
|
+
upload={upload ? (file) => upload(file, node) : undefined}
|
|
341
|
+
readFile={readFile}
|
|
342
|
+
disabled={disabled}
|
|
343
|
+
design={design}
|
|
344
|
+
inputProps={aria}
|
|
345
|
+
style={style}
|
|
346
|
+
/>
|
|
365
347
|
</Field>
|
|
366
348
|
)
|
|
367
349
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { filePicker, isImageFile } from '../controls.js'
|
|
3
|
+
import { fileLabel } from '../values.js'
|
|
4
|
+
import { useFilePreview } from '../useFilePreview.js'
|
|
5
|
+
|
|
6
|
+
// THE FILES A FIELD HOLDS, AND THE PICKER THAT ADDS ONE — a file field's, and
|
|
7
|
+
// a button's `ask.file`. Presentation only: what an upload is, and how a
|
|
8
|
+
// stored picture is read back, are handed in.
|
|
9
|
+
//
|
|
10
|
+
// On a phone the picker is the phone's own: "Files, gallery or camera" opens
|
|
11
|
+
// its menu (Photos, Camera, Files), "Camera only" opens the camera.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param rules { accept, maxSize, multiple, maxFiles, source }
|
|
15
|
+
* @param files the stored paths, as a list
|
|
16
|
+
* @param onChange (paths) → void
|
|
17
|
+
* @param upload async (File) → { path }
|
|
18
|
+
* @param readFile async (path) → Blob — a picture's preview; names only without it
|
|
19
|
+
*/
|
|
20
|
+
export default function FileField({ id, rules, files, onChange, onBlur, upload, readFile, disabled, design, inputProps, style }) {
|
|
21
|
+
const [busy, setBusy] = useState(false)
|
|
22
|
+
const [failed, setFailed] = useState(null)
|
|
23
|
+
const r = rules || {}
|
|
24
|
+
const picker = filePicker(r)
|
|
25
|
+
const several = Boolean(r.multiple)
|
|
26
|
+
const list = Array.isArray(files) ? files : []
|
|
27
|
+
const full = several ? Boolean(r.maxFiles) && list.length >= r.maxFiles : list.length >= 1
|
|
28
|
+
|
|
29
|
+
// One at a time, in the order chosen: each answer is added as it lands, so a
|
|
30
|
+
// refusal of the third keeps the first two.
|
|
31
|
+
const choose = async (chosen) => {
|
|
32
|
+
if (!chosen.length || !upload) return
|
|
33
|
+
setFailed(null)
|
|
34
|
+
setBusy(true)
|
|
35
|
+
let next = list
|
|
36
|
+
try {
|
|
37
|
+
const room = several ? (r.maxFiles ? Math.max(r.maxFiles - list.length, 0) : chosen.length) : 1
|
|
38
|
+
if (chosen.length > room) setFailed(`At most ${r.maxFiles} files — the first ${room} were added`)
|
|
39
|
+
for (const file of chosen.slice(0, room)) {
|
|
40
|
+
const out = await upload(file)
|
|
41
|
+
if (out && out.path) { next = several ? [...next, out.path] : [out.path]; onChange(next) }
|
|
42
|
+
}
|
|
43
|
+
} catch (e) {
|
|
44
|
+
setFailed(e && e.message ? e.message : 'The file could not be uploaded')
|
|
45
|
+
} finally {
|
|
46
|
+
setBusy(false)
|
|
47
|
+
if (onBlur) onBlur()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const remove = (path) => { onChange(list.filter((p) => p !== path)); if (onBlur) onBlur() }
|
|
52
|
+
const hint = [r.accept || 'any file', r.maxSize ? `up to ${r.maxSize} MB` : null, several && r.maxFiles ? `at most ${r.maxFiles} files` : null].filter(Boolean).join(' · ')
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="xeplr-factory-files">
|
|
56
|
+
{list.length > 0 && (
|
|
57
|
+
<ul className="xeplr-factory-files-list">
|
|
58
|
+
{list.map((path) => (
|
|
59
|
+
<FileItem key={path} path={path} readFile={readFile} disabled={disabled || busy} onRemove={() => remove(path)} />
|
|
60
|
+
))}
|
|
61
|
+
</ul>
|
|
62
|
+
)}
|
|
63
|
+
{!full && (
|
|
64
|
+
<label className={`xeplr-factory-files-add${disabled || busy ? ' xeplr-factory-files-add--off' : ''}`} style={style}>
|
|
65
|
+
<input
|
|
66
|
+
className="xeplr-factory-files-input"
|
|
67
|
+
type="file"
|
|
68
|
+
accept={picker.accept || undefined}
|
|
69
|
+
multiple={picker.multiple || undefined}
|
|
70
|
+
capture={picker.capture}
|
|
71
|
+
disabled={disabled || busy}
|
|
72
|
+
onChange={(e) => { const chosen = Array.from(e.target.files || []); e.target.value = ''; choose(chosen) }}
|
|
73
|
+
{...(inputProps || {})}
|
|
74
|
+
id={id}
|
|
75
|
+
/>
|
|
76
|
+
<span className="xeplr-factory-files-add-label">
|
|
77
|
+
{busy ? 'Uploading…' : r.source === 'camera' ? (list.length ? 'Take another photo' : 'Take a photo') : list.length ? 'Add another' : several ? 'Choose files' : 'Choose a file'}
|
|
78
|
+
</span>
|
|
79
|
+
</label>
|
|
80
|
+
)}
|
|
81
|
+
{failed && <div className="xeplr-factory-files-error" role="alert">{failed}</div>}
|
|
82
|
+
{!busy && !list.length && (design || r.accept) && <div className="xeplr-factory-hint">{hint}</div>}
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function FileItem({ path, readFile, disabled, onRemove }) {
|
|
88
|
+
const preview = useFilePreview(path, readFile)
|
|
89
|
+
const picture = isImageFile(path)
|
|
90
|
+
return (
|
|
91
|
+
<li className="xeplr-factory-files-item">
|
|
92
|
+
{picture && preview.url
|
|
93
|
+
? <img className="xeplr-factory-files-thumb" src={preview.url} alt="" />
|
|
94
|
+
: <span className="xeplr-factory-files-icon" aria-hidden="true">{picture ? '🖼' : '📄'}</span>}
|
|
95
|
+
<span className="xeplr-factory-files-name" title={fileLabel(path)}>{fileLabel(path)}</span>
|
|
96
|
+
<button type="button" className="xeplr-factory-files-remove" disabled={disabled} onClick={onRemove} aria-label={`Remove ${fileLabel(path)}`}>Remove</button>
|
|
97
|
+
</li>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
@@ -3,8 +3,8 @@ import { getAtPath } from '../propertyPath.js'
|
|
|
3
3
|
import { FIELD_PRESETS, presetFor } from '../presets.js'
|
|
4
4
|
import { slugify, inputNodes, paramsText, screenParams } from '../document.js'
|
|
5
5
|
import { isBinding, previousBinding } from '../values.js'
|
|
6
|
-
import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS, FILE_KINDS } from '../controls.js'
|
|
7
|
-
import { BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, CONDITION_OPERATORS, NOTE_INPUT, optionValues } from '../buttons.js'
|
|
6
|
+
import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS, FILE_KINDS, FILE_SOURCES, MAX_FILES } from '../controls.js'
|
|
7
|
+
import { BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, CONDITION_OPERATORS, NOTE_INPUT, FILES_INPUT, optionValues } from '../buttons.js'
|
|
8
8
|
|
|
9
9
|
// The selected control's properties, GENERATED from its entry in controls.js:
|
|
10
10
|
// each field there names a path and an editor type, and this file owns only
|
|
@@ -941,6 +941,7 @@ function ButtonsEditor({ value, onChange, functions }) {
|
|
|
941
941
|
const fn = pick.find((f) => f.name === b.action) || null
|
|
942
942
|
const schema = (fn && fn.inputSchema) || []
|
|
943
943
|
const takesNote = schema.some((f) => f.name === NOTE_INPUT)
|
|
944
|
+
const takesFiles = schema.some((f) => f.name === FILES_INPUT)
|
|
944
945
|
return (
|
|
945
946
|
<fieldset key={i} className="xeplr-factory-rows-editor-card">
|
|
946
947
|
<legend>{b.label || `Button ${i + 1}`}</legend>
|
|
@@ -961,7 +962,7 @@ function ButtonsEditor({ value, onChange, functions }) {
|
|
|
961
962
|
</select>
|
|
962
963
|
</MiniField>
|
|
963
964
|
{fn && fn.description && <div className="xeplr-factory-prop-help">{fn.description}</div>}
|
|
964
|
-
{schema.filter((f) => f.name !== NOTE_INPUT).map((f) => (
|
|
965
|
+
{schema.filter((f) => f.name !== NOTE_INPUT && f.name !== FILES_INPUT).map((f) => (
|
|
965
966
|
<MiniField key={f.name} label={(f.label || f.name) + (f.required && f.default === undefined ? ' *' : '')} help={f.description}>
|
|
966
967
|
<ParamInput field={f} value={b.params ? b.params[f.name] : undefined} onChange={(v) => {
|
|
967
968
|
const params = { ...(b.params || {}) }
|
|
@@ -981,6 +982,9 @@ function ButtonsEditor({ value, onChange, functions }) {
|
|
|
981
982
|
{takesNote
|
|
982
983
|
? <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>
|
|
983
984
|
: fn && <div className="xeplr-factory-prop-help">{fn.name} takes no note.</div>}
|
|
985
|
+
{takesFiles
|
|
986
|
+
? <AskFileEditor value={b.ask.file} onChange={(file) => edit(i, { ask: clean({ ...b.ask, file }) })} />
|
|
987
|
+
: 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>}
|
|
984
988
|
</>
|
|
985
989
|
)}
|
|
986
990
|
<ConditionsEditor label="Shows when" value={b.when} onChange={(v) => edit(i, { when: v })} />
|
|
@@ -1001,6 +1005,51 @@ function ButtonsEditor({ value, onChange, functions }) {
|
|
|
1001
1005
|
|
|
1002
1006
|
const VARIANT_LABELS = { default: 'Ordinary', primary: 'Main', danger: 'Undo / remove' }
|
|
1003
1007
|
|
|
1008
|
+
/**
|
|
1009
|
+
* The file a button's press takes (`ask.file`): a file field's rules, set the
|
|
1010
|
+
* same way. Offered only when the function takes "files".
|
|
1011
|
+
*/
|
|
1012
|
+
function AskFileEditor({ value, onChange }) {
|
|
1013
|
+
const f = value && typeof value === 'object' ? value : null
|
|
1014
|
+
const set = (patch) => {
|
|
1015
|
+
const next = { ...f, ...patch }
|
|
1016
|
+
Object.keys(next).forEach((k) => { if (next[k] === undefined || next[k] === '' || next[k] === false) delete next[k] })
|
|
1017
|
+
if (!next.multiple) delete next.maxFiles
|
|
1018
|
+
onChange(next)
|
|
1019
|
+
}
|
|
1020
|
+
return (
|
|
1021
|
+
<>
|
|
1022
|
+
<label className="xeplr-factory-toggle">
|
|
1023
|
+
<input type="checkbox" checked={Boolean(f)} onChange={(e) => onChange(e.target.checked ? { label: 'Photo', accept: FILE_KINDS.image.accept } : undefined)} />
|
|
1024
|
+
<span>Asks for a file</span>
|
|
1025
|
+
</label>
|
|
1026
|
+
{f && (
|
|
1027
|
+
<>
|
|
1028
|
+
<MiniField label="File box label"><input className="xeplr-factory-prop-input" value={f.label || ''} onChange={(e) => set({ label: e.target.value })} /></MiniField>
|
|
1029
|
+
<MiniField label="Allowed types"><AcceptEditor value={f.accept} onChange={(accept) => set({ accept })} /></MiniField>
|
|
1030
|
+
<MiniField label="Take from">
|
|
1031
|
+
<select className="xeplr-factory-prop-input" value={f.source || 'any'} onChange={(e) => set({ source: e.target.value === 'any' ? undefined : e.target.value })}>
|
|
1032
|
+
{FILE_SOURCES.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
1033
|
+
</select>
|
|
1034
|
+
</MiniField>
|
|
1035
|
+
<MiniField label="Largest size (MB)"><input className="xeplr-factory-prop-input" type="number" min={1} max={200} value={f.maxSize ?? ''} onChange={(e) => set({ maxSize: e.target.value === '' ? undefined : Number(e.target.value) })} /></MiniField>
|
|
1036
|
+
<label className="xeplr-factory-toggle">
|
|
1037
|
+
<input type="checkbox" checked={Boolean(f.multiple)} onChange={(e) => set({ multiple: e.target.checked })} />
|
|
1038
|
+
<span>Several files</span>
|
|
1039
|
+
</label>
|
|
1040
|
+
{f.multiple && (
|
|
1041
|
+
<MiniField label="At most (files)"><input className="xeplr-factory-prop-input" type="number" min={1} max={MAX_FILES} value={f.maxFiles ?? ''} onChange={(e) => set({ maxFiles: e.target.value === '' ? undefined : Number(e.target.value) })} /></MiniField>
|
|
1042
|
+
)}
|
|
1043
|
+
<label className="xeplr-factory-toggle">
|
|
1044
|
+
<input type="checkbox" checked={Boolean(f.required)} onChange={(e) => set({ required: e.target.checked })} />
|
|
1045
|
+
<span>Required</span>
|
|
1046
|
+
</label>
|
|
1047
|
+
</>
|
|
1048
|
+
)}
|
|
1049
|
+
</>
|
|
1050
|
+
)
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1004
1053
|
/** One parameter's value: a dropdown when the function names the values allowed. */
|
|
1005
1054
|
function ParamInput({ field, value, onChange }) {
|
|
1006
1055
|
const allowed = optionValues(field.options)
|
|
@@ -220,6 +220,7 @@ export default function ScreenSample({ ctrl, className, style, renderPopup, rend
|
|
|
220
220
|
error={node.props?.name ? ctrl.errors[node.props.name] : undefined}
|
|
221
221
|
options={CHOICE_TYPES.includes(node.type) ? ctrl.optionsFor(node) : undefined}
|
|
222
222
|
upload={node.type === 'file' ? ctrl.upload : undefined}
|
|
223
|
+
readFile={node.type === 'file' ? ctrl.readFile : undefined}
|
|
223
224
|
rows={node.type === 'table' && ctrl.rowsFor ? ctrl.rowsFor(node) : undefined}
|
|
224
225
|
stepper={node.type === 'stepper' ? {
|
|
225
226
|
active: ctrl.steps.active(node.id),
|
package/src/designs/factory.css
CHANGED
|
@@ -157,15 +157,40 @@
|
|
|
157
157
|
.xeplr-factory-choice { display: flex; align-items: center; gap: calc(8 * var(--xf-u)); cursor: pointer; color: var(--xf-ink); }
|
|
158
158
|
.xeplr-factory-choice input { width: calc(16 * var(--xf-u)); height: calc(16 * var(--xf-u)); min-width: 11px; min-height: 11px; margin: 0; accent-color: var(--xf-accent); }
|
|
159
159
|
|
|
160
|
-
/* ── file ───────────────────────────────────────────────────────────────────
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
160
|
+
/* ── file ───────────────────────────────────────────────────────────────────
|
|
161
|
+
What the field holds — a row per file, a thumbnail for a picture — and,
|
|
162
|
+
while it has room, the picker: a box that looks like the other inputs, the
|
|
163
|
+
real <input type="file"> laid over it, invisible, so a tap opens the
|
|
164
|
+
device's own picker (on a phone: Photos, Camera, Files). */
|
|
165
|
+
.xeplr-factory-files { display: flex; flex-direction: column; gap: calc(6 * var(--xf-u, 1px)); min-width: 0; min-height: 0; overflow: auto; }
|
|
166
|
+
.xeplr-factory-files-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: calc(4 * var(--xf-u, 1px)); }
|
|
167
|
+
.xeplr-factory-files-item { display: flex; align-items: center; gap: calc(8 * var(--xf-u, 1px)); min-width: 0; }
|
|
168
|
+
.xeplr-factory-files-thumb {
|
|
169
|
+
flex: none; width: calc(40 * var(--xf-u, 1px)); height: calc(40 * var(--xf-u, 1px)); min-width: 28px; min-height: 28px;
|
|
170
|
+
object-fit: cover; border-radius: calc(4 * var(--xf-u, 1px)); border: 1px solid var(--xf-field-border); background: var(--xf-field-bg);
|
|
171
|
+
}
|
|
172
|
+
.xeplr-factory-files-icon {
|
|
173
|
+
flex: none; width: calc(40 * var(--xf-u, 1px)); height: calc(40 * var(--xf-u, 1px)); min-width: 28px; min-height: 28px;
|
|
174
|
+
display: inline-flex; align-items: center; justify-content: center; border-radius: calc(4 * var(--xf-u, 1px));
|
|
175
|
+
border: 1px solid var(--xf-field-border); background: var(--xf-field-bg);
|
|
176
|
+
}
|
|
177
|
+
.xeplr-factory-files-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--xf-ink); }
|
|
178
|
+
.xeplr-factory-files-remove {
|
|
164
179
|
flex: none; border: 1px solid var(--xf-field-border); background: var(--xf-field-bg); color: var(--xf-muted);
|
|
165
|
-
border-radius: calc(6 * var(--xf-u)); padding: calc(3 * var(--xf-u)) calc(8 * var(--xf-u)); cursor: pointer; font: inherit;
|
|
180
|
+
border-radius: calc(6 * var(--xf-u, 1px)); padding: calc(3 * var(--xf-u, 1px)) calc(8 * var(--xf-u, 1px)); cursor: pointer; font: inherit;
|
|
166
181
|
}
|
|
167
|
-
.xeplr-factory-
|
|
168
|
-
.xeplr-factory-
|
|
182
|
+
.xeplr-factory-files-remove:hover { color: var(--xf-ink); border-color: var(--xf-accent); }
|
|
183
|
+
.xeplr-factory-files-add {
|
|
184
|
+
position: relative; display: flex; align-items: center; justify-content: center; flex: none;
|
|
185
|
+
min-height: calc(34 * var(--xf-u, 1px)); padding: calc(5 * var(--xf-u, 1px)) calc(10 * var(--xf-u, 1px)); box-sizing: border-box;
|
|
186
|
+
border: 1px dashed var(--xf-field-border); border-radius: calc(6 * var(--xf-u, 1px)); background: var(--xf-field-bg);
|
|
187
|
+
color: var(--xf-ink); cursor: pointer;
|
|
188
|
+
}
|
|
189
|
+
.xeplr-factory-files-add:hover, .xeplr-factory-files-add:focus-within { border-color: var(--xf-accent); }
|
|
190
|
+
.xeplr-factory-files-add--off { cursor: default; opacity: .6; }
|
|
191
|
+
.xeplr-factory-files-input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: inherit; margin: 0; }
|
|
192
|
+
.xeplr-factory-files-add-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
193
|
+
.xeplr-factory-files-error { color: var(--xf-danger); font-size: .9em; white-space: normal; }
|
|
169
194
|
|
|
170
195
|
/* ── stepper ──────────────────────────────────────────────────────────────
|
|
171
196
|
The journey across the top: a circle per step, joined by a line. The line
|
|
@@ -804,7 +829,9 @@ button.xeplr-factory-activity-item:hover, button.xeplr-factory-activity-item:foc
|
|
|
804
829
|
the smallest target a thumb hits reliably. */
|
|
805
830
|
.xeplr-factory-input { --xf-min-font: 16px; font-size: max(16px, 1em); flex: none; min-height: 44px; }
|
|
806
831
|
.xeplr-factory-textarea { min-height: 112px; }
|
|
807
|
-
.xeplr-factory-
|
|
832
|
+
.xeplr-factory-files { overflow: visible; }
|
|
833
|
+
.xeplr-factory-files-add { min-height: 44px; }
|
|
834
|
+
.xeplr-factory-files-thumb, .xeplr-factory-files-icon { width: 48px; height: 48px; }
|
|
808
835
|
/* In the flow now: a message may push the next field down, and wraps
|
|
809
836
|
rather than being cut off at the edge of a narrow screen. */
|
|
810
837
|
.xeplr-factory-error { position: static; white-space: normal; }
|
|
@@ -812,7 +839,7 @@ button.xeplr-factory-activity-item:hover, button.xeplr-factory-activity-item:foc
|
|
|
812
839
|
.xeplr-factory-check input { width: 22px; height: 22px; }
|
|
813
840
|
.xeplr-factory-choice input { width: 20px; height: 20px; }
|
|
814
841
|
.xeplr-factory-choices { overflow: visible; }
|
|
815
|
-
.xeplr-factory-
|
|
842
|
+
.xeplr-factory-files-remove { min-height: 40px; padding-inline: 14px; }
|
|
816
843
|
.xeplr-factory-text { height: auto; overflow: visible; }
|
|
817
844
|
.xeplr-factory-stepper { height: auto; }
|
|
818
845
|
.xeplr-factory-list { max-height: none; }
|
package/src/model.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// import { screenFromSpec, validateDocument, formSchema } from '@xeplr/ui-factory/model'
|
|
5
5
|
|
|
6
|
-
export { CONTROLS, OPTION_ID, OPTION_LABEL, LABEL_VARIANTS, LABEL_PRESETS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, FONT_FAMILIES, LAYOUTS, CHOICE_TYPES, STEP_LIMIT, MULTI_SEPARATOR, FILE_KINDS, MAX_FILE_MB, acceptList, controlGroups, withControls } from './controls.js'
|
|
6
|
+
export { CONTROLS, OPTION_ID, OPTION_LABEL, LABEL_VARIANTS, LABEL_PRESETS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, FONT_FAMILIES, LAYOUTS, CHOICE_TYPES, STEP_LIMIT, MULTI_SEPARATOR, FILE_KINDS, MAX_FILE_MB, IMAGE_EXTENSIONS, FILE_SOURCES, MAX_FILES, isImageFile, filePicker, acceptList, controlGroups, withControls } from './controls.js'
|
|
7
7
|
export {
|
|
8
8
|
DOCUMENT_KIND, DOCUMENT_VERSION, DEFAULT_ASPECT, DEFAULT_WIDTH, MARGIN, GAP,
|
|
9
9
|
createScreen, renameScreen, setScreenProperty, addControl, moveNode, setNodeProperty, getNodeProperty, removeNodes,
|
|
@@ -16,12 +16,12 @@ export { validateDocument, assertValidDocument } from './validateDocument.js'
|
|
|
16
16
|
export { SCREEN_EVENTS, SCREEN_EVENT_KEYS, DECIDE_EVENT, FUNCTION_NAME, screenFunctions, missingFunctions, functionProblems, setEvent, functionsFor } from './events.js'
|
|
17
17
|
// Buttons and lists whose rows a function works out — names and values only.
|
|
18
18
|
export {
|
|
19
|
-
BUTTON_EVENT, LIST_ROWS_EVENT, ROW_DETAILS_EVENT, BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, NOTE_INPUT, CONDITION_OPERATORS,
|
|
20
|
-
conditionMet, buttonShown, buttonDisabled, buttonPressed, screenButtons, buttonById, buttonInput, nodeFunctions, paramProblems, optionValues
|
|
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
|
|
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'
|
|
24
|
-
export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, chooseable, saveState, recordValues, displayValue, fileLabel, toDbValue, fromDbValue, listColumns, listSource } from './values.js'
|
|
24
|
+
export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, chooseable, saveState, recordValues, displayValue, fileLabel, isMultiFile, toDbValue, fromDbValue, listColumns, listSource } from './values.js'
|
|
25
25
|
export { screenFromSpec, screensFromSpec, entityNames, SPEC_HEIGHTS, ROW_GAP } from './generate.js'
|
|
26
26
|
export { scaffoldEntity } from './scaffold.js'
|
|
27
27
|
export { getAtPath, setAtPath } from './propertyPath.js'
|
package/src/pages.jsx
CHANGED
|
@@ -65,6 +65,8 @@ export function FactoryBuilder(props) {
|
|
|
65
65
|
* @prop fetchRecord async ({ screen, id }) → record — Edit loads the record fresh
|
|
66
66
|
* @prop onDelete async ({ id, source, record }) → void
|
|
67
67
|
* @prop onChange (values) → void
|
|
68
|
+
* @prop uploadFile async (file, { screen, field }) → { path } — a file field's upload
|
|
69
|
+
* @prop readFile async (path) → Blob — an attached file's bytes, for a picture's preview
|
|
68
70
|
* @prop screens { id → document } — the screens a list's Edit / New open in a popup
|
|
69
71
|
* @prop loadScreen async (id) → document — for screens not in `screens`
|
|
70
72
|
* @prop controls control registry (default: built-ins)
|
|
@@ -107,6 +109,7 @@ export function FactoryScreen(props) {
|
|
|
107
109
|
onDelete={props.onDelete}
|
|
108
110
|
uploadFile={props.uploadFile}
|
|
109
111
|
fileUrl={props.fileUrl}
|
|
112
|
+
readFile={props.readFile}
|
|
110
113
|
screens={props.screens}
|
|
111
114
|
loadScreen={props.loadScreen}
|
|
112
115
|
controls={props.controls}
|
|
@@ -138,6 +141,10 @@ export function FactoryScreen(props) {
|
|
|
138
141
|
fetchRecord={props.fetchRecord}
|
|
139
142
|
fetchRecords={props.fetchRecords}
|
|
140
143
|
onDelete={props.onDelete}
|
|
144
|
+
// A file field in the popup uploads like one on the page.
|
|
145
|
+
uploadFile={props.uploadFile}
|
|
146
|
+
fileUrl={props.fileUrl}
|
|
147
|
+
readFile={props.readFile}
|
|
141
148
|
screens={props.screens}
|
|
142
149
|
loadScreen={props.loadScreen}
|
|
143
150
|
controls={props.controls}
|
package/src/remote.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// in the app's database; this only sends and receives.
|
|
4
4
|
//
|
|
5
5
|
// import { authFetch } from '@xeplr/ui-account'
|
|
6
|
-
// const factory = createFactoryApi({ fetch: authFetch })
|
|
6
|
+
// const factory = createFactoryApi({ fetch: authFetch, fetchFile: authFetchFile })
|
|
7
7
|
// <FactoryScreen document={screen} {...factory.screenProps} />
|
|
8
8
|
// <FactoryBuilder document={screen} {...factory.builderProps} lockedNames={…} />
|
|
9
9
|
//
|
|
@@ -12,10 +12,13 @@
|
|
|
12
12
|
/**
|
|
13
13
|
* @param options.fetch a fetch that adds auth and tenant headers (e.g. @xeplr/ui-account's authFetch)
|
|
14
14
|
* @param options.base prefix before /factory, if the router is mounted elsewhere (default '')
|
|
15
|
+
* @param options.fetchFile (url) → Blob, signed like `fetch` (@xeplr/ui-account's authFetchFile):
|
|
16
|
+
* how an attached picture is read back for its preview. Without it
|
|
17
|
+
* a file field shows names only — an <img> cannot send the token.
|
|
15
18
|
*/
|
|
16
19
|
import { createChildTable } from './masters.js'
|
|
17
20
|
|
|
18
|
-
export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
21
|
+
export function createFactoryApi({ fetch: doFetch, fetchFile, base = '' } = {}) {
|
|
19
22
|
if (typeof doFetch !== 'function') throw new Error('createFactoryApi: pass { fetch } — usually authFetch from @xeplr/ui-account')
|
|
20
23
|
|
|
21
24
|
// Two kinds of fetch are accepted, because both are what apps have:
|
|
@@ -135,7 +138,16 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
135
138
|
// A button's press on one record → { button, message, output }. The
|
|
136
139
|
// server reads the button — its function and parameters — from the
|
|
137
140
|
// published screen; the request names it and carries the note.
|
|
138
|
-
pressButton: async ({ screen, button, id, note }) => one(await call('POST', `/buttons/${enc(screen)}/${enc(button)}`,
|
|
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 } : {})
|
|
143
|
+
})),
|
|
144
|
+
// A file a button's ask.file takes, stored before the press; the press
|
|
145
|
+
// then names it by the path this answers with.
|
|
146
|
+
uploadButtonFile: async (file, { screen, button }) => {
|
|
147
|
+
const form = new FormData()
|
|
148
|
+
form.append('file', file)
|
|
149
|
+
return one(await send('POST', `/buttons/${enc(screen)}/${enc(button)}/files`, form))
|
|
150
|
+
},
|
|
139
151
|
|
|
140
152
|
// A file field's upload. The answer's `path` is what the record stores;
|
|
141
153
|
// the file itself is read back through the same route, with auth.
|
|
@@ -144,10 +156,12 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
144
156
|
form.append('file', file)
|
|
145
157
|
return one(await send('POST', `/files/${enc(screen)}/${enc(field)}`, form))
|
|
146
158
|
},
|
|
147
|
-
fileUrl: (path) => `${base}/factory/files/${String(path).split('/').map(enc).join('/')}
|
|
159
|
+
fileUrl: (path) => `${base}/factory/files/${String(path).split('/').map(enc).join('/')}`,
|
|
160
|
+
// An attached file's bytes (a picture's preview), when the host said how.
|
|
161
|
+
readFile: typeof fetchFile === 'function' ? (path) => fetchFile(api.fileUrl(path)) : undefined
|
|
148
162
|
}
|
|
149
|
-
api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions, uploadFile: api.uploadFile, fileUrl: api.fileUrl,
|
|
150
|
-
fetchRows: api.fetchRows, fetchRowDetails: api.fetchRowDetails, pressButton: api.pressButton }
|
|
163
|
+
api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions, uploadFile: api.uploadFile, fileUrl: api.fileUrl, readFile: api.readFile,
|
|
164
|
+
fetchRows: api.fetchRows, fetchRowDetails: api.fetchRowDetails, pressButton: api.pressButton, uploadButtonFile: api.uploadButtonFile }
|
|
151
165
|
// makeChildTable: a list on a form can make the table it is about to point
|
|
152
166
|
// at — rows belonging to this record — without leaving the designer. A
|
|
153
167
|
// server act, which is why the builder is given it rather than doing it.
|
package/src/tableSchema.js
CHANGED
|
@@ -88,8 +88,10 @@ export function columnForField(node) {
|
|
|
88
88
|
case 'datetime':
|
|
89
89
|
return { ...base, type: 'timestamp' }
|
|
90
90
|
case 'file':
|
|
91
|
-
// The stored file's path, as the upload answered with it
|
|
92
|
-
|
|
91
|
+
// The stored file's path, as the upload answered with it — several of
|
|
92
|
+
// them, comma separated, in a text column (varchar → text is a widening,
|
|
93
|
+
// so turning "several files" on keeps what is saved).
|
|
94
|
+
return p.multiple ? { ...base, type: 'text' } : { ...base, type: 'varchar', length: FILE_PATH_LENGTH }
|
|
93
95
|
case 'multiselect':
|
|
94
96
|
// Every chosen id in one text column, separated by commas.
|
|
95
97
|
return { ...base, type: 'text' }
|
package/src/useFactoryScreen.js
CHANGED
|
@@ -61,7 +61,9 @@ import { buttonShown, buttonDisabled, buttonPressed } from './buttons.js'
|
|
|
61
61
|
* @param hooks a FactoryHooks (or an object with some of its methods) — get / save / delete / actions
|
|
62
62
|
* @param fetchRows async ({ screen, node, q, filter }) → { rows, counts } — a list whose rows a function works out (rowsFrom)
|
|
63
63
|
* @param fetchRowDetails async ({ screen, node, column, id }) → [{ text, sub?, at?, open? }] — an activity cell's hover box
|
|
64
|
-
* @param pressButton async ({ screen, button, id, note }) → { message } — a button's press, run on the server
|
|
64
|
+
* @param pressButton async ({ screen, button, id, note, files }) → { message } — a button's press, run on the server
|
|
65
|
+
* @param uploadButtonFile async (file, { screen, button }) → { path } — a file a button's `ask.file` takes, stored before the press
|
|
66
|
+
* @param readFile async (path) → Blob — an attached file's bytes: a picture's preview (no preview without it)
|
|
65
67
|
* @param onButtonDone ({ button, result, record }) → void — after a press: the host reloads what it shows
|
|
66
68
|
* @param reloadKey anything — when it changes, every list reloads (the host heard something changed)
|
|
67
69
|
* @param urlKey where the open step of each stepper lives in the URL (`<urlKey>Steps`),
|
|
@@ -82,9 +84,9 @@ import { buttonShown, buttonDisabled, buttonPressed } from './buttons.js'
|
|
|
82
84
|
const SEARCH_PAUSE_MS = 250
|
|
83
85
|
|
|
84
86
|
export function useFactoryScreen({
|
|
85
|
-
document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl,
|
|
87
|
+
document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl, readFile,
|
|
86
88
|
onOpenRecord, onDone, onDirtyChange, params, urlKey, previous,
|
|
87
|
-
fetchRows, fetchRowDetails, pressButton, onButtonDone, reloadKey,
|
|
89
|
+
fetchRows, fetchRowDetails, pressButton, uploadButtonFile, onButtonDone, reloadKey,
|
|
88
90
|
link, onReady,
|
|
89
91
|
controls = CONTROLS
|
|
90
92
|
} = {}) {
|
|
@@ -830,6 +832,7 @@ export function useFactoryScreen({
|
|
|
830
832
|
// and is greyed out is read from it; the press runs on the server, which
|
|
831
833
|
// checks the same again on the saved record.
|
|
832
834
|
const pressRef = useRef(pressButton); pressRef.current = pressButton
|
|
835
|
+
const buttonUploadRef = useRef(uploadButtonFile); buttonUploadRef.current = uploadButtonFile
|
|
833
836
|
const buttonDoneRef = useRef(onButtonDone); buttonDoneRef.current = onButtonDone
|
|
834
837
|
const [pressing, setPressing] = useState(null) // button id on the wire
|
|
835
838
|
const [buttonNote, setButtonNote] = useState(null) // { kind: 'done' | 'error', text, button }
|
|
@@ -842,12 +845,20 @@ export function useFactoryScreen({
|
|
|
842
845
|
pressing,
|
|
843
846
|
note: buttonNote,
|
|
844
847
|
clearNote: () => setButtonNote(null),
|
|
845
|
-
|
|
848
|
+
readFile: readFile || null,
|
|
849
|
+
// A file the button's ask.file takes: stored first, its path sent with the press.
|
|
850
|
+
upload: async (file, b) => {
|
|
851
|
+
if (!buttonUploadRef.current) throw new Error('No uploadButtonFile was provided, so this button cannot take a file')
|
|
852
|
+
const out = await buttonUploadRef.current(file, { screen: doc.id, button: b.id })
|
|
853
|
+
if (!out || !out.path) throw new Error('The upload did not answer with the stored file')
|
|
854
|
+
return out
|
|
855
|
+
},
|
|
856
|
+
press: async (b, typed, files) => {
|
|
846
857
|
if (!pressRef.current) { setButtonNote({ kind: 'error', text: 'No pressButton was provided', button: b.id }); return false }
|
|
847
858
|
setPressing(b.id)
|
|
848
859
|
setButtonNote(null)
|
|
849
860
|
try {
|
|
850
|
-
const result = await pressRef.current({ screen: doc.id, button: b.id, id: recordId, note: typed })
|
|
861
|
+
const result = await pressRef.current({ screen: doc.id, button: b.id, id: recordId, note: typed, files })
|
|
851
862
|
setButtonNote({ kind: 'done', text: (result && result.message) || `${b.label} — done`, button: b.id })
|
|
852
863
|
setListVersion((v) => v + 1)
|
|
853
864
|
if (buttonDoneRef.current) buttonDoneRef.current({ button: b, result, record: buttonRecord })
|
|
@@ -859,7 +870,7 @@ export function useFactoryScreen({
|
|
|
859
870
|
setPressing(null)
|
|
860
871
|
}
|
|
861
872
|
}
|
|
862
|
-
}), [buttonRecord, pressing, buttonNote, doc, recordId])
|
|
873
|
+
}), [buttonRecord, pressing, buttonNote, doc, recordId, readFile])
|
|
863
874
|
|
|
864
875
|
return {
|
|
865
876
|
document: doc,
|
|
@@ -889,6 +900,7 @@ export function useFactoryScreen({
|
|
|
889
900
|
visibleNodes,
|
|
890
901
|
upload,
|
|
891
902
|
fileUrl,
|
|
903
|
+
readFile: readFile || null,
|
|
892
904
|
flush,
|
|
893
905
|
save: runSave,
|
|
894
906
|
saveNow: runSave,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { isImageFile } from './controls.js'
|
|
3
|
+
|
|
4
|
+
// A STORED PICTURE, SHOWN. Attachments come back only through the factory's
|
|
5
|
+
// signed route, which an <img src> cannot call — so the bytes are fetched
|
|
6
|
+
// (`readFile`, the host's signed fetch) and shown from a local object URL,
|
|
7
|
+
// released when the path changes or the field goes away. No JSX.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param path a stored path, as the upload answered with it
|
|
11
|
+
* @param readFile async (path) → Blob, or nothing (then: no preview)
|
|
12
|
+
* @returns {{ url, failed }} — url is null until loaded, for a non-picture,
|
|
13
|
+
* or without readFile
|
|
14
|
+
*/
|
|
15
|
+
export function useFilePreview(path, readFile) {
|
|
16
|
+
const [state, setState] = useState({ url: null, failed: false })
|
|
17
|
+
const wanted = Boolean(path && readFile && isImageFile(path))
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!wanted) { setState({ url: null, failed: false }); return undefined }
|
|
21
|
+
let gone = false
|
|
22
|
+
let url = null
|
|
23
|
+
Promise.resolve()
|
|
24
|
+
.then(() => readFile(path))
|
|
25
|
+
.then((blob) => {
|
|
26
|
+
if (gone) return
|
|
27
|
+
url = URL.createObjectURL(blob)
|
|
28
|
+
setState({ url, failed: false })
|
|
29
|
+
})
|
|
30
|
+
.catch(() => { if (!gone) setState({ url: null, failed: true }) })
|
|
31
|
+
return () => {
|
|
32
|
+
gone = true
|
|
33
|
+
if (url) URL.revokeObjectURL(url)
|
|
34
|
+
}
|
|
35
|
+
// readFile is the host's function; a new one each render must not refetch.
|
|
36
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
37
|
+
}, [path, wanted])
|
|
38
|
+
|
|
39
|
+
return state
|
|
40
|
+
}
|
package/src/validateDocument.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// fields writing the same key — looks like a data problem to whoever uses it.
|
|
12
12
|
|
|
13
13
|
import { SCREEN_EVENT_KEYS, FUNCTION_NAME } from './events.js'
|
|
14
|
-
import { CONTROLS, LABEL_VARIANTS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, LAYOUTS, MULTI_SEPARATOR, MAX_FILE_MB, STEP_LIMIT, acceptList } from './controls.js'
|
|
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
16
|
import { RESERVED_COLUMNS, MAX_IDENTIFIER } from './tableSchema.js'
|
|
17
17
|
import { FIELD_PRESETS, INPUT_TYPES, presetFor } from './presets.js'
|
|
@@ -281,19 +281,44 @@ function checkStep(node, doc, at, err) {
|
|
|
281
281
|
|
|
282
282
|
/** A file field: which extensions it takes, and how big a file may be. */
|
|
283
283
|
function checkFile(props, at, err) {
|
|
284
|
-
|
|
285
|
-
|
|
284
|
+
checkFileRules(props, `${at}.props`, err)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A file's rules — a file field's props, or a button's `ask.file`: what it
|
|
289
|
+
* takes, how large, how many, and from where. `at` is where they sit.
|
|
290
|
+
*/
|
|
291
|
+
function checkFileRules(rules, at, err) {
|
|
292
|
+
if (rules.accept !== undefined) {
|
|
293
|
+
if (typeof rules.accept !== 'string') err(`${at}.accept`, 'must be a string of extensions, e.g. ".pdf,.docx"')
|
|
286
294
|
else {
|
|
287
|
-
const list = acceptList(
|
|
288
|
-
if (!list.length) err(`${at}.
|
|
295
|
+
const list = acceptList(rules.accept)
|
|
296
|
+
if (!list.length) err(`${at}.accept`, 'names no extension — ".pdf,.docx", or leave it out for any file')
|
|
289
297
|
list.forEach((x) => {
|
|
290
|
-
if (!EXTENSION.test(x)) err(`${at}.
|
|
298
|
+
if (!EXTENSION.test(x)) err(`${at}.accept`, `"${x}" is not an extension — letters and digits after a dot, e.g. ".xlsx"`)
|
|
291
299
|
})
|
|
292
300
|
}
|
|
293
301
|
}
|
|
294
|
-
if (
|
|
295
|
-
if (!(Number.isFinite(
|
|
296
|
-
err(`${at}.
|
|
302
|
+
if (rules.maxSize !== undefined) {
|
|
303
|
+
if (!(Number.isFinite(rules.maxSize) && rules.maxSize > 0 && rules.maxSize <= MAX_FILE_MB)) {
|
|
304
|
+
err(`${at}.maxSize`, `must be a number of megabytes from 1 to ${MAX_FILE_MB}`)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (rules.multiple !== undefined && typeof rules.multiple !== 'boolean') err(`${at}.multiple`, 'must be true or false')
|
|
308
|
+
if (rules.maxFiles !== undefined) {
|
|
309
|
+
if (!(Number.isInteger(rules.maxFiles) && rules.maxFiles >= 1 && rules.maxFiles <= MAX_FILES)) err(`${at}.maxFiles`, `must be a whole number of files from 1 to ${MAX_FILES}`)
|
|
310
|
+
else if (!rules.multiple) err(`${at}.maxFiles`, 'is for several files — turn on "multiple", or remove it')
|
|
311
|
+
}
|
|
312
|
+
if (rules.source !== undefined) {
|
|
313
|
+
const sources = FILE_SOURCES.map((s) => s.value)
|
|
314
|
+
if (!sources.includes(rules.source)) err(`${at}.source`, `must be one of: ${sources.join(', ')}`)
|
|
315
|
+
else if (rules.source === 'camera') {
|
|
316
|
+
// A camera takes pictures: a field that also takes PDFs could never be
|
|
317
|
+
// filled the way it says, and one that takes no images at all is wrong.
|
|
318
|
+
const list = typeof rules.accept === 'string' ? acceptList(rules.accept) : []
|
|
319
|
+
if (!list.length || list.some((x) => !IMAGE_EXTENSIONS.includes(x))) {
|
|
320
|
+
err(`${at}.accept`, `a camera takes photos — with "source": "camera", the allowed types must be images only (${IMAGE_EXTENSIONS.join(',')})`)
|
|
321
|
+
}
|
|
297
322
|
}
|
|
298
323
|
}
|
|
299
324
|
}
|
|
@@ -524,12 +549,25 @@ function checkFedColumn(c, p, err) {
|
|
|
524
549
|
|
|
525
550
|
const BUTTON_KEYS = ['id', 'label', 'action', 'params', 'ask', 'when', 'disabledWhen', 'pressedWhen', 'variant']
|
|
526
551
|
const ASK_KEYS = ['title', 'message', 'note']
|
|
552
|
+
// A button's `ask.file`: the file a press takes — a file field's rules, plus its label and required.
|
|
553
|
+
const ASK_FILE_KEYS = ['label', 'required', 'accept', 'maxSize', 'multiple', 'maxFiles', 'source']
|
|
527
554
|
|
|
528
555
|
/**
|
|
529
556
|
* A panel of buttons. Each names a function and the values it is given; the
|
|
530
557
|
* server checks the names against what is registered when the screen is
|
|
531
558
|
* published (functionProblems). Here: the shape.
|
|
532
559
|
*/
|
|
560
|
+
function checkAskFile(f, at, err) {
|
|
561
|
+
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
|
+
const bad = Object.keys(f).filter((k) => !ASK_FILE_KEYS.includes(k))
|
|
563
|
+
if (bad.length) err(at, `only ${ASK_FILE_KEYS.map((k) => `"${k}"`).join(', ')} are used — remove: ${bad.join(', ')}`)
|
|
564
|
+
if (f.label !== undefined && typeof f.label !== 'string') err(`${at}.label`, 'must be a string')
|
|
565
|
+
if (f.required !== undefined && typeof f.required !== 'boolean') err(`${at}.required`, 'must be true or false')
|
|
566
|
+
// The server checks an upload against these, so there must be something to check.
|
|
567
|
+
if (f.accept === undefined) err(`${at}.accept`, 'is required — the extensions it takes, e.g. ".png,.jpg,.jpeg" (the server refuses anything else)')
|
|
568
|
+
checkFileRules(f, at, err)
|
|
569
|
+
}
|
|
570
|
+
|
|
533
571
|
function checkButtons(props, at, err, ids) {
|
|
534
572
|
if (props.title !== undefined && typeof props.title !== 'string') err(`${at}.props.title`, 'must be a string')
|
|
535
573
|
if (!Array.isArray(props.buttons)) { err(`${at}.props.buttons`, 'must be an array of buttons — { id, label, action, params? }'); return }
|
|
@@ -553,9 +591,10 @@ function checkButtons(props, at, err, ids) {
|
|
|
553
591
|
if (b.ask !== undefined) {
|
|
554
592
|
if (!b.ask || typeof b.ask !== 'object' || Array.isArray(b.ask)) err(`${p}.ask`, 'must be { title?, message?, note? } — what the person is asked before it runs')
|
|
555
593
|
else {
|
|
556
|
-
const bad = Object.keys(b.ask).filter((k) => !ASK_KEYS.includes(k))
|
|
557
|
-
if (bad.length) err(`${p}.ask`, `only ${ASK_KEYS.map((k) => `"${k}"`).join(', ')} are used — remove: ${bad.join(', ')}`)
|
|
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(', ')}`)
|
|
558
596
|
ASK_KEYS.forEach((k) => { if (b.ask[k] !== undefined && typeof b.ask[k] !== 'string') err(`${p}.ask.${k}`, 'must be a string') })
|
|
597
|
+
if (b.ask.file !== undefined) checkAskFile(b.ask.file, `${p}.ask.file`, err)
|
|
559
598
|
}
|
|
560
599
|
}
|
|
561
600
|
if (b.variant !== undefined && !BUTTON_VARIANTS.includes(b.variant)) err(`${p}.variant`, `must be one of: ${BUTTON_VARIANTS.join(', ')}`)
|
package/src/values.js
CHANGED
|
@@ -20,7 +20,7 @@ export function formSchema(doc, controls = CONTROLS) {
|
|
|
20
20
|
const def = controls[node.type]
|
|
21
21
|
const p = node.props
|
|
22
22
|
const field = { name: p.name }
|
|
23
|
-
if (def.valueType) field.type = def.valueType
|
|
23
|
+
if (def.valueType) field.type = isMultiFile(node) ? 'array' : def.valueType
|
|
24
24
|
if (p.label) field.description = p.label
|
|
25
25
|
if (p.required) field.required = true
|
|
26
26
|
if (p.default !== undefined) field.default = p.default
|
|
@@ -41,6 +41,8 @@ export function formSchema(doc, controls = CONTROLS) {
|
|
|
41
41
|
delete v.notFuture
|
|
42
42
|
if (Object.keys(v).length) field.validation = v
|
|
43
43
|
}
|
|
44
|
+
// Several files: a list of stored paths, no longer than the field holds.
|
|
45
|
+
if (isMultiFile(node)) field.validation = { ...(field.validation || {}), itemType: 'string', ...(p.maxFiles ? { maxItems: p.maxFiles } : {}) }
|
|
44
46
|
return field
|
|
45
47
|
})
|
|
46
48
|
}
|
|
@@ -101,10 +103,10 @@ export function initialValues(doc, provided = {}, controls = CONTROLS, previous
|
|
|
101
103
|
if (resolved !== undefined) out[name] = resolved
|
|
102
104
|
else if (!isBinding(node.props.default)) out[name] = node.props.default
|
|
103
105
|
else if (node.type === 'checkbox') out[name] = false
|
|
104
|
-
else if (node
|
|
106
|
+
else if (listValued(node)) out[name] = []
|
|
105
107
|
}
|
|
106
108
|
else if (node.type === 'checkbox') out[name] = false
|
|
107
|
-
else if (node
|
|
109
|
+
else if (listValued(node)) out[name] = []
|
|
108
110
|
})
|
|
109
111
|
return out
|
|
110
112
|
}
|
|
@@ -116,7 +118,7 @@ export function initialValues(doc, provided = {}, controls = CONTROLS, previous
|
|
|
116
118
|
*/
|
|
117
119
|
export function parseInput(node, raw) {
|
|
118
120
|
if (node.type === 'checkbox') return Boolean(raw)
|
|
119
|
-
if (node.type === 'multiselect') return Array.isArray(raw) ? raw : fromDbValue(node, raw) || []
|
|
121
|
+
if (node.type === 'multiselect' || isMultiFile(node)) return Array.isArray(raw) ? raw : fromDbValue(node, raw) || []
|
|
120
122
|
if (node.type === 'table') return Array.isArray(raw) ? raw : []
|
|
121
123
|
if (raw === undefined || raw === null || raw === '') return undefined
|
|
122
124
|
if (node.type === 'number') {
|
|
@@ -166,6 +168,13 @@ export function fieldError(node, value, loadedOptions) {
|
|
|
166
168
|
if (v.maxItems != null && ticked.length > v.maxItems) return `${label}: tick at most ${v.maxItems}`
|
|
167
169
|
return null
|
|
168
170
|
}
|
|
171
|
+
// SEVERAL FILES: required is at least one, and no more than it holds.
|
|
172
|
+
if (isMultiFile(node)) {
|
|
173
|
+
const files = Array.isArray(value) ? value : []
|
|
174
|
+
if (!files.length) return p.required ? `${label} is required` : null
|
|
175
|
+
if (p.maxFiles && files.length > p.maxFiles) return `${label}: at most ${p.maxFiles} files`
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
169
178
|
if (node.type === 'multiselect') {
|
|
170
179
|
const chosen = Array.isArray(value) ? value : []
|
|
171
180
|
if (!chosen.length) return p.required ? `${label} is required` : null
|
|
@@ -286,7 +295,7 @@ export function recordValues(doc, record, controls = CONTROLS) {
|
|
|
286
295
|
const v = record ? record[name] : undefined
|
|
287
296
|
if (v !== undefined && v !== null) out[name] = fromDbValue(node, v)
|
|
288
297
|
else if (node.type === 'checkbox') out[name] = false
|
|
289
|
-
else if (node
|
|
298
|
+
else if (listValued(node)) out[name] = []
|
|
290
299
|
})
|
|
291
300
|
return out
|
|
292
301
|
}
|
|
@@ -329,7 +338,7 @@ export function displayValue(node, value, options) {
|
|
|
329
338
|
if (value === undefined || value === null || value === '') return ''
|
|
330
339
|
if (!node) return typeof value === 'object' ? JSON.stringify(value) : value
|
|
331
340
|
if (node.type === 'checkbox') return value ? 'Yes' : 'No'
|
|
332
|
-
if (node.type === 'file') return
|
|
341
|
+
if (node.type === 'file') return (isMultiFile(node) ? fromDbValue(node, value) || [] : [value]).map(fileLabel).join(', ')
|
|
333
342
|
if (node.type === 'datetime') return String(value).replace('T', ' ').slice(0, 16)
|
|
334
343
|
if (node.type === 'dropdown' || node.type === 'radio') return optionName(node, value, options)
|
|
335
344
|
if (node.type === 'multiselect') {
|
|
@@ -344,6 +353,16 @@ function optionName(node, value, options) {
|
|
|
344
353
|
return hit ? hit.name : String(value)
|
|
345
354
|
}
|
|
346
355
|
|
|
356
|
+
/** A file field that holds several files: its value is a list of stored paths. */
|
|
357
|
+
export function isMultiFile(node) {
|
|
358
|
+
return Boolean(node && node.type === 'file' && node.props && node.props.multiple)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A field whose value is a list: a multi-select, a table's ticked rows, several files. */
|
|
362
|
+
function listValued(node) {
|
|
363
|
+
return node.type === 'multiselect' || node.type === 'table' || isMultiFile(node)
|
|
364
|
+
}
|
|
365
|
+
|
|
347
366
|
/** The name at the end of a stored file path, without the id that keeps it apart. */
|
|
348
367
|
export function fileLabel(path) {
|
|
349
368
|
const s = String(path)
|
|
@@ -361,7 +380,7 @@ export function fileLabel(path) {
|
|
|
361
380
|
/** A screen value → what its column stores. */
|
|
362
381
|
export function toDbValue(node, value) {
|
|
363
382
|
if (value === undefined || value === null) return value
|
|
364
|
-
if (node.type === 'multiselect') {
|
|
383
|
+
if (node.type === 'multiselect' || isMultiFile(node)) {
|
|
365
384
|
const list = Array.isArray(value) ? value : [value]
|
|
366
385
|
return list.length ? list.map((v) => String(v)).join(MULTI_SEPARATOR) : null
|
|
367
386
|
}
|
|
@@ -371,7 +390,7 @@ export function toDbValue(node, value) {
|
|
|
371
390
|
/** What a column holds → the screen's value. */
|
|
372
391
|
export function fromDbValue(node, value) {
|
|
373
392
|
if (value === undefined || value === null) return value
|
|
374
|
-
if (node.type === 'multiselect') {
|
|
393
|
+
if (node.type === 'multiselect' || isMultiFile(node)) {
|
|
375
394
|
if (Array.isArray(value)) return value
|
|
376
395
|
const s = String(value)
|
|
377
396
|
return s === '' ? [] : s.split(MULTI_SEPARATOR).filter((x) => x !== '')
|