@xeplr/ui-factory 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -323,6 +323,29 @@ Controller: the embedded screen is a second `useFactoryScreen` with `link`
323
323
  save, name }`). `FactoryScreen` wires both; a custom design gets `subformFor(node)`,
324
324
  `registerSubform(id, api)` and `subformDirty(id, dirty)` from the host's controller.
325
325
 
326
+ ## Events — the app's code, picked by name
327
+
328
+ A screen says which of the app's functions run around its records, one per
329
+ event. The code is registered in the app (with `@xeplr/actions`, `event` set
330
+ to one of these); the screen only names it:
331
+
332
+ ```json
333
+ "events": { "beforeSave": "checkSalaryBand", "afterSave": "notifyHR" }
334
+ ```
335
+
336
+ | event | runs | gets | returns |
337
+ |---|---|---|---|
338
+ | `beforeLoad` | Before records are read — the list, and one record for Edit | ctx.query (narrow it), ctx.id (one record) or ctx.many (the list), ctx.user, ctx.tenant | nothing, or a new query |
339
+ | `beforeSave` | Before a record is written — the screen's rules run on what it returns | ctx.values, ctx.isNew, ctx.previous (on update), ctx.user | the values to save (or nothing to keep them); ctx.reject(message, { field }) refuses |
340
+ | `afterSave` | Once the record is saved | ctx.result (the saved row), ctx.isNew, ctx.values | nothing — what it does is the point (an email, another record) |
341
+ | `beforeDelete` | Before a record is deleted | ctx.previous (the row), ctx.id | nothing; ctx.reject(message) refuses |
342
+ | `onError` | When loading, saving or deleting failed | ctx.error, ctx.op (get, save or delete) | nothing — the error is still the answer; this is for telling someone |
343
+
344
+ - **This is the only way to attach code to a screen's records.** One function per event, named in the screen — the screen is the one place that says what runs.
345
+ - **Every event runs on the server** (`@xeplr/factory` 2+). A rule that runs in the browser is one a person can skip.
346
+ - **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
+ - `SCREEN_EVENTS` carries each event's contract (`when`, `gets`, `returns`) — what the designer shows beside each picker.
348
+
326
349
  ## Masters — the lists the rest of the app points at
327
350
 
328
351
  Departments, statuses, countries, categories. Mark a form as a master in its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-factory",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Design data-entry screens on @xeplr/ui-canvas, save them as JSON, run them as auto-saving forms with lists of saved records — built for Claude to draft and people to refine",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/events.js ADDED
@@ -0,0 +1,84 @@
1
+ // A SCREEN'S EVENTS — the moments a developer's code may run around its
2
+ // records, each picked BY NAME from the functions the app registered.
3
+ //
4
+ // The code lives in the app (registered with @xeplr/actions, `event` set to
5
+ // one of the keys below); the screen only names it:
6
+ //
7
+ // doc.events = { beforeSave: 'checkSalaryBand', afterSave: 'notifyHR' }
8
+ //
9
+ // One function per event, and this is the ONLY way to attach one — the screen
10
+ // is the one place that says what runs. Every event runs on the SERVER: a rule
11
+ // that runs in the browser is one a person can skip.
12
+ //
13
+ // What each is called with, and what it may return, is the contract a
14
+ // developer writes to. `@xeplr/factory` runs them; the designer lists them.
15
+
16
+ export const SCREEN_EVENTS = [
17
+ {
18
+ key: 'beforeLoad',
19
+ label: 'Before load',
20
+ when: 'Before records are read — the list, and one record for Edit',
21
+ gets: 'ctx.query (narrow it), ctx.id (one record) or ctx.many (the list), ctx.user, ctx.tenant',
22
+ returns: 'nothing, or a new query'
23
+ },
24
+ {
25
+ key: 'beforeSave',
26
+ label: 'Before save',
27
+ when: 'Before a record is written — the screen\'s rules run on what it returns',
28
+ gets: 'ctx.values, ctx.isNew, ctx.previous (on update), ctx.user',
29
+ returns: 'the values to save (or nothing to keep them); ctx.reject(message, { field }) refuses'
30
+ },
31
+ {
32
+ key: 'afterSave',
33
+ label: 'After save',
34
+ when: 'Once the record is saved',
35
+ gets: 'ctx.result (the saved row), ctx.isNew, ctx.values',
36
+ returns: 'nothing — what it does is the point (an email, another record)'
37
+ },
38
+ {
39
+ key: 'beforeDelete',
40
+ label: 'Before delete',
41
+ when: 'Before a record is deleted',
42
+ gets: 'ctx.previous (the row), ctx.id',
43
+ returns: 'nothing; ctx.reject(message) refuses'
44
+ },
45
+ {
46
+ key: 'onError',
47
+ label: 'On error',
48
+ when: 'When loading, saving or deleting failed',
49
+ gets: 'ctx.error, ctx.op (get, save or delete)',
50
+ returns: 'nothing — the error is still the answer; this is for telling someone'
51
+ }
52
+ ]
53
+
54
+ export const SCREEN_EVENT_KEYS = SCREEN_EVENTS.map((e) => e.key)
55
+
56
+ /**
57
+ * A FLOW's "let a function decide": the function an arrow asks for the next
58
+ * step. Not a screen event — the workflow engine runs it, between screens.
59
+ */
60
+ export const DECIDE_EVENT = 'decide'
61
+
62
+ /** A function's name: what it was registered under. */
63
+ export const FUNCTION_NAME = /^[A-Za-z_][\w.-]{0,99}$/
64
+
65
+ /** The functions a screen names, as [{ event, name }] — what has to be registered for it to run. */
66
+ export function screenFunctions(doc) {
67
+ const ev = (doc && doc.events) || {}
68
+ return SCREEN_EVENT_KEYS.filter((k) => typeof ev[k] === 'string' && ev[k]).map((k) => ({ event: k, name: ev[k] }))
69
+ }
70
+
71
+ /**
72
+ * Which of a screen's functions are not registered, or are registered for a
73
+ * different event — given the registry's list ([{ name, event }]). Empty when
74
+ * the screen can run. Said by name, so the fix is obvious.
75
+ */
76
+ export function missingFunctions(doc, registered) {
77
+ const byName = new Map((registered || []).map((f) => [f.name, f]))
78
+ return screenFunctions(doc).flatMap(({ event, name }) => {
79
+ const f = byName.get(name)
80
+ if (!f) return [{ event, name, problem: `"${name}" is not a registered function` }]
81
+ if (f.event !== event) return [{ event, name, problem: `"${name}" is written for ${f.event ? `"${f.event}"` : 'a workflow step'}, not "${event}"` }]
82
+ return []
83
+ })
84
+ }
package/src/model.js CHANGED
@@ -12,6 +12,8 @@ export {
12
12
  steppers, stepsOf, stepOf, setNodeStep, nodesForSteps, resolveStep, reachableStep, nextOpenStep
13
13
  } from './document.js'
14
14
  export { validateDocument, assertValidDocument } from './validateDocument.js'
15
+ // A screen's events: functions picked by name, run on the server.
16
+ export { SCREEN_EVENTS, SCREEN_EVENT_KEYS, DECIDE_EVENT, FUNCTION_NAME, screenFunctions, missingFunctions } from './events.js'
15
17
  export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, chooseable, saveState, recordValues, displayValue, fileLabel, toDbValue, fromDbValue, listColumns, listSource } from './values.js'
16
18
  export { screenFromSpec, screensFromSpec, entityNames, SPEC_HEIGHTS, ROW_GAP } from './generate.js'
17
19
  export { scaffoldEntity } from './scaffold.js'
@@ -10,6 +10,7 @@
10
10
  // Loud by design: a screen that half-renders — a dropdown with no options, two
11
11
  // fields writing the same key — looks like a data problem to whoever uses it.
12
12
 
13
+ import { SCREEN_EVENT_KEYS, FUNCTION_NAME } from './events.js'
13
14
  import { CONTROLS, LABEL_VARIANTS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, LAYOUTS, MULTI_SEPARATOR, MAX_FILE_MB, STEP_LIMIT, acceptList } from './controls.js'
14
15
  import { DOCUMENT_KIND, DOCUMENT_VERSION } from './document.js'
15
16
  import { RESERVED_COLUMNS, MAX_IDENTIFIER } from './tableSchema.js'
@@ -18,7 +19,7 @@ import { FIELD_PRESETS, INPUT_TYPES, presetFor } from './presets.js'
18
19
  const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
19
20
  const TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_.$-]*$/
20
21
  const SCREEN_ID = /^[A-Za-z0-9_-]+$/
21
- const DOCUMENT_KEYS = ['kind', 'version', 'id', 'name', 'source', 'units', 'aspect', 'width', 'style', 'description', 'master', 'labelField', 'params', 'nodes']
22
+ const DOCUMENT_KEYS = ['kind', 'version', 'id', 'name', 'source', 'units', 'aspect', 'width', 'style', 'description', 'master', 'labelField', 'params', 'events', 'nodes']
22
23
  const COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i
23
24
 
24
25
  /**
@@ -71,6 +72,17 @@ export function validateDocument(doc, controls = CONTROLS) {
71
72
  // {params.<name>} as its default, so a name that is not a plain identifier
72
73
  // could never be referenced, and a duplicate would make the reference mean
73
74
  // two things.
75
+ // EVENTS — functions picked by name, one per event (see events.js). Whether
76
+ // each is REGISTERED is the server's to say, at publish and at run time:
77
+ // this checks only that the screen asks for something that could exist.
78
+ if (doc.events !== undefined) {
79
+ if (!doc.events || typeof doc.events !== 'object' || Array.isArray(doc.events)) err('events', `must be { ${SCREEN_EVENT_KEYS.join(', ')} } — each the name of a registered function`)
80
+ else Object.keys(doc.events).forEach((k) => {
81
+ const v = doc.events[k]
82
+ if (!SCREEN_EVENT_KEYS.includes(k)) err(`events.${k}`, `is not an event — one of: ${SCREEN_EVENT_KEYS.join(', ')}`)
83
+ else if (v !== null && v !== '' && !(typeof v === 'string' && FUNCTION_NAME.test(v))) err(`events.${k}`, 'must be the name of a registered function, e.g. "checkSalaryBand"')
84
+ })
85
+ }
74
86
  if (doc.params !== undefined) {
75
87
  if (!Array.isArray(doc.params)) err('params', 'must be a list of inputs this screen is called with')
76
88
  else {