@xeplr/ui-factory 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AUTHORING.md +16 -5
- package/README.md +61 -9
- 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
|
@@ -15,11 +15,16 @@ It is built to work with Claude: ask for *"a form for employees with name, email
|
|
|
15
15
|
|
|
16
16
|
```sh
|
|
17
17
|
npx xeplr-factory screens employee.entity.json -o src/screens/employee
|
|
18
|
-
# employee-list.screen.json EmployeeList.jsx
|
|
19
|
-
# employee-edit.screen.json EditEmployee.jsx
|
|
20
|
-
# employee.hooks.js
|
|
18
|
+
# employee-list.screen.json EmployeeList.jsx the list page — uses EditEmployee.jsx's hooks
|
|
19
|
+
# employee-edit.screen.json EditEmployee.jsx the form page, holding the FRONT-END hooks (EmployeeHooks extends FactoryHooks)
|
|
20
|
+
# employee.hooks.js SERVER hooks: save / get / delete × before / after / error / override
|
|
21
|
+
# employee.model.js SERVER model: EmployeeModel extends FactoryModel — getters / setters for values
|
|
21
22
|
```
|
|
22
23
|
|
|
24
|
+
`--no-pages` leaves out the two `.jsx` (for an app whose pages live elsewhere). `--force` replaces only the screen JSON: the pages, hooks and model are your code and are always kept.
|
|
25
|
+
|
|
26
|
+
**Rules:** records live in real tables, never JSON · a form's **key** (`employee`) names its screens and table and never changes once published; its **label** (the screens' name) is renamed any time · rules that must hold go in server hooks, never front-end hooks.
|
|
27
|
+
|
|
23
28
|
See [examples/](./examples) for exactly what it writes.
|
|
24
29
|
|
|
25
30
|
(The package name on npm is `@xeplr/ui-factory` — the GitHub repo and folder are named `xeplr-ui-factory`.)
|
|
@@ -82,6 +87,7 @@ import { FactoryScreen } from '@xeplr/ui-factory'
|
|
|
82
87
|
fetchRecord={async ({ screen, id }) => api.record(screen, id)} // optional: Edit loads the record fresh
|
|
83
88
|
onDelete={async ({ id, source }) => api.remove(source, id)}
|
|
84
89
|
fetchOptions={async ({ table }) => api.options(table)}
|
|
90
|
+
uploadFile={async (file, { screen, field }) => api.upload(file, screen, field)} // a file field: → { path }
|
|
85
91
|
screens={{ employee_edit: editScreen }} // what a list's Edit / New open in a popup
|
|
86
92
|
/>
|
|
87
93
|
```
|
|
@@ -90,7 +96,23 @@ import { FactoryScreen } from '@xeplr/ui-factory'
|
|
|
90
96
|
|
|
91
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.
|
|
92
98
|
|
|
93
|
-
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.
|
|
94
116
|
|
|
95
117
|
### Front-end hooks
|
|
96
118
|
|
|
@@ -154,16 +176,45 @@ const clean = schemaHandler.applySchema(formSchema(screen), req.body) // throw
|
|
|
154
176
|
| `textarea` | string | `minLength`, `maxLength` |
|
|
155
177
|
| `number` | number | `min`, `max`, `integer` |
|
|
156
178
|
| `date` | `"YYYY-MM-DD"` | `min`, `max` |
|
|
179
|
+
| `datetime` | `"YYYY-MM-DDTHH:MM"` | `min`, `max` |
|
|
157
180
|
| `checkbox` | boolean | `required` = must be ticked |
|
|
158
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 |
|
|
159
186
|
| `label` | — | text with a heading, subheading or text preset |
|
|
160
187
|
| `list` | — | saved records, with New / Edit / Delete |
|
|
161
188
|
|
|
162
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.
|
|
163
190
|
|
|
164
|
-
###
|
|
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
|
|
165
216
|
|
|
166
|
-
|
|
217
|
+
All three **save an `id` and show a `name`**, from either source:
|
|
167
218
|
|
|
168
219
|
```json
|
|
169
220
|
{ "source": "static", "options": [{ "id": "ft", "name": "Full time" }, { "id": "pt", "name": "Part time" }] }
|
|
@@ -196,7 +247,7 @@ const screen = screenFromSpec({
|
|
|
196
247
|
It lays the fields out as a tidy one- or two-column form and returns a checked document. The same is available on the command line:
|
|
197
248
|
|
|
198
249
|
```sh
|
|
199
|
-
npx xeplr-factory screens entity.json -o dir # an entity → list + edit screens
|
|
250
|
+
npx xeplr-factory screens entity.json -o dir # an entity → list + edit screens, pages, server hooks + model (--no-pages, --force)
|
|
200
251
|
npx xeplr-factory migration edit.screen.json # preview the SQL Publish would run
|
|
201
252
|
npx xeplr-factory generate spec.json -o screen.json # one screen from a spec
|
|
202
253
|
npx xeplr-factory validate screen.json # every problem, with its path
|
|
@@ -248,9 +299,10 @@ src/
|
|
|
248
299
|
validateDocument.js ─ the document checker
|
|
249
300
|
values.js ─ values, validation, autosave readiness, records and list columns, formSchema
|
|
250
301
|
generate.js ─ screenFromSpec, screensFromSpec (list + edit), entity names
|
|
251
|
-
scaffold.js ─ an entity → its screen JSON, .jsx pages
|
|
302
|
+
scaffold.js ─ an entity → its screen JSON, .jsx pages (with front-end hooks), server hooks + model stubs
|
|
303
|
+
hooks.js ─ FactoryHooks: get / save / delete / actions — extend and call super
|
|
252
304
|
tableSchema.js ─ a form → its table; the publish plan (create / add / widen / confirmed drops)
|
|
253
|
-
remote.js ─ createFactoryApi: the calls to @xeplr/factory's routes
|
|
305
|
+
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 })
|
|
254
306
|
model.js ─ everything above, React-free
|
|
255
307
|
useFactoryBuilder.js ─ builder controller
|
|
256
308
|
useFactoryScreen.js ─ screen controller
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/ui-factory",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Design data-entry screens on @xeplr/ui-canvas, save them as JSON, run them as auto-saving forms with lists of saved records — built for Claude to draft and people to refine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/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>
|