@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 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
- | "show / hide / filter rows on screen", "an extra button on each row", "fill in X before saving" (convenience) | front-end hooks in `EditEmployee.jsx` |
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 for the example employee, records kept in localStorage
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
 
@@ -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",
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
- props: ['title', 'source', 'belongsTo', 'editScreen', 'openIn', 'columns', 'pageSize', 'actions', 'style'],
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}