@xeplr/ui-factory 1.0.14 → 1.2.0
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 +29 -5
- package/README.md +167 -20
- package/package.json +7 -2
- package/src/controls.js +19 -13
- package/src/designs/BuilderSample.jsx +77 -19
- package/src/designs/ControlView.jsx +10 -1
- package/src/designs/FlowRunnerSample.jsx +6 -4
- package/src/designs/ListView.jsx +40 -21
- package/src/designs/Palette.jsx +2 -1
- package/src/designs/PropertyPanel.jsx +57 -1
- package/src/designs/ScreenEditorSample.jsx +97 -0
- package/src/designs/ScreenModal.jsx +2 -2
- package/src/designs/ScreenSample.jsx +92 -41
- package/src/designs/factory.css +170 -76
- package/src/designs/index.js +4 -0
- package/src/designs/styles.js +11 -1
- package/src/generate.js +35 -10
- package/src/hooks.js +1 -1
- package/src/index.js +6 -2
- package/src/model.js +2 -1
- package/src/pages.jsx +18 -3
- package/src/presets.js +324 -0
- package/src/remote.js +7 -1
- package/src/scaffold.js +2 -2
- package/src/screenSource.js +91 -0
- package/src/tableSchema.js +109 -6
- package/src/urlState.js +71 -0
- package/src/useFactoryBuilder.js +97 -25
- package/src/useFactoryScreen.js +85 -47
- package/src/useFlowBuilder.js +7 -4
- package/src/useScreenSource.js +81 -0
- package/src/useUrlPlaces.js +48 -0
- package/src/validateDocument.js +5 -1
- package/src/values.js +18 -5
package/AUTHORING.md
CHANGED
|
@@ -60,7 +60,7 @@ A request for "a form for employees" is an **entity**. Every entity is the same
|
|
|
60
60
|
```jsx
|
|
61
61
|
export class EmployeeHooks extends FactoryHooks {
|
|
62
62
|
get(ctx) { return super.get(ctx) } // a list's rows (ctx.many), or the record Edit opens (ctx.id)
|
|
63
|
-
save(values, ctx) { return super.save(values, ctx) } //
|
|
63
|
+
save(values, ctx) { return super.save(values, ctx) } // the Save button; returns the saved record
|
|
64
64
|
delete(record, ctx) { return super.delete(record, ctx) } // a list row, after the person confirmed
|
|
65
65
|
actions(ctx) { return super.actions(ctx) } // extra row buttons: [{ label, onClick: (record, ctx) => … }]
|
|
66
66
|
}
|
|
@@ -92,7 +92,7 @@ A request for "a form for employees" is an **entity**. Every entity is the same
|
|
|
92
92
|
8. **Server hooks, if the request needs them.** Anything beyond storing what the form shows — a value worked out on save, a check against other records, an email afterwards, a list narrowed to the user — goes in `employee.hooks.js`, never in the screen: `save`, `get` (list and one record) and `delete`, each with `before`, `after`, `error` and `override`. Register it with `factory.init({ hooks: { employee_edit: require('./employee.hooks') } })`. `override` replaces the whole operation — rules and the other hooks included — so use it only when the generic save / get / delete does not apply at all. The `screens` command never overwrites an existing hooks file, even with `--force`. Details: the [`@xeplr/factory` README](https://www.npmjs.com/package/@xeplr/factory#hooks).
|
|
93
93
|
9. **Hand over.** The person opens the screens in the designer to move, resize and restyle controls.
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
Do **not** add a button control for saving: every screen's footer already has **Save** (an AJAX call — never a form submit) and **Cancel** where it can go back. If the request mentions seeing or editing what was entered ("…and show the employees below"), add a `list`.
|
|
96
96
|
|
|
97
97
|
Changing an existing screen: edit the document's `props` directly (labels, validation, options), validate, and hand it back. Regenerate from a spec only when the layout should start over.
|
|
98
98
|
|
|
@@ -192,7 +192,7 @@ Each field:
|
|
|
192
192
|
| `text` | `minLength`, `maxLength`, `pattern` (a regex string), `patternMessage` (shown when it fails) |
|
|
193
193
|
| `textarea` | `minLength`, `maxLength` |
|
|
194
194
|
| `number` | `min`, `max`, `integer` |
|
|
195
|
-
| `date` | `min`, `max` — as `"YYYY-MM-DD"` |
|
|
195
|
+
| `date` | `min`, `max` — as `"YYYY-MM-DD"`; `notFuture: true` (a date of birth) |
|
|
196
196
|
| `datetime` | `min`, `max` — as `"YYYY-MM-DDTHH:MM"` |
|
|
197
197
|
| `multiselect` | `minItems`, `maxItems` — how many may be chosen |
|
|
198
198
|
| `checkbox`, `dropdown`, `radio`, `file` | none (use `required`) |
|
|
@@ -237,11 +237,35 @@ A dropdown **always saves an `id` and shows a `name`.**
|
|
|
237
237
|
|
|
238
238
|
## Choosing controls
|
|
239
239
|
|
|
240
|
+
**Ready-made fields first.** For these, give the `type` as the ready-made
|
|
241
|
+
field's key and add nothing else unless the request asks for it — the keyboard,
|
|
242
|
+
pattern, message, limits and options come with it:
|
|
243
|
+
|
|
244
|
+
| the request says | `type` |
|
|
245
|
+
|---|---|
|
|
246
|
+
| email | `email` |
|
|
247
|
+
| phone, mobile | `phone` |
|
|
248
|
+
| website, URL, link | `url` |
|
|
249
|
+
| LinkedIn profile | `linkedin` |
|
|
250
|
+
| age | `age` (whole number, 0–130) |
|
|
251
|
+
| date of birth | `dateOfBirth` (never in the future) |
|
|
252
|
+
| gender | `gender` (Male, Female, Others) |
|
|
253
|
+
| country, nationality | `country` (ISO codes, shown by name) |
|
|
254
|
+
| amount, price, salary, fee | `amount` (0 or more) |
|
|
255
|
+
| percentage | `percentage` (0–100) |
|
|
256
|
+
| a yes/no question to answer explicitly | `yesNo` |
|
|
257
|
+
| postal code, ZIP, PIN | `postalCode` |
|
|
258
|
+
|
|
259
|
+
`{ "label": "Work email", "type": "email", "required": true }` keeps the label
|
|
260
|
+
and adds the rest. A field with **no type** whose label reads like one of these
|
|
261
|
+
gets it anyway (`{ "label": "Age" }` is the Age field); `"type": "text"` opts out.
|
|
262
|
+
Rules you give merge over the ready-made ones (`"validation": { "max": 120 }`).
|
|
263
|
+
|
|
240
264
|
| the request says | use |
|
|
241
265
|
|---|---|
|
|
242
|
-
| a name, title,
|
|
266
|
+
| a name, title, code | `text` (add a `pattern` for a code format) |
|
|
243
267
|
| notes, description, address, comments | `textarea` |
|
|
244
|
-
|
|
|
268
|
+
| quantity, score, count | `number` (`integer: true` for counts) |
|
|
245
269
|
| a date — birth, start, due | `date` |
|
|
246
270
|
| an appointment, a deadline with a time, when something happened | `datetime` |
|
|
247
271
|
| yes/no, agree, active, remote | `checkbox` |
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @xeplr/ui-factory
|
|
2
2
|
|
|
3
|
-
**Design data-entry screens, save them as JSON, run them as working forms.** Drag controls onto a canvas, set their labels, validation, options, fonts and colours. The same document renders as a live form in your app
|
|
3
|
+
**Design data-entry screens, save them as JSON, run them as working forms.** Drag controls onto a canvas, set their labels, validation, options, fonts and colours. The same document renders as a live form in your app, saved by its **Save** button over AJAX, with a list of the saved records to open, edit or delete.
|
|
4
4
|
|
|
5
5
|
It is built to work with Claude: ask for *"a form for employees with name, email, department and start date"*, and Claude creates **two screens** — an employee **list** and an **add / edit** form that the list's Edit and New open in a popup — plus the two pages that show them. A person then refines either one in the designer.
|
|
6
6
|
|
|
@@ -44,10 +44,10 @@ Most internal apps are a long tail of forms, and every one of them is hand-built
|
|
|
44
44
|
## Install
|
|
45
45
|
|
|
46
46
|
```sh
|
|
47
|
-
npm i @xeplr/ui-factory @xeplr/ui-canvas @xeplr/ui-table @tanstack/react-table
|
|
47
|
+
npm i @xeplr/ui-factory @xeplr/ui-canvas @xeplr/ui-table @tanstack/react-table react-router-dom
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
Peer dependencies: `react ^18 || ^19`, [`@xeplr/ui-canvas`](https://www.npmjs.com/package/@xeplr/ui-canvas) (the canvas the builder places controls on)
|
|
50
|
+
Peer dependencies: `react ^18 || ^19`, [`@xeplr/ui-canvas`](https://www.npmjs.com/package/@xeplr/ui-canvas) (the canvas the builder places controls on), [`@xeplr/ui-table`](https://www.npmjs.com/package/@xeplr/ui-table) (lists of saved records) and `react-router-dom ^6 || ^7` (imported by [`@xeplr/ui-utils`](https://www.npmjs.com/package/@xeplr/ui-utils)' `useUrlState`, a dependency, for the URL state below; with no router above, everything stays in memory). Your bundler must compile JSX and import CSS from the package (Vite does both).
|
|
51
51
|
|
|
52
52
|
## The builder
|
|
53
53
|
|
|
@@ -57,7 +57,7 @@ import { FactoryBuilder } from '@xeplr/ui-factory'
|
|
|
57
57
|
<div style={{ height: '100vh' }}>
|
|
58
58
|
<FactoryBuilder
|
|
59
59
|
document={screen} // omit to start a new one
|
|
60
|
-
onSave={async (doc) => api.saveScreen(doc)} // your storage — called
|
|
60
|
+
onSave={async (doc) => api.saveScreen(doc)} // your storage — called when Save is pressed
|
|
61
61
|
listTables={async () => api.listTables()} // for "Saves to", lists and table dropdowns
|
|
62
62
|
fetchOptions={async ({ table }) => api.options(table)} // Preview with real options
|
|
63
63
|
fetchRecords={async ({ source }) => api.records(source)} // Preview with real records
|
|
@@ -70,6 +70,21 @@ import { FactoryBuilder } from '@xeplr/ui-factory'
|
|
|
70
70
|
- Select a control to set its label, field name, placeholder, required, default, validation, and — for a dropdown — where its options come from.
|
|
71
71
|
- **Every look is a property:** font, size, weight, italic, alignment, text colour, background, border colour/width, corner radius — and for an input, its label's size, weight and colour.
|
|
72
72
|
- With nothing selected, the panel shows the **screen**: the table it saves to, its width, and the font and colours every control inherits.
|
|
73
|
+
|
|
74
|
+
**Nothing saves itself — here, or anywhere else in this package.** The bar says
|
|
75
|
+
*Unsaved changes* and offers **Save**; that is the only thing that writes the
|
|
76
|
+
draft, so trying a layout out and walking away leaves the saved screen as it
|
|
77
|
+
was. Closing the tab with unsaved work asks first.
|
|
78
|
+
|
|
79
|
+
It used to save 800ms after every edit, and that was wrong twice over: there
|
|
80
|
+
was no such thing as trying something out, and no moment at which anybody said
|
|
81
|
+
"this is right" — and a field is invalid *while it is being built*, so the
|
|
82
|
+
autosave met half-made work and reported it as an error nobody asked for.
|
|
83
|
+
`autosaveDelay` is still accepted and ignored, so a host passing it is not
|
|
84
|
+
broken.
|
|
85
|
+
|
|
86
|
+
**Publish still saves first**, because publishing what is on screen is the one
|
|
87
|
+
thing it must never get wrong.
|
|
73
88
|
- **No Save button.** Edits are saved (as a draft) a moment after you stop, whenever the screen is valid; problems are marked on the controls that have them until they are fixed.
|
|
74
89
|
- **Publish** (when `onPublish` is given) makes the draft the version everyone sees and changes its table to match. If removed fields would drop columns, it asks first — "Publishing removes `phone` — 1,240 saved values" — with **Remove and publish** / **Cancel**. Unsafe changes (narrowing a field) are refused with the reason.
|
|
75
90
|
- **`lockedNames`**: fields that are already columns of the table. Their names are read-only, because renaming one would leave its data behind.
|
|
@@ -92,7 +107,11 @@ import { FactoryScreen } from '@xeplr/ui-factory'
|
|
|
92
107
|
/>
|
|
93
108
|
```
|
|
94
109
|
|
|
95
|
-
**
|
|
110
|
+
**Save is a button and an AJAX call — never a form submit.** Nothing is written while the person types: the footer says "Unsaved changes" until they press **Save**, which makes one call to your `onSave`. There is no `<form>` post and Enter saves nothing. If something is missing, Save marks every field that needs attention instead of sending. The first save of a new record creates it: return the saved record and its `id` makes every later save an update.
|
|
111
|
+
|
|
112
|
+
- With `onDone` (a page, or the list's popup), the footer has **Cancel** and **Save**. Save saves and then calls `onDone`. Cancel calls `onDone` without saving, asking first if anything is unsaved. Without `onDone` (a form embedded in a page) there is Save alone, and it shows "Saved".
|
|
113
|
+
- Leaving with unsaved changes asks first: Cancel, the popup's ×/Escape, a list-on-the-form opening another record, and closing or reloading the tab. **In-app navigation (a side-rail click) is the host router's to guard** — `ctrl.dirty` / `onDirtyChange(dirty)` say when there is something to lose.
|
|
114
|
+
- `useFactoryScreen` returns `save()` (the button), `dirty`, `confirmDiscard()` and `flush()` (save only if unsaved — what a flow's Next does). `AUTOSAVE_DELAY` and `autosaveDelay` are gone.
|
|
96
115
|
|
|
97
116
|
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.
|
|
98
117
|
|
|
@@ -143,7 +162,7 @@ const taskHooks = new TaskHooks()
|
|
|
143
162
|
| method | default | ctx |
|
|
144
163
|
|---|---|---|
|
|
145
164
|
| `get(ctx)` | `fetchRecords` for a list, `fetchRecord` for the record Edit opens | `many`, `id`, `screen`, `source` |
|
|
146
|
-
| `save(values, ctx)` | `onSave` —
|
|
165
|
+
| `save(values, ctx)` | `onSave` — when Save is pressed; return the saved record | `id`, `isNew`, `screen`, `source` |
|
|
147
166
|
| `delete(record, ctx)` | `onDelete`, after the person confirmed | `id`, `screen`, `source` |
|
|
148
167
|
| `actions(ctx)` | `[]` — extra row buttons `{ label, onClick(record, ctx) }` beside Edit / Delete | `screen`, `refresh()` |
|
|
149
168
|
|
|
@@ -153,7 +172,7 @@ Rules that must hold belong in the server's hooks; the browser only shapes what
|
|
|
153
172
|
|
|
154
173
|
### Lists of saved records
|
|
155
174
|
|
|
156
|
-
A **List** control shows the records of the table the screen saves to (or another), in `@xeplr/ui-table`, with the columns you tick. Its **Edit in** names the edit screen: **Edit** and **New** open that screen in a popup
|
|
175
|
+
A **List** control shows the records of the table the screen saves to (or another), in `@xeplr/ui-table`, with the columns you tick. Its **Edit in** names the edit screen: **Edit** and **New** open that screen in a popup: its Save saves, closes the popup and refreshes the list; Cancel closes it unsaved.
|
|
157
176
|
|
|
158
177
|
**Opens in: a page of its own** sends them to a page in your app instead. The factory has no router, so it asks — `onOpenRecord` on the list's screen, and `onDone` on the page, which draws a **Done** button that saves what is pending and calls it:
|
|
159
178
|
|
|
@@ -178,6 +197,47 @@ import schemaHandler from '@xeplr/schema-handler' // CommonJS: default
|
|
|
178
197
|
const clean = schemaHandler.applySchema(formSchema(screen), req.body) // throws ValidationError listing each field
|
|
179
198
|
```
|
|
180
199
|
|
|
200
|
+
## Your screens, inside someone else's designer
|
|
201
|
+
|
|
202
|
+
`@xeplr/ui-workflow`'s flow designer has a **Screen** step, and it asks its
|
|
203
|
+
host two things: which screens exist and what each one hands back, and how to
|
|
204
|
+
design one without leaving the flow. It cannot answer either itself — it
|
|
205
|
+
carries a screen key and knows nothing else about forms, on purpose, so an app
|
|
206
|
+
with a different screen system is not locked out.
|
|
207
|
+
|
|
208
|
+
This package answers both, in one hook:
|
|
209
|
+
|
|
210
|
+
```jsx
|
|
211
|
+
import { useScreenSource, FactoryBuilder } from '@xeplr/ui-factory'
|
|
212
|
+
|
|
213
|
+
const { screens, screenEditor } = useScreenSource(factory, { Builder: FactoryBuilder })
|
|
214
|
+
<WorkflowDesigner screens={screens} screenEditor={screenEditor} … />
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
- **`screens`** is `[{ key, name, fields }]`. `fields` comes from
|
|
218
|
+
`inputNodes(doc)` — this package's own answer to "which nodes hold a value"
|
|
219
|
+
— so a label or a stepper is never offered as a field, and a control type
|
|
220
|
+
added here reaches every app without one of them being edited. Names arrive
|
|
221
|
+
first and fields follow, so the menu is usable while documents load; a
|
|
222
|
+
document that will not load leaves its screen choosable without fields.
|
|
223
|
+
- **`screenEditor`** is the body of the designer's modal: the builder, on the
|
|
224
|
+
form that step shows, or on a new one it makes (`screenKey: null` → name it
|
|
225
|
+
→ `createEntity`). **Publishing** is what fires `onDone({ key, name, fields })`,
|
|
226
|
+
because a draft changes nothing anybody can run — and that is what tells the
|
|
227
|
+
flow the form's new shape, so a field added there appears in every step that
|
|
228
|
+
shows that form.
|
|
229
|
+
|
|
230
|
+
**Why this is a package and not four lines in your app.** It used to be the
|
|
231
|
+
latter, and that is a bug: an app is generated once and then lives its own
|
|
232
|
+
life, so glue copied into it can never be fixed centrally. An app scaffolded
|
|
233
|
+
last month would keep the old version forever, and a control added here would
|
|
234
|
+
never reach it. Anything every app would write identically belongs on this
|
|
235
|
+
side of the line — `@xeplr/cli`'s generated `FlowDesigner.jsx` is two props,
|
|
236
|
+
and its test suite fails if the glue reappears in the app.
|
|
237
|
+
|
|
238
|
+
The pure half is `screenSource.js` — `describeScreen`, `loadScreens`,
|
|
239
|
+
`keyFromName`, `editScreenOf` — testable with no React and no server.
|
|
240
|
+
|
|
181
241
|
## Flows — screens, one after another
|
|
182
242
|
|
|
183
243
|
A screen is a form; a **flow** is the journey across several of them. Design it
|
|
@@ -207,7 +267,7 @@ that does — Xeplr Workflow — so a run remembers where it got to: a journey c
|
|
|
207
267
|
be left and picked up later, by someone else, on another day. Without such a
|
|
208
268
|
service there are no flows; the factory has no engine of its own, on purpose.
|
|
209
269
|
|
|
210
|
-
**Each screen still saves
|
|
270
|
+
**Each screen still saves into its own table** — the flow's **Next** saves what is unsaved (one AJAX call) and then moves on. What travels with the
|
|
211
271
|
run is only what the arrows need and the id of the row that was written
|
|
212
272
|
(`recordId`), so the data is exactly where it would be without a flow.
|
|
213
273
|
|
|
@@ -231,10 +291,10 @@ Both walk someone through something, and they answer different questions:
|
|
|
231
291
|
|
|
232
292
|
| type | value | validation |
|
|
233
293
|
|---|---|---|
|
|
234
|
-
| `text` | string | `minLength`, `maxLength`, `pattern`, `patternMessage` |
|
|
294
|
+
| `text` | string | `minLength`, `maxLength`, `pattern`, `patternMessage` — and `inputType` (`text`, `email`, `tel`, `url`): the phone keyboard and autofill |
|
|
235
295
|
| `textarea` | string | `minLength`, `maxLength` |
|
|
236
296
|
| `number` | number | `min`, `max`, `integer` |
|
|
237
|
-
| `date` | `"YYYY-MM-DD"` | `min`, `max` |
|
|
297
|
+
| `date` | `"YYYY-MM-DD"` | `min`, `max`, `notFuture` (on or before today, whenever it is filled in) |
|
|
238
298
|
| `datetime` | `"YYYY-MM-DDTHH:MM"` | `min`, `max` |
|
|
239
299
|
| `checkbox` | boolean | `required` = must be ticked |
|
|
240
300
|
| `dropdown` | option id | — |
|
|
@@ -245,10 +305,68 @@ Both walk someone through something, and they answer different questions:
|
|
|
245
305
|
| `label` | — | text with a heading, subheading or text preset |
|
|
246
306
|
| `list` | — | saved records, with New / Edit / Delete |
|
|
247
307
|
|
|
248
|
-
A screen is a **sheet
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
308
|
+
A screen is a **sheet**, centred in the space it is given and shown at its
|
|
309
|
+
design width: an elevated card in the app's theme (`--xeplr-bg-tertiary`, with
|
|
310
|
+
`--xeplr-border-primary` round it) — white in a light app, the lighter grey in
|
|
311
|
+
a dark one, with inputs in the theme's input colours. A screen whose style sets
|
|
312
|
+
its own `color` / `background` keeps them everywhere, input boxes included, so
|
|
313
|
+
dark ink on paper is never shown on a dark theme's input. Set
|
|
314
|
+
`--xeplr-factory-paper` on the host to change every sheet. A screen with no
|
|
315
|
+
fields (only lists) draws **no** sheet — the list is its own card.
|
|
316
|
+
|
|
317
|
+
Under the fields sits **one footer**, inset to line up with them: Back and
|
|
318
|
+
Next, then "Step 2 of 3" with the save status under it ("Unsaved changes",
|
|
319
|
+
"Saved"), then Cancel and Save. A screen without steps has the status, Cancel
|
|
320
|
+
and Save alone.
|
|
321
|
+
|
|
322
|
+
A **list** is a single card, as tall as its rows: the title and + New sit in
|
|
323
|
+
the table's own toolbar, paging appears only when there is more than one page,
|
|
324
|
+
and "Nothing saved yet." shows under the title when there are no rows.
|
|
325
|
+
|
|
326
|
+
### Ready-made fields
|
|
327
|
+
|
|
328
|
+
Twelve fields nobody should have to design twice, in the builder's palette under
|
|
329
|
+
**Common fields**: Email, Phone, LinkedIn profile, Website, Age, Date of birth,
|
|
330
|
+
Gender (Male, Female, Others), Country, Amount, Percentage, Yes / No, Postal code.
|
|
331
|
+
|
|
332
|
+
| field | made of | what it sets |
|
|
333
|
+
|---|---|---|
|
|
334
|
+
| Email / Phone / Website / LinkedIn profile | `text` | `inputType` (email, tel, url keyboards), a `pattern` with a plain-English message, a placeholder, a `maxLength` |
|
|
335
|
+
| Postal code | `text` | letters, digits, spaces and `-`, up to 10 |
|
|
336
|
+
| Age | `number` | a whole number, 0–130 |
|
|
337
|
+
| Amount / Percentage | `number` | 0 or more / 0–100 |
|
|
338
|
+
| Date of birth | `date` | `notFuture` |
|
|
339
|
+
| Gender / Yes / No | `radio`, side by side | its options |
|
|
340
|
+
| Country | `dropdown` | every country — stored as its ISO code (`IN`), shown by name |
|
|
341
|
+
|
|
342
|
+
A ready-made field is **not a new control**: its settings are copied into an
|
|
343
|
+
ordinary one, where they stay editable, and `props.preset` remembers where it
|
|
344
|
+
came from. Changing the catalogue never changes a form already made with it.
|
|
345
|
+
The rules are the field's own, so the server enforces them like any others.
|
|
346
|
+
|
|
347
|
+
**Any field can change kind at any time** — the panel's **Field type**, at the
|
|
348
|
+
top, lists the controls and the ready-made fields. The label, name, position,
|
|
349
|
+
step and required are kept; whatever does not carry over is said ("Not carried
|
|
350
|
+
over: the pattern"). A name that is already a column is never changed.
|
|
351
|
+
|
|
352
|
+
**Forgot?** A plain field whose label reads like a ready-made one ("LinkedIn
|
|
353
|
+
URL" as a text box) gets an offer in its panel — **Make it LinkedIn profile** /
|
|
354
|
+
**Keep as is** — and the builder's bar counts them ("1 suggestion"). They never
|
|
355
|
+
stop a save or a publish; **Keep as is** sets `preset: false` and stops asking.
|
|
356
|
+
|
|
357
|
+
**When the column has to follow.** A change of kind that stays text (text →
|
|
358
|
+
LinkedIn) publishes as usual. One that changes the column's kind (text → Age,
|
|
359
|
+
a number) is a **conversion**: publishing first checks every saved value, across
|
|
360
|
+
every company. If any will not fit, nothing changes and the publish says which
|
|
361
|
+
("`age` has 1 saved value that cannot become integer ("forty")"). If all fit,
|
|
362
|
+
it asks — **Convert and publish** — and then converts, blanks becoming empty.
|
|
363
|
+
A timestamp → date, or a dropdown moved to another table, is still refused.
|
|
364
|
+
|
|
365
|
+
In code: `FIELD_PRESETS`, `presetFor`, `presetProps`, `suggestPreset(label)`,
|
|
366
|
+
`presetHints(doc)`, `convertField(doc, id, target, controls, { lockedNames })`,
|
|
367
|
+
and `conversionFor(table, from, to)` / `planTableChange(…, { confirmConvert })`.
|
|
368
|
+
The builder hook adds `convertControl(id, target)` and `hints`; `publish(confirmDrop,
|
|
369
|
+
confirmConvert)`.
|
|
252
370
|
|
|
253
371
|
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.
|
|
254
372
|
|
|
@@ -304,8 +422,7 @@ same for a screen with more than one (`ctx.steps.disable('approval', stepperId)`
|
|
|
304
422
|
learns which branch it is on. "Step 2 of 4" counts only the steps that apply.
|
|
305
423
|
|
|
306
424
|
**A step is not a separate form.** Every field is a column, is checked, and is
|
|
307
|
-
saved, whichever step is showing — a
|
|
308
|
-
in. **Next** holds at a step whose own fields are not filled in, and says which,
|
|
425
|
+
saved, whichever step is showing — Save can be pressed on any step. **Next** holds at a step whose own fields are not filled in, and says which,
|
|
309
426
|
rather than moving on and leaving a problem behind where nobody is looking.
|
|
310
427
|
|
|
311
428
|
### Options: dropdown, radio group, multi-select
|
|
@@ -369,6 +486,26 @@ npx xeplr-factory controls # controls, props and rules
|
|
|
369
486
|
|
|
370
487
|
Layout is **proportional**, like `@xeplr/ui-canvas` dashboards: `x` and `w` are fractions of the width, `y` and `h` fractions of a page (`width × aspect`). The screen is shown at its design `width`; on a narrower display everything — positions and text — scales down together.
|
|
371
488
|
|
|
489
|
+
## Where you are lives in the URL: `urlKey`
|
|
490
|
+
|
|
491
|
+
The control whose properties are open, the step a stepper is open at, and the flow step being edited are places a person navigates to. Give a controller (or its ready-made page) a `urlKey` and they are written to the URL's query, so a refresh or a shared link lands on them again:
|
|
492
|
+
|
|
493
|
+
| controller / page | key | holds | history |
|
|
494
|
+
|---|---|---|---|
|
|
495
|
+
| `useFactoryBuilder` / `<FactoryBuilder>` | `<urlKey>Selected` | the id of the one selected control | opening or closing its properties adds an entry (Back closes them); moving to another control replaces it |
|
|
496
|
+
| `useFactoryBuilder` / `<FactoryBuilder>` | `<urlKey>Steps` | the step each stepper is open at: `stepperId:index`, comma-separated, step 0 left out | replaces |
|
|
497
|
+
| `useFactoryScreen` / `<FactoryScreen>`, `<FlowRunner>` | `<urlKey>Steps` | as above | replaces |
|
|
498
|
+
| `useFlowBuilder` / `<FlowBuilder>` | `<urlKey>Selected` | the key of the selected flow step | as for a control |
|
|
499
|
+
|
|
500
|
+
```jsx
|
|
501
|
+
<FactoryBuilder urlKey="form" document={doc} onSave={save} /> // ?formSelected=email&formSteps=stepper:1
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
- **The key is the host's, never a default.** A builder, its Preview and a list's Edit popup can share a page, and the host has keys of its own, so nothing is written to the URL without a `urlKey` — give each its own. **Without one the state stays in memory**, as before. The Preview and the Edit popup are never given one.
|
|
505
|
+
- **A multi-selection is not a place.** Several controls selected is a working set for Delete and Duplicate, not somewhere a person goes, so it stays in memory and the URL then names nothing. Only a single selection — the one whose properties are open — is in the URL.
|
|
506
|
+
- **Ids, found again.** The selected control is looked up in the document; one that is not there selects nothing. A document handed in after mounting keeps the URL's control, so a host that loads the screen late still lands on it. A step past the last is pulled back to the last, and steppers not in the document (a screen still loading) are left alone.
|
|
507
|
+
- The rule is in `src/urlState.js` (`factoryUrlKeys`, `encodeSteps`, `decodeSteps`, `replacesHistory`, `selectionPlace`); the hooks that keep it are `src/useUrlPlaces.js`.
|
|
508
|
+
|
|
372
509
|
## Three ways to use it
|
|
373
510
|
|
|
374
511
|
1. **Ready-made** — `<FactoryBuilder>` and `<FactoryScreen>`.
|
|
@@ -377,7 +514,9 @@ Layout is **proportional**, like `@xeplr/ui-canvas` dashboards: `x` and `w` are
|
|
|
377
514
|
|
|
378
515
|
## Theming
|
|
379
516
|
|
|
380
|
-
Styles are namespaced `.xeplr-factory-*` and read
|
|
517
|
+
Styles are namespaced `.xeplr-factory-*` and read `@xeplr/ui-account`'s theme variables (theme.css), each with a light fallback: `--xeplr-accent`, `--xeplr-accent-text`, `--xeplr-text-primary`, `--xeplr-text-secondary`, `--xeplr-border-primary`, `--xeplr-bg-primary` (the builder's plane), `--xeplr-bg-tertiary` (sheets, panels, popups), `--xeplr-bg-hover`, `--xeplr-bg-overlay`, `--xeplr-input-bg`, `--xeplr-input-border`, `--xeplr-success`, `--xeplr-warning`, `--xeplr-danger`. The screen, builder and popup follow the app's light or dark theme with the @xeplr/ui-table inside them — never a white sheet in a dark app.
|
|
518
|
+
|
|
519
|
+
Buttons, steps and list actions all show hover, a visible press, and a keyboard-only focus ring.
|
|
381
520
|
|
|
382
521
|
## Try it
|
|
383
522
|
|
|
@@ -393,16 +532,22 @@ src/
|
|
|
393
532
|
controls.js ─ the control registry: palette, property panel contract, rules
|
|
394
533
|
document.js ─ create and edit screen documents
|
|
395
534
|
validateDocument.js ─ the document checker
|
|
396
|
-
values.js ─ values, validation,
|
|
535
|
+
values.js ─ values, validation, whether Save can go out, records and list columns, formSchema
|
|
397
536
|
generate.js ─ screenFromSpec, screensFromSpec (list + edit), entity names
|
|
398
537
|
scaffold.js ─ an entity → its screen JSON, .jsx pages (with front-end hooks), server hooks + model stubs
|
|
399
538
|
hooks.js ─ FactoryHooks: get / save / delete / actions — extend and call super
|
|
400
|
-
|
|
539
|
+
presets.js ─ ready-made fields (Email, Age, Gender…), changing a field's kind, label suggestions
|
|
540
|
+
tableSchema.js ─ a form → its table; the publish plan (create / add / widen / confirmed drops and conversions)
|
|
401
541
|
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 })
|
|
542
|
+
screenSource.js ─ this app's screens as another designer wants them: describeScreen, loadScreens, keyFromName, editScreenOf (React-free)
|
|
402
543
|
model.js ─ everything above, React-free
|
|
403
544
|
useFactoryBuilder.js ─ builder controller
|
|
404
545
|
useFactoryScreen.js ─ screen controller
|
|
405
|
-
|
|
546
|
+
useFlowBuilder.js ─ flow designer controller
|
|
547
|
+
urlState.js ─ the URL keys and values for selection and steps (React-free)
|
|
548
|
+
useUrlPlaces.js ─ useUrlSteps, useUrlSelection: those places, in the URL or in memory
|
|
549
|
+
useScreenSource.js ─ { screens, screenEditor } for @xeplr/ui-workflow's Screen step
|
|
550
|
+
designs/ ─ BuilderSample, ScreenSample, ScreenEditorSample, ControlView, ListView, Palette, PropertyPanel, styles.js, factory.css
|
|
406
551
|
pages.jsx ─ FactoryBuilder, FactoryScreen
|
|
407
552
|
bin/xeplr-factory.js ─ screens / generate / validate / controls / schema
|
|
408
553
|
examples/ ─ employee.entity.json and what `screens` makes of it
|
|
@@ -415,6 +560,8 @@ dev/ ─ the stand-in app for `npm run dev` (not published)
|
|
|
415
560
|
npm test
|
|
416
561
|
```
|
|
417
562
|
|
|
563
|
+
`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`.
|
|
564
|
+
|
|
418
565
|
## License
|
|
419
566
|
|
|
420
567
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/ui-factory",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
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",
|
|
@@ -48,7 +48,8 @@
|
|
|
48
48
|
"@tanstack/react-table": "^8.0.0",
|
|
49
49
|
"@xeplr/ui-canvas": "^1.0.2",
|
|
50
50
|
"@xeplr/ui-table": "^1.0.6",
|
|
51
|
-
"react": "^18.0.0 || ^19.0.0"
|
|
51
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
52
|
+
"react-router-dom": "^6.0.0 || ^7.0.0"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
55
|
"@tanstack/react-table": "^8.21.3",
|
|
@@ -58,6 +59,10 @@
|
|
|
58
59
|
"@xeplr/ui-table": "^1.0.6",
|
|
59
60
|
"react": "^19.2.4",
|
|
60
61
|
"react-dom": "^19.2.4",
|
|
62
|
+
"react-router-dom": "^7.18.4",
|
|
61
63
|
"vite": "^7.3.1"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@xeplr/ui-utils": "^1.1.2"
|
|
62
67
|
}
|
|
63
68
|
}
|
package/src/controls.js
CHANGED
|
@@ -164,11 +164,14 @@ export const CONTROLS = {
|
|
|
164
164
|
valueType: 'string',
|
|
165
165
|
defaultSize: { w: 0.44, h: 0.08 },
|
|
166
166
|
defaults: { label: 'Text' },
|
|
167
|
-
|
|
167
|
+
// inputType: the box's keyboard and autofill — text, email, tel, url.
|
|
168
|
+
props: ['name', 'label', 'preset', 'placeholder', 'required', 'default', 'validation', 'inputType', 'style'],
|
|
168
169
|
validation: ['minLength', 'maxLength', 'pattern', 'patternMessage'],
|
|
169
170
|
styles: INPUT_STYLES,
|
|
170
171
|
properties: [
|
|
171
|
-
FIELD_BASICS([PLACEHOLDER, { path: 'props.default', label: 'Default value', type: 'text' }
|
|
172
|
+
FIELD_BASICS([PLACEHOLDER, { path: 'props.default', label: 'Default value', type: 'text' }, { path: 'props.inputType', label: 'Keyboard', type: 'select', options: [
|
|
173
|
+
{ value: 'text', label: 'Text' }, { value: 'email', label: 'Email' }, { value: 'tel', label: 'Phone' }, { value: 'url', label: 'Web address' }
|
|
174
|
+
], help: 'What a phone shows to type with, and what autofill offers' }]),
|
|
172
175
|
{
|
|
173
176
|
key: 'validation',
|
|
174
177
|
title: 'Validation',
|
|
@@ -191,7 +194,7 @@ export const CONTROLS = {
|
|
|
191
194
|
valueType: 'string',
|
|
192
195
|
defaultSize: { w: 0.92, h: 0.18 },
|
|
193
196
|
defaults: { label: 'Notes' },
|
|
194
|
-
props: ['name', 'label', 'placeholder', 'required', 'default', 'validation', 'style'],
|
|
197
|
+
props: ['name', 'label', 'preset', 'placeholder', 'required', 'default', 'validation', 'style'],
|
|
195
198
|
validation: ['minLength', 'maxLength'],
|
|
196
199
|
styles: INPUT_STYLES,
|
|
197
200
|
properties: [
|
|
@@ -216,7 +219,7 @@ export const CONTROLS = {
|
|
|
216
219
|
valueType: 'number',
|
|
217
220
|
defaultSize: { w: 0.44, h: 0.08 },
|
|
218
221
|
defaults: { label: 'Number' },
|
|
219
|
-
props: ['name', 'label', 'placeholder', 'required', 'default', 'validation', 'style'],
|
|
222
|
+
props: ['name', 'label', 'preset', 'placeholder', 'required', 'default', 'validation', 'style'],
|
|
220
223
|
validation: ['min', 'max', 'integer'],
|
|
221
224
|
styles: INPUT_STYLES,
|
|
222
225
|
properties: [
|
|
@@ -242,8 +245,10 @@ export const CONTROLS = {
|
|
|
242
245
|
valueType: 'date',
|
|
243
246
|
defaultSize: { w: 0.44, h: 0.08 },
|
|
244
247
|
defaults: { label: 'Date' },
|
|
245
|
-
props: ['name', 'label', 'required', 'default', 'validation', 'style'],
|
|
246
|
-
|
|
248
|
+
props: ['name', 'label', 'preset', 'required', 'default', 'validation', 'style'],
|
|
249
|
+
// notFuture: on or before TODAY, whenever the form is filled in (a date of
|
|
250
|
+
// birth) — a fixed `max` would go stale.
|
|
251
|
+
validation: ['min', 'max', 'notFuture'],
|
|
247
252
|
styles: INPUT_STYLES,
|
|
248
253
|
properties: [
|
|
249
254
|
FIELD_BASICS([{ path: 'props.default', label: 'Default value', type: 'date' }]),
|
|
@@ -252,7 +257,8 @@ export const CONTROLS = {
|
|
|
252
257
|
title: 'Validation',
|
|
253
258
|
fields: [
|
|
254
259
|
{ path: 'props.validation.min', label: 'Earliest', type: 'date' },
|
|
255
|
-
{ path: 'props.validation.max', label: 'Latest', type: 'date' }
|
|
260
|
+
{ path: 'props.validation.max', label: 'Latest', type: 'date' },
|
|
261
|
+
{ path: 'props.validation.notFuture', label: 'Not in the future', type: 'toggle' }
|
|
256
262
|
]
|
|
257
263
|
},
|
|
258
264
|
...styleGroups(INPUT_STYLES)
|
|
@@ -268,7 +274,7 @@ export const CONTROLS = {
|
|
|
268
274
|
defaultSize: { w: 0.44, h: 0.05 },
|
|
269
275
|
defaults: { label: 'Checkbox', default: false },
|
|
270
276
|
// `required` on a checkbox means it must be ticked — "I accept".
|
|
271
|
-
props: ['name', 'label', 'required', 'default', 'style'],
|
|
277
|
+
props: ['name', 'label', 'preset', 'required', 'default', 'style'],
|
|
272
278
|
validation: [],
|
|
273
279
|
styles: ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'color'],
|
|
274
280
|
properties: [
|
|
@@ -286,7 +292,7 @@ export const CONTROLS = {
|
|
|
286
292
|
valueType: null,
|
|
287
293
|
defaultSize: { w: 0.44, h: 0.08 },
|
|
288
294
|
defaults: { label: 'Dropdown', placeholder: 'Select…', data: { source: 'static', options: [] } },
|
|
289
|
-
props: ['name', 'label', 'placeholder', 'required', 'default', 'data', 'style'],
|
|
295
|
+
props: ['name', 'label', 'preset', 'placeholder', 'required', 'default', 'data', 'style'],
|
|
290
296
|
validation: [],
|
|
291
297
|
styles: INPUT_STYLES.filter((k) => k !== 'textAlign'),
|
|
292
298
|
properties: [
|
|
@@ -311,7 +317,7 @@ export const CONTROLS = {
|
|
|
311
317
|
valueType: null,
|
|
312
318
|
defaultSize: { w: 0.44, h: 0.14 },
|
|
313
319
|
defaults: { label: 'Choose one', data: { source: 'static', options: [] }, layout: 'vertical' },
|
|
314
|
-
props: ['name', 'label', 'required', 'default', 'data', 'layout', 'style'],
|
|
320
|
+
props: ['name', 'label', 'preset', 'required', 'default', 'data', 'layout', 'style'],
|
|
315
321
|
validation: [],
|
|
316
322
|
styles: INPUT_STYLES.filter((k) => k !== 'textAlign'),
|
|
317
323
|
properties: [
|
|
@@ -338,7 +344,7 @@ export const CONTROLS = {
|
|
|
338
344
|
valueType: 'array',
|
|
339
345
|
defaultSize: { w: 0.44, h: 0.18 },
|
|
340
346
|
defaults: { label: 'Choose any', data: { source: 'static', options: [] }, layout: 'vertical' },
|
|
341
|
-
props: ['name', 'label', 'required', 'data', 'layout', 'validation', 'style'],
|
|
347
|
+
props: ['name', 'label', 'preset', 'required', 'data', 'layout', 'validation', 'style'],
|
|
342
348
|
validation: ['minItems', 'maxItems'],
|
|
343
349
|
styles: INPUT_STYLES.filter((k) => k !== 'textAlign'),
|
|
344
350
|
properties: [
|
|
@@ -371,7 +377,7 @@ export const CONTROLS = {
|
|
|
371
377
|
valueType: 'date',
|
|
372
378
|
defaultSize: { w: 0.44, h: 0.08 },
|
|
373
379
|
defaults: { label: 'Date and time' },
|
|
374
|
-
props: ['name', 'label', 'required', 'default', 'validation', 'style'],
|
|
380
|
+
props: ['name', 'label', 'preset', 'required', 'default', 'validation', 'style'],
|
|
375
381
|
validation: ['min', 'max'],
|
|
376
382
|
styles: INPUT_STYLES,
|
|
377
383
|
properties: [
|
|
@@ -397,7 +403,7 @@ export const CONTROLS = {
|
|
|
397
403
|
valueType: 'string',
|
|
398
404
|
defaultSize: { w: 0.44, h: 0.1 },
|
|
399
405
|
defaults: { label: 'File', accept: FILE_KINDS.document.accept, maxSize: 10 },
|
|
400
|
-
props: ['name', 'label', 'required', 'accept', 'maxSize', 'style'],
|
|
406
|
+
props: ['name', 'label', 'preset', 'required', 'accept', 'maxSize', 'style'],
|
|
401
407
|
validation: [],
|
|
402
408
|
styles: INPUT_STYLES.filter((k) => k !== 'textAlign'),
|
|
403
409
|
properties: [
|