@xeplr/ui-factory 1.2.0 → 1.4.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 +201 -0
- package/bin/xeplr-factory.js +2 -1
- package/package.json +1 -1
- package/src/controls.js +97 -2
- package/src/designs/BuilderSample.jsx +47 -1
- package/src/designs/ControlView.jsx +73 -2
- package/src/designs/ListView.jsx +3 -1
- package/src/designs/PropertyPanel.jsx +237 -7
- package/src/designs/ScreenSample.jsx +15 -9
- package/src/designs/factory.css +64 -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 +37 -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 +202 -29
- package/src/useFlowRun.js +6 -3
- package/src/useScreenSource.js +8 -4
- package/src/validateDocument.js +82 -1
- package/src/values.js +118 -2
package/src/masters.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// MAKING A MASTER — the one form shape every application repeats.
|
|
2
|
+
//
|
|
3
|
+
// Departments, statuses, countries, categories, reasons, priorities. They are
|
|
4
|
+
// all the same three things, and designing them one by one is drawing the same
|
|
5
|
+
// form over and over:
|
|
6
|
+
//
|
|
7
|
+
// name what it is called — what every picker shows
|
|
8
|
+
// sortOrder the order somebody wants to see them in, which is almost
|
|
9
|
+
// never alphabetical: Low / Medium / High, not High / Low /
|
|
10
|
+
// Medium
|
|
11
|
+
// parent the master it belongs to, when there is one — a state in a
|
|
12
|
+
// country, a sub-category in a category
|
|
13
|
+
//
|
|
14
|
+
// So a master is MADE, not designed: name it, say what it belongs to, and it
|
|
15
|
+
// exists. It is still an ordinary form afterwards — its design opens in the
|
|
16
|
+
// builder like any other, and a fourth field can be added to it — because a
|
|
17
|
+
// master is a flag on a form and not a kind of form (see screenSource.js).
|
|
18
|
+
//
|
|
19
|
+
// Pure except for `createMaster`, which is the one place that talks to the
|
|
20
|
+
// server; the spec it builds is a plain object anything can check.
|
|
21
|
+
|
|
22
|
+
import { screensFromSpec, entityNames } from './generate.js'
|
|
23
|
+
import { describeScreen, formKeyOf } from './screenSource.js'
|
|
24
|
+
|
|
25
|
+
/** The field every master is shown by. Said once, here and in labelField. */
|
|
26
|
+
export var MASTER_LABEL = 'name'
|
|
27
|
+
|
|
28
|
+
/** The field every master is ordered by. */
|
|
29
|
+
export var MASTER_ORDER = 'sortOrder'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* "Departments" → "department": what the ENTITY is called.
|
|
33
|
+
*
|
|
34
|
+
* A master is named in the plural, because it is a list — and an entity is
|
|
35
|
+
* named in the singular, because it is one row. entityNames does not know
|
|
36
|
+
* that, so "Departments" straight through makes a `departmentses` table and a
|
|
37
|
+
* screen called department**s**_edit. Nobody notices until the table is
|
|
38
|
+
* created, and then it cannot be renamed.
|
|
39
|
+
*
|
|
40
|
+
* Crude on purpose: enough for the words masters are actually called, and
|
|
41
|
+
* wrong quietly rather than cleverly — the key is shown before anything is
|
|
42
|
+
* made, and can be typed over.
|
|
43
|
+
*/
|
|
44
|
+
export function singularOf(label) {
|
|
45
|
+
var words = String(label || '').trim().split(/\s+/).filter(Boolean)
|
|
46
|
+
if (!words.length) return ''
|
|
47
|
+
var last = words[words.length - 1]
|
|
48
|
+
words[words.length - 1] = /ies$/i.test(last) ? last.slice(0, -3) + 'y'
|
|
49
|
+
: /(ss|us|is)$/i.test(last) ? last
|
|
50
|
+
: /s$/i.test(last) ? last.slice(0, -1)
|
|
51
|
+
: last
|
|
52
|
+
return words.join(' ')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The entity spec for a master.
|
|
57
|
+
*
|
|
58
|
+
* @param name what it is called, as a person writes it — "Departments"
|
|
59
|
+
* @param parent optional { table, label } — the master this one belongs to
|
|
60
|
+
* @param plural optional, when the plural is not name + s
|
|
61
|
+
*/
|
|
62
|
+
export function masterSpec({ name, parent, plural }) {
|
|
63
|
+
var label = String(name || '').trim()
|
|
64
|
+
if (!label) throw new Error('A master needs a name, e.g. "Departments"')
|
|
65
|
+
|
|
66
|
+
var fields = [
|
|
67
|
+
// Required, because a master row with no name is a blank line in every
|
|
68
|
+
// dropdown that points at it.
|
|
69
|
+
{ label: 'Name', type: 'text', required: true },
|
|
70
|
+
// Not required: most lists are fine alphabetical, and a number somebody
|
|
71
|
+
// is forced to invent for every row is a tax on adding one.
|
|
72
|
+
{ label: 'Sort order', type: 'number' }
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
if (parent && parent.table) {
|
|
76
|
+
fields.push({
|
|
77
|
+
label: parent.label || 'Belongs to',
|
|
78
|
+
type: 'dropdown',
|
|
79
|
+
table: parent.table,
|
|
80
|
+
required: true
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { entity: entityNames(singularOf(label), plural).singular, plural: plural, fields: fields }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Make one: both screens, marked as a master, published.
|
|
89
|
+
*
|
|
90
|
+
* PUBLISHED, because a master that is a draft has no table, so nothing can
|
|
91
|
+
* point at it and nobody can add a row — which is the whole of what a master
|
|
92
|
+
* is for. The one case where publishing on somebody's behalf is right: there
|
|
93
|
+
* is no design decision left to make.
|
|
94
|
+
*
|
|
95
|
+
* @param api a createFactoryApi() instance
|
|
96
|
+
* @param spec { name, parent, plural } — see masterSpec
|
|
97
|
+
* @returns the new master, as describeScreen reports it
|
|
98
|
+
*/
|
|
99
|
+
export async function createMaster(api, spec) {
|
|
100
|
+
var pair = screensFromSpec(masterSpec(spec))
|
|
101
|
+
|
|
102
|
+
// The FLAG goes on the edit screen: it is the one that describes the
|
|
103
|
+
// record, and labelField can only name a field that exists there.
|
|
104
|
+
var edit = Object.assign({}, pair.edit, { master: true, labelField: MASTER_LABEL })
|
|
105
|
+
var list = Object.assign({}, pair.list, { master: true })
|
|
106
|
+
|
|
107
|
+
// Drafts first, then published — the same two steps the builder takes, so a
|
|
108
|
+
// master is not a second way of writing a screen.
|
|
109
|
+
await api.saveDraft(edit)
|
|
110
|
+
await api.saveDraft(list)
|
|
111
|
+
await api.publish(edit)
|
|
112
|
+
await api.publish(list)
|
|
113
|
+
|
|
114
|
+
return describeScreen(edit)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A CHILD TABLE — rows that belong to one record of another form.
|
|
119
|
+
*
|
|
120
|
+
* This material's characteristics, this order's lines. Two fields and a link,
|
|
121
|
+
* which is the whole of what a sub-table is before somebody designs it:
|
|
122
|
+
*
|
|
123
|
+
* name what the row is
|
|
124
|
+
* value what it says — optional, but it is why most sub-tables exist
|
|
125
|
+
* <link> the record it belongs to, DECLARED AS AN INPUT so the list that
|
|
126
|
+
* opens it can supply it (see screenParams). Bound with
|
|
127
|
+
* {params.<link>} rather than typed, hidden when given and asked
|
|
128
|
+
* when not — the same field, whether it is opened from a material
|
|
129
|
+
* or on its own.
|
|
130
|
+
*
|
|
131
|
+
* @param name what the child is called — "Material characteristics"
|
|
132
|
+
* @param parent { table, label, link } — the form it belongs to, and the
|
|
133
|
+
* column pointing back at it
|
|
134
|
+
*/
|
|
135
|
+
export function childSpec({ name, parent, plural }) {
|
|
136
|
+
var label = String(name || '').trim()
|
|
137
|
+
if (!label) throw new Error('A table needs a name, e.g. "Characteristics"')
|
|
138
|
+
if (!parent || !parent.table || !parent.link) throw new Error('A child table needs the record it belongs to')
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
entity: entityNames(singularOf(label), plural).singular,
|
|
142
|
+
plural: plural,
|
|
143
|
+
fields: [
|
|
144
|
+
// The link first, because it is what the row IS before it is anything
|
|
145
|
+
// else — and hidden in practice, since the list supplies it.
|
|
146
|
+
{ label: parent.linkLabel || parent.label || 'Belongs to', name: parent.link,
|
|
147
|
+
type: 'dropdown', table: parent.table, required: true, default: '{params.' + parent.link + '}' },
|
|
148
|
+
{ label: 'Name', type: 'text', required: true },
|
|
149
|
+
{ label: 'Value', type: 'text' }
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Make one, published, with the link declared as an input.
|
|
156
|
+
*
|
|
157
|
+
* Published for the same reason a master is: a draft has no table, so the
|
|
158
|
+
* list that is about to point at it would have nothing to read.
|
|
159
|
+
*
|
|
160
|
+
* @returns { key, edit, list, link } — what the List control needs to be
|
|
161
|
+
* pointed at it.
|
|
162
|
+
*/
|
|
163
|
+
export async function createChildTable(api, spec) {
|
|
164
|
+
var pair = screensFromSpec(childSpec(spec))
|
|
165
|
+
var link = spec.parent.link
|
|
166
|
+
|
|
167
|
+
// The link is what this screen is CALLED WITH. Declared here rather than
|
|
168
|
+
// left as an ordinary field so the form knows not to ask for it when the
|
|
169
|
+
// list already said.
|
|
170
|
+
var edit = Object.assign({}, pair.edit, { params: [{ name: link }] })
|
|
171
|
+
|
|
172
|
+
await api.saveDraft(edit)
|
|
173
|
+
await api.saveDraft(pair.list)
|
|
174
|
+
await api.publish(edit)
|
|
175
|
+
await api.publish(pair.list)
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
key: formKeyOf(edit.id), edit: edit.id, list: pair.list.id, source: edit.source, link: link,
|
|
179
|
+
// What it holds, so a list pointed at it can show the right columns
|
|
180
|
+
// without reading the document back.
|
|
181
|
+
fields: describeScreen(edit).fields.map(function (f) { return { name: f.name, label: f.name } })
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The masters another master may belong to: every one except itself.
|
|
187
|
+
*
|
|
188
|
+
* A master that is its own parent is a row that can never be filled in, and
|
|
189
|
+
* the dropdown would offer the record being created.
|
|
190
|
+
*/
|
|
191
|
+
export function possibleParents(masters, exceptKey) {
|
|
192
|
+
return (masters || []).filter(function (m) { return m && m.key && m.key !== exceptKey })
|
|
193
|
+
}
|
package/src/model.js
CHANGED
|
@@ -8,6 +8,7 @@ export {
|
|
|
8
8
|
DOCUMENT_KIND, DOCUMENT_VERSION, DEFAULT_ASPECT, DEFAULT_WIDTH, MARGIN, GAP,
|
|
9
9
|
createScreen, renameScreen, setScreenProperty, addControl, moveNode, setNodeProperty, getNodeProperty, removeNodes,
|
|
10
10
|
inputNodes, readingOrder, contentBottom, camelName, slugify, uniqueFieldName, nextPosition,
|
|
11
|
+
screenParams, paramsText, setScreenParams,
|
|
11
12
|
steppers, stepsOf, stepOf, setNodeStep, nodesForSteps, resolveStep, reachableStep, nextOpenStep
|
|
12
13
|
} from './document.js'
|
|
13
14
|
export { validateDocument, assertValidDocument } from './validateDocument.js'
|
|
@@ -21,6 +22,10 @@ export {
|
|
|
21
22
|
columnForField, tableForScreen, columnSql, widening, diffTables, migrationFor, nextMigrationName,
|
|
22
23
|
createStatements, addStatements, alterStatement, dropStatement, columnFromDatabase, planTableChange, conversionFor
|
|
23
24
|
} from './tableSchema.js'
|
|
25
|
+
// A value with a {params.x} token is a binding; a plain value is a literal.
|
|
26
|
+
export { resolveBinding, isBinding } from './values.js'
|
|
27
|
+
// A code people can read out, unlike the record's own key.
|
|
28
|
+
export { codeFields, countedFields, generateCode } from './values.js'
|
|
24
29
|
export { FIELD_PRESETS, PATTERNS, INPUT_TYPES, presetFor, presetProps, suggestPreset, presetGroup, presetHints, convertField } from './presets.js'
|
|
25
30
|
export { createFlowsApi } from './flows.js'
|
|
26
31
|
export {
|
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
|
|
@@ -76,6 +79,32 @@ export function FactoryScreen(props) {
|
|
|
76
79
|
ctrl={ctrl}
|
|
77
80
|
className={props.className}
|
|
78
81
|
style={props.style}
|
|
82
|
+
// A form inside this one: the same component again, saving into its own
|
|
83
|
+
// table, checked and saved by this one's Save (see useFactoryScreen).
|
|
84
|
+
// Keyed by the form, never by this record's id — a new record's first
|
|
85
|
+
// save changes that id while the subform is being saved.
|
|
86
|
+
renderSubform={(node, sub) => (
|
|
87
|
+
<FactoryScreen
|
|
88
|
+
key={`${node.id}:${sub.document.id}`}
|
|
89
|
+
document={sub.document}
|
|
90
|
+
record={sub.record || undefined}
|
|
91
|
+
link={node.props.belongsTo}
|
|
92
|
+
onReady={(api) => ctrl.registerSubform(node.id, api)}
|
|
93
|
+
onDirtyChange={(d) => ctrl.subformDirty(node.id, d)}
|
|
94
|
+
recordKey={props.recordKey}
|
|
95
|
+
onSave={props.onSave}
|
|
96
|
+
fetchOptions={props.fetchOptions}
|
|
97
|
+
fetchRecord={props.fetchRecord}
|
|
98
|
+
fetchRecords={props.fetchRecords}
|
|
99
|
+
onDelete={props.onDelete}
|
|
100
|
+
uploadFile={props.uploadFile}
|
|
101
|
+
fileUrl={props.fileUrl}
|
|
102
|
+
screens={props.screens}
|
|
103
|
+
loadScreen={props.loadScreen}
|
|
104
|
+
controls={props.controls}
|
|
105
|
+
hooks={props.hooks}
|
|
106
|
+
/>
|
|
107
|
+
)}
|
|
79
108
|
renderPopup={(popup) => (
|
|
80
109
|
<ScreenModal
|
|
81
110
|
title={popup.document ? popup.document.name : 'Loading…'}
|
|
@@ -91,6 +120,10 @@ export function FactoryScreen(props) {
|
|
|
91
120
|
key={`${popup.screenId}:${popup.record ? popup.record[ctrl.recordKey] : 'new'}`}
|
|
92
121
|
document={popup.document}
|
|
93
122
|
record={popup.record}
|
|
123
|
+
// What the sub-table calls it with: the column pointing back at
|
|
124
|
+
// the record this list is on. Hidden on the form when the
|
|
125
|
+
// screen says a field is filled in by whoever opens it.
|
|
126
|
+
params={popup.params || undefined}
|
|
94
127
|
recordKey={props.recordKey}
|
|
95
128
|
onSave={ctrl.popupSave}
|
|
96
129
|
fetchOptions={props.fetchOptions}
|
|
@@ -134,7 +167,10 @@ export function FlowRunner(props) {
|
|
|
134
167
|
const screen = useFactoryScreen({
|
|
135
168
|
...props,
|
|
136
169
|
document: run.document || EMPTY_SCREEN,
|
|
137
|
-
record: run.values || undefined
|
|
170
|
+
record: run.values || undefined,
|
|
171
|
+
// What the flow called this screen with — its step's own input, resolved
|
|
172
|
+
// by the engine. A field bound to {params.x} is filled from here.
|
|
173
|
+
params: run.given || undefined
|
|
138
174
|
})
|
|
139
175
|
return (
|
|
140
176
|
<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,
|