@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,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
|
-
|
|
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
|
|
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.
|
|
35
|
-
|
|
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', '
|
|
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
|
-
*
|
|
59
|
-
*
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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')
|
|
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,
|
|
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 —
|
|
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
|
|
13
|
-
* @prop
|
|
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
|
-
|
|
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
|
|
36
|
-
* @prop
|
|
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
|
|
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
|
+
}
|
package/src/scaffold.js
ADDED
|
@@ -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
|
+
}
|