@xeplr/ui-factory 1.1.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 +189 -2
- package/bin/xeplr-factory.js +2 -1
- package/package.json +7 -2
- 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 +14 -1
- package/src/remote.js +22 -2
- package/src/screenSource.js +85 -2
- package/src/tableSchema.js +16 -0
- package/src/urlState.js +71 -0
- package/src/useFactoryBuilder.js +134 -10
- package/src/useFactoryScreen.js +45 -14
- package/src/useFlowBuilder.js +7 -4
- package/src/useFlowRun.js +6 -3
- package/src/useScreenSource.js +8 -4
- package/src/useUrlPlaces.js +48 -0
- package/src/validateDocument.js +67 -1
- package/src/values.js +118 -2
package/src/pages.jsx
CHANGED
|
@@ -19,6 +19,7 @@ import { BuilderSample, ScreenSample, ScreenModal, FlowRunnerSample, FlowBuilder
|
|
|
19
19
|
* @prop lockedNames field names that are already columns of the table — shown read-only
|
|
20
20
|
* @prop screens [{ id, name, document? }] — other screens: "Edit in" choices, and Preview's popups
|
|
21
21
|
* @prop controls control registry (default: built-ins)
|
|
22
|
+
* @prop urlKey keep the selected control and open steps in the URL (<urlKey>Selected, <urlKey>Steps)
|
|
22
23
|
*/
|
|
23
24
|
export function FactoryBuilder(props) {
|
|
24
25
|
const ctrl = useFactoryBuilder(props)
|
|
@@ -27,6 +28,9 @@ export function FactoryBuilder(props) {
|
|
|
27
28
|
ctrl={ctrl}
|
|
28
29
|
className={props.className}
|
|
29
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}
|
|
30
34
|
renderPreview={(doc) => (
|
|
31
35
|
// Preview never writes: onSave/onDelete are left out on purpose.
|
|
32
36
|
<FactoryScreen
|
|
@@ -57,6 +61,7 @@ export function FactoryBuilder(props) {
|
|
|
57
61
|
* @prop loadScreen async (id) → document — for screens not in `screens`
|
|
58
62
|
* @prop controls control registry (default: built-ins)
|
|
59
63
|
* @prop hooks your FactoryHooks subclass (get / save / delete / actions) — also used by the popup
|
|
64
|
+
* @prop urlKey keep the open step of each stepper in the URL (<urlKey>Steps); never passed to the popup
|
|
60
65
|
*/
|
|
61
66
|
export function FactoryScreen(props) {
|
|
62
67
|
const ctrl = useFactoryScreen(props)
|
|
@@ -89,6 +94,10 @@ export function FactoryScreen(props) {
|
|
|
89
94
|
key={`${popup.screenId}:${popup.record ? popup.record[ctrl.recordKey] : 'new'}`}
|
|
90
95
|
document={popup.document}
|
|
91
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}
|
|
92
101
|
recordKey={props.recordKey}
|
|
93
102
|
onSave={ctrl.popupSave}
|
|
94
103
|
fetchOptions={props.fetchOptions}
|
|
@@ -132,7 +141,10 @@ export function FlowRunner(props) {
|
|
|
132
141
|
const screen = useFactoryScreen({
|
|
133
142
|
...props,
|
|
134
143
|
document: run.document || EMPTY_SCREEN,
|
|
135
|
-
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
|
|
136
148
|
})
|
|
137
149
|
return (
|
|
138
150
|
<FlowRunnerSample
|
|
@@ -166,6 +178,7 @@ const EMPTY_SCREEN = {
|
|
|
166
178
|
* @prop onSave async (flow) → void
|
|
167
179
|
* @prop onPublish async (key) → void
|
|
168
180
|
* @prop onChange (flow) → void
|
|
181
|
+
* @prop urlKey keep the selected step in the URL (<urlKey>Selected)
|
|
169
182
|
*/
|
|
170
183
|
export function FlowBuilder(props) {
|
|
171
184
|
const ctrl = useFlowBuilder(props)
|
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/urlState.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// WHERE YOU ARE LIVES IN THE URL — the pure half, no React.
|
|
2
|
+
//
|
|
3
|
+
// The step a stepper is open at, and the control (or flow step) whose
|
|
4
|
+
// properties are open, are places a person navigates to: a refresh or a
|
|
5
|
+
// shared link must land on them again. This file is how they are named in the
|
|
6
|
+
// URL's query; useUrlPlaces.js reads and writes them.
|
|
7
|
+
//
|
|
8
|
+
// THE KEYS ARE THE HOST'S. A builder, a running screen and the preview inside
|
|
9
|
+
// the builder can share one page, and the host app has keys of its own, so a
|
|
10
|
+
// hook writes the URL only when given a `urlKey`. Without one it keeps the
|
|
11
|
+
// same values in memory, exactly as before.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The query keys under a host's `urlKey`, or null (in memory).
|
|
15
|
+
* factoryUrlKeys('form') → { steps: 'formSteps', selected: 'formSelected' }
|
|
16
|
+
*/
|
|
17
|
+
export function factoryUrlKeys(urlKey) {
|
|
18
|
+
if (!urlKey) return null
|
|
19
|
+
return { steps: `${urlKey}Steps`, selected: `${urlKey}Selected` }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Which step each stepper is open at → one query value. Step 0 is where a
|
|
24
|
+
* stepper starts, so it is left out — a screen on its first steps has a
|
|
25
|
+
* clean URL. Ids are URI-encoded, so one holding ':' or ',' splits back.
|
|
26
|
+
* { s1: 2, s2: 0 } → 's1:2'
|
|
27
|
+
*/
|
|
28
|
+
export function encodeSteps(open) {
|
|
29
|
+
return Object.keys(open || {})
|
|
30
|
+
.filter((id) => Number.isInteger(open[id]) && open[id] > 0)
|
|
31
|
+
.map((id) => `${encodeURIComponent(id)}:${open[id]}`)
|
|
32
|
+
.join(',') || null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The query value back to { stepper id: index }; anything unreadable is dropped. */
|
|
36
|
+
export function decodeSteps(value) {
|
|
37
|
+
const open = {}
|
|
38
|
+
if (!value || typeof value !== 'string') return open
|
|
39
|
+
value.split(',').forEach((pair) => {
|
|
40
|
+
const at = pair.lastIndexOf(':')
|
|
41
|
+
if (at <= 0) return
|
|
42
|
+
if (!/^\d+$/.test(pair.slice(at + 1))) return
|
|
43
|
+
const index = Number(pair.slice(at + 1))
|
|
44
|
+
let id
|
|
45
|
+
try { id = decodeURIComponent(pair.slice(0, at)) } catch { return }
|
|
46
|
+
open[id] = index
|
|
47
|
+
})
|
|
48
|
+
return open
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Selecting adds a history entry when it opens or closes the properties
|
|
53
|
+
* (nothing → something, something → nothing), so Back closes them; moving
|
|
54
|
+
* from one selected thing to another is a switch and replaces the entry.
|
|
55
|
+
*/
|
|
56
|
+
export function replacesHistory(previous, next) {
|
|
57
|
+
const had = previous !== null && previous !== undefined && previous !== ''
|
|
58
|
+
const has = next !== null && next !== undefined && next !== ''
|
|
59
|
+
return had && has
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A multi-selection is NOT a place: it is a working set for Delete or
|
|
64
|
+
* Duplicate, not somewhere a person goes. Only a single selection — the one
|
|
65
|
+
* whose properties are open — is named in the URL.
|
|
66
|
+
* one id → that id none, or several → null
|
|
67
|
+
*/
|
|
68
|
+
export function selectionPlace(ids) {
|
|
69
|
+
const list = ids ? [...ids] : []
|
|
70
|
+
return list.length === 1 ? list[0] : null
|
|
71
|
+
}
|
package/src/useFactoryBuilder.js
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
} from './document.js'
|
|
8
8
|
import { validateDocument } from './validateDocument.js'
|
|
9
9
|
import { presetFor, presetProps, presetGroup, presetHints, convertField } from './presets.js'
|
|
10
|
+
import { useUrlSteps, useUrlSelection } from './useUrlPlaces.js'
|
|
11
|
+
import { selectionPlace } from './urlState.js'
|
|
10
12
|
|
|
11
13
|
// The builder's controller: the document being edited, what is selected, and
|
|
12
14
|
// every change the builder can make. No JSX — designs/BuilderSample.jsx draws
|
|
@@ -36,10 +38,44 @@ import { presetFor, presetProps, presetGroup, presetHints, convertField } from '
|
|
|
36
38
|
* shown); throw with `confirm: [{ column, records }]` when removed fields would drop
|
|
37
39
|
* columns — the builder asks, and calls again with confirmDrop naming them.
|
|
38
40
|
* @param controls the control registry (default: the built-ins)
|
|
41
|
+
* @param urlKey where the selected control and the open steps live in the URL:
|
|
42
|
+
* `<urlKey>Selected` and `<urlKey>Steps`, so a refresh or a shared
|
|
43
|
+
* link lands on the same control. Omitted → kept in memory.
|
|
39
44
|
*/
|
|
40
|
-
|
|
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 } = {}) {
|
|
41
61
|
const [doc, setDoc] = useState(() => given || createScreen({ name }))
|
|
42
|
-
|
|
62
|
+
|
|
63
|
+
// ── selection ─────────────────────────────────────────────────────────
|
|
64
|
+
// ONE selected control is a place — its properties are open — so it is
|
|
65
|
+
// named in the URL (by id, found again in the document). Several selected
|
|
66
|
+
// is a working set for Delete or Duplicate, not a place: it stays here, and
|
|
67
|
+
// the URL then names nothing.
|
|
68
|
+
const [placeId, selectPlace] = useUrlSelection(urlKey)
|
|
69
|
+
const [several, setSeveral] = useState(null)
|
|
70
|
+
const placeNode = placeId === null ? null : doc.nodes.find((n) => String(n.id) === placeId) || null
|
|
71
|
+
const placeNodeId = placeNode ? placeNode.id : null
|
|
72
|
+
const selected = useMemo(() => several || new Set(placeNodeId === null ? [] : [placeNodeId]), [several, placeNodeId])
|
|
73
|
+
const selectedRef = useRef(selected); selectedRef.current = selected
|
|
74
|
+
const setSelected = useCallback((next) => {
|
|
75
|
+
const ids = [...((typeof next === 'function' ? next(selectedRef.current) : next) || [])]
|
|
76
|
+
setSeveral(ids.length > 1 ? new Set(ids) : null)
|
|
77
|
+
selectPlace(selectionPlace(ids))
|
|
78
|
+
}, [selectPlace])
|
|
43
79
|
const [dirty, setDirty] = useState(false)
|
|
44
80
|
const [saving, setSaving] = useState(false)
|
|
45
81
|
const [saveError, setSaveError] = useState(null)
|
|
@@ -57,7 +93,10 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
57
93
|
if (JSON.stringify(given) === JSON.stringify(docRef.current)) return
|
|
58
94
|
docRef.current = given
|
|
59
95
|
setDoc(given)
|
|
60
|
-
|
|
96
|
+
// A multi-selection belongs to the old document. The URL's control is
|
|
97
|
+
// kept: a host that loads the screen after mounting hands it in here, and
|
|
98
|
+
// the link's control is found in it — or, if it is not there, nothing is.
|
|
99
|
+
setSeveral(null)
|
|
61
100
|
setDirty(false)
|
|
62
101
|
}, [given])
|
|
63
102
|
|
|
@@ -81,6 +120,11 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
81
120
|
const [tables, setTables] = useState({ loading: Boolean(listTables), items: [], error: null })
|
|
82
121
|
const listTablesRef = useRef(listTables); listTablesRef.current = listTables
|
|
83
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)
|
|
84
128
|
useEffect(() => {
|
|
85
129
|
if (!hasListTables) return undefined
|
|
86
130
|
let cancelled = false
|
|
@@ -93,7 +137,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
93
137
|
})
|
|
94
138
|
.catch((err) => { if (!cancelled) setTables({ loading: false, items: [], error: err.message || 'Could not list tables' }) })
|
|
95
139
|
return () => { cancelled = true }
|
|
96
|
-
}, [hasListTables])
|
|
140
|
+
}, [hasListTables, tableRound])
|
|
97
141
|
|
|
98
142
|
// ── edits ─────────────────────────────────────────────────────────────
|
|
99
143
|
const rename = useCallback((value) => update((d) => renameScreen(d, value)), [update])
|
|
@@ -104,7 +148,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
104
148
|
// Which step of each stepper is open on the canvas. Everything dropped while
|
|
105
149
|
// a step is open belongs to that step, and the canvas shows only that step —
|
|
106
150
|
// so one screen holds as many little screens as it has steps.
|
|
107
|
-
const [openSteps, setOpenSteps] =
|
|
151
|
+
const [openSteps, setOpenSteps] = useUrlSteps(urlKey)
|
|
108
152
|
const stepperNodes = useMemo(() => steppers(doc, controls), [doc, controls])
|
|
109
153
|
const [building, setBuilding] = useState(null) // the stepper being built
|
|
110
154
|
const activeStepper = stepperNodes.find((n) => n.id === building) || stepperNodes[stepperNodes.length - 1] || null
|
|
@@ -113,7 +157,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
113
157
|
const goToStep = useCallback((id, index) => {
|
|
114
158
|
setBuilding(id)
|
|
115
159
|
setOpenSteps((o) => ({ ...o, [id]: index }))
|
|
116
|
-
}, [])
|
|
160
|
+
}, [setOpenSteps])
|
|
117
161
|
|
|
118
162
|
/** The step a new control lands on: the one open on the stepper being built. */
|
|
119
163
|
const dropStep = useCallback(() => (
|
|
@@ -142,7 +186,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
142
186
|
})
|
|
143
187
|
if (added) setSelected(new Set([added.id]))
|
|
144
188
|
return added
|
|
145
|
-
}, [update, controls, dropStep])
|
|
189
|
+
}, [update, controls, dropStep, setSelected])
|
|
146
190
|
|
|
147
191
|
const moveControl = useCallback((id, patch) => update((d) => moveNode(d, id, patch)), [update])
|
|
148
192
|
|
|
@@ -164,13 +208,12 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
164
208
|
return dropped
|
|
165
209
|
}, [update, controls])
|
|
166
210
|
|
|
167
|
-
const selectedRef = useRef(selected); selectedRef.current = selected
|
|
168
211
|
const removeSelected = useCallback(() => {
|
|
169
212
|
const ids = [...selectedRef.current]
|
|
170
213
|
if (!ids.length) return
|
|
171
214
|
update((d) => removeNodes(d, ids))
|
|
172
215
|
setSelected(new Set())
|
|
173
|
-
}, [update])
|
|
216
|
+
}, [update, setSelected])
|
|
174
217
|
|
|
175
218
|
/** Everything selected moves to a copy, just below the original. */
|
|
176
219
|
const duplicateSelected = useCallback(() => {
|
|
@@ -191,7 +234,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
191
234
|
return next
|
|
192
235
|
})
|
|
193
236
|
setSelected(new Set(created))
|
|
194
|
-
}, [update, controls])
|
|
237
|
+
}, [update, controls, setSelected])
|
|
195
238
|
|
|
196
239
|
// ── validation + save ─────────────────────────────────────────────────
|
|
197
240
|
const validation = useMemo(() => validateDocument(doc, controls), [doc, controls])
|
|
@@ -253,6 +296,8 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
253
296
|
// Deliberate, unlike saving: a half-finished design must not reach everyone
|
|
254
297
|
// filling the form in.
|
|
255
298
|
const onPublishRef = useRef(onPublish); onPublishRef.current = onPublish
|
|
299
|
+
const makeChildTableRef = useRef(makeChildTable); makeChildTableRef.current = makeChildTable
|
|
300
|
+
const [designingChild, setDesigningChild] = useState(null)
|
|
256
301
|
const [publishing, setPublishing] = useState(false)
|
|
257
302
|
const [publishResult, setPublishResult] = useState(null) // { ok, message, version?, detail? }
|
|
258
303
|
/**
|
|
@@ -356,6 +401,85 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
356
401
|
},
|
|
357
402
|
visibleNodes,
|
|
358
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,
|
|
359
483
|
convertControl,
|
|
360
484
|
hints,
|
|
361
485
|
removeSelected,
|