@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.
@@ -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
+ }
@@ -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 [showErrors, setShowErrors] = useState(false)
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. Compared by identity: the
37
- // host re-rendering with the same object must not throw away edits.
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 setProperty = useCallback((id, path, value) => update((d) => setNodeProperty(d, id, path, value, controls)), [update, controls])
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
- setShowErrors(true)
149
- setSaveError(null)
150
- if (!validation.ok) return { ok: false, errors: validation.errors }
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(doc)
155
- setDirty(false)
156
- setShowErrors(false)
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
- }, [doc, validation])
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
- save
274
+ savedAt,
275
+ status,
276
+ save,
277
+ canPublish: Boolean(onPublish),
278
+ publish,
279
+ publishing,
280
+ publishResult,
281
+ clearPublishResult: () => setPublishResult(null)
208
282
  }
209
283
  }