@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,59 @@
1
+ // A control's `props.style` → CSS.
2
+ //
3
+ // Sizes are PIXELS at the screen's design width. `--xf-u` is one such pixel: a
4
+ // real pixel at the design width or wider, less on a narrower screen (see
5
+ // factory.css). So text stays exactly the size somebody chose, and only ever
6
+ // shrinks to keep the layout's proportions.
7
+
8
+ const u = (n) => `calc(${n} * var(--xf-u))`
9
+
10
+ /** Wrapper-level: the font the whole control uses. */
11
+ export function fieldStyle(style) {
12
+ const s = style || {}
13
+ return clean({ fontFamily: s.fontFamily })
14
+ }
15
+
16
+ /** The label above an input. */
17
+ export function labelStyle(style) {
18
+ const s = style || {}
19
+ return clean({
20
+ fontSize: s.labelFontSize != null ? u(s.labelFontSize) : undefined,
21
+ fontWeight: s.labelFontWeight,
22
+ color: s.labelColor
23
+ })
24
+ }
25
+
26
+ /** The input box itself, or a label control's text block. */
27
+ export function boxStyle(style) {
28
+ const s = style || {}
29
+ return clean({
30
+ fontSize: s.fontSize != null ? u(s.fontSize) : undefined,
31
+ fontWeight: s.fontWeight,
32
+ fontStyle: s.fontStyle,
33
+ textAlign: s.textAlign,
34
+ color: s.color,
35
+ background: s.background,
36
+ borderColor: s.borderColor,
37
+ borderWidth: s.borderWidth != null ? `${s.borderWidth}px` : undefined,
38
+ borderStyle: s.borderWidth != null ? 'solid' : undefined,
39
+ borderRadius: s.borderRadius != null ? u(s.borderRadius) : undefined
40
+ })
41
+ }
42
+
43
+ /** The screen's defaults, on its container: what every control inherits. */
44
+ export function screenStyle(doc) {
45
+ const s = doc.style || {}
46
+ return clean({
47
+ '--xf-width': doc.width,
48
+ '--xf-font': s.fontSize,
49
+ fontFamily: s.fontFamily,
50
+ color: s.color,
51
+ background: s.background
52
+ })
53
+ }
54
+
55
+ function clean(obj) {
56
+ const out = {}
57
+ Object.keys(obj).forEach((k) => { if (obj[k] !== undefined && obj[k] !== null && obj[k] !== '') out[k] = obj[k] })
58
+ return out
59
+ }
package/src/document.js CHANGED
@@ -5,7 +5,10 @@
5
5
  // {
6
6
  // kind: 'xeplr-screen', version: 1,
7
7
  // id: 'new_employee', name: 'New employee',
8
+ // source?: 'employees', ← the table records are saved to (the app's)
8
9
  // units: 'fraction', aspect: 1,
10
+ // width: 800, ← design width in px: styles are px at this width
11
+ // style?: { fontFamily, fontSize, color, background }, ← screen-wide defaults
9
12
  // nodes: [ { id, type, x, y, w, h, z?, groupId?, props } ]
10
13
  // }
11
14
  //
@@ -22,29 +25,44 @@ import { getAtPath, setAtPath } from './propertyPath.js'
22
25
  export const DOCUMENT_KIND = 'xeplr-screen'
23
26
  export const DOCUMENT_VERSION = 1
24
27
  export const DEFAULT_ASPECT = 1
28
+ /** Px. Styles are sizes at this width; narrower shrinks them, wider never grows them. */
29
+ export const DEFAULT_WIDTH = 800
25
30
 
26
31
  /** Space kept around and between controls, as fractions. */
27
32
  export const MARGIN = 0.04
28
33
  export const GAP = 0.025
29
34
 
30
35
  /** A new, empty screen. */
31
- export function createScreen({ name, id, aspect } = {}) {
36
+ export function createScreen({ name, id, aspect, width, source, style } = {}) {
32
37
  const title = String(name || 'Untitled screen').trim() || 'Untitled screen'
33
- return {
38
+ const doc = {
34
39
  kind: DOCUMENT_KIND,
35
40
  version: DOCUMENT_VERSION,
36
41
  id: id || slugify(title, '_') || 'screen',
37
42
  name: title,
38
43
  units: 'fraction',
39
44
  aspect: aspect > 0 ? aspect : DEFAULT_ASPECT,
45
+ width: width > 0 ? width : DEFAULT_WIDTH,
40
46
  nodes: []
41
47
  }
48
+ if (source) doc.source = source
49
+ if (style && Object.keys(style).length) doc.style = style
50
+ return doc
42
51
  }
43
52
 
44
53
  export function renameScreen(doc, name) {
45
54
  return { ...doc, name: String(name == null ? '' : name) }
46
55
  }
47
56
 
57
+ /**
58
+ * Sets a SCREEN property by path — `source`, `width`, `style.fontSize`. A blank
59
+ * value removes the key, like a control's properties.
60
+ */
61
+ export function setScreenProperty(doc, path, value) {
62
+ if (path === 'nodes' || path.startsWith('nodes.')) throw new Error('setScreenProperty cannot change nodes')
63
+ return setAtPath(doc, path, value)
64
+ }
65
+
48
66
  /**
49
67
  * Adds a control of `type`. Placed at `at` ({ x, y } — the drop point, as the
50
68
  * control's top-left) or, without one, below everything already on the screen.
@@ -87,10 +105,19 @@ export function moveNode(doc, id, patch) {
87
105
  * name is still the one made for it: a dropdown dropped as "Dropdown" and
88
106
  * relabelled "Location" should save as `location`, not `dropdown`. A name
89
107
  * someone typed is theirs, and is left alone.
108
+ *
109
+ * A LOCKED name — one that is already a column of the table — never changes:
110
+ * not by typing, and not by following the label. Renaming it would start a new
111
+ * empty column and strand the saved data in the old one.
112
+ *
113
+ * @param options.lockedNames field names that are columns already
90
114
  */
91
- export function setNodeProperty(doc, id, path, value, controls = CONTROLS) {
115
+ export function setNodeProperty(doc, id, path, value, controls = CONTROLS, options = {}) {
92
116
  const node = doc.nodes.find((n) => n.id === id)
93
- const followLabel = path === 'props.label' && node && controls[node.type]?.input && isAutoName(node, controls)
117
+ const locked = options.lockedNames ? new Set(options.lockedNames) : null
118
+ const isLocked = Boolean(node && locked && locked.has(node.props?.name))
119
+ if (path === 'props.name' && isLocked) return doc
120
+ const followLabel = path === 'props.label' && node && !isLocked && controls[node.type]?.input && isAutoName(node, controls)
94
121
  let next = mapNode(doc, id, (n) => {
95
122
  const updated = setAtPath(n, path, value)
96
123
  // setAtPath prunes an emptied object; `props` itself must always exist.
package/src/generate.js CHANGED
@@ -24,15 +24,15 @@
24
24
  // there it is edited like any other.
25
25
 
26
26
  import { CONTROLS } from './controls.js'
27
- import { createScreen, addControl, MARGIN, GAP, slugify } from './document.js'
27
+ import { createScreen, addControl, MARGIN, GAP, slugify, camelName } from './document.js'
28
28
  import { assertValidDocument } from './validateDocument.js'
29
29
 
30
30
  /** Heights by control, as page fractions (a page is as tall as it is wide). */
31
31
  export const SPEC_HEIGHTS = {
32
32
  text: 0.08, number: 0.08, date: 0.08, dropdown: 0.08,
33
33
  textarea: 0.18,
34
- checkbox: 0.06,
35
- button: 0.07,
34
+ checkbox: 0.05,
35
+ list: 0.42,
36
36
  label: { heading: 0.07, subheading: 0.055, text: 0.045 }
37
37
  }
38
38
 
@@ -42,8 +42,9 @@ export const SPEC_HEIGHTS = {
42
42
  */
43
43
  export const ROW_GAP = 0.04
44
44
 
45
- const FIELD_KEYS = ['type', 'label', 'name', 'required', 'placeholder', 'default', 'validation', 'data', 'options', 'table', 'width', 'text', 'variant']
46
- const SPEC_KEYS = ['name', 'id', 'columns', 'heading', 'fields', 'submit', 'reset', 'aspect']
45
+ const FIELD_KEYS = ['type', 'label', 'name', 'required', 'placeholder', 'default', 'validation', 'data', 'options', 'table', 'width', 'text', 'variant', 'style']
46
+ const SPEC_KEYS = ['name', 'id', 'source', 'columns', 'heading', 'fields', 'list', 'aspect', 'width', 'style']
47
+ const LIST_KEYS = ['title', 'source', 'columns', 'pageSize', 'actions', 'style']
47
48
 
48
49
  /**
49
50
  * @param spec
@@ -55,8 +56,13 @@ const SPEC_KEYS = ['name', 'id', 'columns', 'heading', 'fields', 'submit', 'rese
55
56
  * default?, validation?, width?: 'half'|'full' }
56
57
  * dropdown: options: ['Full time', …] or [{ id, name }] —or— table: 'departments'
57
58
  * section heading: { type: 'label', text, variant?: 'subheading' }
58
- * submit? button label (default 'Save'); false for no submit button
59
- * reset? reset button label; omitted/false for none
59
+ * source? the table records are saved to, e.g. 'employees'
60
+ * width? design width in px (default 800) — style sizes are px at this width
61
+ * style? screen-wide defaults: { fontFamily, fontSize, color, background }
62
+ * list? true, or { title, source, columns: [{ field, label }], pageSize, actions } —
63
+ * a list of the saved records below the fields, with New / Edit / Delete
64
+ *
65
+ * There is no submit button: a screen saves itself as it is filled in.
60
66
  */
61
67
  export function screenFromSpec(spec, controls = CONTROLS) {
62
68
  if (!spec || typeof spec !== 'object') throw new Error('screenFromSpec: spec must be an object')
@@ -66,7 +72,7 @@ export function screenFromSpec(spec, controls = CONTROLS) {
66
72
  const columns = spec.columns === undefined ? 2 : spec.columns
67
73
  if (columns !== 1 && columns !== 2) throw new Error('screenFromSpec: spec.columns must be 1 or 2')
68
74
 
69
- let doc = createScreen({ name: spec.name, id: spec.id, aspect: spec.aspect })
75
+ let doc = createScreen({ name: spec.name, id: spec.id, aspect: spec.aspect, width: spec.width, source: spec.source, style: spec.style })
70
76
  const full = 1 - 2 * MARGIN
71
77
  const colW = round((full - (columns - 1) * GAP) / columns)
72
78
  let y = MARGIN
@@ -75,7 +81,7 @@ export function screenFromSpec(spec, controls = CONTROLS) {
75
81
 
76
82
  const place = (type, props, width) => {
77
83
  const h = heightOf(type, props)
78
- const isFull = width === 'full' || columns === 1 || type === 'textarea' || type === 'label'
84
+ const isFull = width === 'full' || columns === 1 || type === 'textarea' || type === 'label' || type === 'list'
79
85
  if (isFull) {
80
86
  if (col > 0) { y += rowH + ROW_GAP; col = 0; rowH = 0 }
81
87
  ;({ document: doc } = addControl(doc, type, { at: { x: MARGIN, y: round(y) }, size: { w: round(full), h }, props }, controls))
@@ -105,34 +111,35 @@ export function screenFromSpec(spec, controls = CONTROLS) {
105
111
 
106
112
  if (col > 0) { y += rowH + ROW_GAP; col = 0; rowH = 0 }
107
113
 
108
- // One row of buttons, left-aligned. Narrow on purpose: a full-width Save
109
- // reads as a banner, not a button.
110
- const buttons = []
111
- if (spec.submit !== false) buttons.push({ label: typeof spec.submit === 'string' ? spec.submit : 'Save', action: 'submit' })
112
- if (spec.reset) buttons.push({ label: typeof spec.reset === 'string' ? spec.reset : 'Reset', action: 'reset' })
113
- const buttonW = 0.18
114
- buttons.forEach((props, i) => {
115
- const at = { x: round(MARGIN + i * (buttonW + GAP)), y: round(y) }
116
- ;({ document: doc } = addControl(doc, 'button', { at, size: { w: buttonW, h: SPEC_HEIGHTS.button }, props }, controls))
117
- })
114
+ if (spec.list) {
115
+ const list = spec.list === true ? {} : spec.list
116
+ if (typeof list !== 'object') throw new Error('screenFromSpec: spec.list must be true or an object')
117
+ unknownKeys(list, LIST_KEYS, 'spec.list')
118
+ const props = { title: 'Saved records', pageSize: 10, actions: ['new', 'edit', 'delete'], ...list }
119
+ place('list', props, 'full')
120
+ }
118
121
 
119
122
  return assertValidDocument(doc, controls)
120
123
  }
121
124
 
122
125
  function propsFromField(type, field, at) {
123
126
  if (type === 'label') {
124
- return { text: field.text ?? field.label ?? '', variant: field.variant || 'subheading' }
125
- }
126
- if (type === 'button') {
127
- return { label: field.label || 'Save', action: 'submit' }
127
+ const props = { text: field.text ?? field.label ?? '', variant: field.variant || 'subheading' }
128
+ if (field.style) props.style = field.style
129
+ return props
128
130
  }
129
131
  if (!field.label) throw new Error(`screenFromSpec: ${at}.label is required`)
130
132
  const props = { label: field.label }
131
- ;['name', 'required', 'placeholder', 'default', 'validation'].forEach((k) => {
133
+ ;['name', 'required', 'placeholder', 'default', 'validation', 'style'].forEach((k) => {
132
134
  if (field[k] !== undefined) props[k] = field[k]
133
135
  })
134
136
  if (type === 'dropdown' && props.placeholder === undefined) props.placeholder = 'Select…'
135
- if (type === 'dropdown') props.data = dataFromField(field, at)
137
+ if (type === 'dropdown') {
138
+ props.data = dataFromField(field, at)
139
+ // A dropdown reading another table stores that row's id: name the column
140
+ // for what it holds — "Department" → departmentId, a foreign key.
141
+ if (props.data.source === 'table' && props.name === undefined) props.name = camelName(field.label) + 'Id'
142
+ }
136
143
  else if (field.options || field.table || field.data) throw new Error(`screenFromSpec: ${at} — options/table/data only apply to a dropdown`)
137
144
  return props
138
145
  }
@@ -165,3 +172,107 @@ function unknownKeys(obj, allowed, at) {
165
172
  function round(v) {
166
173
  return Math.round(v * 10000) / 10000
167
174
  }
175
+
176
+ // ── an entity: a LIST screen and an EDIT screen ──────────────────────────
177
+ //
178
+ // "Create a form for employees" is two screens, not one:
179
+ //
180
+ // employee_list — the saved employees, in a list; Edit and New open…
181
+ // employee_edit — …the add / edit form, in a popup (or on its own page)
182
+ //
183
+ // The list names the edit screen (`editScreen`), which is the only link
184
+ // between them; both save to and read from the same table.
185
+
186
+ const ENTITY_KEYS = ['entity', 'plural', 'source', 'fields', 'columns', 'listColumns', 'pageSize', 'actions', 'width', 'style', 'aspect']
187
+
188
+ /**
189
+ * @param spec
190
+ * entity singular name, e.g. "employee" (required)
191
+ * plural? e.g. "employees" (default: entity + "s")
192
+ * source? table records are saved in (default: plural, snake_cased)
193
+ * fields the edit form's fields — exactly as screenFromSpec
194
+ * columns? 1 or 2 — the edit form's layout (default 2)
195
+ * listColumns? [{ field, label }] or ["firstName", …] — the list's columns
196
+ * (default: up to the first 5 fields)
197
+ * pageSize?, actions?, width?, style?
198
+ * @returns {{ list, edit }} two checked screen documents
199
+ */
200
+ export function screensFromSpec(spec, controls = CONTROLS) {
201
+ if (!spec || typeof spec !== 'object') throw new Error('screensFromSpec: spec must be an object')
202
+ unknownKeys(spec, ENTITY_KEYS, 'spec')
203
+ if (!spec.entity || typeof spec.entity !== 'string') throw new Error('screensFromSpec: spec.entity is required, e.g. "employee"')
204
+ const names = entityNames(spec.entity, spec.plural)
205
+ const source = spec.source || names.table
206
+
207
+ const edit = screenFromSpec({
208
+ name: `Add / edit ${names.singular}`,
209
+ id: `${names.key}_edit`,
210
+ source,
211
+ columns: spec.columns,
212
+ fields: spec.fields,
213
+ width: spec.width,
214
+ style: spec.style,
215
+ aspect: spec.aspect
216
+ }, controls)
217
+
218
+ const fieldNodes = edit.nodes.filter((n) => controls[n.type]?.input)
219
+ const byName = Object.fromEntries(fieldNodes.map((n) => [n.props.name, n]))
220
+ let listColumns
221
+ if (Array.isArray(spec.listColumns) && spec.listColumns.length) {
222
+ listColumns = spec.listColumns.map((c, i) => {
223
+ const field = typeof c === 'string' ? c : c && c.field
224
+ if (!byName[field]) throw new Error(`screensFromSpec: spec.listColumns[${i}] "${field}" is not a field of the edit form — one of: ${Object.keys(byName).join(', ')}`)
225
+ return { field, label: (typeof c === 'object' && c.label) || byName[field].props.label }
226
+ })
227
+ } else {
228
+ listColumns = fieldNodes.slice(0, 5).map((n) => ({ field: n.props.name, label: n.props.label }))
229
+ }
230
+
231
+ let list = createScreen({ name: names.pluralTitle, id: `${names.key}_list`, source, width: spec.width, style: spec.style, aspect: spec.aspect })
232
+ ;({ document: list } = addControl(list, 'list', {
233
+ at: { x: MARGIN, y: MARGIN },
234
+ size: { w: round(1 - 2 * MARGIN), h: 0.8 },
235
+ props: {
236
+ title: names.pluralTitle,
237
+ editScreen: edit.id,
238
+ columns: listColumns,
239
+ pageSize: spec.pageSize || 10,
240
+ actions: spec.actions || ['new', 'edit', 'delete']
241
+ }
242
+ }, controls))
243
+
244
+ return { list: assertValidDocument(list, controls), edit }
245
+ }
246
+
247
+ /**
248
+ * "employee" → the names the pair uses everywhere, so the ids, files, table and
249
+ * components always agree.
250
+ */
251
+ export function entityNames(entity, plural) {
252
+ const words = String(entity).trim().replace(/[^A-Za-z0-9]+/g, ' ').trim().split(/\s+/).filter(Boolean)
253
+ if (!words.length) throw new Error('entity must contain letters or digits')
254
+ const pluralWords = plural
255
+ ? String(plural).trim().replace(/[^A-Za-z0-9]+/g, ' ').trim().split(/\s+/).filter(Boolean)
256
+ : [...words.slice(0, -1), pluralise(words[words.length - 1])]
257
+ const lower = (ws) => ws.map((w) => w.toLowerCase())
258
+ const pascal = (ws) => ws.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join('')
259
+ return {
260
+ singular: lower(words).join(' '), // "employee"
261
+ pluralTitle: capitalise(lower(pluralWords).join(' ')), // "Employees"
262
+ key: lower(words).join('_'), // "employee" → ids employee_list / employee_edit
263
+ file: lower(words).join('-'), // "employee" → employee-list.screen.json
264
+ table: lower(pluralWords).join('_'), // "employees"
265
+ listComponent: `${pascal(words)}List`, // "EmployeeList"
266
+ editComponent: `Edit${pascal(words)}` // "EditEmployee"
267
+ }
268
+ }
269
+
270
+ function pluralise(word) {
271
+ if (/[^aeiou]y$/i.test(word)) return word.slice(0, -1) + 'ies'
272
+ if (/(s|x|z|ch|sh)$/i.test(word)) return word + 'es'
273
+ return word + 's'
274
+ }
275
+
276
+ function capitalise(s) {
277
+ return s.charAt(0).toUpperCase() + s.slice(1)
278
+ }
package/src/index.js CHANGED
@@ -10,10 +10,10 @@ export * from './model.js'
10
10
 
11
11
  // Controllers
12
12
  export { useFactoryBuilder } from './useFactoryBuilder.js'
13
- export { useFactoryScreen, normaliseOptions } from './useFactoryScreen.js'
13
+ export { useFactoryScreen, normaliseOptions, AUTOSAVE_DELAY } from './useFactoryScreen.js'
14
14
 
15
15
  // Designs
16
- export { BuilderSample, ScreenSample, ControlView, VIEWS, Palette, DRAG_TYPE, PropertyPanel, EDITORS } from './designs/index.js'
16
+ export { BuilderSample, ScreenSample, ListView, ScreenModal, ControlView, VIEWS, Palette, DRAG_TYPE, PropertyPanel, EDITORS } from './designs/index.js'
17
17
 
18
18
  // Ready-made
19
19
  export { FactoryBuilder, FactoryScreen } from './pages.jsx'
package/src/model.js CHANGED
@@ -3,13 +3,19 @@
3
3
  //
4
4
  // import { screenFromSpec, validateDocument, formSchema } from '@xeplr/ui-factory/model'
5
5
 
6
- export { CONTROLS, OPTION_ID, OPTION_LABEL, LABEL_VARIANTS, BUTTON_ACTIONS, controlGroups, withControls } from './controls.js'
6
+ export { CONTROLS, OPTION_ID, OPTION_LABEL, LABEL_VARIANTS, LABEL_PRESETS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, FONT_FAMILIES, controlGroups, withControls } from './controls.js'
7
7
  export {
8
- DOCUMENT_KIND, DOCUMENT_VERSION, DEFAULT_ASPECT, MARGIN, GAP,
9
- createScreen, renameScreen, addControl, moveNode, setNodeProperty, getNodeProperty, removeNodes,
8
+ DOCUMENT_KIND, DOCUMENT_VERSION, DEFAULT_ASPECT, DEFAULT_WIDTH, MARGIN, GAP,
9
+ createScreen, renameScreen, setScreenProperty, addControl, moveNode, setNodeProperty, getNodeProperty, removeNodes,
10
10
  inputNodes, readingOrder, contentBottom, camelName, slugify, uniqueFieldName, nextPosition
11
11
  } from './document.js'
12
12
  export { validateDocument, assertValidDocument } from './validateDocument.js'
13
- export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue } from './values.js'
14
- export { screenFromSpec, SPEC_HEIGHTS, ROW_GAP } from './generate.js'
13
+ export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, saveState, recordValues, displayValue, listColumns, listSource } from './values.js'
14
+ export { screenFromSpec, screensFromSpec, entityNames, SPEC_HEIGHTS, ROW_GAP } from './generate.js'
15
+ export { scaffoldEntity } from './scaffold.js'
15
16
  export { getAtPath, setAtPath } from './propertyPath.js'
17
+ export { createFactoryApi } from './remote.js'
18
+ export {
19
+ STANDARD_COLUMNS, RESERVED_COLUMNS, MAX_IDENTIFIER,
20
+ columnForField, tableForScreen, columnSql, widening, diffTables, migrationFor, nextMigrationName
21
+ } from './tableSchema.js'
package/src/pages.jsx CHANGED
@@ -1,16 +1,20 @@
1
1
  import { useFactoryBuilder } from './useFactoryBuilder.js'
2
2
  import { useFactoryScreen } from './useFactoryScreen.js'
3
- import { BuilderSample, ScreenSample } from './designs/index.js'
3
+ import { BuilderSample, ScreenSample, ScreenModal } from './designs/index.js'
4
4
 
5
5
  /**
6
- * The builder, ready-made.
6
+ * The builder, ready-made. Saves itself — there is no Save button.
7
7
  *
8
8
  * @prop document the screen to edit (omit for a new one)
9
9
  * @prop name name for a new screen
10
- * @prop onSave async (document) → void — the app stores it; only called with a valid document
10
+ * @prop onSave async (document) → void — called automatically after edits, only with a valid document
11
11
  * @prop onChange (document) → void — every edit
12
- * @prop listTables async () → [{ id, name }] | string[] — for "dropdown from a table"
13
- * @prop fetchOptions async ({ table }) → [{ id, name }] — enables Preview with real options
12
+ * @prop onPublish async (document) → { version } — shows a Publish button; throw to refuse, with `detail` to show (e.g. the migration to run)
13
+ * @prop listTables async () → [{ id, name }] | string[] — for "Saves to", lists and table dropdowns
14
+ * @prop fetchOptions async ({ table }) → [{ id, name }] — Preview with real options
15
+ * @prop fetchRecords async ({ source }) → records — Preview with real records
16
+ * @prop lockedNames field names that are already columns of the table — shown read-only
17
+ * @prop screens [{ id, name, document? }] — other screens: "Edit in" choices, and Preview's popups
14
18
  * @prop controls control registry (default: built-ins)
15
19
  */
16
20
  export function FactoryBuilder(props) {
@@ -21,22 +25,68 @@ export function FactoryBuilder(props) {
21
25
  className={props.className}
22
26
  style={props.style}
23
27
  renderPreview={(doc) => (
24
- <FactoryScreen document={doc} fetchOptions={props.fetchOptions} controls={props.controls} />
28
+ // Preview never writes: onSave/onDelete are left out on purpose.
29
+ <FactoryScreen
30
+ document={doc}
31
+ fetchOptions={props.fetchOptions}
32
+ fetchRecords={props.fetchRecords}
33
+ screens={Object.fromEntries((props.screens || []).filter((s) => s.document).map((s) => [s.id, s.document]))}
34
+ controls={props.controls}
35
+ />
25
36
  )}
26
37
  />
27
38
  )
28
39
  }
29
40
 
30
41
  /**
31
- * A saved screen, running.
42
+ * A saved screen, running. Saves itself in the background — there is no submit.
32
43
  *
33
44
  * @prop document a screen document (an invalid one renders its problems, loudly)
45
+ * @prop record the record to open (omit for a new one)
46
+ * @prop recordKey the id field on a record (default 'id')
47
+ * @prop onSave async (values, { id, source, document }) → the saved record, with its id
34
48
  * @prop fetchOptions async ({ table }) → [{ id, name }] — rows for table dropdowns
35
- * @prop onSubmit async (values, { document }) → void
36
- * @prop values starting values, e.g. the record being edited
49
+ * @prop fetchRecords async ({ source }) → records — rows for lists
50
+ * @prop onDelete async ({ id, source, record }) → void
51
+ * @prop onChange (values) → void
52
+ * @prop screens { id → document } — the screens a list's Edit / New open in a popup
53
+ * @prop loadScreen async (id) → document — for screens not in `screens`
37
54
  * @prop controls control registry (default: built-ins)
38
55
  */
39
56
  export function FactoryScreen(props) {
40
57
  const ctrl = useFactoryScreen(props)
41
- return <ScreenSample ctrl={ctrl} className={props.className} style={props.style} />
58
+ return (
59
+ <ScreenSample
60
+ ctrl={ctrl}
61
+ className={props.className}
62
+ style={props.style}
63
+ renderPopup={(popup) => (
64
+ <ScreenModal
65
+ title={popup.document ? popup.document.name : 'Loading…'}
66
+ width={popup.document ? popup.document.width : undefined}
67
+ onClose={ctrl.closeEditor}
68
+ >
69
+ {popup.error && <div className="xeplr-factory-invalid" role="alert">{popup.error}</div>}
70
+ {popup.loading && <div className="xeplr-factory-hint">Loading…</div>}
71
+ {popup.document && (
72
+ // The same component, one level down: the edit screen saves itself,
73
+ // and every save refreshes the list behind the popup.
74
+ <FactoryScreen
75
+ key={`${popup.screenId}:${popup.record ? popup.record[ctrl.recordKey] : 'new'}`}
76
+ document={popup.document}
77
+ record={popup.record}
78
+ recordKey={props.recordKey}
79
+ onSave={ctrl.popupSave}
80
+ fetchOptions={props.fetchOptions}
81
+ fetchRecords={props.fetchRecords}
82
+ onDelete={props.onDelete}
83
+ screens={props.screens}
84
+ loadScreen={props.loadScreen}
85
+ controls={props.controls}
86
+ />
87
+ )}
88
+ </ScreenModal>
89
+ )}
90
+ />
91
+ )
42
92
  }
package/src/remote.js ADDED
@@ -0,0 +1,57 @@
1
+ // The browser side of @xeplr/factory: every call FactoryScreen and
2
+ // FactoryBuilder need, as requests to its routes. Records live in REAL tables
3
+ // in the app's database; this only sends and receives.
4
+ //
5
+ // import { authFetch } from '@xeplr/ui-account'
6
+ // const factory = createFactoryApi({ fetch: authFetch })
7
+ // <FactoryScreen document={screen} {...factory.screenProps} />
8
+ // <FactoryBuilder document={screen} {...factory.builderProps} lockedNames={…} />
9
+ //
10
+ // Responses are xeplr's { code, message, error, dataArray }.
11
+
12
+ /**
13
+ * @param options.fetch a fetch that adds auth and tenant headers (e.g. @xeplr/ui-account's authFetch)
14
+ * @param options.base prefix before /factory, if the router is mounted elsewhere (default '')
15
+ */
16
+ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
17
+ if (typeof doFetch !== 'function') throw new Error('createFactoryApi: pass { fetch } — usually authFetch from @xeplr/ui-account')
18
+
19
+ async function call(method, path, body) {
20
+ const res = await doFetch(`${base}/factory${path}`, {
21
+ method,
22
+ headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
23
+ body: body === undefined ? undefined : JSON.stringify(body)
24
+ })
25
+ let json = null
26
+ try { json = await res.json() } catch (_) { /* not JSON — reported below */ }
27
+ if (!res.ok) {
28
+ const err = new Error((json && json.message) || `${method} ${path} failed (${res.status})`)
29
+ err.status = res.status
30
+ err.fields = json && json.error && json.error.fields
31
+ err.detail = json && json.dataArray && json.dataArray[0] && json.dataArray[0].migration
32
+ throw err
33
+ }
34
+ return json ? json.dataArray : []
35
+ }
36
+ const one = (rows) => (Array.isArray(rows) ? rows[0] : rows)
37
+ const enc = encodeURIComponent
38
+
39
+ const api = {
40
+ // screens
41
+ listScreens: () => call('GET', '/screens'),
42
+ loadScreen: async (key) => one(await call('GET', `/screens/${enc(key)}`)).document,
43
+ loadDraft: async (key) => one(await call('GET', `/screens/${enc(key)}?draft=true`)),
44
+ saveDraft: (doc) => call('PUT', `/screens/${enc(doc.id)}/draft`, { document: doc }),
45
+ publish: async (doc) => one(await call('POST', `/screens/${enc(doc.id)}/publish`)),
46
+ listTables: () => call('GET', '/tables'),
47
+
48
+ // records
49
+ fetchRecords: ({ screen }) => call('GET', `/records/${enc(screen)}`),
50
+ onSave: async (values, { id, screen }) => one(await call('POST', `/records/${enc(screen)}/save`, { id, values })),
51
+ onDelete: ({ id, screen }) => call('POST', `/records/${enc(screen)}/delete`, { id }),
52
+ fetchOptions: ({ table }) => call('GET', `/options/${enc(table)}`)
53
+ }
54
+ api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, onDelete: api.onDelete, fetchOptions: api.fetchOptions }
55
+ api.builderProps = { onSave: api.saveDraft, onPublish: api.publish, listTables: api.listTables, fetchOptions: api.fetchOptions, fetchRecords: api.fetchRecords }
56
+ return api
57
+ }
@@ -0,0 +1,73 @@
1
+ // THE FILES an entity becomes in an app.
2
+ //
3
+ // Asked for "a form for employees", Claude produces two screens and two
4
+ // pages that show them:
5
+ //
6
+ // employee-list.screen.json EmployeeList.jsx — the list; Edit / New open a popup
7
+ // employee-edit.screen.json EditEmployee.jsx — the add / edit form, also a page on its own
8
+ //
9
+ // The pages are thin on purpose: a screen document and the app's data calls,
10
+ // nothing else. Changing what a screen shows is editing its JSON in the
11
+ // designer, never these files.
12
+ //
13
+ // Pure: returns { path → contents }. Writing them is the caller's (the CLI).
14
+
15
+ import { screensFromSpec, entityNames } from './generate.js'
16
+
17
+ /**
18
+ * @param spec as screensFromSpec
19
+ * @returns {{ files: Object<string, string>, names, list, edit }}
20
+ */
21
+ export function scaffoldEntity(spec, controls) {
22
+ const { list, edit } = screensFromSpec(spec, controls)
23
+ const names = entityNames(spec.entity, spec.plural)
24
+ const listJson = `${names.file}-list.screen.json`
25
+ const editJson = `${names.file}-edit.screen.json`
26
+
27
+ const files = {
28
+ [listJson]: JSON.stringify(list, null, 2) + '\n',
29
+ [editJson]: JSON.stringify(edit, null, 2) + '\n',
30
+ [`${names.listComponent}.jsx`]: listPage(names, listJson, editJson, list, edit),
31
+ [`${names.editComponent}.jsx`]: editPage(names, editJson, edit)
32
+ }
33
+ return { files, names, list, edit }
34
+ }
35
+
36
+ function listPage(n, listJson, editJson, list, edit) {
37
+ return `import { FactoryScreen } from '@xeplr/ui-factory'
38
+ import listScreen from './${listJson}'
39
+ import editScreen from './${editJson}'
40
+
41
+ // ${n.pluralTitle} — the saved ${list.source}, with New / Edit / Delete.
42
+ // Edit and New open "${edit.name}" (${edit.id}) in a popup.
43
+ //
44
+ // \`api\` is the app's data calls — the factory never touches a database:
45
+ // onSave(values, { id, source }) → the saved record, with its id
46
+ // fetchRecords({ source }) → records
47
+ // onDelete({ id, source, record })
48
+ // fetchOptions({ table }) → [{ id, name }] for dropdowns
49
+ export default function ${n.listComponent}({ api }) {
50
+ return (
51
+ <FactoryScreen
52
+ document={listScreen}
53
+ screens={{ [editScreen.id]: editScreen }}
54
+ {...api}
55
+ />
56
+ )
57
+ }
58
+ `
59
+ }
60
+
61
+ function editPage(n, editJson, edit) {
62
+ return `import { FactoryScreen } from '@xeplr/ui-factory'
63
+ import editScreen from './${editJson}'
64
+
65
+ // ${edit.name} — saves itself as it is filled in; there is no submit.
66
+ // Opened in a popup from ${n.listComponent}, or on its own page:
67
+ // <${n.editComponent} api={api} /> a new ${n.singular}
68
+ // <${n.editComponent} api={api} record={row} /> an existing one
69
+ export default function ${n.editComponent}({ api, record }) {
70
+ return <FactoryScreen document={editScreen} record={record} {...api} />
71
+ }
72
+ `
73
+ }