@xeplr/ui-factory 1.0.13 → 1.1.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 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) } // every autosave; returns the saved record
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
- There is **no submit button** to add: a screen saves itself as it is filled in. If the request mentions seeing or editing what was entered ("…and show the employees below"), add a `list`.
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, email, phone, code | `text` (add a `pattern` for email/phone/code formats) |
266
+ | a name, title, code | `text` (add a `pattern` for a code format) |
243
267
  | notes, description, address, comments | `textarea` |
244
- | amount, salary, quantity, age | `number` (`integer: true` for counts) |
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 that **saves itself** as it is filled in, with a list of the saved records to open, edit or delete.
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
 
@@ -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 automatically
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
- **There is no submit.** The screen saves itself in the background (one AJAX call to your `onSave`) a moment after a change, once the entered values are acceptable. Until then it says what is missing — "Fill in the required fields to save" — and a field's message appears once the person has been in it. The first save of a new record creates it: return the saved record and its `id` makes every later save an update.
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` — on every autosave; return the saved record | `id`, `isNew`, `screen`, `source` |
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, where it saves itself and the list refreshes behind it.
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 itself, into its own table.** What travels with the
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**: it paints its own surface, so a screen designed in
249
- dark ink stays readable inside a dark app. Give the screen a `background` in
250
- its own style to change it, or set `--xeplr-factory-paper` on the host for all
251
- of them.
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 screen still saves itself as it is filled
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
@@ -377,7 +494,9 @@ Layout is **proportional**, like `@xeplr/ui-canvas` dashboards: `x` and `w` are
377
494
 
378
495
  ## Theming
379
496
 
380
- Styles are namespaced `.xeplr-factory-*` and read the xeplr theme variables with light fallbacks: `--xeplr-accent`, `--xeplr-text`, `--xeplr-muted`, `--xeplr-border`, `--xeplr-surface`, `--xeplr-danger`, `--xeplr-input-bg`, `--xeplr-input-border`.
497
+ 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.
498
+
499
+ Buttons, steps and list actions all show hover, a visible press, and a keyboard-only focus ring.
381
500
 
382
501
  ## Try it
383
502
 
@@ -393,16 +512,19 @@ src/
393
512
  controls.js ─ the control registry: palette, property panel contract, rules
394
513
  document.js ─ create and edit screen documents
395
514
  validateDocument.js ─ the document checker
396
- values.js ─ values, validation, autosave readiness, records and list columns, formSchema
515
+ values.js ─ values, validation, whether Save can go out, records and list columns, formSchema
397
516
  generate.js ─ screenFromSpec, screensFromSpec (list + edit), entity names
398
517
  scaffold.js ─ an entity → its screen JSON, .jsx pages (with front-end hooks), server hooks + model stubs
399
518
  hooks.js ─ FactoryHooks: get / save / delete / actions — extend and call super
400
- tableSchema.js ─ a form → its table; the publish plan (create / add / widen / confirmed drops)
519
+ presets.js ─ ready-made fields (Email, Age, Gender…), changing a field's kind, label suggestions
520
+ tableSchema.js ─ a form → its table; the publish plan (create / add / widen / confirmed drops and conversions)
401
521
  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 })
522
+ screenSource.js ─ this app's screens as another designer wants them: describeScreen, loadScreens, keyFromName, editScreenOf (React-free)
402
523
  model.js ─ everything above, React-free
403
524
  useFactoryBuilder.js ─ builder controller
404
525
  useFactoryScreen.js ─ screen controller
405
- designs/ ─ BuilderSample, ScreenSample, ControlView, ListView, Palette, PropertyPanel, styles.js, factory.css
526
+ useScreenSource.js ─ { screens, screenEditor } for @xeplr/ui-workflow's Screen step
527
+ designs/ ─ BuilderSample, ScreenSample, ScreenEditorSample, ControlView, ListView, Palette, PropertyPanel, styles.js, factory.css
406
528
  pages.jsx ─ FactoryBuilder, FactoryScreen
407
529
  bin/xeplr-factory.js ─ screens / generate / validate / controls / schema
408
530
  examples/ ─ employee.entity.json and what `screens` makes of it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-factory",
3
- "version": "1.0.13",
3
+ "version": "1.1.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",
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
- props: ['name', 'label', 'placeholder', 'required', 'default', 'validation', 'style'],
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
- validation: ['min', 'max'],
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: [
@@ -1,9 +1,10 @@
1
- import { useState } from 'react'
1
+ import { useEffect, useState } from 'react'
2
2
  import { XeplrCanvas } from '@xeplr/ui-canvas'
3
3
  import ControlView from './ControlView.jsx'
4
4
  import Palette, { DRAG_TYPE } from './Palette.jsx'
5
5
  import PropertyPanel from './PropertyPanel.jsx'
6
6
  import { screenStyle } from './styles.js'
7
+ import { presetFor } from '../presets.js'
7
8
 
8
9
  // The builder, drawn: name and save status on top, controls on the left, the
9
10
  // screen on the canvas in the middle, properties on the right — the selected
@@ -14,12 +15,15 @@ import { screenStyle } from './styles.js'
14
15
  // units with pageAspect = the screen's aspect: the same geometry ScreenSample
15
16
  // renders with, so the builder shows the screen at its real size.
16
17
 
18
+ // NOTHING SAVES ITSELF — see the note in useFactoryBuilder. These say what
19
+ // IS, not what is about to happen on its own: "unsaved" is a fact about the
20
+ // screen, and the Save button beside it is what changes it.
17
21
  const STATUS = {
18
22
  idle: '',
19
- pending: 'Unsaved changes…',
23
+ unsaved: 'Unsaved changes',
20
24
  saving: 'Saving…',
21
- saved: 'All changes saved',
22
- invalid: 'Not saved — fix the problems',
25
+ saved: 'Saved',
26
+ invalid: 'Fix the problems to save',
23
27
  error: 'Not saved'
24
28
  }
25
29
 
@@ -30,8 +34,10 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
30
34
 
31
35
  function onDrop(e) {
32
36
  const type = e.dataTransfer.getData(DRAG_TYPE) || e.dataTransfer.getData('text/plain')
33
- const def = ctrl.controls[type]
34
- if (!def) return
37
+ const preset = presetFor(type)
38
+ const base = ctrl.controls[preset ? preset.control : type]
39
+ if (!base) return
40
+ const def = { defaultSize: { ...base.defaultSize, ...(preset && preset.size) } }
35
41
  e.preventDefault()
36
42
  const inner = e.currentTarget.querySelector('.xeplr-canvas-inner')
37
43
  if (!inner) return ctrl.addControl(type)
@@ -45,6 +51,16 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
45
51
  })
46
52
  }
47
53
 
54
+ // WITH NOTHING SAVING ITSELF, closing the tab is how work is lost. The
55
+ // browser's own question is the only one that can be asked this late, and
56
+ // it is asked ONLY when there is something to lose.
57
+ useEffect(() => {
58
+ if (!ctrl.dirty) return undefined
59
+ function ask(e) { e.preventDefault(); e.returnValue = '' }
60
+ window.addEventListener('beforeunload', ask)
61
+ return () => window.removeEventListener('beforeunload', ask)
62
+ }, [ctrl.dirty])
63
+
48
64
  return (
49
65
  <div className={'xeplr-factory-builder' + (className ? ' ' + className : '')} style={style}>
50
66
  <header className="xeplr-factory-bar">
@@ -63,8 +79,32 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
63
79
  {problemCount} problem{problemCount === 1 ? '' : 's'}
64
80
  </span>
65
81
  )}
66
- {ctrl.status === 'error' && (
67
- <button type="button" className="xeplr-factory-secondary" onClick={ctrl.save}>Retry</button>
82
+ {/* Suggestions, not problems: they never stop a save or a publish.
83
+ Clicking one opens the field, where the panel offers the change. */}
84
+ {ctrl.hints.length > 0 && (
85
+ <button
86
+ type="button"
87
+ className="xeplr-factory-hints"
88
+ title={ctrl.hints.map((h) => h.message).join('\n')}
89
+ onClick={() => ctrl.setSelected(new Set([ctrl.hints[0].nodeId]))}
90
+ >
91
+ {ctrl.hints.length} suggestion{ctrl.hints.length === 1 ? '' : 's'}
92
+ </button>
93
+ )}
94
+ {/* SAVE IS AN ACT. It is offered whenever there is something to save
95
+ (and after a failure, which is the same thing), and it is the only
96
+ thing that writes the draft — trying a layout out and walking away
97
+ leaves the saved screen as it was. */}
98
+ {(ctrl.dirty || ctrl.status === 'error') && (
99
+ <button
100
+ type="button"
101
+ className="xeplr-factory-secondary"
102
+ onClick={ctrl.save}
103
+ disabled={ctrl.saving || !ctrl.validation.ok}
104
+ title={ctrl.validation.ok ? 'Save this design' : 'Fix the problems first'}
105
+ >
106
+ {ctrl.status === 'error' ? 'Try saving again' : ctrl.saving ? 'Saving…' : 'Save'}
107
+ </button>
68
108
  )}
69
109
  {renderPreview && (
70
110
  <button type="button" className="xeplr-factory-secondary" aria-pressed={preview} onClick={() => setPreview((p) => !p)}>
@@ -79,20 +119,35 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
79
119
  </header>
80
120
 
81
121
  {ctrl.publishResult && ctrl.publishResult.confirm && (
82
- <div className="xeplr-factory-publish is-confirm" role="alertdialog" aria-label="Confirm removing columns">
122
+ <div className="xeplr-factory-publish is-confirm" role="alertdialog" aria-label="Confirm changes to saved data">
83
123
  <div className="xeplr-factory-confirm-text">
84
- <strong>Publishing removes {ctrl.publishResult.confirm.length === 1 ? 'a column' : `${ctrl.publishResult.confirm.length} columns`} and all the data in {ctrl.publishResult.confirm.length === 1 ? 'it' : 'them'}.</strong>
85
- <ul>
86
- {ctrl.publishResult.confirm.map((c) => (
87
- <li key={c.column}><code>{c.column}</code> — {c.records} saved value{c.records === 1 ? '' : 's'}, for every company using this table</li>
88
- ))}
89
- </ul>
90
- This cannot be undone. To keep the data, put the field back before publishing.
124
+ {ctrl.publishResult.confirm.length > 0 && (
125
+ <>
126
+ <strong>Publishing removes {ctrl.publishResult.confirm.length === 1 ? 'a column' : `${ctrl.publishResult.confirm.length} columns`} and all the data in {ctrl.publishResult.confirm.length === 1 ? 'it' : 'them'}.</strong>
127
+ <ul>
128
+ {ctrl.publishResult.confirm.map((c) => (
129
+ <li key={c.column}><code>{c.column}</code> — {c.records} saved value{c.records === 1 ? '' : 's'}, for every company using this table</li>
130
+ ))}
131
+ </ul>
132
+ This cannot be undone. To keep the data, put the field back before publishing.
133
+ </>
134
+ )}
135
+ {/* A field that changed kind: every saved value was checked and fits. */}
136
+ {(ctrl.publishResult.convert || []).length > 0 && (
137
+ <>
138
+ <strong>Publishing converts saved values to their field's new kind.</strong>
139
+ <ul>
140
+ {ctrl.publishResult.convert.map((c) => (
141
+ <li key={c.column}><code>{c.column}</code> — {c.from} → {c.to}: all {c.records} saved value{c.records === 1 ? '' : 's'} fit; blank ones become empty</li>
142
+ ))}
143
+ </ul>
144
+ </>
145
+ )}
91
146
  </div>
92
147
  <div className="xeplr-factory-confirm-actions">
93
- <button type="button" className="xeplr-factory-danger" disabled={ctrl.publishing}
94
- onClick={() => ctrl.publish(ctrl.publishResult.confirm.map((c) => c.column))}>
95
- {ctrl.publishing ? 'Publishing…' : 'Remove and publish'}
148
+ <button type="button" className={ctrl.publishResult.confirm.length ? 'xeplr-factory-danger' : 'xeplr-factory-primary'} disabled={ctrl.publishing}
149
+ onClick={() => ctrl.publish(ctrl.publishResult.confirm.map((c) => c.column), (ctrl.publishResult.convert || []).map((c) => c.column))}>
150
+ {ctrl.publishing ? 'Publishing…' : ctrl.publishResult.confirm.length ? 'Remove and publish' : 'Convert and publish'}
96
151
  </button>
97
152
  <button type="button" className="xeplr-factory-secondary" onClick={ctrl.clearPublishResult}>Cancel</button>
98
153
  </div>
@@ -179,12 +234,15 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
179
234
  doc={doc}
180
235
  node={ctrl.selectedNode}
181
236
  control={ctrl.selectedControl}
237
+ controls={ctrl.controls}
182
238
  selectionCount={ctrl.selected.size}
183
239
  errors={ctrl.selectedNode ? ctrl.errorsByNode[ctrl.selectedNode.id] : ctrl.errorsByNode._document}
184
240
  tables={ctrl.tables}
185
241
  screens={ctrl.screens}
186
242
  lockedNames={ctrl.lockedNames}
187
243
  onChange={(path, value) => ctrl.setProperty(ctrl.selectedNode.id, path, value)}
244
+ onConvert={(target) => ctrl.convertControl(ctrl.selectedNode.id, target)}
245
+ hint={ctrl.selectedNode ? ctrl.hints.find((h) => h.nodeId === ctrl.selectedNode.id) : null}
188
246
  onScreenChange={ctrl.setScreen}
189
247
  onRemove={ctrl.removeSelected}
190
248
  />
@@ -65,12 +65,21 @@ function inputProps(node, p, error, disabled, onBlur) {
65
65
  }
66
66
  }
67
67
 
68
+ // What each keyboard setting makes the box: its type (the phone keyboard) and
69
+ // what the browser's autofill offers.
70
+ const TEXT_INPUT = {
71
+ email: { type: 'email', autoComplete: 'email', inputMode: 'email' },
72
+ tel: { type: 'tel', autoComplete: 'tel', inputMode: 'tel' },
73
+ url: { type: 'url', autoComplete: 'url', inputMode: 'url' }
74
+ }
75
+
68
76
  function TextView({ node, p, value, error, onChange, onBlur, disabled }) {
77
+ const kind = TEXT_INPUT[p.inputType] || { type: 'text' }
69
78
  return (
70
79
  <Field node={node} p={p} error={error}>
71
80
  <input
72
81
  className="xeplr-factory-input"
73
- type="text"
82
+ {...kind}
74
83
  placeholder={p.placeholder || ''}
75
84
  value={value ?? ''}
76
85
  onChange={(e) => onChange(e.target.value)}