@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 +23 -0
- package/package.json +1 -1
- package/src/events.js +84 -0
- package/src/model.js +2 -0
- package/src/validateDocument.js +13 -1
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.
|
|
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'
|
package/src/validateDocument.js
CHANGED
|
@@ -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 {
|