@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/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
- fetchRecords: ({ screen }) => call('GET', `/records/${enc(screen)}`),
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
- api.builderProps = { onSave: api.saveDraft, onPublish: api.publish, listTables: api.listTables, fetchOptions: api.fetchOptions, fetchRecords: api.fetchRecords }
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
  }
@@ -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)
@@ -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
 
@@ -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
- export function useFactoryBuilder({ document: given, name, onSave, onChange, onPublish, listTables, screens, lockedNames, controls = CONTROLS, autosaveDelay = 800, urlKey } = {}) {
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,