@xeplr/ui-factory 1.8.2 → 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 +190 -5
- package/bin/xeplr-factory.js +27 -1
- package/package.json +7 -3
- 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/ScreenModal.jsx +2 -1
- package/src/designs/ScreenSample.jsx +28 -7
- package/src/designs/factory.css +199 -2
- package/src/designs/styles.js +4 -1
- 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
|
@@ -117,6 +117,21 @@ If `onSave` throws an error with `fields: [{ field, message }]` (what `createFac
|
|
|
117
117
|
|
|
118
118
|
Values arrive typed: numbers as numbers, checkboxes as booleans, dates as `"YYYY-MM-DD"`, a date and time as `"YYYY-MM-DDTHH:MM"`, a dropdown or radio group as the option's own `id` (a table's numeric id stays a number), a multi-select as an **array** of ids, and a file as the stored file's **path**.
|
|
119
119
|
|
|
120
|
+
### On a phone
|
|
121
|
+
|
|
122
|
+
Below **640px wide** (the viewport, not the box the screen sits in) a screen stops being a scaled-down canvas and **stacks**: every control takes the full width, one per row, in reading order — top to bottom, left to right across a row, the same order `inputNodes` reads. Nothing to set and nothing to design twice: the positions you placed are simply not used there.
|
|
123
|
+
|
|
124
|
+
- Controls are drawn at their real size, not shrunk. Inputs are at least **16px** (smaller makes iOS zoom the page when a field is focused) and **44px** tall; tick boxes, radio buttons and Remove are thumb-sized.
|
|
125
|
+
- Error messages sit under their field and wrap, rather than being cut off at the screen's edge.
|
|
126
|
+
- The footer wraps: what the save is doing on its own line, the buttons under it sharing the width.
|
|
127
|
+
- A popup (a list's Edit / New) fills the screen.
|
|
128
|
+
- A list keeps its table, which scrolls sideways inside its card; its row buttons stay pinned at the right edge (that part is `@xeplr/ui-table`, 1.1.3 or later).
|
|
129
|
+
- An identifier still to be numbered takes no space.
|
|
130
|
+
|
|
131
|
+
Above 640px nothing changes: a screen is shown at its design width, scaled down only when its box is narrower. A screen in a narrow side panel on a desktop keeps its designed layout. The builder and the flow designer are not for phones and are left as they are.
|
|
132
|
+
|
|
133
|
+
How: `ScreenSample` hands each control's position to CSS as variables (`--xf-x`, `--xf-y`, `--xf-w`, `--xf-h`) and its reading-order place as `--xf-order`; `factory.css` uses the positions on a desktop and the order on a phone. A design of your own can do the same.
|
|
134
|
+
|
|
120
135
|
### Where each value is kept
|
|
121
136
|
|
|
122
137
|
A record is columns in a real table, never JSON — so the two types that are not
|
|
@@ -346,6 +361,163 @@ to one of these); the screen only names it:
|
|
|
346
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)`).
|
|
347
362
|
- `SCREEN_EVENTS` carries each event's contract (`when`, `gets`, `returns`) — what the designer shows beside each picker.
|
|
348
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
|
+
|
|
349
521
|
## The flow designer, as a list — `<FlowDesigner>`
|
|
350
522
|
|
|
351
523
|
Your screens as a journey: the steps down the left; for the one picked, its
|
|
@@ -616,7 +788,8 @@ Both walk someone through something, and they answer different questions:
|
|
|
616
788
|
| `file` | the stored file's path | `accept` (extensions), `maxSize` (MB) |
|
|
617
789
|
| `stepper` | — | a journey across the top; every other control says which step it is on |
|
|
618
790
|
| `label` | — | text with a heading, subheading or text preset |
|
|
619
|
-
| `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**) |
|
|
620
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 |
|
|
621
794
|
| `subform` | — | another form drawn inside this one (palette: **Form**), saving into its own table — see below |
|
|
622
795
|
|
|
@@ -811,6 +984,7 @@ The control whose properties are open, the step a stepper is open at, and the fl
|
|
|
811
984
|
| `useFactoryBuilder` / `<FactoryBuilder>` | `<urlKey>Steps` | the step each stepper is open at: `stepperId:index`, comma-separated, step 0 left out | replaces |
|
|
812
985
|
| `useFactoryScreen` / `<FactoryScreen>`, `<FlowRunner>` | `<urlKey>Steps` | as above | replaces |
|
|
813
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 |
|
|
814
988
|
|
|
815
989
|
```jsx
|
|
816
990
|
<FactoryBuilder urlKey="form" document={doc} onSave={save} /> // ?formSelected=email&formSteps=stepper:1
|
|
@@ -839,7 +1013,7 @@ Buttons, steps and list actions all show hover, a visible press, and a keyboard-
|
|
|
839
1013
|
|
|
840
1014
|
```sh
|
|
841
1015
|
npm install
|
|
842
|
-
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
|
|
843
1017
|
```
|
|
844
1018
|
|
|
845
1019
|
## Files
|
|
@@ -861,13 +1035,15 @@ src/
|
|
|
861
1035
|
useFactoryBuilder.js ─ builder controller
|
|
862
1036
|
useFactoryScreen.js ─ screen controller
|
|
863
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)
|
|
864
1040
|
urlState.js ─ the URL keys and values for selection and steps (React-free)
|
|
865
1041
|
useUrlPlaces.js ─ useUrlSteps, useUrlSelection: those places, in the URL or in memory
|
|
866
1042
|
useScreenSource.js ─ { screens, screenEditor } for @xeplr/ui-workflow's Screen step
|
|
867
|
-
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
|
|
868
1044
|
pages.jsx ─ FactoryBuilder, FactoryScreen
|
|
869
|
-
bin/xeplr-factory.js ─ screens / generate / validate / controls / schema
|
|
870
|
-
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)
|
|
871
1047
|
dev/ ─ the stand-in app for `npm run dev` (not published)
|
|
872
1048
|
```
|
|
873
1049
|
|
|
@@ -877,8 +1053,17 @@ dev/ ─ the stand-in app for `npm run dev` (not published)
|
|
|
877
1053
|
npm test
|
|
878
1054
|
```
|
|
879
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
|
+
|
|
880
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`.
|
|
881
1059
|
|
|
1060
|
+
```sh
|
|
1061
|
+
npx playwright install chromium-headless-shell # once
|
|
1062
|
+
npm run test:phone
|
|
1063
|
+
```
|
|
1064
|
+
|
|
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.
|
|
1066
|
+
|
|
882
1067
|
## License
|
|
883
1068
|
|
|
884
1069
|
MIT
|
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",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
21
|
"dev": "vite",
|
|
22
|
-
"test": "for f in test/*.test.js; do node \"$f\" || exit 1; done"
|
|
22
|
+
"test": "for f in test/*.test.js; do node \"$f\" || exit 1; done",
|
|
23
|
+
"test:phone": "node test/phone.check.js"
|
|
23
24
|
},
|
|
24
25
|
"keywords": [
|
|
25
26
|
"form-builder",
|
|
@@ -49,6 +50,7 @@
|
|
|
49
50
|
"@xeplr/ui-canvas": "^1.0.2",
|
|
50
51
|
"@xeplr/ui-table": "^1.0.6",
|
|
51
52
|
"react": "^18.0.0 || ^19.0.0",
|
|
53
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
52
54
|
"react-router-dom": "^6.0.0 || ^7.0.0"
|
|
53
55
|
},
|
|
54
56
|
"devDependencies": {
|
|
@@ -56,13 +58,15 @@
|
|
|
56
58
|
"@vitejs/plugin-react": "^5.2.0",
|
|
57
59
|
"@xeplr/schema-handler": "^1.0.5",
|
|
58
60
|
"@xeplr/ui-canvas": "^1.0.1",
|
|
59
|
-
"@xeplr/ui-table": "^1.
|
|
61
|
+
"@xeplr/ui-table": "^1.1.3",
|
|
62
|
+
"playwright": "^1.64.1",
|
|
60
63
|
"react": "^19.2.4",
|
|
61
64
|
"react-dom": "^19.2.4",
|
|
62
65
|
"react-router-dom": "^7.18.4",
|
|
63
66
|
"vite": "^7.3.1"
|
|
64
67
|
},
|
|
65
68
|
"dependencies": {
|
|
69
|
+
"@xeplr/rules": "^1.0.8",
|
|
66
70
|
"@xeplr/ui-utils": "^1.1.2"
|
|
67
71
|
}
|
|
68
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}
|