@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/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
- 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
 
@@ -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
+ }
@@ -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
- export function useFactoryBuilder({ document: given, name, onSave, onChange, onPublish, listTables, screens, lockedNames, controls = CONTROLS, autosaveDelay = 800 } = {}) {
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
- const [selected, setSelected] = useState(() => new Set())
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
- setSelected(new Set())
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] = useState({})
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,