@xeplr/ui-factory 1.2.0 → 1.3.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 +162 -0
- package/bin/xeplr-factory.js +2 -1
- package/package.json +1 -1
- package/src/controls.js +62 -2
- package/src/designs/BuilderSample.jsx +47 -1
- package/src/designs/ControlView.jsx +36 -0
- package/src/designs/ListView.jsx +3 -1
- package/src/designs/PropertyPanel.jsx +218 -7
- package/src/designs/factory.css +50 -0
- package/src/designs/styles.js +1 -1
- package/src/document.js +51 -0
- package/src/index.js +3 -1
- package/src/masters.js +193 -0
- package/src/model.js +5 -0
- package/src/pages.jsx +11 -1
- package/src/remote.js +22 -2
- package/src/screenSource.js +85 -2
- package/src/tableSchema.js +16 -0
- package/src/useFactoryBuilder.js +103 -2
- package/src/useFactoryScreen.js +34 -6
- package/src/useFlowRun.js +6 -3
- package/src/useScreenSource.js +8 -4
- package/src/validateDocument.js +67 -1
- package/src/values.js +118 -2
package/src/pages.jsx
CHANGED
|
@@ -28,6 +28,9 @@ export function FactoryBuilder(props) {
|
|
|
28
28
|
ctrl={ctrl}
|
|
29
29
|
className={props.className}
|
|
30
30
|
style={props.style}
|
|
31
|
+
// So a child table made here can be designed here — see the modal in
|
|
32
|
+
// BuilderSample. Supplied by createFactoryApi's builderProps.
|
|
33
|
+
screenApi={props.screenApi}
|
|
31
34
|
renderPreview={(doc) => (
|
|
32
35
|
// Preview never writes: onSave/onDelete are left out on purpose.
|
|
33
36
|
<FactoryScreen
|
|
@@ -91,6 +94,10 @@ export function FactoryScreen(props) {
|
|
|
91
94
|
key={`${popup.screenId}:${popup.record ? popup.record[ctrl.recordKey] : 'new'}`}
|
|
92
95
|
document={popup.document}
|
|
93
96
|
record={popup.record}
|
|
97
|
+
// What the sub-table calls it with: the column pointing back at
|
|
98
|
+
// the record this list is on. Hidden on the form when the
|
|
99
|
+
// screen says a field is filled in by whoever opens it.
|
|
100
|
+
params={popup.params || undefined}
|
|
94
101
|
recordKey={props.recordKey}
|
|
95
102
|
onSave={ctrl.popupSave}
|
|
96
103
|
fetchOptions={props.fetchOptions}
|
|
@@ -134,7 +141,10 @@ export function FlowRunner(props) {
|
|
|
134
141
|
const screen = useFactoryScreen({
|
|
135
142
|
...props,
|
|
136
143
|
document: run.document || EMPTY_SCREEN,
|
|
137
|
-
record: run.values || undefined
|
|
144
|
+
record: run.values || undefined,
|
|
145
|
+
// What the flow called this screen with — its step's own input, resolved
|
|
146
|
+
// by the engine. A field bound to {params.x} is filled from here.
|
|
147
|
+
params: run.given || undefined
|
|
138
148
|
})
|
|
139
149
|
return (
|
|
140
150
|
<FlowRunnerSample
|
package/src/remote.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* @param options.fetch a fetch that adds auth and tenant headers (e.g. @xeplr/ui-account's authFetch)
|
|
14
14
|
* @param options.base prefix before /factory, if the router is mounted elsewhere (default '')
|
|
15
15
|
*/
|
|
16
|
+
import { createChildTable } from './masters.js'
|
|
17
|
+
|
|
16
18
|
export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
17
19
|
if (typeof doFetch !== 'function') throw new Error('createFactoryApi: pass { fetch } — usually authFetch from @xeplr/ui-account')
|
|
18
20
|
|
|
@@ -95,11 +97,23 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
95
97
|
confirmConvert: (options && options.confirmConvert) || []
|
|
96
98
|
})),
|
|
97
99
|
listTables: () => call('GET', '/tables'),
|
|
100
|
+
// DELETE A FORM'S SCREEN. The design always goes; the table only when it
|
|
101
|
+
// is empty and nothing else describes it — the answer says which
|
|
102
|
+
// happened, and how many records kept it. Refused while another
|
|
103
|
+
// published screen chooses from this form's table.
|
|
104
|
+
deleteScreen: async (key) => one(await call('DELETE', `/screens/${enc(key)}`)),
|
|
98
105
|
// A new form from its name: { entity, plural? } → { entity, name, source, edit, list } — both screens as drafts.
|
|
99
106
|
createEntity: async (spec) => one(await call('POST', '/entities', spec)),
|
|
100
107
|
|
|
101
108
|
// records
|
|
102
|
-
|
|
109
|
+
// `belongsTo` + `belongsToId`: a sub-table on a form asks for the rows of
|
|
110
|
+
// another table that belong to THIS record. Without an owner the server
|
|
111
|
+
// answers with none — a record that has not been saved yet owns nothing,
|
|
112
|
+
// and showing the whole table there is how somebody edits another
|
|
113
|
+
// record's lines.
|
|
114
|
+
fetchRecords: ({ screen, belongsTo, belongsToId }) => call('GET', belongsTo
|
|
115
|
+
? `/records/${enc(screen)}?belongsTo=${enc(belongsTo)}&belongsToId=${enc(belongsToId == null ? '' : belongsToId)}`
|
|
116
|
+
: `/records/${enc(screen)}`),
|
|
103
117
|
// One record, through the screen's get hooks — what Edit opens.
|
|
104
118
|
fetchRecord: async ({ screen, id }) => one(await call('GET', `/records/${enc(screen)}/${enc(id)}`)),
|
|
105
119
|
onSave: async (values, { id, screen }) => one(await call('POST', `/records/${enc(screen)}/save`, { id, values })),
|
|
@@ -116,6 +130,12 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
116
130
|
fileUrl: (path) => `${base}/factory/files/${String(path).split('/').map(enc).join('/')}`
|
|
117
131
|
}
|
|
118
132
|
api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions, uploadFile: api.uploadFile, fileUrl: api.fileUrl }
|
|
119
|
-
|
|
133
|
+
// makeChildTable: a list on a form can make the table it is about to point
|
|
134
|
+
// at — rows belonging to this record — without leaving the designer. A
|
|
135
|
+
// server act, which is why the builder is given it rather than doing it.
|
|
136
|
+
api.builderProps = { onSave: api.saveDraft, onPublish: api.publish, listTables: api.listTables, fetchOptions: api.fetchOptions, fetchRecords: api.fetchRecords, makeChildTable: function (spec) { return createChildTable(api, spec) },
|
|
137
|
+
// The builder designs a child table in place, which needs the same calls
|
|
138
|
+
// this api already makes — handed over rather than rebuilt.
|
|
139
|
+
screenApi: api }
|
|
120
140
|
return api
|
|
121
141
|
}
|
package/src/screenSource.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
//
|
|
16
16
|
// Pure: no React. The hook around it is useScreenSource.js.
|
|
17
17
|
|
|
18
|
-
import { inputNodes } from './document.js'
|
|
18
|
+
import { inputNodes, screenParams } from './document.js'
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* A screen document → what the designer needs.
|
|
@@ -29,12 +29,65 @@ export function describeScreen(doc) {
|
|
|
29
29
|
return {
|
|
30
30
|
key: doc.id,
|
|
31
31
|
name: doc.name || doc.id,
|
|
32
|
+
description: doc.description || null,
|
|
32
33
|
fields: inputNodes(doc).map(function (n) {
|
|
33
34
|
return { name: n.id, type: n.type }
|
|
34
|
-
})
|
|
35
|
+
}),
|
|
36
|
+
// WHAT THIS SCREEN IS CALLED WITH — the inputs it declares, the way an
|
|
37
|
+
// action declares its inputSchema. A flow's Screen step asks for exactly
|
|
38
|
+
// these; a screen that declares none is asked for nothing.
|
|
39
|
+
inputs: screenParams(doc).map(function (p) {
|
|
40
|
+
return { name: p.name, label: p.label || p.name, type: p.type || 'string', required: Boolean(p.required) }
|
|
41
|
+
}),
|
|
42
|
+
// A MASTER is a list other forms point at. Reported here so a picker can
|
|
43
|
+
// offer "Departments" before it offers thirty raw table names, and so the
|
|
44
|
+
// admin section can gather them without reading every document again.
|
|
45
|
+
master: Boolean(doc.master),
|
|
46
|
+
labelField: doc.labelField || null,
|
|
47
|
+
source: doc.source || null
|
|
35
48
|
}
|
|
36
49
|
}
|
|
37
50
|
|
|
51
|
+
/** The form a screen belongs to: task_edit and task_list are one form. */
|
|
52
|
+
export function formKeyOf(screenKey) {
|
|
53
|
+
var m = /^(.*)_(edit|list)$/.exec(String(screenKey || ''))
|
|
54
|
+
return m ? m[1] : String(screenKey || '')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* THE MASTERS, gathered from the screens an app has.
|
|
59
|
+
*
|
|
60
|
+
* A master is a property of the FORM, and a form is two screens — the edit
|
|
61
|
+
* screen is where it is declared, because that is the one that describes the
|
|
62
|
+
* record. The list screen is what the admin section opens, because looking at
|
|
63
|
+
* a master means looking at its rows.
|
|
64
|
+
*
|
|
65
|
+
* Returns [{ key, name, labelField, edit, list, source }], by name.
|
|
66
|
+
*/
|
|
67
|
+
export function masterForms(screens) {
|
|
68
|
+
var byForm = {}
|
|
69
|
+
;(screens || []).forEach(function (sc) {
|
|
70
|
+
if (!sc || !sc.key) return
|
|
71
|
+
var form = formKeyOf(sc.key)
|
|
72
|
+
var entry = byForm[form] || (byForm[form] = { key: form, name: null, labelField: null, edit: null, list: null, source: null })
|
|
73
|
+
if (/_list$/.test(sc.key)) entry.list = sc.key
|
|
74
|
+
else entry.edit = sc.key
|
|
75
|
+
if (sc.master) {
|
|
76
|
+
entry.master = true
|
|
77
|
+
entry.name = entry.name || sc.name
|
|
78
|
+
entry.labelField = entry.labelField || sc.labelField
|
|
79
|
+
entry.source = entry.source || sc.source
|
|
80
|
+
}
|
|
81
|
+
// A name to show even when the flag is on the other half of the pair.
|
|
82
|
+
if (!entry.name && !/_list$/.test(sc.key)) entry.name = sc.name
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
return Object.keys(byForm)
|
|
86
|
+
.map(function (k) { return byForm[k] })
|
|
87
|
+
.filter(function (f) { return f.master })
|
|
88
|
+
.sort(function (a, b) { return String(a.name).localeCompare(String(b.name)) })
|
|
89
|
+
}
|
|
90
|
+
|
|
38
91
|
/** A listing row → the same shape, before its document has been read. */
|
|
39
92
|
export function screenStub(row) {
|
|
40
93
|
if (!row) return null
|
|
@@ -69,6 +122,36 @@ export async function loadScreens(api, onList) {
|
|
|
69
122
|
}))
|
|
70
123
|
}
|
|
71
124
|
|
|
125
|
+
/**
|
|
126
|
+
* DELETE A FORM — both of its screens.
|
|
127
|
+
*
|
|
128
|
+
* The list first, then the edit screen: while the list still exists the table
|
|
129
|
+
* is still described, so the edit screen's delete is the one that can find
|
|
130
|
+
* the table unused and empty. The other order leaves the table behind.
|
|
131
|
+
*
|
|
132
|
+
* @returns what became of the table: { table, tableDropped, records }
|
|
133
|
+
*/
|
|
134
|
+
export async function deleteForm(api, form) {
|
|
135
|
+
if (!form || !form.key) throw new Error('Which form?')
|
|
136
|
+
|
|
137
|
+
// A HALF OF A FORM IS A KEY OR THE ROW THAT CARRIES ONE. Callers group
|
|
138
|
+
// screens for their own listing and hold whatever was convenient — a key
|
|
139
|
+
// here, a listing row there — and a row put into a URL reads as
|
|
140
|
+
// "[object Object]", which is a 404 naming a screen nobody could have
|
|
141
|
+
// asked for.
|
|
142
|
+
var half = function (x) {
|
|
143
|
+
if (!x) return null
|
|
144
|
+
return typeof x === 'string' ? x : (x.screenKey || x.id || x.key || null)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
var list = half(form.list)
|
|
148
|
+
var edit = half(form.edit) || form.key
|
|
149
|
+
|
|
150
|
+
if (list) await api.deleteScreen(list)
|
|
151
|
+
var last = await api.deleteScreen(edit)
|
|
152
|
+
return last || { table: null, tableDropped: false, records: null }
|
|
153
|
+
}
|
|
154
|
+
|
|
72
155
|
/** "Leave request" → "leave_request": what a new form is keyed by. */
|
|
73
156
|
export function keyFromName(label) {
|
|
74
157
|
return String(label == null ? '' : label)
|
package/src/tableSchema.js
CHANGED
|
@@ -55,6 +55,8 @@ export const MAX_IDENTIFIER = 63
|
|
|
55
55
|
|
|
56
56
|
const TEXT_DEFAULT_LENGTH = 255
|
|
57
57
|
const OPTION_MIN_LENGTH = 50
|
|
58
|
+
// prefix (8) + separator + six characters, with room to spare.
|
|
59
|
+
const ID_CODE_LENGTH = 24
|
|
58
60
|
const FILE_PATH_LENGTH = 255
|
|
59
61
|
|
|
60
62
|
/**
|
|
@@ -68,6 +70,15 @@ export function columnForField(node) {
|
|
|
68
70
|
switch (node.type) {
|
|
69
71
|
case 'text':
|
|
70
72
|
return { ...base, type: 'varchar', length: v.maxLength || TEXT_DEFAULT_LENGTH }
|
|
73
|
+
// AN IDENTIFIER. The kind decides the column, which is the point of it
|
|
74
|
+
// being a type: a counted number is the database's to assign (identity,
|
|
75
|
+
// so an insert that does not mention it still gets one), and a code is a
|
|
76
|
+
// short string the server writes. Unique either way — an identifier two
|
|
77
|
+
// rows share is not one.
|
|
78
|
+
case 'id':
|
|
79
|
+
return p.idKind === 'auto'
|
|
80
|
+
? { ...base, type: 'integer', identity: true, notNull: true, unique: true }
|
|
81
|
+
: { ...base, type: 'varchar', length: ID_CODE_LENGTH, unique: true }
|
|
71
82
|
case 'textarea':
|
|
72
83
|
return { ...base, type: 'text' }
|
|
73
84
|
case 'number':
|
|
@@ -114,8 +125,13 @@ export function columnSql(col) {
|
|
|
114
125
|
const type = col.type === 'varchar' ? `varchar(${col.length})` : col.type
|
|
115
126
|
const parts = [type]
|
|
116
127
|
if (col.references) parts.push(`REFERENCES ${q(col.references)}("id")`)
|
|
128
|
+
// BY DEFAULT, not ALWAYS: the database counts, and an import carrying real
|
|
129
|
+
// numbers can still write its own. ALWAYS would refuse them outright.
|
|
130
|
+
if (col.identity) parts.push('GENERATED BY DEFAULT AS IDENTITY')
|
|
117
131
|
if (col.notNull) parts.push('NOT NULL')
|
|
118
132
|
if (col.default !== undefined) parts.push(`DEFAULT ${col.default}`)
|
|
133
|
+
// An identifier two rows share is not one.
|
|
134
|
+
if (col.unique) parts.push('UNIQUE')
|
|
119
135
|
return parts.join(' ')
|
|
120
136
|
}
|
|
121
137
|
|
package/src/useFactoryBuilder.js
CHANGED
|
@@ -42,7 +42,22 @@ import { selectionPlace } from './urlState.js'
|
|
|
42
42
|
* `<urlKey>Selected` and `<urlKey>Steps`, so a refresh or a shared
|
|
43
43
|
* link lands on the same control. Omitted → kept in memory.
|
|
44
44
|
*/
|
|
45
|
-
|
|
45
|
+
/**
|
|
46
|
+
* What the link column on a child is called: materials → materialId.
|
|
47
|
+
*
|
|
48
|
+
* From the parent's TABLE rather than its name, because the table is what the
|
|
49
|
+
* column points at and is the thing that cannot be renamed later.
|
|
50
|
+
*/
|
|
51
|
+
function linkNameFor(doc) {
|
|
52
|
+
var table = String((doc && doc.source) || 'parent').replace(/[^A-Za-z0-9]+/g, '_').replace(/_+$/, '')
|
|
53
|
+
var singular = /ies$/.test(table) ? table.slice(0, -3) + 'y'
|
|
54
|
+
: /(ss|us|is)$/.test(table) ? table
|
|
55
|
+
: /s$/.test(table) ? table.slice(0, -1)
|
|
56
|
+
: table
|
|
57
|
+
return singular.replace(/_([a-z])/g, function (_, c) { return c.toUpperCase() }) + 'Id'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function useFactoryBuilder({ document: given, name, onSave, onChange, onPublish, listTables, makeChildTable, screens, lockedNames, controls = CONTROLS, autosaveDelay = 800, urlKey } = {}) {
|
|
46
61
|
const [doc, setDoc] = useState(() => given || createScreen({ name }))
|
|
47
62
|
|
|
48
63
|
// ── selection ─────────────────────────────────────────────────────────
|
|
@@ -105,6 +120,11 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
105
120
|
const [tables, setTables] = useState({ loading: Boolean(listTables), items: [], error: null })
|
|
106
121
|
const listTablesRef = useRef(listTables); listTablesRef.current = listTables
|
|
107
122
|
const hasListTables = Boolean(listTables)
|
|
123
|
+
// Bumped when this designer MAKES a table. The list is otherwise fetched
|
|
124
|
+
// once, which was right while tables only ever appeared elsewhere — and
|
|
125
|
+
// wrong the moment "New table…" existed: the table was made, the list was
|
|
126
|
+
// pointed at it, and the picker had never heard of it, so it showed blank.
|
|
127
|
+
const [tableRound, setTableRound] = useState(0)
|
|
108
128
|
useEffect(() => {
|
|
109
129
|
if (!hasListTables) return undefined
|
|
110
130
|
let cancelled = false
|
|
@@ -117,7 +137,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
117
137
|
})
|
|
118
138
|
.catch((err) => { if (!cancelled) setTables({ loading: false, items: [], error: err.message || 'Could not list tables' }) })
|
|
119
139
|
return () => { cancelled = true }
|
|
120
|
-
}, [hasListTables])
|
|
140
|
+
}, [hasListTables, tableRound])
|
|
121
141
|
|
|
122
142
|
// ── edits ─────────────────────────────────────────────────────────────
|
|
123
143
|
const rename = useCallback((value) => update((d) => renameScreen(d, value)), [update])
|
|
@@ -276,6 +296,8 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
276
296
|
// Deliberate, unlike saving: a half-finished design must not reach everyone
|
|
277
297
|
// filling the form in.
|
|
278
298
|
const onPublishRef = useRef(onPublish); onPublishRef.current = onPublish
|
|
299
|
+
const makeChildTableRef = useRef(makeChildTable); makeChildTableRef.current = makeChildTable
|
|
300
|
+
const [designingChild, setDesigningChild] = useState(null)
|
|
279
301
|
const [publishing, setPublishing] = useState(false)
|
|
280
302
|
const [publishResult, setPublishResult] = useState(null) // { ok, message, version?, detail? }
|
|
281
303
|
/**
|
|
@@ -379,6 +401,85 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
379
401
|
},
|
|
380
402
|
visibleNodes,
|
|
381
403
|
setProperty,
|
|
404
|
+
/**
|
|
405
|
+
* A list's table, and the columns that only made sense for the old one.
|
|
406
|
+
*
|
|
407
|
+
* Chosen columns are field names. Point the list at another table and
|
|
408
|
+
* every one of them is a name that table has never heard of — so the grid
|
|
409
|
+
* keeps drawing the old headers over blank cells, which reads as the
|
|
410
|
+
* panel not having noticed the change. Clearing them falls back to "all
|
|
411
|
+
* the columns the rows have", which is right for whatever it now points
|
|
412
|
+
* at.
|
|
413
|
+
*
|
|
414
|
+
* The link goes too: it belonged to the old table's shape.
|
|
415
|
+
*/
|
|
416
|
+
setListSource: function (nodeId, table) {
|
|
417
|
+
update(function (d) {
|
|
418
|
+
var node = d.nodes.find(function (n) { return n.id === nodeId })
|
|
419
|
+
var was = node && node.props ? node.props.source : undefined
|
|
420
|
+
var next = setNodeProperty(d, nodeId, 'props.source', table || undefined)
|
|
421
|
+
if ((was || '') === (table || '')) return next
|
|
422
|
+
next = setNodeProperty(next, nodeId, 'props.columns', undefined)
|
|
423
|
+
return setNodeProperty(next, nodeId, 'props.belongsTo', undefined)
|
|
424
|
+
})
|
|
425
|
+
},
|
|
426
|
+
/**
|
|
427
|
+
* Make the table a list is about to point at, and point it there.
|
|
428
|
+
*
|
|
429
|
+
* TWO PROPERTIES AT ONCE, which is why it is here rather than a button in
|
|
430
|
+
* the panel: a table without its link is a list of everybody's rows, and
|
|
431
|
+
* a link without a table is nothing at all. Neither half is a state worth
|
|
432
|
+
* being able to reach.
|
|
433
|
+
*
|
|
434
|
+
* The host does the making (it is a server act, and this package does not
|
|
435
|
+
* know where the server is) and answers with what the list needs.
|
|
436
|
+
*/
|
|
437
|
+
// The child just made, waiting to be designed — { nodeId, screenKey }.
|
|
438
|
+
// Nothing is forced: closing it leaves a table with the two fields it was
|
|
439
|
+
// made with, which is a working sub-table.
|
|
440
|
+
designingChild: designingChild,
|
|
441
|
+
closeChild: function (screen) {
|
|
442
|
+
// Designed and published: its fields are the list's columns now. A
|
|
443
|
+
// close with nothing keeps what was set when it was made.
|
|
444
|
+
if (screen && Array.isArray(screen.fields) && designingChild) {
|
|
445
|
+
var link = designingChild.link
|
|
446
|
+
var columns = screen.fields
|
|
447
|
+
.filter(function (f) { return f.name !== link })
|
|
448
|
+
.map(function (f) { return { field: f.name, label: f.label || f.name } })
|
|
449
|
+
if (columns.length) {
|
|
450
|
+
var nodeId = designingChild.nodeId
|
|
451
|
+
update(function (d) { return setNodeProperty(d, nodeId, 'props.columns', columns) })
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
setDesigningChild(null)
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
makeChildTable: makeChildTableRef.current ? async function (nodeId, name) {
|
|
458
|
+
var made = await makeChildTableRef.current({
|
|
459
|
+
name: name,
|
|
460
|
+
parent: { table: docRef.current.source, label: docRef.current.name, link: linkNameFor(docRef.current) }
|
|
461
|
+
})
|
|
462
|
+
if (!made || !made.source) throw new Error('Could not make that table')
|
|
463
|
+
// So the picker knows the table this list is about to name.
|
|
464
|
+
setTableRound(function (n) { return n + 1 })
|
|
465
|
+
update(function (d) {
|
|
466
|
+
var withTable = setNodeProperty(d, nodeId, 'props.source', made.source)
|
|
467
|
+
var linked = setNodeProperty(withTable, nodeId, 'props.belongsTo', made.link)
|
|
468
|
+
// The child's own columns, which are known because it was just made
|
|
469
|
+
// — minus the link, which is the list itself and not a column anybody
|
|
470
|
+
// asked to see.
|
|
471
|
+
var columns = (made.fields || [])
|
|
472
|
+
.filter(function (f) { return f.name !== made.link })
|
|
473
|
+
.map(function (f) { return { field: f.name, label: f.label || f.name } })
|
|
474
|
+
return columns.length ? setNodeProperty(linked, nodeId, 'props.columns', columns) : linked
|
|
475
|
+
})
|
|
476
|
+
// STRAIGHT INTO ITS DESIGNER. A sub-table is made for the fields it is
|
|
477
|
+
// going to hold, and "Name" and "Value" are a guess at those — so the
|
|
478
|
+
// form opens where it was made, rather than being something to go and
|
|
479
|
+
// find afterwards with the thought already lost.
|
|
480
|
+
setDesigningChild({ nodeId: nodeId, screenKey: made.edit, link: made.link, name: name })
|
|
481
|
+
return made
|
|
482
|
+
} : null,
|
|
382
483
|
convertControl,
|
|
383
484
|
hints,
|
|
384
485
|
removeSelected,
|
package/src/useFactoryScreen.js
CHANGED
|
@@ -52,9 +52,16 @@ import { useUrlSteps } from './useUrlPlaces.js'
|
|
|
52
52
|
* @param urlKey where the open step of each stepper lives in the URL (`<urlKey>Steps`),
|
|
53
53
|
* so a refresh or a shared link lands on the same step. Omitted → in memory.
|
|
54
54
|
*/
|
|
55
|
+
/**
|
|
56
|
+
* @param params what this screen was CALLED WITH — { name: value } for the
|
|
57
|
+
* inputs it declares. A flow's Screen step supplies them; somebody opening
|
|
58
|
+
* the form from a list supplies nothing. A field whose default is
|
|
59
|
+
* `{params.x}` is filled from here, and left empty (for the person to fill
|
|
60
|
+
* in) when nobody said.
|
|
61
|
+
*/
|
|
55
62
|
export function useFactoryScreen({
|
|
56
63
|
document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl,
|
|
57
|
-
onOpenRecord, onDone, onDirtyChange, urlKey,
|
|
64
|
+
onOpenRecord, onDone, onDirtyChange, params, urlKey,
|
|
58
65
|
controls = CONTROLS
|
|
59
66
|
} = {}) {
|
|
60
67
|
const check = useMemo(() => validateDocument(doc, controls), [doc, controls])
|
|
@@ -100,9 +107,13 @@ export function useFactoryScreen({
|
|
|
100
107
|
}, [check.ok, doc, inputs, fieldsFor])
|
|
101
108
|
const source = check.ok ? doc.source || null : null
|
|
102
109
|
|
|
110
|
+
// WHAT IT WAS CALLED WITH, then what was actually saved: a record being
|
|
111
|
+
// reopened wins, because the row is the truth once there is one.
|
|
103
112
|
const startValues = useCallback(
|
|
104
|
-
(rec) => (rec
|
|
105
|
-
|
|
113
|
+
(rec) => (rec
|
|
114
|
+
? { ...initialValues(doc, params || {}, controls), ...recordValues(doc, rec, controls) }
|
|
115
|
+
: initialValues(doc, params || {}, controls)),
|
|
116
|
+
[doc, controls, params]
|
|
106
117
|
)
|
|
107
118
|
|
|
108
119
|
const [values, setValues] = useState(() => (check.ok ? startValues(record) : {}))
|
|
@@ -484,7 +495,16 @@ export function useFactoryScreen({
|
|
|
484
495
|
defaults: {
|
|
485
496
|
get: () => {
|
|
486
497
|
if (!fetchRecordsRef.current) throw new Error(`No fetchRecords was provided to list "${src}"`)
|
|
487
|
-
|
|
498
|
+
// A SUB-TABLE IS THIS RECORD'S. `belongsTo` names the column on
|
|
499
|
+
// those rows that points back here, and the id is this record's
|
|
500
|
+
// — which is nothing until it has been saved, and the server
|
|
501
|
+
// answers with no rows rather than with everybody's.
|
|
502
|
+
var link = node.props && node.props.belongsTo
|
|
503
|
+
return fetchRecordsRef.current({
|
|
504
|
+
source: src, screen: doc.id, node,
|
|
505
|
+
belongsTo: link || undefined,
|
|
506
|
+
belongsToId: link ? live.current.recordId : undefined
|
|
507
|
+
})
|
|
488
508
|
}
|
|
489
509
|
}
|
|
490
510
|
}))
|
|
@@ -545,12 +565,20 @@ export function useFactoryScreen({
|
|
|
545
565
|
const openRecordRef = useRef(onOpenRecord); openRecordRef.current = onOpenRecord
|
|
546
566
|
const openEditor = useCallback(async (node, rec) => {
|
|
547
567
|
const screenId = node.props.editScreen
|
|
568
|
+
|
|
569
|
+
// WHAT THE CHILD IS CALLED WITH. A sub-table's row belongs to THIS
|
|
570
|
+
// record, so the link column is handed to the screen that adds one — the
|
|
571
|
+
// same `params` a flow's Screen step passes. The person never sees it,
|
|
572
|
+
// and never has to know which material they are adding a line to.
|
|
573
|
+
const link = node.props.belongsTo
|
|
574
|
+
const given = link && live.current.recordId ? { [link]: live.current.recordId } : null
|
|
575
|
+
|
|
548
576
|
if (node.props.openIn === 'page') {
|
|
549
577
|
if (!openRecordRef.current) throw new Error('This list opens records on a page, but no onOpenRecord was provided to go there')
|
|
550
578
|
const id = rec ? rec[live.current.recordKey] : null
|
|
551
|
-
return openRecordRef.current({ screen: screenId, id, record: rec || null, source: listSource(doc, node), node })
|
|
579
|
+
return openRecordRef.current({ screen: screenId, id, record: rec || null, source: listSource(doc, node), node, params: given })
|
|
552
580
|
}
|
|
553
|
-
const base = { node, screenId, record: rec || null, document: null, loading: true, error: null }
|
|
581
|
+
const base = { node, screenId, record: rec || null, document: null, loading: true, error: null, params: given }
|
|
554
582
|
setPopup(base)
|
|
555
583
|
try {
|
|
556
584
|
let found = screensRef.current && screensRef.current[screenId]
|
package/src/useFlowRun.js
CHANGED
|
@@ -15,14 +15,17 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
15
15
|
* @param flowKey which flow to run (starting one, or picking up a run)
|
|
16
16
|
* @param runId an existing run to continue — omit to start one
|
|
17
17
|
* @param startRun async (flowKey) → { runId, status, stepKey, screen }
|
|
18
|
-
* @param loadRun async (runId) → { runId, status, stepKey, screen, values }
|
|
18
|
+
* @param loadRun async (runId) → { runId, status, stepKey, screen, values, given }
|
|
19
19
|
* @param submitRun async (runId, values, recordId) → { status, stepKey, screen }
|
|
20
20
|
* @param loadScreen async (key) → the screen document
|
|
21
21
|
* @param onStep (step) → void — told each time the run moves, for the address bar
|
|
22
22
|
* @param onFinish (run) → void — the journey is over
|
|
23
23
|
*/
|
|
24
24
|
export function useFlowRun({ flowKey, runId, startRun, loadRun, submitRun, loadScreen, onStep, onFinish } = {}) {
|
|
25
|
-
|
|
25
|
+
// `given` is what the flow called this screen with — the step's own input,
|
|
26
|
+
// resolved by the engine (see @xeplr/workflow's screen-show). A field bound
|
|
27
|
+
// to {params.x} is filled from it.
|
|
28
|
+
const [state, setState] = useState({ loading: true, error: null, runId: runId || null, status: null, stepKey: null, screen: null, document: null, values: null, given: null })
|
|
26
29
|
const [sending, setSending] = useState(false)
|
|
27
30
|
const live = useRef({})
|
|
28
31
|
live.current = { startRun, loadRun, submitRun, loadScreen, onStep, onFinish, flowKey }
|
|
@@ -40,7 +43,7 @@ export function useFlowRun({ flowKey, runId, startRun, loadRun, submitRun, loadS
|
|
|
40
43
|
setState({
|
|
41
44
|
loading: false, error: null,
|
|
42
45
|
runId: run.runId, status: run.status || 'running', stepKey: run.stepKey, screen: run.screen,
|
|
43
|
-
document: doc, values: run.values || null
|
|
46
|
+
document: doc, values: run.values || null, given: run.given || null
|
|
44
47
|
})
|
|
45
48
|
if (live.current.onStep) live.current.onStep(run)
|
|
46
49
|
}, [])
|
package/src/useScreenSource.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// createElement so the file stays .js.
|
|
22
22
|
|
|
23
23
|
import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
|
|
24
|
-
import { loadScreens } from './screenSource.js'
|
|
24
|
+
import { loadScreens, masterForms } from './screenSource.js'
|
|
25
25
|
import { ScreenEditorSample } from './designs/ScreenEditorSample.jsx'
|
|
26
26
|
|
|
27
27
|
/**
|
|
@@ -29,7 +29,7 @@ import { ScreenEditorSample } from './designs/ScreenEditorSample.jsx'
|
|
|
29
29
|
* @param opts { Builder, labels } — the builder component to draw, normally
|
|
30
30
|
* FactoryBuilder. Passed in rather than imported so this hook does
|
|
31
31
|
* not pull the whole builder into a bundle that never opens it.
|
|
32
|
-
* @returns { screens, screenEditor, reload, loading, error }
|
|
32
|
+
* @returns { screens, masters, screenEditor, reload, loading, error }
|
|
33
33
|
*/
|
|
34
34
|
export function useScreenSource(api, opts) {
|
|
35
35
|
opts = opts || {}
|
|
@@ -74,8 +74,12 @@ export function useScreenSource(api, opts) {
|
|
|
74
74
|
})
|
|
75
75
|
}, [api, Builder, labels, reload])
|
|
76
76
|
|
|
77
|
+
// The lists other forms point at, one entry per FORM — what the admin
|
|
78
|
+
// section shows. Derived here so no application groups screens by hand.
|
|
79
|
+
const masters = useMemo(() => masterForms(screens), [screens])
|
|
80
|
+
|
|
77
81
|
return useMemo(
|
|
78
|
-
() => ({ screens, screenEditor, reload, loading, error }),
|
|
79
|
-
[screens, screenEditor, reload, loading, error]
|
|
82
|
+
() => ({ screens, masters, screenEditor, reload, loading, error }),
|
|
83
|
+
[screens, masters, screenEditor, reload, loading, error]
|
|
80
84
|
)
|
|
81
85
|
}
|
package/src/validateDocument.js
CHANGED
|
@@ -18,7 +18,7 @@ import { FIELD_PRESETS, INPUT_TYPES, presetFor } from './presets.js'
|
|
|
18
18
|
const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
19
19
|
const TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_.$-]*$/
|
|
20
20
|
const SCREEN_ID = /^[A-Za-z0-9_-]+$/
|
|
21
|
-
const DOCUMENT_KEYS = ['kind', 'version', 'id', 'name', 'source', 'units', 'aspect', 'width', 'style', 'nodes']
|
|
21
|
+
const DOCUMENT_KEYS = ['kind', 'version', 'id', 'name', 'source', 'units', 'aspect', 'width', 'style', 'description', 'master', 'labelField', 'params', 'nodes']
|
|
22
22
|
const COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i
|
|
23
23
|
|
|
24
24
|
/**
|
|
@@ -41,6 +41,49 @@ export function validateDocument(doc, controls = CONTROLS) {
|
|
|
41
41
|
if (!(typeof doc.aspect === 'number' && doc.aspect > 0)) err('aspect', 'must be a number greater than 0 (1 = page as tall as it is wide)')
|
|
42
42
|
if (!(Number.isInteger(doc.width) && doc.width >= 320 && doc.width <= 1920)) err('width', 'must be a whole number of pixels from 320 to 1920 — the width the screen is designed at')
|
|
43
43
|
if (doc.source !== undefined && !(nonEmptyString(doc.source) && TABLE_NAME.test(doc.source))) err('source', 'must name the table records are saved to, e.g. "employees"')
|
|
44
|
+
|
|
45
|
+
// WHAT THIS FORM IS FOR, in the maker's own words. Three forms called
|
|
46
|
+
// "Inspection" are told apart by this and by who made them — never by their
|
|
47
|
+
// fields, which say what a form holds and not what it was for.
|
|
48
|
+
if (doc.description !== undefined && doc.description !== null && doc.description !== '') {
|
|
49
|
+
if (typeof doc.description !== 'string') err('description', 'must be a sentence about what this form is for')
|
|
50
|
+
else if (doc.description.length > 500) err('description', 'must be under 500 characters — a sentence, not the manual')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A MASTER is a form whose records other forms point at — departments,
|
|
54
|
+
// statuses, countries. It is a FLAG on an ordinary form, not a kind of its
|
|
55
|
+
// own: a master has a table, a list, an edit screen and permissions like
|
|
56
|
+
// anything else, and a separate type would be two things that behave the
|
|
57
|
+
// same until they do not, with every later feature asking which it has.
|
|
58
|
+
if (doc.master !== undefined && typeof doc.master !== 'boolean') {
|
|
59
|
+
err('master', 'must be true or false — whether other forms pick from this one')
|
|
60
|
+
}
|
|
61
|
+
// What a picker SHOWS for a record. Said once here rather than on every
|
|
62
|
+
// dropdown that points at this form.
|
|
63
|
+
if (doc.labelField !== undefined) {
|
|
64
|
+
if (!nonEmptyString(doc.labelField)) err('labelField', 'must name a field of this screen, e.g. "name"')
|
|
65
|
+
else if (!(doc.nodes || []).some((n) => n.props && n.props.name === doc.labelField)) {
|
|
66
|
+
err('labelField', `"${doc.labelField}" is not a field on this screen`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// WHAT THIS SCREEN IS CALLED WITH. Names only — a field binds to one with
|
|
71
|
+
// {params.<name>} as its default, so a name that is not a plain identifier
|
|
72
|
+
// could never be referenced, and a duplicate would make the reference mean
|
|
73
|
+
// two things.
|
|
74
|
+
if (doc.params !== undefined) {
|
|
75
|
+
if (!Array.isArray(doc.params)) err('params', 'must be a list of inputs this screen is called with')
|
|
76
|
+
else {
|
|
77
|
+
const seen = new Set()
|
|
78
|
+
doc.params.forEach((p, i) => {
|
|
79
|
+
const name = p && p.name
|
|
80
|
+
if (!nonEmptyString(name)) err(`params.${i}.name`, 'must be a name, e.g. "complaintId"')
|
|
81
|
+
else if (!/^[A-Za-z_][\w]*$/.test(name)) err(`params.${i}.name`, `"${name}" must be letters, digits and _, starting with a letter`)
|
|
82
|
+
else if (seen.has(name)) err(`params.${i}.name`, `"${name}" is declared twice — a field binding to it would mean two things`)
|
|
83
|
+
else seen.add(name)
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
}
|
|
44
87
|
checkStyle(doc.style, SCREEN_STYLE_KEYS, 'style', err, 'the screen')
|
|
45
88
|
Object.keys(doc).forEach((k) => {
|
|
46
89
|
if (!DOCUMENT_KEYS.includes(k)) err(k, `is not a screen property — allowed: ${DOCUMENT_KEYS.join(', ')}`)
|
|
@@ -92,6 +135,18 @@ export function validateDocument(doc, controls = CONTROLS) {
|
|
|
92
135
|
// Where the field came from (a ready-made field's key), or false: plain on purpose.
|
|
93
136
|
if (props.preset !== undefined && props.preset !== false && !presetFor(props.preset)) err(`${at}.props.preset`, `"${props.preset}" is not a ready-made field — one of: ${FIELD_PRESETS.map((p) => p.key).join(', ')}, or false`)
|
|
94
137
|
if (props.inputType !== undefined && !INPUT_TYPES.includes(props.inputType)) err(`${at}.props.inputType`, `must be one of: ${INPUT_TYPES.join(', ')}`)
|
|
138
|
+
|
|
139
|
+
// AN IDENTIFIER — the number or code this record is known by. How it is
|
|
140
|
+
// made decides the column, so it is checked here rather than discovered
|
|
141
|
+
// at publish time.
|
|
142
|
+
if (props.idKind !== undefined && props.idKind !== 'code' && props.idKind !== 'auto') {
|
|
143
|
+
err(`${at}.props.idKind`, 'must be "code" (CMP-7K3QF9, assigned on save) or "auto" (a number the database counts)')
|
|
144
|
+
}
|
|
145
|
+
if (props.codePrefix !== undefined && props.codePrefix !== '') {
|
|
146
|
+
if (typeof props.codePrefix !== 'string' || !/^[A-Za-z0-9]{1,8}$/.test(props.codePrefix)) {
|
|
147
|
+
err(`${at}.props.codePrefix`, 'must be up to 8 letters or digits, e.g. "CMP" — it is what the code starts with')
|
|
148
|
+
}
|
|
149
|
+
}
|
|
95
150
|
checkDefault(node, def, at, err)
|
|
96
151
|
checkValidation(node, def, at, err)
|
|
97
152
|
}
|
|
@@ -330,6 +385,17 @@ function checkList(props, doc, at, err) {
|
|
|
330
385
|
if (props.source !== undefined && !(nonEmptyString(props.source) && TABLE_NAME.test(props.source))) err(`${at}.props.source`, 'must name the table to list records from')
|
|
331
386
|
if (props.source === undefined && doc.source === undefined) err(`${at}.props.source`, 'is required when the screen has no "source" — which table should the list read?')
|
|
332
387
|
if (props.editScreen !== undefined && !(nonEmptyString(props.editScreen) && SCREEN_ID.test(props.editScreen))) err(`${at}.props.editScreen`, 'must be the id of the screen that edits a record, e.g. "employee_edit"')
|
|
388
|
+
|
|
389
|
+
// THE LINK BACK, for a sub-table on a form: this material's
|
|
390
|
+
// characteristics. A column name, checked as one — a value that could never
|
|
391
|
+
// be a column is a list that is always empty and nobody knows why.
|
|
392
|
+
if (props.belongsTo !== undefined && props.belongsTo !== '') {
|
|
393
|
+
if (!(nonEmptyString(props.belongsTo) && /^[A-Za-z_][\w]*$/.test(props.belongsTo))) {
|
|
394
|
+
err(`${at}.props.belongsTo`, 'must be a column name, e.g. "materialId"')
|
|
395
|
+
} else if (!props.source) {
|
|
396
|
+
err(`${at}.props.belongsTo`, 'only means something with "Records from" set — a list of this screen\'s own table already belongs to this record')
|
|
397
|
+
}
|
|
398
|
+
}
|
|
333
399
|
if (props.pageSize !== undefined && !(Number.isInteger(props.pageSize) && props.pageSize >= 1 && props.pageSize <= 200)) err(`${at}.props.pageSize`, 'must be a whole number from 1 to 200')
|
|
334
400
|
if (props.actions !== undefined) {
|
|
335
401
|
if (!Array.isArray(props.actions)) err(`${at}.props.actions`, `must be an array of: ${LIST_ACTIONS.join(', ')}`)
|