@xeplr/ui-factory 1.0.1 → 1.0.2
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/AUTHORING.md +100 -12
- package/README.md +69 -22
- package/bin/xeplr-factory.js +67 -1
- package/package.json +6 -2
- package/src/controls.js +142 -45
- package/src/designs/BuilderSample.jsx +60 -36
- package/src/designs/ControlView.jsx +43 -36
- package/src/designs/ListView.jsx +95 -0
- package/src/designs/PropertyPanel.jsx +187 -13
- package/src/designs/ScreenModal.jsx +42 -0
- package/src/designs/ScreenSample.jsx +49 -9
- package/src/designs/factory.css +68 -20
- package/src/designs/index.js +2 -0
- package/src/designs/styles.js +59 -0
- package/src/document.js +31 -4
- package/src/generate.js +136 -25
- package/src/index.js +2 -2
- package/src/model.js +11 -5
- package/src/pages.jsx +60 -10
- package/src/remote.js +57 -0
- package/src/scaffold.js +73 -0
- package/src/tableSchema.js +225 -0
- package/src/useFactoryBuilder.js +91 -17
- package/src/useFactoryScreen.js +265 -78
- package/src/validateDocument.js +73 -5
- package/src/values.js +61 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// A FORM IS A REAL TABLE.
|
|
2
|
+
//
|
|
3
|
+
// Records are never stored as JSON: every entity has its own table, and every
|
|
4
|
+
// field of its edit screen is a column of that table, named exactly as the
|
|
5
|
+
// field. `select * from employees` is how you read them, and reporting is plain
|
|
6
|
+
// SQL.
|
|
7
|
+
//
|
|
8
|
+
// This file derives that table from a screen and writes the migration that
|
|
9
|
+
// gets the database there — the first time a CREATE TABLE, after that only what
|
|
10
|
+
// changed. The migrations are ordinary files in the app's migrations folder,
|
|
11
|
+
// written (usually by Claude) and reviewed like any other; nothing here touches
|
|
12
|
+
// a database.
|
|
13
|
+
//
|
|
14
|
+
// Conventions are the xeplr ones (see xeplr-bi 0066_dashboard_groups.sql):
|
|
15
|
+
// varchar(25) ids, quoted camelCase columns, isActive for soft delete, mtId1–4
|
|
16
|
+
// for tenancy, recordCreated/Modified Date/By for audit.
|
|
17
|
+
//
|
|
18
|
+
// ── CHANGES THAT ARE SAFE, AND CHANGES THAT ARE REFUSED ──────────────────
|
|
19
|
+
// new field ADD COLUMN
|
|
20
|
+
// wider (varchar 80 → 120, varchar → text, integer → numeric) ALTER TYPE
|
|
21
|
+
// narrower, or a different type (text → number) REFUSED — data would be
|
|
22
|
+
// lost or rejected; write that migration by hand
|
|
23
|
+
// field removed column KEPT, reported as unused — dropping data
|
|
24
|
+
// is a deliberate migration, never a side effect
|
|
25
|
+
// field renamed seen as a NEW column beside an unused old one, with
|
|
26
|
+
// the data left behind — which is why a field's name is
|
|
27
|
+
// locked once its table exists (see the builder's
|
|
28
|
+
// lockedNames). A real rename is a manual migration.
|
|
29
|
+
|
|
30
|
+
import { CONTROLS } from './controls.js'
|
|
31
|
+
import { inputNodes } from './document.js'
|
|
32
|
+
|
|
33
|
+
/** Columns every entity table has, in this order. Fields may not use these names. */
|
|
34
|
+
export const STANDARD_COLUMNS = [
|
|
35
|
+
{ name: 'id', sql: 'varchar(25) PRIMARY KEY' },
|
|
36
|
+
{ name: 'isActive', sql: 'boolean DEFAULT true' },
|
|
37
|
+
{ name: 'mtId1', sql: 'varchar(25)' },
|
|
38
|
+
{ name: 'mtId2', sql: 'varchar(25)' },
|
|
39
|
+
{ name: 'mtId3', sql: 'varchar(25)' },
|
|
40
|
+
{ name: 'mtId4', sql: 'varchar(25)' },
|
|
41
|
+
{ name: 'recordCreatedDate', sql: 'timestamp DEFAULT now()' },
|
|
42
|
+
{ name: 'recordModifiedDate', sql: 'timestamp DEFAULT now()' },
|
|
43
|
+
{ name: 'recordCreatedBy', sql: 'varchar(25)' },
|
|
44
|
+
{ name: 'recordModifiedBy', sql: 'varchar(25)' }
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
export const RESERVED_COLUMNS = STANDARD_COLUMNS.map((c) => c.name)
|
|
48
|
+
|
|
49
|
+
/** Postgres truncates identifiers past this — two long fields would collide silently. */
|
|
50
|
+
export const MAX_IDENTIFIER = 63
|
|
51
|
+
|
|
52
|
+
const TEXT_DEFAULT_LENGTH = 255
|
|
53
|
+
const OPTION_MIN_LENGTH = 50
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The column a field is stored in.
|
|
57
|
+
* @returns {{ name, type: 'varchar'|'text'|'integer'|'numeric'|'date'|'boolean', length?, references?, notNull?, default? }}
|
|
58
|
+
*/
|
|
59
|
+
export function columnForField(node) {
|
|
60
|
+
const p = node.props || {}
|
|
61
|
+
const v = p.validation || {}
|
|
62
|
+
const base = { name: p.name, label: p.label }
|
|
63
|
+
switch (node.type) {
|
|
64
|
+
case 'text':
|
|
65
|
+
return { ...base, type: 'varchar', length: v.maxLength || TEXT_DEFAULT_LENGTH }
|
|
66
|
+
case 'textarea':
|
|
67
|
+
return { ...base, type: 'text' }
|
|
68
|
+
case 'number':
|
|
69
|
+
return { ...base, type: v.integer ? 'integer' : 'numeric' }
|
|
70
|
+
case 'date':
|
|
71
|
+
return { ...base, type: 'date' }
|
|
72
|
+
case 'checkbox':
|
|
73
|
+
return { ...base, type: 'boolean', notNull: true, default: 'false' }
|
|
74
|
+
case 'dropdown': {
|
|
75
|
+
const data = p.data || {}
|
|
76
|
+
if (data.source === 'table') {
|
|
77
|
+
// A real foreign key to the other entity's id.
|
|
78
|
+
return { ...base, type: 'varchar', length: 25, references: data.table }
|
|
79
|
+
}
|
|
80
|
+
const longest = (data.options || []).reduce((m, o) => Math.max(m, String(o.id).length), 0)
|
|
81
|
+
return { ...base, type: 'varchar', length: Math.max(OPTION_MIN_LENGTH, longest) }
|
|
82
|
+
}
|
|
83
|
+
default:
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The table an edit screen saves to.
|
|
90
|
+
* @returns {{ table, columns }} — columns are the fields, in reading order
|
|
91
|
+
*/
|
|
92
|
+
export function tableForScreen(doc, controls = CONTROLS) {
|
|
93
|
+
if (!doc || !doc.source) throw new Error('The screen has no "source" — which table does it save to?')
|
|
94
|
+
const columns = inputNodes(doc, controls).map(columnForField).filter(Boolean)
|
|
95
|
+
if (!columns.length) throw new Error(`Screen "${doc.id}" has no fields, so it defines no table columns — generate the migration from the edit screen`)
|
|
96
|
+
return { table: doc.source, columns }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function columnSql(col) {
|
|
100
|
+
const type = col.type === 'varchar' ? `varchar(${col.length})` : col.type
|
|
101
|
+
const parts = [type]
|
|
102
|
+
if (col.references) parts.push(`REFERENCES ${q(col.references)}("id")`)
|
|
103
|
+
if (col.notNull) parts.push('NOT NULL')
|
|
104
|
+
if (col.default !== undefined) parts.push(`DEFAULT ${col.default}`)
|
|
105
|
+
return parts.join(' ')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Is changing `from` to `to` safe for data already in the column? */
|
|
109
|
+
export function widening(from, to) {
|
|
110
|
+
if (from.references || to.references) {
|
|
111
|
+
if (from.references !== to.references) return { ok: false, reason: `it points at "${from.references || 'no table'}" and would point at "${to.references || 'no table'}"` }
|
|
112
|
+
}
|
|
113
|
+
if (from.type === to.type) {
|
|
114
|
+
if (from.type !== 'varchar') return { ok: true, same: true }
|
|
115
|
+
if (to.length === from.length) return { ok: true, same: true }
|
|
116
|
+
if (to.length > from.length) return { ok: true }
|
|
117
|
+
return { ok: false, reason: `varchar(${from.length}) → varchar(${to.length}) would cut longer values` }
|
|
118
|
+
}
|
|
119
|
+
if (from.type === 'varchar' && to.type === 'text') return { ok: true }
|
|
120
|
+
if (from.type === 'integer' && to.type === 'numeric') return { ok: true }
|
|
121
|
+
return { ok: false, reason: `${describe(from)} → ${describe(to)} is a different kind of value` }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What it takes to go from the table as it was to the table as the screen now
|
|
126
|
+
* describes it.
|
|
127
|
+
* @param before { table, columns } or null for a new table
|
|
128
|
+
* @returns {{ create, add, alter, unused, refused, same }}
|
|
129
|
+
*/
|
|
130
|
+
export function diffTables(before, after) {
|
|
131
|
+
if (before && before.table !== after.table) {
|
|
132
|
+
return { create: false, add: [], alter: [], unused: [], same: false, refused: [{ column: null, reason: `the screen now saves to "${after.table}" instead of "${before.table}" — moving records to another table is a manual migration` }] }
|
|
133
|
+
}
|
|
134
|
+
if (!before) return { create: true, add: after.columns, alter: [], unused: [], refused: [], same: false }
|
|
135
|
+
const was = Object.fromEntries(before.columns.map((c) => [c.name, c]))
|
|
136
|
+
const now = Object.fromEntries(after.columns.map((c) => [c.name, c]))
|
|
137
|
+
const add = []
|
|
138
|
+
const alter = []
|
|
139
|
+
const refused = []
|
|
140
|
+
after.columns.forEach((c) => {
|
|
141
|
+
const old = was[c.name]
|
|
142
|
+
if (!old) { add.push(c); return }
|
|
143
|
+
const w = widening(old, c)
|
|
144
|
+
if (!w.ok) refused.push({ column: c.name, reason: w.reason })
|
|
145
|
+
else if (!w.same) alter.push({ from: old, to: c })
|
|
146
|
+
})
|
|
147
|
+
const unused = before.columns.filter((c) => !now[c.name])
|
|
148
|
+
return { create: false, add, alter, unused, refused, same: !add.length && !alter.length && !unused.length && !refused.length }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The migration SQL from one version of an edit screen to the next.
|
|
153
|
+
* @param previous the edit screen as last migrated, or null
|
|
154
|
+
* @param next the edit screen now
|
|
155
|
+
* @returns {{ sql, diff, table, empty }} — throws if a change is refused
|
|
156
|
+
*/
|
|
157
|
+
export function migrationFor(previous, next, controls = CONTROLS) {
|
|
158
|
+
const after = tableForScreen(next, controls)
|
|
159
|
+
const before = previous ? tableForScreen(previous, controls) : null
|
|
160
|
+
const diff = diffTables(before, after)
|
|
161
|
+
if (diff.refused.length) {
|
|
162
|
+
const e = new Error(`Cannot migrate "${after.table}" safely:\n` + diff.refused.map((r) => ` ${r.column ? `"${r.column}": ` : ''}${r.reason}`).join('\n'))
|
|
163
|
+
e.refused = diff.refused
|
|
164
|
+
throw e
|
|
165
|
+
}
|
|
166
|
+
if (diff.same || (!diff.create && !diff.add.length && !diff.alter.length)) {
|
|
167
|
+
return { sql: '', diff, table: after.table, empty: true }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const t = after.table
|
|
171
|
+
const lines = []
|
|
172
|
+
const header = [
|
|
173
|
+
`-- ${diff.create ? 'Create' : 'Update'} "${t}" for screen "${next.id}" (${next.name}).`,
|
|
174
|
+
'--',
|
|
175
|
+
'-- Written from the screen by @xeplr/ui-factory: one column per field, named'
|
|
176
|
+
, '-- exactly as the field. Review before applying.'
|
|
177
|
+
]
|
|
178
|
+
if (diff.unused.length) {
|
|
179
|
+
header.push('--', `-- Kept, no longer on the screen (drop by hand if the data is not needed): ${diff.unused.map((c) => `"${c.name}"`).join(', ')}`)
|
|
180
|
+
}
|
|
181
|
+
lines.push(...header, '')
|
|
182
|
+
|
|
183
|
+
if (diff.create) {
|
|
184
|
+
const cols = [...STANDARD_COLUMNS.slice(0, 1), ...after.columns.map((c) => ({ name: c.name, sql: columnSql(c), label: c.label })), ...STANDARD_COLUMNS.slice(1)]
|
|
185
|
+
const width = Math.max(...cols.map((c) => c.name.length)) + 2
|
|
186
|
+
lines.push(`CREATE TABLE IF NOT EXISTS ${q(t)} (`)
|
|
187
|
+
cols.forEach((c, i) => {
|
|
188
|
+
const comma = i < cols.length - 1 ? ',' : ''
|
|
189
|
+
const note = c.label ? ` -- ${c.label}` : ''
|
|
190
|
+
lines.push(` ${q(c.name).padEnd(width)} ${c.sql}${comma}${note}`)
|
|
191
|
+
})
|
|
192
|
+
lines.push(');', '')
|
|
193
|
+
lines.push(`CREATE INDEX IF NOT EXISTS ${q(`${t}_mt_index`)} ON ${q(t)} ("mtId1", "mtId2");`)
|
|
194
|
+
after.columns.filter((c) => c.references).forEach((c) => {
|
|
195
|
+
lines.push(`CREATE INDEX IF NOT EXISTS ${q(`${t}_${c.name}_index`)} ON ${q(t)} (${q(c.name)});`)
|
|
196
|
+
})
|
|
197
|
+
} else {
|
|
198
|
+
diff.add.forEach((c) => {
|
|
199
|
+
lines.push(`ALTER TABLE ${q(t)} ADD COLUMN IF NOT EXISTS ${q(c.name)} ${columnSql(c)}; -- ${c.label}`)
|
|
200
|
+
if (c.references) lines.push(`CREATE INDEX IF NOT EXISTS ${q(`${t}_${c.name}_index`)} ON ${q(t)} (${q(c.name)});`)
|
|
201
|
+
})
|
|
202
|
+
diff.alter.forEach(({ from, to }) => {
|
|
203
|
+
const type = to.type === 'varchar' ? `varchar(${to.length})` : to.type
|
|
204
|
+
lines.push(`ALTER TABLE ${q(t)} ALTER COLUMN ${q(to.name)} TYPE ${type}; -- was ${describe(from)}`)
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
return { sql: lines.join('\n') + '\n', diff, table: t, empty: false }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Next migration file name in a folder of NNNN_*.sql files. */
|
|
211
|
+
export function nextMigrationName(existingFiles, table, create) {
|
|
212
|
+
const top = (existingFiles || []).reduce((m, f) => {
|
|
213
|
+
const n = /^(\d+)_/.exec(f)
|
|
214
|
+
return n ? Math.max(m, Number(n[1])) : m
|
|
215
|
+
}, 0)
|
|
216
|
+
return `${String(top + 1).padStart(4, '0')}_factory_${table}_${create ? 'create' : 'update'}.sql`
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function describe(c) {
|
|
220
|
+
return c.type === 'varchar' ? `varchar(${c.length})` : c.type
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function q(identifier) {
|
|
224
|
+
return `"${String(identifier).replace(/"/g, '""')}"`
|
|
225
|
+
}
|
package/src/useFactoryBuilder.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { CONTROLS, controlGroups } from './controls.js'
|
|
3
3
|
import {
|
|
4
|
-
createScreen, renameScreen, addControl as addControlTo, moveNode, setNodeProperty,
|
|
4
|
+
createScreen, renameScreen, setScreenProperty, addControl as addControlTo, moveNode, setNodeProperty,
|
|
5
5
|
removeNodes
|
|
6
6
|
} from './document.js'
|
|
7
7
|
import { validateDocument } from './validateDocument.js'
|
|
@@ -14,36 +14,47 @@ import { validateDocument } from './validateDocument.js'
|
|
|
14
14
|
// the `document` it is handed and gives the result to `onSave`. A document
|
|
15
15
|
// handed in LATER (Claude regenerated the screen, another record was opened)
|
|
16
16
|
// replaces what is being edited.
|
|
17
|
+
//
|
|
18
|
+
// NO SAVE BUTTON. Every edit is saved a moment after the last one, whenever the
|
|
19
|
+
// screen is valid; while it is not, the problems are shown and nothing is sent.
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
22
|
* @param document the screen to edit; omitted → a new empty screen
|
|
20
23
|
* @param name the name for a new screen (when no document)
|
|
21
|
-
* @param onSave async (document) → void; called only with a valid document
|
|
24
|
+
* @param onSave async (document) → void; called automatically, only with a valid document
|
|
25
|
+
* @param autosaveDelay ms of quiet after the last edit before saving (default 800)
|
|
22
26
|
* @param onChange (document) → void; every edit, for hosts that autosave or preview
|
|
23
27
|
* @param listTables async () → [{ id, name }] | string[]; offered in a dropdown's
|
|
24
28
|
* "from a table" picker. Omitted → table sources can still be typed.
|
|
29
|
+
* @param screens [{ id, name, document? }] — other screens, offered for a list's "Edit in";
|
|
30
|
+
* with its document, a list screen can pick columns from its fields
|
|
31
|
+
* @param lockedNames field names that are already columns of the table — they cannot be renamed
|
|
32
|
+
* @param onPublish async (document) → { version } — makes the saved draft the version everyone sees.
|
|
33
|
+
* It may refuse (e.g. the table is missing columns) by throwing; the message is shown.
|
|
25
34
|
* @param controls the control registry (default: the built-ins)
|
|
26
35
|
*/
|
|
27
|
-
export function useFactoryBuilder({ document: given, name, onSave, onChange, listTables, controls = CONTROLS } = {}) {
|
|
36
|
+
export function useFactoryBuilder({ document: given, name, onSave, onChange, onPublish, listTables, screens, lockedNames, controls = CONTROLS, autosaveDelay = 800 } = {}) {
|
|
28
37
|
const [doc, setDoc] = useState(() => given || createScreen({ name }))
|
|
29
38
|
const [selected, setSelected] = useState(() => new Set())
|
|
30
39
|
const [dirty, setDirty] = useState(false)
|
|
31
40
|
const [saving, setSaving] = useState(false)
|
|
32
41
|
const [saveError, setSaveError] = useState(null)
|
|
33
|
-
const [
|
|
42
|
+
const [savedAt, setSavedAt] = useState(null)
|
|
34
43
|
const docRef = useRef(doc); docRef.current = doc
|
|
35
44
|
|
|
36
|
-
// A NEW document from outside replaces the draft.
|
|
37
|
-
//
|
|
45
|
+
// A NEW document from outside replaces the draft. Not a new OBJECT: a host
|
|
46
|
+
// that re-reads what it just saved hands back the same screen as a fresh
|
|
47
|
+
// object on every autosave, and replacing the draft with it would drop the
|
|
48
|
+
// selection each time. Only different content replaces what is being edited.
|
|
38
49
|
const lastGiven = useRef(given)
|
|
39
50
|
useEffect(() => {
|
|
40
51
|
if (!given || given === lastGiven.current) return
|
|
41
52
|
lastGiven.current = given
|
|
53
|
+
if (JSON.stringify(given) === JSON.stringify(docRef.current)) return
|
|
42
54
|
docRef.current = given
|
|
43
55
|
setDoc(given)
|
|
44
56
|
setSelected(new Set())
|
|
45
57
|
setDirty(false)
|
|
46
|
-
setShowErrors(false)
|
|
47
58
|
}, [given])
|
|
48
59
|
|
|
49
60
|
// Every edit goes through here. It works on a REF of the current document,
|
|
@@ -82,6 +93,8 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, lis
|
|
|
82
93
|
|
|
83
94
|
// ── edits ─────────────────────────────────────────────────────────────
|
|
84
95
|
const rename = useCallback((value) => update((d) => renameScreen(d, value)), [update])
|
|
96
|
+
/** A screen property by path — `source`, `width`, `style.fontSize`. */
|
|
97
|
+
const setScreen = useCallback((path, value) => update((d) => setScreenProperty(d, path, value)), [update])
|
|
85
98
|
|
|
86
99
|
/** Adds a control and selects it, so its properties are open straight away. */
|
|
87
100
|
const addControl = useCallback((type, at) => {
|
|
@@ -97,7 +110,8 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, lis
|
|
|
97
110
|
|
|
98
111
|
const moveControl = useCallback((id, patch) => update((d) => moveNode(d, id, patch)), [update])
|
|
99
112
|
|
|
100
|
-
const
|
|
113
|
+
const lockedRef = useRef(lockedNames); lockedRef.current = lockedNames
|
|
114
|
+
const setProperty = useCallback((id, path, value) => update((d) => setNodeProperty(d, id, path, value, controls, { lockedNames: lockedRef.current })), [update, controls])
|
|
101
115
|
|
|
102
116
|
const selectedRef = useRef(selected); selectedRef.current = selected
|
|
103
117
|
const removeSelected = useCallback(() => {
|
|
@@ -144,24 +158,75 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, lis
|
|
|
144
158
|
}, [validation, doc.nodes])
|
|
145
159
|
|
|
146
160
|
const onSaveRef = useRef(onSave); onSaveRef.current = onSave
|
|
161
|
+
const savingRef = useRef(false)
|
|
147
162
|
const save = useCallback(async () => {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (!
|
|
163
|
+
const current = docRef.current
|
|
164
|
+
const check = validateDocument(current, controls)
|
|
165
|
+
if (!check.ok) return { ok: false, errors: check.errors }
|
|
151
166
|
if (!onSaveRef.current) return { ok: false, errors: [{ path: '', message: 'No onSave was provided' }] }
|
|
167
|
+
if (savingRef.current) return { ok: false, busy: true }
|
|
168
|
+
savingRef.current = true
|
|
152
169
|
setSaving(true)
|
|
170
|
+
setSaveError(null)
|
|
153
171
|
try {
|
|
154
|
-
await onSaveRef.current(
|
|
155
|
-
|
|
156
|
-
|
|
172
|
+
await onSaveRef.current(current)
|
|
173
|
+
// Only clean if nothing changed while the save was out.
|
|
174
|
+
if (docRef.current === current) setDirty(false)
|
|
175
|
+
setSavedAt(new Date())
|
|
157
176
|
return { ok: true }
|
|
158
177
|
} catch (err) {
|
|
159
178
|
setSaveError(err.message || 'Could not save')
|
|
160
179
|
return { ok: false, errors: [{ path: '', message: err.message }] }
|
|
161
180
|
} finally {
|
|
181
|
+
savingRef.current = false
|
|
162
182
|
setSaving(false)
|
|
163
183
|
}
|
|
164
|
-
}, [
|
|
184
|
+
}, [controls])
|
|
185
|
+
|
|
186
|
+
// AUTOSAVE: a quiet moment after the last edit, if the screen is valid.
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (!dirty || !validation.ok || !onSaveRef.current) return undefined
|
|
189
|
+
const t = setTimeout(save, autosaveDelay)
|
|
190
|
+
return () => clearTimeout(t)
|
|
191
|
+
}, [doc, dirty, validation.ok, save, autosaveDelay, saving])
|
|
192
|
+
|
|
193
|
+
// PUBLISH: the draft becomes the next version — what screens actually show.
|
|
194
|
+
// Deliberate, unlike saving: a half-finished design must not reach everyone
|
|
195
|
+
// filling the form in.
|
|
196
|
+
const onPublishRef = useRef(onPublish); onPublishRef.current = onPublish
|
|
197
|
+
const [publishing, setPublishing] = useState(false)
|
|
198
|
+
const [publishResult, setPublishResult] = useState(null) // { ok, message, version?, detail? }
|
|
199
|
+
const publish = useCallback(async () => {
|
|
200
|
+
if (!onPublishRef.current) return { ok: false }
|
|
201
|
+
const check = validateDocument(docRef.current, controls)
|
|
202
|
+
if (!check.ok) {
|
|
203
|
+
const r = { ok: false, message: `Fix ${check.errors.length} problem${check.errors.length === 1 ? '' : 's'} before publishing` }
|
|
204
|
+
setPublishResult(r)
|
|
205
|
+
return r
|
|
206
|
+
}
|
|
207
|
+
setPublishing(true)
|
|
208
|
+
setPublishResult(null)
|
|
209
|
+
try {
|
|
210
|
+
const saved = await save()
|
|
211
|
+
if (!saved.ok && !saved.busy) throw new Error((saved.errors && saved.errors[0] && saved.errors[0].message) || 'Could not save the draft')
|
|
212
|
+
const res = await onPublishRef.current(docRef.current)
|
|
213
|
+
const r = { ok: true, version: res && res.version, message: res && res.version ? `Published version ${res.version}` : 'Published' }
|
|
214
|
+
setPublishResult(r)
|
|
215
|
+
return r
|
|
216
|
+
} catch (err) {
|
|
217
|
+
const r = { ok: false, message: err.message || 'Could not publish', detail: err.detail }
|
|
218
|
+
setPublishResult(r)
|
|
219
|
+
return r
|
|
220
|
+
} finally {
|
|
221
|
+
setPublishing(false)
|
|
222
|
+
}
|
|
223
|
+
}, [controls, save])
|
|
224
|
+
|
|
225
|
+
const status = saveError ? 'error'
|
|
226
|
+
: saving ? 'saving'
|
|
227
|
+
: !validation.ok ? 'invalid'
|
|
228
|
+
: dirty ? 'pending'
|
|
229
|
+
: savedAt ? 'saved' : 'idle'
|
|
165
230
|
|
|
166
231
|
// ── keyboard: Delete / Backspace removes, Cmd/Ctrl+D duplicates ────────
|
|
167
232
|
useEffect(() => {
|
|
@@ -192,18 +257,27 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, lis
|
|
|
192
257
|
selectedNode,
|
|
193
258
|
selectedControl: selectedNode ? controls[selectedNode.type] : null,
|
|
194
259
|
rename,
|
|
260
|
+
setScreen,
|
|
195
261
|
addControl,
|
|
196
262
|
moveControl,
|
|
197
263
|
setProperty,
|
|
198
264
|
removeSelected,
|
|
199
265
|
duplicateSelected,
|
|
200
266
|
tables,
|
|
267
|
+
screens: screens || [],
|
|
268
|
+
lockedNames: lockedNames || [],
|
|
201
269
|
validation,
|
|
202
270
|
errorsByNode,
|
|
203
|
-
showErrors,
|
|
204
271
|
dirty,
|
|
205
272
|
saving,
|
|
206
273
|
saveError,
|
|
207
|
-
|
|
274
|
+
savedAt,
|
|
275
|
+
status,
|
|
276
|
+
save,
|
|
277
|
+
canPublish: Boolean(onPublish),
|
|
278
|
+
publish,
|
|
279
|
+
publishing,
|
|
280
|
+
publishResult,
|
|
281
|
+
clearPublishResult: () => setPublishResult(null)
|
|
208
282
|
}
|
|
209
283
|
}
|