@xeplr/ui-factory 1.0.6 → 1.0.8
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 +16 -5
- package/README.md +49 -3
- package/package.json +1 -1
- package/src/controls.js +191 -0
- package/src/designs/BuilderSample.jsx +38 -4
- package/src/designs/ControlView.jsx +202 -2
- package/src/designs/PropertyPanel.jsx +71 -1
- package/src/designs/ScreenSample.jsx +34 -3
- package/src/designs/factory.css +65 -0
- package/src/document.js +67 -3
- package/src/generate.js +83 -19
- package/src/model.js +4 -3
- package/src/remote.js +32 -2
- package/src/tableSchema.js +16 -2
- package/src/useFactoryBuilder.js +40 -3
- package/src/useFactoryScreen.js +61 -5
- package/src/validateDocument.js +89 -6
- package/src/values.js +89 -10
package/AUTHORING.md
CHANGED
|
@@ -168,15 +168,20 @@ Each field:
|
|
|
168
168
|
| key | meaning |
|
|
169
169
|
|---|---|
|
|
170
170
|
| `label` | **required** for inputs — shown above the field |
|
|
171
|
-
| `type` | `text` (default), `textarea`, `number`, `date`, `checkbox`, `dropdown`, or `label` for a section heading |
|
|
171
|
+
| `type` | `text` (default), `textarea`, `number`, `date`, `datetime`, `checkbox`, `dropdown`, `radio`, `multiselect`, `file`, `stepper`, or `label` for a section heading |
|
|
172
172
|
| `name` | the key the value is saved under; defaults to the label in camelCase (`"Date of birth"` → `dateOfBirth`) |
|
|
173
173
|
| `required` | `true` to require a value (a checkbox must be ticked) |
|
|
174
174
|
| `placeholder` | text, textarea, number, dropdown |
|
|
175
175
|
| `default` | starting value — a string, a number, `true`/`false`, or a date as `"YYYY-MM-DD"` |
|
|
176
176
|
| `validation` | see below |
|
|
177
177
|
| `width` | `"full"` to take the whole row in a two-column form (textareas and labels always do) |
|
|
178
|
-
| `options` | dropdown: `["Full time", "Part time"]` (ids are made from the names: `full_time`) or `[{ "id": "ft", "name": "Full time" }]` |
|
|
179
|
-
| `table` | dropdown: read options from this table in the app's database |
|
|
178
|
+
| `options` | dropdown, radio, multiselect: `["Full time", "Part time"]` (ids are made from the names: `full_time`) or `[{ "id": "ft", "name": "Full time" }]` |
|
|
179
|
+
| `table` | dropdown, radio, multiselect: read options from this table in the app's database |
|
|
180
|
+
| `layout` | radio, multiselect: `"vertical"` (default) or `"horizontal"` |
|
|
181
|
+
| `accept` | file: the extensions it takes, e.g. `".pdf,.docx"` |
|
|
182
|
+
| `steps` | stepper: `["Connect", "Transform", "Review"]` — at least two |
|
|
183
|
+
| `step` | which step this field is on: the step's label, or its number from 1. Needs a `stepper` earlier in `fields`; without a `step` a field shows on every step |
|
|
184
|
+
| `maxSize` | file: the largest file in megabytes (default 10) |
|
|
180
185
|
| `text`, `variant` | `label` only — `variant` is `heading`, `subheading` (default for a section) or `text` |
|
|
181
186
|
| `style` | how it looks — see Styles |
|
|
182
187
|
|
|
@@ -188,7 +193,9 @@ Each field:
|
|
|
188
193
|
| `textarea` | `minLength`, `maxLength` |
|
|
189
194
|
| `number` | `min`, `max`, `integer` |
|
|
190
195
|
| `date` | `min`, `max` — as `"YYYY-MM-DD"` |
|
|
191
|
-
| `
|
|
196
|
+
| `datetime` | `min`, `max` — as `"YYYY-MM-DDTHH:MM"` |
|
|
197
|
+
| `multiselect` | `minItems`, `maxItems` — how many may be chosen |
|
|
198
|
+
| `checkbox`, `dropdown`, `radio`, `file` | none (use `required`) |
|
|
192
199
|
|
|
193
200
|
Anything else is rejected, with the list of what is allowed.
|
|
194
201
|
|
|
@@ -236,10 +243,14 @@ A dropdown **always saves an `id` and shows a `name`.**
|
|
|
236
243
|
| notes, description, address, comments | `textarea` |
|
|
237
244
|
| amount, salary, quantity, age | `number` (`integer: true` for counts) |
|
|
238
245
|
| a date — birth, start, due | `date` |
|
|
246
|
+
| an appointment, a deadline with a time, when something happened | `datetime` |
|
|
239
247
|
| yes/no, agree, active, remote | `checkbox` |
|
|
240
|
-
| one of a known set | `dropdown` with `options` |
|
|
248
|
+
| one of a known set | `dropdown` with `options` — a `radio` group when there are two or three and they should all be visible |
|
|
241
249
|
| one of the app's records | `dropdown` with `table` |
|
|
250
|
+
| several of a set — tags, skills, days | `multiselect` |
|
|
251
|
+
| a document, a contract, a spreadsheet, a photo | `file` with `accept` (`".pdf,.docx"`) |
|
|
242
252
|
| a section title between groups | `{ "type": "label", "text": "…", "variant": "subheading" }` |
|
|
253
|
+
| a long form people work through in stages | a `stepper`, then `step` on each field |
|
|
243
254
|
|
|
244
255
|
Keep labels short and in sentence case ("Start date", not "START DATE:"). Mark only what the request needs as required.
|
|
245
256
|
|
package/README.md
CHANGED
|
@@ -87,6 +87,7 @@ import { FactoryScreen } from '@xeplr/ui-factory'
|
|
|
87
87
|
fetchRecord={async ({ screen, id }) => api.record(screen, id)} // optional: Edit loads the record fresh
|
|
88
88
|
onDelete={async ({ id, source }) => api.remove(source, id)}
|
|
89
89
|
fetchOptions={async ({ table }) => api.options(table)}
|
|
90
|
+
uploadFile={async (file, { screen, field }) => api.upload(file, screen, field)} // a file field: → { path }
|
|
90
91
|
screens={{ employee_edit: editScreen }} // what a list's Edit / New open in a popup
|
|
91
92
|
/>
|
|
92
93
|
```
|
|
@@ -95,7 +96,23 @@ import { FactoryScreen } from '@xeplr/ui-factory'
|
|
|
95
96
|
|
|
96
97
|
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.
|
|
97
98
|
|
|
98
|
-
Values arrive typed: numbers as numbers, checkboxes as booleans, dates as `"YYYY-MM-DD"`, and a dropdown as the option's own `id` (a table's numeric id stays a number)
|
|
99
|
+
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**.
|
|
100
|
+
|
|
101
|
+
### Where each value is kept
|
|
102
|
+
|
|
103
|
+
A record is columns in a real table, never JSON — so the two types that are not
|
|
104
|
+
one scalar have a rule of their own:
|
|
105
|
+
|
|
106
|
+
* **Multi-select** — every chosen id in ONE `text` column, joined by commas. An
|
|
107
|
+
option id may therefore not contain a comma; the checker refuses one that
|
|
108
|
+
does. `toDbValue` / `fromDbValue` do the conversion, and both the browser and
|
|
109
|
+
the server use them, so it is written once.
|
|
110
|
+
* **File** — the column holds the path the upload answered with, not the file.
|
|
111
|
+
The file itself is stored by `@xeplr/factory` and read back through its
|
|
112
|
+
authenticated route; nothing is ever served without a check.
|
|
113
|
+
* **Date and time** — a `timestamp` column, read back as text so no timezone can
|
|
114
|
+
move the moment. A `date` column may be widened to `timestamp` on publish
|
|
115
|
+
(the day becomes midnight); the reverse is refused, as it would lose the time.
|
|
99
116
|
|
|
100
117
|
### Front-end hooks
|
|
101
118
|
|
|
@@ -159,16 +176,45 @@ const clean = schemaHandler.applySchema(formSchema(screen), req.body) // throw
|
|
|
159
176
|
| `textarea` | string | `minLength`, `maxLength` |
|
|
160
177
|
| `number` | number | `min`, `max`, `integer` |
|
|
161
178
|
| `date` | `"YYYY-MM-DD"` | `min`, `max` |
|
|
179
|
+
| `datetime` | `"YYYY-MM-DDTHH:MM"` | `min`, `max` |
|
|
162
180
|
| `checkbox` | boolean | `required` = must be ticked |
|
|
163
181
|
| `dropdown` | option id | — |
|
|
182
|
+
| `radio` | option id | — |
|
|
183
|
+
| `multiselect` | array of option ids | `minItems`, `maxItems` |
|
|
184
|
+
| `file` | the stored file's path | `accept` (extensions), `maxSize` (MB) |
|
|
185
|
+
| `stepper` | — | a journey across the top; every other control says which step it is on |
|
|
164
186
|
| `label` | — | text with a heading, subheading or text preset |
|
|
165
187
|
| `list` | — | saved records, with New / Edit / Delete |
|
|
166
188
|
|
|
167
189
|
Every control also takes `style` — see [AUTHORING.md](./AUTHORING.md#styles) for the keys. Sizes are pixels at the screen's design `width` (default 800): a screen is shown at that size, never stretched, and scaled down only on a narrower display.
|
|
168
190
|
|
|
169
|
-
###
|
|
191
|
+
### Steps
|
|
192
|
+
|
|
193
|
+
A **stepper** turns one screen into several: a bar of steps across the top, and
|
|
194
|
+
one step's controls showing at a time. Type the steps as one line — `Connect,
|
|
195
|
+
Transform, Review` — and **click a step on the canvas to build it**: what you
|
|
196
|
+
drop while it is open belongs to it, and the canvas shows only that step. A
|
|
197
|
+
control can be put on *every* step instead (a heading, a list), which is what
|
|
198
|
+
one with no step means.
|
|
199
|
+
|
|
200
|
+
```json
|
|
201
|
+
{ "id": "n7", "type": "stepper", "props": { "steps": [{ "key": "connect", "label": "Connect" }, { "key": "review", "label": "Review" }] } }
|
|
202
|
+
{ "id": "n8", "type": "text", "step": { "of": "n7", "index": 0 }, "props": { "name": "host", "label": "Host" } }
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`step` sits on the **node**, beside `x`/`y`/`w`/`h` — not in `props`. The
|
|
206
|
+
document stays flat, so a control is still moved, styled, checked and saved
|
|
207
|
+
exactly as before; the step only decides when it is on screen. Renaming a step
|
|
208
|
+
keeps its `key`, so the controls on it stay where they are.
|
|
209
|
+
|
|
210
|
+
**A step is not a separate form.** Every field is a column, is checked, and is
|
|
211
|
+
saved, whichever step is showing — a screen still saves itself as it is filled
|
|
212
|
+
in. **Next** holds at a step whose own fields are not filled in, and says which,
|
|
213
|
+
rather than moving on and leaving a problem behind where nobody is looking.
|
|
214
|
+
|
|
215
|
+
### Options: dropdown, radio group, multi-select
|
|
170
216
|
|
|
171
|
-
|
|
217
|
+
All three **save an `id` and show a `name`**, from either source:
|
|
172
218
|
|
|
173
219
|
```json
|
|
174
220
|
{ "source": "static", "options": [{ "id": "ft", "name": "Full time" }, { "id": "pt", "name": "Part time" }] }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/ui-factory",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
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/controls.js
CHANGED
|
@@ -101,6 +101,60 @@ const FIELD_BASICS = (extra) => ({
|
|
|
101
101
|
|
|
102
102
|
const PLACEHOLDER = { path: 'props.placeholder', label: 'Placeholder', type: 'text' }
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* A multi-select keeps its chosen ids in ONE text column, joined by this. An
|
|
106
|
+
* option id may therefore not contain it — the document checker refuses one
|
|
107
|
+
* that does, rather than letting a value split itself in two on the way back.
|
|
108
|
+
*/
|
|
109
|
+
export const MULTI_SEPARATOR = ','
|
|
110
|
+
|
|
111
|
+
/** The controls whose value is chosen from options — static, or a table's rows. */
|
|
112
|
+
export const CHOICE_TYPES = ['dropdown', 'radio', 'multiselect']
|
|
113
|
+
|
|
114
|
+
/** How a group of options is laid out — one per line, or side by side. */
|
|
115
|
+
export const LAYOUTS = ['vertical', 'horizontal']
|
|
116
|
+
|
|
117
|
+
const LAYOUT = { path: 'props.layout', label: 'Arrangement', type: 'select', options: [
|
|
118
|
+
{ value: 'vertical', label: 'One per line' },
|
|
119
|
+
{ value: 'horizontal', label: 'Side by side' }
|
|
120
|
+
] }
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What a file field may accept, as EXTENSIONS. Extensions, not MIME types,
|
|
124
|
+
* because that is how people describe a file ("a PDF", "a spreadsheet") and
|
|
125
|
+
* how the server checks it — a browser's reported type is the sender's word
|
|
126
|
+
* for it, and can be anything.
|
|
127
|
+
*/
|
|
128
|
+
export const FILE_KINDS = {
|
|
129
|
+
document: { label: 'Documents', accept: '.pdf,.doc,.docx,.odt,.rtf,.txt' },
|
|
130
|
+
pdf: { label: 'PDF only', accept: '.pdf' },
|
|
131
|
+
spreadsheet: { label: 'Spreadsheets', accept: '.xls,.xlsx,.csv,.ods' },
|
|
132
|
+
image: { label: 'Images', accept: '.png,.jpg,.jpeg,.gif,.webp,.svg' },
|
|
133
|
+
any: { label: 'Any of the above', accept: '.pdf,.doc,.docx,.odt,.rtf,.txt,.xls,.xlsx,.csv,.ods,.png,.jpg,.jpeg,.gif,.webp,.svg' }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** An extension list as one string → the same list, cleaned and lower-cased. */
|
|
137
|
+
export function acceptList(accept) {
|
|
138
|
+
return String(accept || '')
|
|
139
|
+
.split(',')
|
|
140
|
+
.map((s) => s.trim().toLowerCase())
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.map((s) => (s.startsWith('.') ? s : '.' + s))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A STEP a control belongs to, on the node itself (beside x/y/w/h/z) rather
|
|
147
|
+
* than in its props: it is not something the control does, it is where the
|
|
148
|
+
* control is. A node with no `step` is on the screen whatever step is showing
|
|
149
|
+
* — a heading, a list, the stepper itself.
|
|
150
|
+
*
|
|
151
|
+
* node.step = { of: '<the stepper node's id>', index: 0 }
|
|
152
|
+
*/
|
|
153
|
+
export const STEP_LIMIT = 12
|
|
154
|
+
|
|
155
|
+
/** Largest upload a file field may take, in bytes. */
|
|
156
|
+
export const MAX_FILE_MB = 200
|
|
157
|
+
|
|
104
158
|
export const CONTROLS = {
|
|
105
159
|
text: {
|
|
106
160
|
type: 'text',
|
|
@@ -248,6 +302,143 @@ export const CONTROLS = {
|
|
|
248
302
|
]
|
|
249
303
|
},
|
|
250
304
|
|
|
305
|
+
radio: {
|
|
306
|
+
type: 'radio',
|
|
307
|
+
label: 'Radio group',
|
|
308
|
+
group: 'Inputs',
|
|
309
|
+
input: true,
|
|
310
|
+
// One of the options' ids — a table's may be numbers, like a dropdown.
|
|
311
|
+
valueType: null,
|
|
312
|
+
defaultSize: { w: 0.44, h: 0.14 },
|
|
313
|
+
defaults: { label: 'Choose one', data: { source: 'static', options: [] }, layout: 'vertical' },
|
|
314
|
+
props: ['name', 'label', 'required', 'default', 'data', 'layout', 'style'],
|
|
315
|
+
validation: [],
|
|
316
|
+
styles: INPUT_STYLES.filter((k) => k !== 'textAlign'),
|
|
317
|
+
properties: [
|
|
318
|
+
FIELD_BASICS(),
|
|
319
|
+
{
|
|
320
|
+
key: 'data',
|
|
321
|
+
title: 'Options',
|
|
322
|
+
fields: [
|
|
323
|
+
{ path: 'props.data', label: 'Options come from', type: 'dataSource' },
|
|
324
|
+
LAYOUT
|
|
325
|
+
]
|
|
326
|
+
},
|
|
327
|
+
...styleGroups(INPUT_STYLES.filter((k) => k !== 'textAlign'))
|
|
328
|
+
]
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
multiselect: {
|
|
332
|
+
type: 'multiselect',
|
|
333
|
+
label: 'Multi-select',
|
|
334
|
+
group: 'Inputs',
|
|
335
|
+
input: true,
|
|
336
|
+
// Several ids. Stored in ONE text column, joined by commas — which is why
|
|
337
|
+
// an option's id may not contain one (the document checker says so).
|
|
338
|
+
valueType: 'array',
|
|
339
|
+
defaultSize: { w: 0.44, h: 0.18 },
|
|
340
|
+
defaults: { label: 'Choose any', data: { source: 'static', options: [] }, layout: 'vertical' },
|
|
341
|
+
props: ['name', 'label', 'required', 'data', 'layout', 'validation', 'style'],
|
|
342
|
+
validation: ['minItems', 'maxItems'],
|
|
343
|
+
styles: INPUT_STYLES.filter((k) => k !== 'textAlign'),
|
|
344
|
+
properties: [
|
|
345
|
+
FIELD_BASICS(),
|
|
346
|
+
{
|
|
347
|
+
key: 'data',
|
|
348
|
+
title: 'Options',
|
|
349
|
+
fields: [
|
|
350
|
+
{ path: 'props.data', label: 'Options come from', type: 'dataSource' },
|
|
351
|
+
LAYOUT
|
|
352
|
+
]
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
key: 'validation',
|
|
356
|
+
title: 'Validation',
|
|
357
|
+
fields: [
|
|
358
|
+
{ path: 'props.validation.minItems', label: 'Choose at least', type: 'number' },
|
|
359
|
+
{ path: 'props.validation.maxItems', label: 'Choose at most', type: 'number' }
|
|
360
|
+
]
|
|
361
|
+
},
|
|
362
|
+
...styleGroups(INPUT_STYLES.filter((k) => k !== 'textAlign'))
|
|
363
|
+
]
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
datetime: {
|
|
367
|
+
type: 'datetime',
|
|
368
|
+
label: 'Date & time',
|
|
369
|
+
group: 'Inputs',
|
|
370
|
+
input: true,
|
|
371
|
+
valueType: 'date',
|
|
372
|
+
defaultSize: { w: 0.44, h: 0.08 },
|
|
373
|
+
defaults: { label: 'Date and time' },
|
|
374
|
+
props: ['name', 'label', 'required', 'default', 'validation', 'style'],
|
|
375
|
+
validation: ['min', 'max'],
|
|
376
|
+
styles: INPUT_STYLES,
|
|
377
|
+
properties: [
|
|
378
|
+
FIELD_BASICS([{ path: 'props.default', label: 'Default value', type: 'datetime' }]),
|
|
379
|
+
{
|
|
380
|
+
key: 'validation',
|
|
381
|
+
title: 'Validation',
|
|
382
|
+
fields: [
|
|
383
|
+
{ path: 'props.validation.min', label: 'Earliest', type: 'datetime' },
|
|
384
|
+
{ path: 'props.validation.max', label: 'Latest', type: 'datetime' }
|
|
385
|
+
]
|
|
386
|
+
},
|
|
387
|
+
...styleGroups(INPUT_STYLES)
|
|
388
|
+
]
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
file: {
|
|
392
|
+
type: 'file',
|
|
393
|
+
label: 'File upload',
|
|
394
|
+
group: 'Inputs',
|
|
395
|
+
input: true,
|
|
396
|
+
// The value is the stored file's path, as the upload answered with it.
|
|
397
|
+
valueType: 'string',
|
|
398
|
+
defaultSize: { w: 0.44, h: 0.1 },
|
|
399
|
+
defaults: { label: 'File', accept: FILE_KINDS.document.accept, maxSize: 10 },
|
|
400
|
+
props: ['name', 'label', 'required', 'accept', 'maxSize', 'style'],
|
|
401
|
+
validation: [],
|
|
402
|
+
styles: INPUT_STYLES.filter((k) => k !== 'textAlign'),
|
|
403
|
+
properties: [
|
|
404
|
+
FIELD_BASICS(),
|
|
405
|
+
{
|
|
406
|
+
key: 'file',
|
|
407
|
+
title: 'File',
|
|
408
|
+
fields: [
|
|
409
|
+
{ path: 'props.accept', label: 'Allowed types', type: 'accept', help: 'Extensions, comma separated — the server refuses anything else' },
|
|
410
|
+
{ path: 'props.maxSize', label: 'Largest size (MB)', type: 'number', min: 1, max: 200 }
|
|
411
|
+
]
|
|
412
|
+
},
|
|
413
|
+
...styleGroups(INPUT_STYLES.filter((k) => k !== 'textAlign'))
|
|
414
|
+
]
|
|
415
|
+
},
|
|
416
|
+
|
|
417
|
+
stepper: {
|
|
418
|
+
type: 'stepper',
|
|
419
|
+
label: 'Stepper',
|
|
420
|
+
group: 'Content',
|
|
421
|
+
input: false,
|
|
422
|
+
defaultSize: { w: 0.92, h: 0.09 },
|
|
423
|
+
// Steps are typed as one line — "Connect, Transform, Review" — because
|
|
424
|
+
// that is how someone describes them. Each becomes { key, label }.
|
|
425
|
+
defaults: { steps: [{ key: 'step_1', label: 'Step 1' }, { key: 'step_2', label: 'Step 2' }], showNumbers: true },
|
|
426
|
+
props: ['steps', 'showNumbers', 'style'],
|
|
427
|
+
validation: [],
|
|
428
|
+
styles: ['fontFamily', 'fontSize', 'fontWeight', 'color', 'background', 'borderColor', 'borderWidth', 'borderRadius'],
|
|
429
|
+
properties: [
|
|
430
|
+
{
|
|
431
|
+
key: 'steps',
|
|
432
|
+
title: 'Steps',
|
|
433
|
+
fields: [
|
|
434
|
+
{ path: 'props.steps', label: 'Steps', type: 'steps', help: 'One line, separated by commas. Click a step on the canvas to build it.' },
|
|
435
|
+
{ path: 'props.showNumbers', label: 'Number the steps', type: 'toggle' }
|
|
436
|
+
]
|
|
437
|
+
},
|
|
438
|
+
...styleGroups(['fontFamily', 'fontSize', 'fontWeight', 'color', 'background', 'borderColor', 'borderWidth', 'borderRadius'])
|
|
439
|
+
]
|
|
440
|
+
},
|
|
441
|
+
|
|
251
442
|
label: {
|
|
252
443
|
type: 'label',
|
|
253
444
|
label: 'Label',
|
|
@@ -117,11 +117,36 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
|
|
|
117
117
|
onDragOver={(e) => { if ([...e.dataTransfer.types].includes(DRAG_TYPE)) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }}
|
|
118
118
|
onDrop={onDrop}
|
|
119
119
|
>
|
|
120
|
+
{ctrl.steps.building && (
|
|
121
|
+
<div className="xeplr-factory-building" style={{ maxWidth: doc.width }}>
|
|
122
|
+
<span className="xeplr-factory-building-what">Building</span>
|
|
123
|
+
{ctrl.steps.labels(ctrl.steps.building).map((s2, i) => (
|
|
124
|
+
<button
|
|
125
|
+
key={s2.key}
|
|
126
|
+
type="button"
|
|
127
|
+
className={'xeplr-factory-building-step' + (i === ctrl.steps.active(ctrl.steps.building) ? ' is-active' : '')}
|
|
128
|
+
onClick={() => ctrl.steps.go(ctrl.steps.building, i)}
|
|
129
|
+
>
|
|
130
|
+
{s2.label}
|
|
131
|
+
</button>
|
|
132
|
+
))}
|
|
133
|
+
{ctrl.selectedNode && ctrl.selectedNode.type !== 'stepper' && (
|
|
134
|
+
<label className="xeplr-factory-building-every">
|
|
135
|
+
<input
|
|
136
|
+
type="checkbox"
|
|
137
|
+
checked={!ctrl.steps.of(ctrl.selectedNode)}
|
|
138
|
+
onChange={(e) => ctrl.steps.set(ctrl.selectedNode.id, e.target.checked ? null : { of: ctrl.steps.building, index: ctrl.steps.active(ctrl.steps.building) })}
|
|
139
|
+
/>
|
|
140
|
+
<span>Show the selected control on every step</span>
|
|
141
|
+
</label>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
120
145
|
<div className="xeplr-factory-frame" style={{ maxWidth: doc.width }}>
|
|
121
146
|
<XeplrCanvas
|
|
122
147
|
className="xeplr-factory-canvas"
|
|
123
148
|
style={screenStyle(doc)}
|
|
124
|
-
items={
|
|
149
|
+
items={ctrl.visibleNodes}
|
|
125
150
|
units="fraction"
|
|
126
151
|
pageAspect={doc.aspect}
|
|
127
152
|
features={{ resize: true, marquee: true }}
|
|
@@ -131,11 +156,20 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
|
|
|
131
156
|
minSizeFor={() => ({ minWidth: 16, minHeight: 12 })}
|
|
132
157
|
renderItem={(node, { selected }) => (
|
|
133
158
|
<div className={`xeplr-factory-design-node${ctrl.errorsByNode[node.id] ? ' has-error' : ''}${selected ? ' is-selected' : ''}`}>
|
|
134
|
-
<ControlView
|
|
159
|
+
<ControlView
|
|
160
|
+
node={node}
|
|
161
|
+
mode="design"
|
|
162
|
+
list={node.type === 'list' ? { doc } : undefined}
|
|
163
|
+
// Clicking a step on the canvas OPENS it: the canvas then
|
|
164
|
+
// shows that step's controls, and what you drop joins them.
|
|
165
|
+
stepper={node.type === 'stepper' ? { active: ctrl.steps.active(node.id), onStep: (i) => ctrl.steps.go(node.id, i) } : undefined}
|
|
166
|
+
/>
|
|
135
167
|
</div>
|
|
136
168
|
)}
|
|
137
|
-
underlay={
|
|
138
|
-
<div className="xeplr-factory-empty">
|
|
169
|
+
underlay={ctrl.visibleNodes.length === 0 && (
|
|
170
|
+
<div className="xeplr-factory-empty">
|
|
171
|
+
{doc.nodes.length === 0 ? 'Drag controls here from the left' : 'This step is empty — drop a control to start it'}
|
|
172
|
+
</div>
|
|
139
173
|
)}
|
|
140
174
|
/>
|
|
141
175
|
</div>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
1
2
|
import { LABEL_PRESETS } from '../controls.js'
|
|
3
|
+
import { stepsOf } from '../document.js'
|
|
2
4
|
import ListView from './ListView.jsx'
|
|
3
5
|
import { fieldStyle, labelStyle, boxStyle } from './styles.js'
|
|
4
6
|
|
|
@@ -11,7 +13,7 @@ import { fieldStyle, labelStyle, boxStyle } from './styles.js'
|
|
|
11
13
|
|
|
12
14
|
const ids = (node) => `xeplr-factory-${node.id}`
|
|
13
15
|
|
|
14
|
-
export default function ControlView({ node, mode = 'live', value, error, options, onChange, onBlur, disabled, list }) {
|
|
16
|
+
export default function ControlView({ node, mode = 'live', value, error, options, onChange, onBlur, disabled, list, upload, stepper }) {
|
|
15
17
|
const View = VIEWS[node.type]
|
|
16
18
|
if (!View) {
|
|
17
19
|
return <div className="xeplr-factory-unknown">Unknown control “{node.type}”</div>
|
|
@@ -29,6 +31,8 @@ export default function ControlView({ node, mode = 'live', value, error, options
|
|
|
29
31
|
onBlur={onBlur || (() => {})}
|
|
30
32
|
disabled={design || disabled}
|
|
31
33
|
list={list}
|
|
34
|
+
upload={upload}
|
|
35
|
+
stepper={stepper}
|
|
32
36
|
/>
|
|
33
37
|
)
|
|
34
38
|
}
|
|
@@ -37,7 +41,7 @@ function Field({ node, p, error, children, inline }) {
|
|
|
37
41
|
return (
|
|
38
42
|
<div className={`xeplr-factory-field${inline ? ' xeplr-factory-field--inline' : ''}${error ? ' has-error' : ''}`} style={fieldStyle(p.style)}>
|
|
39
43
|
{!inline && (
|
|
40
|
-
<label className="xeplr-factory-label" htmlFor={ids(node)} style={labelStyle(p.style)}>
|
|
44
|
+
<label className="xeplr-factory-label" id={`${ids(node)}-label`} htmlFor={ids(node)} style={labelStyle(p.style)}>
|
|
41
45
|
{p.label}{p.required && <span className="xeplr-factory-required" aria-hidden="true"> *</span>}
|
|
42
46
|
</label>
|
|
43
47
|
)}
|
|
@@ -168,6 +172,197 @@ function DropdownView({ node, p, design, value, error, options, onChange, onBlur
|
|
|
168
172
|
)
|
|
169
173
|
}
|
|
170
174
|
|
|
175
|
+
/** The options a group control shows: typed in, or loaded from a table. */
|
|
176
|
+
function groupItems(p, design, options) {
|
|
177
|
+
const data = p.data || {}
|
|
178
|
+
return data.source === 'static' ? (data.options || []) : (design ? [] : options.items)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** What the designer shows in place of options it cannot load on the canvas. */
|
|
182
|
+
function OptionsHint({ data }) {
|
|
183
|
+
const n = (data.options || []).length
|
|
184
|
+
return (
|
|
185
|
+
<div className="xeplr-factory-hint">
|
|
186
|
+
{data.source === 'table'
|
|
187
|
+
? (data.table ? `from table ${data.table}` : 'no table chosen')
|
|
188
|
+
: `${n} option${n === 1 ? '' : 's'}`}
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function RadioView({ node, p, design, value, error, options, onChange, onBlur, disabled }) {
|
|
194
|
+
const items = groupItems(p, design, options)
|
|
195
|
+
const text = boxStyle(p.style)
|
|
196
|
+
return (
|
|
197
|
+
<Field node={node} p={p} error={error || (!design && options.error) || null}>
|
|
198
|
+
<div className={`xeplr-factory-choices xeplr-factory-choices--${p.layout === 'horizontal' ? 'horizontal' : 'vertical'}`} role="radiogroup" aria-labelledby={`${ids(node)}-label`}>
|
|
199
|
+
{items.map((o) => (
|
|
200
|
+
<label key={String(o.id)} className="xeplr-factory-choice" style={{ fontSize: text.fontSize, fontWeight: text.fontWeight, fontStyle: text.fontStyle, color: text.color }}>
|
|
201
|
+
<input
|
|
202
|
+
type="radio"
|
|
203
|
+
name={p.name}
|
|
204
|
+
value={String(o.id)}
|
|
205
|
+
checked={value !== undefined && value !== null && String(value) === String(o.id)}
|
|
206
|
+
disabled={disabled || (!design && options.loading)}
|
|
207
|
+
tabIndex={disabled ? -1 : undefined}
|
|
208
|
+
onChange={() => onChange(o.id)}
|
|
209
|
+
onBlur={onBlur}
|
|
210
|
+
/>
|
|
211
|
+
<span>{o.name}</span>
|
|
212
|
+
</label>
|
|
213
|
+
))}
|
|
214
|
+
</div>
|
|
215
|
+
{design && <OptionsHint data={p.data || {}} />}
|
|
216
|
+
{!design && options.loading && <div className="xeplr-factory-hint">Loading…</div>}
|
|
217
|
+
</Field>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function MultiselectView({ node, p, design, value, error, options, onChange, onBlur, disabled }) {
|
|
222
|
+
const items = groupItems(p, design, options)
|
|
223
|
+
const chosen = Array.isArray(value) ? value : []
|
|
224
|
+
const text = boxStyle(p.style)
|
|
225
|
+
const toggle = (id, on) => {
|
|
226
|
+
const next = chosen.filter((v) => String(v) !== String(id))
|
|
227
|
+
onChange(on ? [...next, id] : next)
|
|
228
|
+
}
|
|
229
|
+
return (
|
|
230
|
+
<Field node={node} p={p} error={error || (!design && options.error) || null}>
|
|
231
|
+
<div className={`xeplr-factory-choices xeplr-factory-choices--${p.layout === 'horizontal' ? 'horizontal' : 'vertical'}`} role="group" aria-labelledby={`${ids(node)}-label`}>
|
|
232
|
+
{items.map((o) => (
|
|
233
|
+
<label key={String(o.id)} className="xeplr-factory-choice" style={{ fontSize: text.fontSize, fontWeight: text.fontWeight, fontStyle: text.fontStyle, color: text.color }}>
|
|
234
|
+
<input
|
|
235
|
+
type="checkbox"
|
|
236
|
+
name={p.name}
|
|
237
|
+
value={String(o.id)}
|
|
238
|
+
checked={chosen.some((v) => String(v) === String(o.id))}
|
|
239
|
+
disabled={disabled || (!design && options.loading)}
|
|
240
|
+
tabIndex={disabled ? -1 : undefined}
|
|
241
|
+
onChange={(e) => toggle(o.id, e.target.checked)}
|
|
242
|
+
onBlur={onBlur}
|
|
243
|
+
/>
|
|
244
|
+
<span>{o.name}</span>
|
|
245
|
+
</label>
|
|
246
|
+
))}
|
|
247
|
+
</div>
|
|
248
|
+
{design && <OptionsHint data={p.data || {}} />}
|
|
249
|
+
{!design && options.loading && <div className="xeplr-factory-hint">Loading…</div>}
|
|
250
|
+
</Field>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function DatetimeView({ node, p, value, error, onChange, onBlur, disabled }) {
|
|
255
|
+
const v = p.validation || {}
|
|
256
|
+
return (
|
|
257
|
+
<Field node={node} p={p} error={error}>
|
|
258
|
+
<input
|
|
259
|
+
className="xeplr-factory-input"
|
|
260
|
+
type="datetime-local"
|
|
261
|
+
min={v.min}
|
|
262
|
+
max={v.max}
|
|
263
|
+
value={value ?? ''}
|
|
264
|
+
onChange={(e) => onChange(e.target.value)}
|
|
265
|
+
{...inputProps(node, p, error, disabled, onBlur)}
|
|
266
|
+
/>
|
|
267
|
+
</Field>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A file field holds the stored file's PATH, not the file. Picking one uploads
|
|
273
|
+
* it straight away (`upload`, supplied by the screen) and what comes back is
|
|
274
|
+
* the value that is saved with the record.
|
|
275
|
+
*/
|
|
276
|
+
function FileView({ node, p, design, value, error, onChange, onBlur, disabled, upload }) {
|
|
277
|
+
const [busy, setBusy] = useState(false)
|
|
278
|
+
const [failed, setFailed] = useState(null)
|
|
279
|
+
const accept = p.accept || ''
|
|
280
|
+
const choose = async (file) => {
|
|
281
|
+
if (!file || !upload) return
|
|
282
|
+
setFailed(null)
|
|
283
|
+
setBusy(true)
|
|
284
|
+
try {
|
|
285
|
+
const out = await upload(file, node)
|
|
286
|
+
onChange(out && out.path ? out.path : undefined)
|
|
287
|
+
} catch (e) {
|
|
288
|
+
setFailed(e && e.message ? e.message : 'The file could not be uploaded')
|
|
289
|
+
} finally {
|
|
290
|
+
setBusy(false)
|
|
291
|
+
onBlur()
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return (
|
|
295
|
+
<Field node={node} p={p} error={error || failed}>
|
|
296
|
+
{value
|
|
297
|
+
? (
|
|
298
|
+
<div className="xeplr-factory-file">
|
|
299
|
+
<span className="xeplr-factory-file-name" title={String(value)}>{fileName(value)}</span>
|
|
300
|
+
<button type="button" className="xeplr-factory-file-clear" disabled={disabled} onClick={() => { onChange(undefined); onBlur() }}>Remove</button>
|
|
301
|
+
</div>
|
|
302
|
+
)
|
|
303
|
+
: (
|
|
304
|
+
<input
|
|
305
|
+
className="xeplr-factory-input xeplr-factory-file-input"
|
|
306
|
+
type="file"
|
|
307
|
+
accept={accept}
|
|
308
|
+
disabled={disabled || busy}
|
|
309
|
+
onChange={(e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; choose(f) }}
|
|
310
|
+
{...inputProps(node, p, error, disabled || busy, onBlur)}
|
|
311
|
+
/>
|
|
312
|
+
)}
|
|
313
|
+
{busy && <div className="xeplr-factory-hint">Uploading…</div>}
|
|
314
|
+
{!busy && !value && (design || accept) && (
|
|
315
|
+
<div className="xeplr-factory-hint">{accept || 'any file'}{p.maxSize ? ` · up to ${p.maxSize} MB` : ''}</div>
|
|
316
|
+
)}
|
|
317
|
+
</Field>
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The name at the end of a stored path, for showing what is attached. */
|
|
322
|
+
export function fileName(path) {
|
|
323
|
+
const s = String(path)
|
|
324
|
+
const cut = s.slice(s.lastIndexOf('/') + 1)
|
|
325
|
+
// Stored names start with the id that keeps them apart: "a1b2c3__report.pdf".
|
|
326
|
+
const sep = cut.indexOf('__')
|
|
327
|
+
return sep === -1 ? cut : cut.slice(sep + 2)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The journey across the top: a circle per step, the ones behind it done. It is
|
|
332
|
+
* the same bar on the canvas and on a live screen — clicking a step opens it,
|
|
333
|
+
* which in the builder is how you get at that step's controls.
|
|
334
|
+
*/
|
|
335
|
+
function StepperView({ node, p, design, stepper }) {
|
|
336
|
+
const steps = stepsOf(node)
|
|
337
|
+
const active = stepper && Number.isInteger(stepper.active) ? stepper.active : 0
|
|
338
|
+
const go = stepper && stepper.onStep
|
|
339
|
+
const text = boxStyle(p.style)
|
|
340
|
+
return (
|
|
341
|
+
<div className="xeplr-factory-stepper" style={{ ...fieldStyle(p.style), ...text }}>
|
|
342
|
+
<ol className="xeplr-factory-steps">
|
|
343
|
+
{steps.map((s, i) => {
|
|
344
|
+
const state = i === active ? 'is-active' : i < active ? 'is-done' : ''
|
|
345
|
+
return (
|
|
346
|
+
<li key={s.key} className={`xeplr-factory-step ${state}`} aria-current={i === active ? 'step' : undefined}>
|
|
347
|
+
<button
|
|
348
|
+
type="button"
|
|
349
|
+
className="xeplr-factory-step-button"
|
|
350
|
+
// On the canvas a press must still be able to drag the control,
|
|
351
|
+
// so the step is taken on click, never on mousedown.
|
|
352
|
+
onClick={(e) => { if (go) { e.stopPropagation(); go(i) } }}
|
|
353
|
+
tabIndex={design ? -1 : undefined}
|
|
354
|
+
>
|
|
355
|
+
<span className="xeplr-factory-step-mark" aria-hidden="true">{i < active ? '✓' : (p.showNumbers === false ? '' : i + 1)}</span>
|
|
356
|
+
<span className="xeplr-factory-step-label">{s.label}</span>
|
|
357
|
+
</button>
|
|
358
|
+
</li>
|
|
359
|
+
)
|
|
360
|
+
})}
|
|
361
|
+
</ol>
|
|
362
|
+
</div>
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
|
|
171
366
|
function LabelView({ p }) {
|
|
172
367
|
const variant = p.variant || 'text'
|
|
173
368
|
const preset = LABEL_PRESETS[variant] || LABEL_PRESETS.text
|
|
@@ -194,6 +389,11 @@ export const VIEWS = {
|
|
|
194
389
|
date: DateView,
|
|
195
390
|
checkbox: CheckboxView,
|
|
196
391
|
dropdown: DropdownView,
|
|
392
|
+
radio: RadioView,
|
|
393
|
+
multiselect: MultiselectView,
|
|
394
|
+
datetime: DatetimeView,
|
|
395
|
+
file: FileView,
|
|
396
|
+
stepper: StepperView,
|
|
197
397
|
label: LabelView,
|
|
198
398
|
list: ListControl
|
|
199
399
|
}
|