@xeplr/ui-factory 1.8.3 → 1.8.4
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 +21 -1
- package/README.md +169 -6
- package/bin/xeplr-factory.js +27 -1
- package/package.json +3 -1
- package/src/buttons.js +142 -0
- package/src/controls.js +48 -1
- package/src/designs/BuilderSample.jsx +1 -0
- package/src/designs/ButtonsView.jsx +107 -0
- package/src/designs/ControlView.jsx +4 -1
- package/src/designs/FedList.jsx +248 -0
- package/src/designs/ListView.jsx +6 -1
- package/src/designs/PropertyPanel.jsx +336 -4
- package/src/designs/ScreenSample.jsx +12 -1
- package/src/designs/factory.css +139 -0
- package/src/events.js +21 -0
- package/src/model.js +8 -1
- package/src/pagesInventory.js +91 -0
- package/src/remote.js +17 -2
- package/src/urlState.js +2 -2
- package/src/useFactoryBuilder.js +9 -1
- package/src/useFactoryScreen.js +103 -8
- package/src/useUrlPlaces.js +13 -0
- package/src/validateDocument.js +147 -3
package/AUTHORING.md
CHANGED
|
@@ -25,7 +25,9 @@ A request for "a form for employees" is an **entity**. Every entity is the same
|
|
|
25
25
|
| the request says… | change |
|
|
26
26
|
|---|---|
|
|
27
27
|
| a field, a label, a rule, a dropdown, the layout, a colour | the screen JSON (then validate) |
|
|
28
|
-
| "
|
|
28
|
+
| "a button that does something to a record" — set a status, confirm, approve | a **Buttons** control naming a function (event `button`) and its parameters; the function in `employee.functions.js` — one function, many buttons with different parameters |
|
|
29
|
+
| "a list whose rows are worked out", "a search box", "filter chips with counts" | a list with `rowsFrom` naming a function (event `listRows`) that returns `{ rows, counts }` — the text it shows is the function's |
|
|
30
|
+
| "show / hide / filter rows on screen", "fill in X before saving" (browser convenience — web only) | front-end hooks in `EditEmployee.jsx` |
|
|
29
31
|
| "must", "only if", "check against…", "email when…", "only managers see…" | a server function in `employee.functions.js`, named in the screen's `events` |
|
|
30
32
|
| "store as…", "convert…", "comma-separated / array", "work out X from Y" (data shape) | server model in `employee.model.js` |
|
|
31
33
|
| a query in your own server code | `factory.table('employees')` — this company, active rows, the model applied |
|
|
@@ -228,6 +230,23 @@ For an entity, `screens` makes the list for you. By hand: a `list` shows the sav
|
|
|
228
230
|
|
|
229
231
|
A list needs a table: set the screen's `source` or the list's own.
|
|
230
232
|
|
|
233
|
+
## Buttons
|
|
234
|
+
|
|
235
|
+
A button is **a registered function and its parameters** — never code. Several buttons that do the same thing with different values (eight statuses) are one function:
|
|
236
|
+
|
|
237
|
+
```json
|
|
238
|
+
{ "type": "buttons", "props": { "buttons": [
|
|
239
|
+
{ "id": "packed", "label": "Packed", "action": "setOrderStatus", "params": { "status": "packed" },
|
|
240
|
+
"ask": { "note": "Reason (optional)" }, "disabledWhen": [{ "field": "customStatus", "op": "eq", "value": "packed" }] }
|
|
241
|
+
] } }
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Register the function with `event: 'button'` and an `inputSchema` (with `options` for the values allowed, and a `note` input if a button asks for one). `when` / `disabledWhen` are `@xeplr/rules` rules on the record. Publishing refuses parameters the function does not take. See the README's "Buttons".
|
|
245
|
+
|
|
246
|
+
## When the document cannot say it
|
|
247
|
+
|
|
248
|
+
Say so — "this needs a new control" — rather than writing a page by hand around it. If a hand-written page is agreed, list it in the app's `ui/pages.json` under `webOnly` with the reason: it will not be in a phone app, and `npx xeplr-factory pages` says so on every commit.
|
|
249
|
+
|
|
231
250
|
## Dropdowns
|
|
232
251
|
|
|
233
252
|
A dropdown **always saves an `id` and shows a `name`.**
|
|
@@ -312,6 +331,7 @@ npx xeplr-factory generate <spec.json|-> [-o <screen.json>] # spec → documen
|
|
|
312
331
|
npx xeplr-factory validate <screen.json|-> # exit 1 with every problem listed
|
|
313
332
|
npx xeplr-factory controls # controls, props, validation rules
|
|
314
333
|
npx xeplr-factory schema <screen.json|-> # the form's @xeplr/schema-handler schema
|
|
334
|
+
npx xeplr-factory pages [<ui dir>] [--strict] # warn about web-only pages and routes ui/pages.json does not list
|
|
315
335
|
```
|
|
316
336
|
|
|
317
337
|
The `schema` output is what the app's server checks a submission against: `applySchema(formSchema(document), body)`.
|
package/README.md
CHANGED
|
@@ -361,6 +361,163 @@ to one of these); the screen only names it:
|
|
|
361
361
|
- **Checked twice.** Here, that each name could be a function and each event exists. On the server, at publish and when it runs, that the function is registered *for that event* — a missing one refuses with its name (`missingFunctions(doc, registry)`).
|
|
362
362
|
- `SCREEN_EVENTS` carries each event's contract (`when`, `gets`, `returns`) — what the designer shows beside each picker.
|
|
363
363
|
|
|
364
|
+
## Screens and buttons — the rules
|
|
365
|
+
|
|
366
|
+
These are the rules every app's `CLAUDE.md` points to. They keep a screen
|
|
367
|
+
drawable by **anything that reads a document** — the web today, a phone app
|
|
368
|
+
next — and they hold whoever does the work.
|
|
369
|
+
|
|
370
|
+
1. **A screen is a document.** To change a screen, change its document (the
|
|
371
|
+
`.screen.json`, or the builder) and run `npx xeplr-factory validate`. Never
|
|
372
|
+
hand-write a page for something a document can say.
|
|
373
|
+
2. **A button is a function and its parameters.** The app registers the
|
|
374
|
+
function once on the server (`@xeplr/actions`, `event: 'button'`, an
|
|
375
|
+
`inputSchema` saying what it takes); the document names it and gives the
|
|
376
|
+
values. Eight status buttons are **one** function with eight `status`
|
|
377
|
+
values — not eight functions, and never code in the document.
|
|
378
|
+
3. **Rows that are worked out come from a function too** (`rowsFrom`, event
|
|
379
|
+
`listRows`), and so does a cell's hover box (`details`, event
|
|
380
|
+
`rowDetails`). Formatting — times in the business zone, money, "2h ago" —
|
|
381
|
+
is the function's: the screen shows the text it wrote.
|
|
382
|
+
4. **When the document cannot say it**, say so first — "this needs a new
|
|
383
|
+
control" — and add the control to this package, with its web view now
|
|
384
|
+
and a phone view to follow. Do not build around it.
|
|
385
|
+
5. **If a page must be hand-written anyway, it is web only, out loud.** List
|
|
386
|
+
it in the app's `ui/pages.json` under `webOnly` with the reason.
|
|
387
|
+
`npx xeplr-factory pages` (the pre-commit hook) warns about every web-only
|
|
388
|
+
page and every route nobody listed, and the app's menu settings tag the
|
|
389
|
+
item "Web only". It is never refused — only never secret.
|
|
390
|
+
6. **Checked on the server.** Publishing refuses a screen whose functions are
|
|
391
|
+
not registered, are registered for something else, or are given parameters
|
|
392
|
+
they do not take — saying which button and why (`functionProblems`).
|
|
393
|
+
|
|
394
|
+
## Buttons — a function and its parameters
|
|
395
|
+
|
|
396
|
+
A **Buttons** control (palette: Actions → Buttons) is a panel of buttons, each
|
|
397
|
+
naming a function of the app and the values it is given:
|
|
398
|
+
|
|
399
|
+
```json
|
|
400
|
+
{ "type": "buttons", "props": { "title": "Status", "buttons": [
|
|
401
|
+
{ "id": "packed", "label": "Packed", "action": "setOrderStatus", "params": { "status": "packed" },
|
|
402
|
+
"ask": { "title": "Change status to “Packed”?", "note": "Reason (optional)" },
|
|
403
|
+
"disabledWhen": [{ "field": "customStatus", "op": "eq", "value": "packed" }] },
|
|
404
|
+
{ "id": "cod-confirm", "label": "Confirm COD", "action": "confirmCod", "params": { "undo": false },
|
|
405
|
+
"when": [{ "field": "paymentMethod", "op": "eq", "value": "COD" }], "variant": "primary" }
|
|
406
|
+
] } }
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
The function, in the app's server code:
|
|
410
|
+
|
|
411
|
+
```js
|
|
412
|
+
actions.register({
|
|
413
|
+
name: 'setOrderStatus', event: 'button',
|
|
414
|
+
description: 'Sets the order status and posts who changed it',
|
|
415
|
+
inputSchema: [
|
|
416
|
+
{ name: 'status', type: 'string', required: true, options: [{ value: 'packed', label: 'Packed' }, { value: 'shipped', label: 'Shipped' }] },
|
|
417
|
+
{ name: 'note', type: 'string' }
|
|
418
|
+
],
|
|
419
|
+
execute: async ({ input, system }) => {
|
|
420
|
+
// system: { record, id, user, tenant, knex, db, screen, screenKey, button, reject }
|
|
421
|
+
await system.db('orders').where('id', system.id).update({ customStatus: input.status })
|
|
422
|
+
return { message: 'Order ' + system.record.name + ' is now ' + input.status }
|
|
423
|
+
}
|
|
424
|
+
})
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
| key | |
|
|
428
|
+
|---|---|
|
|
429
|
+
| `id` | what a press names the button by — unique on the screen |
|
|
430
|
+
| `label` | the button's text |
|
|
431
|
+
| `action` | the registered function (event `button`) |
|
|
432
|
+
| `params` | the values it is given — plain values, checked against its `inputSchema` (`options` = the values allowed) at publish |
|
|
433
|
+
| `ask` | asked first: `title`, `message`, and `note` — the label of a note box, whose text reaches the function as `note` (it must take one) |
|
|
434
|
+
| `when` | shown only when every rule matches the record |
|
|
435
|
+
| `disabledWhen` | greyed out when every rule matches |
|
|
436
|
+
| `variant` | `default`, `primary`, `danger` |
|
|
437
|
+
|
|
438
|
+
Rules are [`@xeplr/rules`](https://www.npmjs.com/package/@xeplr/rules) rules — `{ field, op, value }`, with `eq`, `neq`, `isEmpty`, `contains`, `gt`, … — read from **the record the screen is about**: the `record` handed to `<FactoryScreen>`, with the form's own changes on top.
|
|
439
|
+
|
|
440
|
+
- **The press runs on the server** (`POST /factory/buttons/:screen/:button`). It reads the button — function and parameters — from the **published** screen, so a request cannot run another function or change the values; it sends the record's id and the note, nothing else.
|
|
441
|
+
- **The record decides.** The server reads the record (this company, active rows) and checks `when` / `disabledWhen` again, so a hidden or greyed-out button cannot be pressed by sending the request — "does not apply to this record now" (409) when it changed underneath.
|
|
442
|
+
- **What happens next is the host's.** The panel shows the function's `message`; `onButtonDone({ button, result, record })` lets the page reload what it shows. Lists on the same screen reload by themselves.
|
|
443
|
+
- **In the builder**, picking a function lists the parameters it takes — a dropdown where it names the values allowed — so nobody types a status by hand. Functions come from `listFunctions` (`createFactoryApi`'s `builderProps` has it).
|
|
444
|
+
|
|
445
|
+
## A list whose rows a function works out — `rowsFrom`
|
|
446
|
+
|
|
447
|
+
A list reads one table's records. When the rows are **worked out** — an
|
|
448
|
+
order's state from its tasks, "New" for the person looking, an urgency sort —
|
|
449
|
+
a registered function answers instead (event `listRows`), with a search box,
|
|
450
|
+
filter chips and the count on each:
|
|
451
|
+
|
|
452
|
+
```json
|
|
453
|
+
{ "type": "list", "props": {
|
|
454
|
+
"title": "Orders", "rowsFrom": "orderList", "actions": [], "editScreen": "order_actions", "openIn": "page", "rowOpen": true,
|
|
455
|
+
"search": { "placeholder": "Search order, customer or city" },
|
|
456
|
+
"filters": [{ "key": "all", "label": "All" }, { "key": "packed", "label": "Packed" }],
|
|
457
|
+
"refreshEvery": 60, "highlight": "isNew", "pageSize": 50, "grow": true,
|
|
458
|
+
"columns": [
|
|
459
|
+
{ "field": "orderNumber", "label": "Order", "text": "orderLabel", "badges": [{ "field": "isNew", "label": "New" }, { "field": "cancelled", "label": "Cancelled", "tone": "danger" }] },
|
|
460
|
+
{ "field": "customerName", "label": "Customer", "sub": "city" },
|
|
461
|
+
{ "field": "images", "label": "Items", "display": "images", "more": "moreImages", "sortable": false },
|
|
462
|
+
{ "field": "ageMs", "label": "Aging", "text": "agingText" },
|
|
463
|
+
{ "field": "lastActivityAt", "label": "Last activity", "text": "lastActivityText", "display": "activity", "details": "orderActivity", "sortable": false }
|
|
464
|
+
] } }
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
```js
|
|
468
|
+
actions.register({ name: 'orderList', event: 'listRows', execute: async (ctx) => {
|
|
469
|
+
// ctx.query = { q, filter } — what was typed, and the chip (always one of the list's own)
|
|
470
|
+
// ctx.user, ctx.tenant, ctx.db(table), ctx.knex
|
|
471
|
+
return { rows: [...], counts: { all: 41, packed: 6 } }
|
|
472
|
+
} })
|
|
473
|
+
actions.register({ name: 'orderActivity', event: 'rowDetails', execute: async (ctx) =>
|
|
474
|
+
// ctx.id — the row; ctx.column
|
|
475
|
+
[{ text: 'Status set to Packed', sub: 'Asha', at: 'Today, 4:00 pm', open: { tab: 'comments' } }]
|
|
476
|
+
})
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
| column key | |
|
|
480
|
+
|---|---|
|
|
481
|
+
| `field` | the value the column **sorts by** (a number, an ISO time) |
|
|
482
|
+
| `text` | the field holding what is **shown** — written by the function, in the app's words and time zone. Blank → the value |
|
|
483
|
+
| `sub` | a second line, smaller |
|
|
484
|
+
| `badges` | `[{ field, label, tone }]` after the text, where `field` is true. Tones: `accent`, `success`, `warning`, `danger`, `muted` |
|
|
485
|
+
| `display` | `text` (default), `images` (`[{ src, name }]`, plus `more`: how many others), `activity` (a line with a hover box) |
|
|
486
|
+
| `details` | for `activity`: the function (event `rowDetails`) whose entries the hover box lists; an entry with `open` opens the row at that place (passed to `onOpenRecord` as `params`) |
|
|
487
|
+
| `sortable` | `false` for a column whose header should not sort |
|
|
488
|
+
|
|
489
|
+
- **The search and the chip live in the URL** under the screen's `urlKey` (`<urlKey>Q`, `<urlKey>Filter`), so a refresh shows the same rows. The search waits for a pause in typing.
|
|
490
|
+
- **`refreshEvery`** (seconds, 10–3600) asks again — for text that changes with time. **`reloadKey`** on `<FactoryScreen>` reloads every list when it changes (the host heard something happen).
|
|
491
|
+
- **`grow`** (a list that is its screen's **one** control) lets the page grow with the rows on a desktop, instead of the list scrolling inside the box it was placed in — a full-page list like Orders. Phones always flow.
|
|
492
|
+
- **`rowOpen`** makes a click anywhere on a row open it (the first cell is also a button, for the keyboard); **`highlight`** marks rows whose field is true.
|
|
493
|
+
- **Rows that are worked out are not a table's records**: no Delete, and Edit / New only with an `editScreen`.
|
|
494
|
+
- Routes: `GET /factory/rows/:screen` (`?node=&q=&filter=`) and `/factory/rows/:screen/details` (`?node=&column=&id=`) — see `@xeplr/factory`.
|
|
495
|
+
|
|
496
|
+
## Web-only pages — `ui/pages.json`
|
|
497
|
+
|
|
498
|
+
Each app keeps an inventory of its pages: drawn from documents, or
|
|
499
|
+
hand-written for the browser (and so **not** in a phone app).
|
|
500
|
+
|
|
501
|
+
```json
|
|
502
|
+
{
|
|
503
|
+
"routes": ["src/App.jsx"],
|
|
504
|
+
"documents": [{ "path": "/orders", "menu": "Orders", "screen": "orders_board" }],
|
|
505
|
+
"webOnly": [{ "path": "/home", "menu": "Home", "reason": "The dashboard's cards are hand-written" }]
|
|
506
|
+
}
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
```sh
|
|
510
|
+
npx xeplr-factory pages ui # warnings, exit 0
|
|
511
|
+
npx xeplr-factory pages ui --strict # warnings fail
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
It warns about every web-only page (with its reason), every `<Route path>`
|
|
515
|
+
in the `routes` files that the inventory does not list, and every listed page
|
|
516
|
+
no route declares any more. Redirects and `*` are not pages. Run it from the
|
|
517
|
+
app's pre-commit hook; it never blocks a commit. `webOnlyMenus(inventory)`
|
|
518
|
+
gives `{ menu key → reason }`, for a menu-settings screen to tag those items
|
|
519
|
+
"Web only".
|
|
520
|
+
|
|
364
521
|
## The flow designer, as a list — `<FlowDesigner>`
|
|
365
522
|
|
|
366
523
|
Your screens as a journey: the steps down the left; for the one picked, its
|
|
@@ -631,7 +788,8 @@ Both walk someone through something, and they answer different questions:
|
|
|
631
788
|
| `file` | the stored file's path | `accept` (extensions), `maxSize` (MB) |
|
|
632
789
|
| `stepper` | — | a journey across the top; every other control says which step it is on |
|
|
633
790
|
| `label` | — | text with a heading, subheading or text preset |
|
|
634
|
-
| `list` | — | saved records, with New / Edit / Delete |
|
|
791
|
+
| `list` | — | saved records, with New / Edit / Delete — or rows a function works out (`rowsFrom`), with search, filter chips and counts; see above |
|
|
792
|
+
| `buttons` | — | a panel of buttons, each a registered function and its parameters — see above (palette: **Actions → Buttons**) |
|
|
635
793
|
| `table` | array of the rows ticked | `minItems`, `maxItems`; `required` = at least one. Rows the screen is **handed** (`rows: '{params.lines}'`), not a table it reads; `columns` (`ref, amount`) or every key of the first row; `selectable` (a tick per row, on by default). Adds no column — see below |
|
|
636
794
|
| `subform` | — | another form drawn inside this one (palette: **Form**), saving into its own table — see below |
|
|
637
795
|
|
|
@@ -826,6 +984,7 @@ The control whose properties are open, the step a stepper is open at, and the fl
|
|
|
826
984
|
| `useFactoryBuilder` / `<FactoryBuilder>` | `<urlKey>Steps` | the step each stepper is open at: `stepperId:index`, comma-separated, step 0 left out | replaces |
|
|
827
985
|
| `useFactoryScreen` / `<FactoryScreen>`, `<FlowRunner>` | `<urlKey>Steps` | as above | replaces |
|
|
828
986
|
| `useFlowBuilder` / `<FlowBuilder>` | `<urlKey>Selected` | the key of the selected flow step | as for a control |
|
|
987
|
+
| `useFactoryScreen` / `<FactoryScreen>` | `<urlKey>Q`, `<urlKey>Filter` | a function-fed list's search and chip (the first chip is left out) | replaces |
|
|
829
988
|
|
|
830
989
|
```jsx
|
|
831
990
|
<FactoryBuilder urlKey="form" document={doc} onSave={save} /> // ?formSelected=email&formSteps=stepper:1
|
|
@@ -854,7 +1013,7 @@ Buttons, steps and list actions all show hover, a visible press, and a keyboard-
|
|
|
854
1013
|
|
|
855
1014
|
```sh
|
|
856
1015
|
npm install
|
|
857
|
-
npm run dev # http://localhost:19006 — Screen · List / Edit and Designer · List / Edit
|
|
1016
|
+
npm run dev # http://localhost:19006 — Screen · List / Edit / Orders and Designer · List / Edit / Orders / Buttons, records kept in localStorage
|
|
858
1017
|
```
|
|
859
1018
|
|
|
860
1019
|
## Files
|
|
@@ -876,13 +1035,15 @@ src/
|
|
|
876
1035
|
useFactoryBuilder.js ─ builder controller
|
|
877
1036
|
useFactoryScreen.js ─ screen controller
|
|
878
1037
|
useFlowBuilder.js ─ flow designer controller
|
|
1038
|
+
buttons.js ─ buttons and function-fed lists: conditions, what a press sends, functionProblems' checks (React-free)
|
|
1039
|
+
pagesInventory.js ─ ui/pages.json: routesIn, pageWarnings, webOnlyMenus (React-free)
|
|
879
1040
|
urlState.js ─ the URL keys and values for selection and steps (React-free)
|
|
880
1041
|
useUrlPlaces.js ─ useUrlSteps, useUrlSelection: those places, in the URL or in memory
|
|
881
1042
|
useScreenSource.js ─ { screens, screenEditor } for @xeplr/ui-workflow's Screen step
|
|
882
|
-
designs/ ─ BuilderSample, ScreenSample, ScreenEditorSample, ControlView, ListView, Palette, PropertyPanel, styles.js, factory.css
|
|
1043
|
+
designs/ ─ BuilderSample, ScreenSample, ScreenEditorSample, ControlView, ListView, FedList, ButtonsView, Palette, PropertyPanel, styles.js, factory.css
|
|
883
1044
|
pages.jsx ─ FactoryBuilder, FactoryScreen
|
|
884
|
-
bin/xeplr-factory.js ─ screens / generate / validate / controls / schema
|
|
885
|
-
examples/ ─ employee.entity.json and what `screens` makes of it
|
|
1045
|
+
bin/xeplr-factory.js ─ screens / generate / validate / controls / schema / pages
|
|
1046
|
+
examples/ ─ employee.entity.json and what `screens` makes of it; orders-board and order-actions (a function-fed list, buttons)
|
|
886
1047
|
dev/ ─ the stand-in app for `npm run dev` (not published)
|
|
887
1048
|
```
|
|
888
1049
|
|
|
@@ -892,6 +1053,8 @@ dev/ ─ the stand-in app for `npm run dev` (not published)
|
|
|
892
1053
|
npm test
|
|
893
1054
|
```
|
|
894
1055
|
|
|
1056
|
+
`test/buttons.test.js` covers buttons and function-fed lists in the document: what the checker refuses, when a button shows or is greyed out, what a press sends (the document's parameters, never the request's), and `functionProblems` against a registry. `test/cli.test.js` includes `pages`.
|
|
1057
|
+
|
|
895
1058
|
`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`.
|
|
896
1059
|
|
|
897
1060
|
```sh
|
|
@@ -899,7 +1062,7 @@ npx playwright install chromium-headless-shell # once
|
|
|
899
1062
|
npm run test:phone
|
|
900
1063
|
```
|
|
901
1064
|
|
|
902
|
-
`test/phone.check.js` opens the dev app (`dev/`) in a real browser at 360, 390 and 430px wide and holds every screen to the phone rules: the page never scrolls sideways; fields are one per row, full width, in reading order, none overlapping; inputs are 16px or larger and 44px tall; the footer's buttons are 44px and on screen; a list's row buttons are on screen without scrolling the table; a popup fills the screen. At 1280px it checks that the designed layout is untouched. It is not part of `npm test` — it needs a browser and starts a dev server — so run it before releasing anything that changes how a screen is drawn. The list checks use the `@xeplr/ui-table` installed here.
|
|
1065
|
+
`test/phone.check.js` opens the dev app (`dev/`) in a real browser at 360, 390 and 430px wide and holds every screen to the phone rules: the page never scrolls sideways; fields are one per row, full width, in reading order, none overlapping; inputs are 16px or larger and 44px tall; the footer's buttons are 44px and on screen; a list's row buttons are on screen without scrolling the table; a popup fills the screen. At 1280px it checks that the designed layout is untouched. On the Orders page it checks the function-fed list and buttons at phone sizes (16px / 44px search, chips on screen, an "ask first" that fills the screen) and, at 1280px, that they work: search and chips narrow the rows and the counts, the hover box lists entries, a row opens, a press with a note changes the order and greys its button out, and Confirm COD gives way to Undo. It is not part of `npm test` — it needs a browser and starts a dev server — so run it before releasing anything that changes how a screen is drawn. The list checks use the `@xeplr/ui-table` installed here.
|
|
903
1066
|
|
|
904
1067
|
## License
|
|
905
1068
|
|
package/bin/xeplr-factory.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// xeplr-factory validate screen.json check a document (exit 1 on problems)
|
|
13
13
|
// xeplr-factory controls the controls and their props
|
|
14
14
|
// xeplr-factory schema screen.json the form's @xeplr/schema-handler schema
|
|
15
|
+
// xeplr-factory pages [ui dir] [--strict] warn about pages that are web only, or not listed in pages.json
|
|
15
16
|
//
|
|
16
17
|
// "-" as the file reads stdin.
|
|
17
18
|
|
|
@@ -23,6 +24,7 @@ import { scaffoldEntity } from '../src/scaffold.js'
|
|
|
23
24
|
import { migrationFor, nextMigrationName } from '../src/tableSchema.js'
|
|
24
25
|
import { validateDocument } from '../src/validateDocument.js'
|
|
25
26
|
import { formSchema } from '../src/values.js'
|
|
27
|
+
import { routesIn, pageWarnings } from '../src/pagesInventory.js'
|
|
26
28
|
|
|
27
29
|
const USAGE = `usage:
|
|
28
30
|
xeplr-factory screens <entity.json|-> [-o <dir>] [--force] [--no-pages]
|
|
@@ -30,7 +32,8 @@ const USAGE = `usage:
|
|
|
30
32
|
xeplr-factory generate <spec.json|-> [-o <out.json>]
|
|
31
33
|
xeplr-factory validate <screen.json|->
|
|
32
34
|
xeplr-factory controls
|
|
33
|
-
xeplr-factory schema <screen.json
|
|
35
|
+
xeplr-factory schema <screen.json|->
|
|
36
|
+
xeplr-factory pages [<ui dir>] [--strict]`
|
|
34
37
|
|
|
35
38
|
function readJson(file) {
|
|
36
39
|
const text = readFileSync(file === '-' ? 0 : file, 'utf8')
|
|
@@ -167,6 +170,29 @@ switch (cmd) {
|
|
|
167
170
|
break
|
|
168
171
|
}
|
|
169
172
|
|
|
173
|
+
// WEB-ONLY PAGES, SAID OUT LOUD. Reads <ui dir>/pages.json and the routes
|
|
174
|
+
// files it names; prints a warning per page written by hand for the browser
|
|
175
|
+
// and per route nobody listed. Warnings only — exit 0 — unless --strict.
|
|
176
|
+
// Run by an app's pre-commit hook, so a hand-written page is never a secret.
|
|
177
|
+
case 'pages': {
|
|
178
|
+
const dir = path.resolve(args.find((a) => !a.startsWith('--')) || '.')
|
|
179
|
+
const file = path.join(dir, 'pages.json')
|
|
180
|
+
let inv = null
|
|
181
|
+
if (existsSync(file)) inv = readJson(file)
|
|
182
|
+
const routeFiles = (inv && Array.isArray(inv.routes) && inv.routes.length ? inv.routes : ['src/App.jsx']).map((f) => path.join(dir, f))
|
|
183
|
+
const routes = routeFiles.filter((f) => existsSync(f)).flatMap((f) => routesIn(readFileSync(f, 'utf8')))
|
|
184
|
+
const warnings = inv
|
|
185
|
+
? pageWarnings(inv, routes)
|
|
186
|
+
: [{ kind: 'inventory', message: `no pages.json in ${dir} — none of its ${routes.length} routes says whether it works outside the browser` }]
|
|
187
|
+
routeFiles.filter((f) => !existsSync(f)).forEach((f) => warnings.push({ kind: 'inventory', message: `${path.relative(dir, f)} (in pages.json "routes") does not exist` }))
|
|
188
|
+
if (!warnings.length) { process.stdout.write('pages: every page is listed, none is web only\n'); break }
|
|
189
|
+
process.stderr.write(warnings.map((w) => `warning: ${w.message}`).join('\n') + '\n')
|
|
190
|
+
const web = warnings.filter((w) => w.kind === 'webOnly').length
|
|
191
|
+
process.stderr.write(`pages: ${web} web-only page${web === 1 ? '' : 's'}, ${warnings.length - web} other warning${warnings.length - web === 1 ? '' : 's'}\n`)
|
|
192
|
+
if (args.includes('--strict')) process.exit(1)
|
|
193
|
+
break
|
|
194
|
+
}
|
|
195
|
+
|
|
170
196
|
default:
|
|
171
197
|
fail(USAGE, cmd ? 2 : 0)
|
|
172
198
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/ui-factory",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.4",
|
|
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",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"@xeplr/ui-canvas": "^1.0.2",
|
|
51
51
|
"@xeplr/ui-table": "^1.0.6",
|
|
52
52
|
"react": "^18.0.0 || ^19.0.0",
|
|
53
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
53
54
|
"react-router-dom": "^6.0.0 || ^7.0.0"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
@@ -65,6 +66,7 @@
|
|
|
65
66
|
"vite": "^7.3.1"
|
|
66
67
|
},
|
|
67
68
|
"dependencies": {
|
|
69
|
+
"@xeplr/rules": "^1.0.8",
|
|
68
70
|
"@xeplr/ui-utils": "^1.1.2"
|
|
69
71
|
}
|
|
70
72
|
}
|
package/src/buttons.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// BUTTONS AND FUNCTION-FED LISTS — the parts of a screen that run the app's
|
|
2
|
+
// own code, named in the document and registered on the server.
|
|
3
|
+
//
|
|
4
|
+
// A button is an ACTION NAME PLUS PARAMETERS. Eight status buttons are one
|
|
5
|
+
// registered function, setOrderStatus, and eight buttons that give it a
|
|
6
|
+
// different `status`:
|
|
7
|
+
//
|
|
8
|
+
// { id: 'packed', label: 'Packed', action: 'setOrderStatus', params: { status: 'packed' },
|
|
9
|
+
// ask: { title: 'Change status to Packed', note: 'Reason (optional)' },
|
|
10
|
+
// disabledWhen: [{ field: 'customStatus', op: 'eq', value: 'packed' }] }
|
|
11
|
+
//
|
|
12
|
+
// The function lives in the app, registered with @xeplr/actions and an
|
|
13
|
+
// `event` saying what it is for:
|
|
14
|
+
//
|
|
15
|
+
// button a button's press. Its inputSchema says which parameters it
|
|
16
|
+
// takes — and, with `options`, which values each may have — so
|
|
17
|
+
// the designer offers them and publishing checks them.
|
|
18
|
+
// listRows the rows of a list that are worked out rather than read from
|
|
19
|
+
// one table: { rows, counts } for a search and a filter chip.
|
|
20
|
+
// rowDetails what a list cell's hover box lists for one row.
|
|
21
|
+
//
|
|
22
|
+
// Nothing here runs code. A document holds names and values only, so the same
|
|
23
|
+
// screen can be drawn by anything that reads it, and what a button may do is
|
|
24
|
+
// decided on the server, where nobody can skip it.
|
|
25
|
+
//
|
|
26
|
+
// Conditions — when a button shows, when it is disabled — are @xeplr/rules
|
|
27
|
+
// rules, the same condition language as the rest of xeplr, read from the
|
|
28
|
+
// record the screen is about. Every rule must match.
|
|
29
|
+
|
|
30
|
+
import { matches, OPERATORS } from '@xeplr/rules'
|
|
31
|
+
|
|
32
|
+
export const BUTTON_EVENT = 'button'
|
|
33
|
+
export const LIST_ROWS_EVENT = 'listRows'
|
|
34
|
+
export const ROW_DETAILS_EVENT = 'rowDetails'
|
|
35
|
+
|
|
36
|
+
/** How a button looks: an ordinary one, the main one, or one that undoes or removes. */
|
|
37
|
+
export const BUTTON_VARIANTS = ['default', 'primary', 'danger']
|
|
38
|
+
|
|
39
|
+
/** What a list column can be drawn as. Every one shows text the server wrote. */
|
|
40
|
+
export const COLUMN_DISPLAYS = ['text', 'images', 'activity']
|
|
41
|
+
|
|
42
|
+
/** A badge's colour. */
|
|
43
|
+
export const BADGE_TONES = ['accent', 'success', 'warning', 'danger', 'muted']
|
|
44
|
+
|
|
45
|
+
/** The name a button's note reaches its function under. */
|
|
46
|
+
export const NOTE_INPUT = 'note'
|
|
47
|
+
|
|
48
|
+
/** Operators a button's condition can use: every one that compares a single value. */
|
|
49
|
+
export const CONDITION_OPERATORS = OPERATORS.filter((o) => !o.needsRows)
|
|
50
|
+
|
|
51
|
+
/** Whether every rule matches the record. No rules: true. */
|
|
52
|
+
export function conditionMet(rules, record) {
|
|
53
|
+
if (!Array.isArray(rules) || !rules.length) return true
|
|
54
|
+
const rec = record || {}
|
|
55
|
+
return rules.every((r) => matches(r, rec[r.field]))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether a button shows for this record. */
|
|
59
|
+
export function buttonShown(button, record) {
|
|
60
|
+
return conditionMet(button && button.when, record)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Whether a shown button is greyed out for this record. No disabledWhen: never. */
|
|
64
|
+
export function buttonDisabled(button, record) {
|
|
65
|
+
const rules = button && button.disabledWhen
|
|
66
|
+
return Array.isArray(rules) && rules.length > 0 && conditionMet(rules, record)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Every button on a screen, with the panel it is on. */
|
|
70
|
+
export function screenButtons(doc) {
|
|
71
|
+
return ((doc && doc.nodes) || [])
|
|
72
|
+
.filter((n) => n.type === 'buttons')
|
|
73
|
+
.flatMap((node) => ((node.props && node.props.buttons) || []).map((button) => ({ node, button })))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One button by its id — what a press names. Null when the screen has none of that id. */
|
|
77
|
+
export function buttonById(doc, id) {
|
|
78
|
+
return screenButtons(doc).find((b) => b.button && b.button.id === id) || null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What a press sends the function: the document's parameters, and the person's note when it asks for one. */
|
|
82
|
+
export function buttonInput(button, note) {
|
|
83
|
+
const input = { ...((button && button.params) || {}) }
|
|
84
|
+
if (button && button.ask && button.ask.note && note !== undefined && note !== null && String(note).trim() !== '') {
|
|
85
|
+
input[NOTE_INPUT] = String(note).trim()
|
|
86
|
+
}
|
|
87
|
+
return input
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The functions a screen's CONTROLS name, as [{ event, name, at }] — a list's
|
|
92
|
+
* rows, a column's hover box, each button. `at` is where in the document, so
|
|
93
|
+
* a refusal says which one.
|
|
94
|
+
*/
|
|
95
|
+
export function nodeFunctions(doc) {
|
|
96
|
+
const out = []
|
|
97
|
+
;((doc && doc.nodes) || []).forEach((node, i) => {
|
|
98
|
+
const p = node.props || {}
|
|
99
|
+
if (node.type === 'list') {
|
|
100
|
+
if (typeof p.rowsFrom === 'string' && p.rowsFrom) out.push({ event: LIST_ROWS_EVENT, name: p.rowsFrom, at: `nodes[${i}].props.rowsFrom` })
|
|
101
|
+
;(Array.isArray(p.columns) ? p.columns : []).forEach((c, j) => {
|
|
102
|
+
if (c && typeof c.details === 'string' && c.details) out.push({ event: ROW_DETAILS_EVENT, name: c.details, at: `nodes[${i}].props.columns[${j}].details` })
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
if (node.type === 'buttons') {
|
|
106
|
+
;(Array.isArray(p.buttons) ? p.buttons : []).forEach((b, j) => {
|
|
107
|
+
if (b && typeof b.action === 'string' && b.action) out.push({ event: BUTTON_EVENT, name: b.action, at: `nodes[${i}].props.buttons[${j}].action`, button: b })
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* What stops a button's parameters reaching its function: one it does not
|
|
116
|
+
* take, a required one missing, a value outside its options, a note it has no
|
|
117
|
+
* input for. Read from the function's inputSchema, as the server lists it.
|
|
118
|
+
*/
|
|
119
|
+
export function paramProblems(button, inputSchema) {
|
|
120
|
+
const schema = Array.isArray(inputSchema) ? inputSchema : []
|
|
121
|
+
const byName = new Map(schema.filter((f) => f && f.name).map((f) => [f.name, f]))
|
|
122
|
+
const params = (button && button.params) || {}
|
|
123
|
+
const problems = []
|
|
124
|
+
Object.keys(params).forEach((k) => {
|
|
125
|
+
const f = byName.get(k)
|
|
126
|
+
if (!f) { problems.push(`takes no parameter "${k}"${byName.size ? ` — it takes: ${[...byName.keys()].join(', ')}` : ''}`); return }
|
|
127
|
+
const allowed = optionValues(f.options)
|
|
128
|
+
if (allowed.length && !allowed.includes(params[k])) problems.push(`"${k}" must be one of: ${allowed.join(', ')}`)
|
|
129
|
+
})
|
|
130
|
+
schema.forEach((f) => {
|
|
131
|
+
if (!f || !f.name || !f.required || f.default !== undefined) return
|
|
132
|
+
if (f.name === NOTE_INPUT && button && button.ask && button.ask.note) return
|
|
133
|
+
if (params[f.name] === undefined || params[f.name] === null || params[f.name] === '') problems.push(`needs "${f.name}"`)
|
|
134
|
+
})
|
|
135
|
+
if (button && button.ask && button.ask.note && !byName.has(NOTE_INPUT)) problems.push(`asks for a note, but takes no "${NOTE_INPUT}"`)
|
|
136
|
+
return problems
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A schema field's allowed values: `options` as plain values or { value, label }. */
|
|
140
|
+
export function optionValues(options) {
|
|
141
|
+
return (Array.isArray(options) ? options : []).map((o) => (o && typeof o === 'object' && 'value' in o ? o.value : o))
|
|
142
|
+
}
|
package/src/controls.js
CHANGED
|
@@ -545,7 +545,10 @@ CONTROLS.list = {
|
|
|
545
545
|
// PAGE of the app's own (openIn: 'page', which calls onOpenRecord and lets
|
|
546
546
|
// the app navigate). Without one, Edit opens the row in this screen's own
|
|
547
547
|
// fields (a list on a form).
|
|
548
|
-
|
|
548
|
+
// rowsFrom: rows a registered function works out (event "listRows") —
|
|
549
|
+
// with its search box, filter chips and their counts — instead of one
|
|
550
|
+
// table's records. See buttons.js.
|
|
551
|
+
props: ['title', 'source', 'belongsTo', 'editScreen', 'openIn', 'columns', 'pageSize', 'actions', 'rowsFrom', 'search', 'filters', 'refreshEvery', 'rowOpen', 'highlight', 'grow', 'style'],
|
|
549
552
|
validation: [],
|
|
550
553
|
styles: ['fontFamily', 'fontSize', 'color', 'background', 'borderColor', 'borderWidth', 'borderRadius'],
|
|
551
554
|
properties: [
|
|
@@ -570,6 +573,21 @@ CONTROLS.list = {
|
|
|
570
573
|
{ path: 'props.pageSize', label: 'Rows per page', type: 'number' }
|
|
571
574
|
]
|
|
572
575
|
},
|
|
576
|
+
{
|
|
577
|
+
key: 'rows',
|
|
578
|
+
title: 'Rows from a function',
|
|
579
|
+
fields: [
|
|
580
|
+
{ path: 'props.rowsFrom', label: 'Rows from', type: 'function', event: 'listRows',
|
|
581
|
+
help: 'A function of the app that works the rows out — instead of reading one table. Blank → the table above.' },
|
|
582
|
+
{ path: 'props.search.placeholder', label: 'Search box hint', type: 'text', help: 'Blank → no search box. Needs "Rows from".' },
|
|
583
|
+
{ path: 'props.filters', label: 'Filter chips', type: 'filters', help: 'The first is where the list starts. The function counts each.' },
|
|
584
|
+
{ path: 'props.refreshEvery', label: 'Refresh every (seconds)', type: 'number', help: 'For rows whose text changes with time, like "2h ago". Blank → never.' },
|
|
585
|
+
{ path: 'props.rowOpen', label: 'A click on a row opens it', type: 'toggle' },
|
|
586
|
+
{ path: 'props.highlight', label: 'Mark rows where', type: 'text', help: 'A field of the row; a row where it is true is marked — "isNew".' },
|
|
587
|
+
{ path: 'props.grow', label: 'As tall as its rows', type: 'toggle',
|
|
588
|
+
help: 'The page grows with the list instead of the list scrolling in its box. Only for a list that is the screen\'s one control.' }
|
|
589
|
+
]
|
|
590
|
+
},
|
|
573
591
|
{ key: 'columns', title: 'Columns', fields: [{ path: 'props.columns', label: 'Columns', type: 'columns' }] },
|
|
574
592
|
{ key: 'actions', title: 'Actions', fields: [{ path: 'props.actions', label: 'Actions', type: 'actions' }] },
|
|
575
593
|
...styleGroups(['fontFamily', 'fontSize', 'color', 'background', 'borderColor', 'borderWidth', 'borderRadius'])
|
|
@@ -664,6 +682,35 @@ CONTROLS.subform = {
|
|
|
664
682
|
]
|
|
665
683
|
}
|
|
666
684
|
|
|
685
|
+
// A PANEL OF BUTTONS, each the name of a function the app registered (event
|
|
686
|
+
// "button") and the parameters it is given — eight status buttons are one
|
|
687
|
+
// function with eight `status` values. Whether one shows, and whether it is
|
|
688
|
+
// greyed out, are conditions on the record the screen is about. The press
|
|
689
|
+
// runs on the server, which reads the button from the PUBLISHED screen, so
|
|
690
|
+
// nobody can send a function or parameters of their own. See buttons.js.
|
|
691
|
+
CONTROLS.buttons = {
|
|
692
|
+
type: 'buttons',
|
|
693
|
+
label: 'Buttons',
|
|
694
|
+
group: 'Actions',
|
|
695
|
+
input: false,
|
|
696
|
+
defaultSize: { w: 0.92, h: 0.07 },
|
|
697
|
+
defaults: { buttons: [] },
|
|
698
|
+
props: ['title', 'buttons', 'style'],
|
|
699
|
+
validation: [],
|
|
700
|
+
styles: ['fontFamily', 'fontSize', 'color', 'background', 'borderColor', 'borderWidth', 'borderRadius'],
|
|
701
|
+
properties: [
|
|
702
|
+
{
|
|
703
|
+
key: 'buttons',
|
|
704
|
+
title: 'Buttons',
|
|
705
|
+
fields: [
|
|
706
|
+
{ path: 'props.title', label: 'Title', type: 'text', help: 'Shown before the buttons. Blank → none' },
|
|
707
|
+
{ path: 'props.buttons', label: 'Buttons', type: 'buttons' }
|
|
708
|
+
]
|
|
709
|
+
},
|
|
710
|
+
...styleGroups(['fontFamily', 'fontSize', 'color', 'background', 'borderColor', 'borderWidth', 'borderRadius'])
|
|
711
|
+
]
|
|
712
|
+
}
|
|
713
|
+
|
|
667
714
|
export const LABEL_VARIANTS = ['heading', 'subheading', 'text']
|
|
668
715
|
|
|
669
716
|
/** The sizes a label preset starts from, in px at the design width. */
|
|
@@ -273,6 +273,7 @@ export default function BuilderSample({ ctrl, renderPreview, className, style, s
|
|
|
273
273
|
selectionCount={ctrl.selected.size}
|
|
274
274
|
errors={ctrl.selectedNode ? ctrl.errorsByNode[ctrl.selectedNode.id] : ctrl.errorsByNode._document}
|
|
275
275
|
tables={ctrl.tables}
|
|
276
|
+
functions={ctrl.functions}
|
|
276
277
|
screens={ctrl.screens}
|
|
277
278
|
forms={forms}
|
|
278
279
|
lockedNames={ctrl.lockedNames}
|