@xeplr/ui-factory 1.0.1 → 1.0.3
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 +101 -12
- package/README.md +73 -22
- package/bin/xeplr-factory.js +72 -1
- package/package.json +6 -2
- package/src/controls.js +142 -45
- package/src/designs/BuilderSample.jsx +80 -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 +72 -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 +12 -5
- package/src/pages.jsx +62 -10
- package/src/remote.js +66 -0
- package/src/scaffold.js +116 -0
- package/src/tableSchema.js +329 -0
- package/src/useFactoryBuilder.js +102 -17
- package/src/useFactoryScreen.js +290 -78
- package/src/validateDocument.js +73 -5
- package/src/values.js +61 -0
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
import ControlView from './ControlView.jsx'
|
|
2
|
-
import { contentBottom, MARGIN } from '../document.js'
|
|
2
|
+
import { contentBottom, inputNodes, MARGIN } from '../document.js'
|
|
3
|
+
import { screenStyle } from './styles.js'
|
|
3
4
|
|
|
4
5
|
// A saved screen, drawn. Presentation only — everything comes from
|
|
5
6
|
// useFactoryScreen.
|
|
6
7
|
//
|
|
7
8
|
// NO MEASURING. Positions are CSS: x and w as percentages of the width, y and h
|
|
8
9
|
// in container-query units (cqw = 1% of the screen's width) times the aspect.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
10
|
+
// The screen is never wider than the width it was designed at, so what was
|
|
11
|
+
// designed at 800px is shown at 800px, and only a narrower screen scales it.
|
|
12
|
+
//
|
|
13
|
+
// NO SUBMIT. The status line says what the background save is doing.
|
|
14
|
+
|
|
15
|
+
const STATUS = {
|
|
16
|
+
idle: '',
|
|
17
|
+
pending: 'Unsaved changes…',
|
|
18
|
+
saving: 'Saving…',
|
|
19
|
+
saved: 'All changes saved',
|
|
20
|
+
incomplete: 'Fill in the required fields to save',
|
|
21
|
+
invalid: 'Fix the highlighted fields to save'
|
|
22
|
+
}
|
|
11
23
|
|
|
12
|
-
export default function ScreenSample({ ctrl, className, style }) {
|
|
24
|
+
export default function ScreenSample({ ctrl, className, style, renderPopup }) {
|
|
13
25
|
const { document: doc } = ctrl
|
|
14
26
|
|
|
15
27
|
if (ctrl.documentErrors.length) {
|
|
@@ -25,13 +37,27 @@ export default function ScreenSample({ ctrl, className, style }) {
|
|
|
25
37
|
|
|
26
38
|
const aspect = doc.aspect
|
|
27
39
|
const pageHeight = (contentBottom(doc) + MARGIN) * aspect * 100
|
|
40
|
+
const fieldNodes = inputNodes(doc)
|
|
41
|
+
const statusText = ctrl.status === 'error' ? `Not saved — ${ctrl.saveError}` : STATUS[ctrl.status]
|
|
42
|
+
// A screen with no fields of its own (a list screen) has nothing to save.
|
|
43
|
+
const hasFields = fieldNodes.length > 0
|
|
28
44
|
|
|
29
45
|
return (
|
|
30
|
-
<div
|
|
46
|
+
<div
|
|
47
|
+
className={'xeplr-factory-screen' + (className ? ' ' + className : '')}
|
|
48
|
+
style={{ maxWidth: doc.width, ...screenStyle(doc), ...style }}
|
|
49
|
+
>
|
|
50
|
+
{hasFields && (
|
|
51
|
+
<div className={`xeplr-factory-status xeplr-factory-status--${ctrl.status}`} role="status" aria-live="polite">
|
|
52
|
+
<span>{ctrl.recordId !== null && ctrl.recordId !== undefined ? 'Editing record' : 'New record'}</span>
|
|
53
|
+
<span>{statusText}</span>
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
31
56
|
<form
|
|
32
57
|
className="xeplr-factory-page"
|
|
33
58
|
style={{ height: `${pageHeight}cqw` }}
|
|
34
|
-
|
|
59
|
+
// Enter in a field saves now rather than posting a page.
|
|
60
|
+
onSubmit={(e) => { e.preventDefault(); ctrl.saveNow() }}
|
|
35
61
|
noValidate
|
|
36
62
|
aria-label={doc.name}
|
|
37
63
|
>
|
|
@@ -53,13 +79,27 @@ export default function ScreenSample({ ctrl, className, style }) {
|
|
|
53
79
|
error={node.props?.name ? ctrl.errors[node.props.name] : undefined}
|
|
54
80
|
options={node.type === 'dropdown' ? ctrl.optionsFor(node) : undefined}
|
|
55
81
|
onChange={(raw) => ctrl.setValue(node, raw)}
|
|
56
|
-
|
|
57
|
-
|
|
82
|
+
onBlur={() => ctrl.touch(node)}
|
|
83
|
+
list={node.type === 'list' ? {
|
|
84
|
+
doc,
|
|
85
|
+
list: ctrl.listFor(node),
|
|
86
|
+
fieldNodes: ctrl.fieldsFor(node),
|
|
87
|
+
optionsFor: ctrl.optionsFor,
|
|
88
|
+
currentId: ctrl.recordId,
|
|
89
|
+
recordKey: ctrl.recordKey,
|
|
90
|
+
canDelete: ctrl.canDelete,
|
|
91
|
+
// A list with an edit screen opens it in a popup; one on a form
|
|
92
|
+
// opens the row in the form's own fields.
|
|
93
|
+
onEdit: (rec) => (node.props.editScreen ? ctrl.openEditor(node, rec) : ctrl.openRecord(rec)),
|
|
94
|
+
// eslint-disable-next-line no-alert
|
|
95
|
+
onDelete: (rec) => ctrl.deleteRecord(node, rec).catch((err) => window.alert(err.message || 'Could not delete')),
|
|
96
|
+
onNew: () => (node.props.editScreen ? ctrl.openEditor(node, null) : ctrl.newRecord())
|
|
97
|
+
} : undefined}
|
|
58
98
|
/>
|
|
59
99
|
</div>
|
|
60
100
|
))}
|
|
61
101
|
</form>
|
|
62
|
-
{ctrl.
|
|
102
|
+
{ctrl.popup && renderPopup && renderPopup(ctrl.popup, ctrl)}
|
|
63
103
|
</div>
|
|
64
104
|
)
|
|
65
105
|
}
|
package/src/designs/factory.css
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
Colours come from the xeplr theme variables, each with a light fallback, so
|
|
3
3
|
a host that defines none still gets a finished look.
|
|
4
4
|
|
|
5
|
-
ONE SIZE UNIT, --xf-u: one
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
ONE SIZE UNIT, --xf-u: one pixel at the screen's design width (--xf-width,
|
|
6
|
+
set from the document). At that width or wider it IS a pixel — text is
|
|
7
|
+
exactly the size chosen, and a screen never stretches. On a narrower screen
|
|
8
|
+
it shrinks, so the layout keeps its proportions. */
|
|
8
9
|
|
|
9
10
|
.xeplr-factory-screen,
|
|
10
11
|
.xeplr-factory-canvas {
|
|
11
12
|
container-type: inline-size;
|
|
12
|
-
--xf-u: calc(100cqw / 800);
|
|
13
|
+
--xf-u: min(1px, calc(100cqw / var(--xf-width, 800)));
|
|
13
14
|
--xf-accent: var(--xeplr-accent, #2563eb);
|
|
14
15
|
--xf-ink: var(--xeplr-text, #1a1a19);
|
|
15
16
|
--xf-muted: var(--xeplr-muted, #6b6a63);
|
|
@@ -20,6 +21,12 @@
|
|
|
20
21
|
|
|
21
22
|
/* ── the screen ─────────────────────────────────────────────────────────── */
|
|
22
23
|
.xeplr-factory-screen { position: relative; width: 100%; color: var(--xf-ink); }
|
|
24
|
+
.xeplr-factory-status {
|
|
25
|
+
display: flex; justify-content: space-between; gap: 12px; min-height: 18px;
|
|
26
|
+
padding: 4px 2px 8px; font-size: 12px; color: var(--xf-muted);
|
|
27
|
+
}
|
|
28
|
+
.xeplr-factory-status--saved { color: var(--xeplr-status-good, #1d7a4c); }
|
|
29
|
+
.xeplr-factory-status--invalid, .xeplr-factory-status--error { color: var(--xf-danger); }
|
|
23
30
|
.xeplr-factory-page { position: relative; width: 100%; margin: 0; }
|
|
24
31
|
.xeplr-factory-node { position: absolute; box-sizing: border-box; }
|
|
25
32
|
|
|
@@ -36,10 +43,10 @@
|
|
|
36
43
|
position: relative;
|
|
37
44
|
display: flex; flex-direction: column; gap: calc(4 * var(--xf-u));
|
|
38
45
|
width: 100%; height: 100%; box-sizing: border-box;
|
|
39
|
-
font-size: max(
|
|
46
|
+
font-size: max(10px, calc(var(--xf-font, 14) * var(--xf-u)));
|
|
40
47
|
}
|
|
41
48
|
.xeplr-factory-field--inline { justify-content: center; }
|
|
42
|
-
.xeplr-factory-label { font-weight: 600; font-size: max(
|
|
49
|
+
.xeplr-factory-label { font-weight: 600; font-size: max(10px, calc((var(--xf-font, 14) - 1) * var(--xf-u))); color: var(--xf-ink); line-height: 1.2; }
|
|
43
50
|
.xeplr-factory-required { color: var(--xf-danger); }
|
|
44
51
|
.xeplr-factory-input {
|
|
45
52
|
flex: 1; min-height: 0; width: 100%; box-sizing: border-box;
|
|
@@ -61,20 +68,30 @@
|
|
|
61
68
|
.xeplr-factory-check { display: flex; align-items: center; gap: calc(8 * var(--xf-u)); cursor: pointer; font-weight: 500; }
|
|
62
69
|
.xeplr-factory-check input { width: calc(18 * var(--xf-u)); height: calc(18 * var(--xf-u)); min-width: 12px; min-height: 12px; margin: 0; accent-color: var(--xf-accent); }
|
|
63
70
|
|
|
64
|
-
|
|
65
|
-
.xeplr-factory-text
|
|
66
|
-
.xeplr-factory-text--
|
|
67
|
-
.xeplr-factory-text--text { font-size: max(11px, calc(14 * var(--xf-u))); color: var(--xf-muted); }
|
|
71
|
+
/* A label's size and weight are inline — its preset, overridden by its style. */
|
|
72
|
+
.xeplr-factory-text { margin: 0; color: var(--xf-ink); height: 100%; overflow: hidden; line-height: 1.25; box-sizing: border-box; }
|
|
73
|
+
.xeplr-factory-text--text { color: var(--xf-muted); }
|
|
68
74
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
font
|
|
73
|
-
border-radius: calc(
|
|
75
|
+
/* ── list ───────────────────────────────────────────────────────────────── */
|
|
76
|
+
.xeplr-factory-list {
|
|
77
|
+
display: flex; flex-direction: column; width: 100%; height: 100%; box-sizing: border-box; overflow: auto;
|
|
78
|
+
font-size: max(10px, calc(var(--xf-font, 14) * var(--xf-u)));
|
|
79
|
+
border: 1px solid var(--xf-border); border-radius: calc(8 * var(--xf-u)); background: var(--xf-surface);
|
|
80
|
+
}
|
|
81
|
+
.xeplr-factory-list-head { display: flex; align-items: center; gap: 10px; padding: calc(8 * var(--xf-u)) calc(10 * var(--xf-u)); border-bottom: 1px solid var(--xf-border); }
|
|
82
|
+
.xeplr-factory-list-title { font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
83
|
+
.xeplr-factory-list-new {
|
|
84
|
+
font: inherit; font-weight: 600; font-size: .9em; padding: 3px 10px; border-radius: 5px; cursor: pointer;
|
|
85
|
+
color: var(--xf-accent); background: transparent; border: 1px solid var(--xf-accent);
|
|
74
86
|
}
|
|
75
|
-
.xeplr-factory-
|
|
76
|
-
.xeplr-factory-
|
|
77
|
-
.xeplr-factory-
|
|
87
|
+
.xeplr-factory-list-empty { padding: 16px; color: var(--xf-muted); text-align: center; }
|
|
88
|
+
.xeplr-factory-error-inline { color: var(--xf-danger); font-size: .9em; }
|
|
89
|
+
.xeplr-factory-list-mock { width: 100%; border-collapse: collapse; }
|
|
90
|
+
.xeplr-factory-list-mock th { text-align: left; font-weight: 600; font-size: .9em; padding: calc(6 * var(--xf-u)) calc(10 * var(--xf-u)); border-bottom: 1px solid var(--xf-border); color: var(--xf-muted); }
|
|
91
|
+
.xeplr-factory-list-mock td { padding: calc(8 * var(--xf-u)) calc(10 * var(--xf-u)); border-bottom: 1px solid var(--xf-border); }
|
|
92
|
+
.xeplr-factory-list-bar { display: block; height: calc(8 * var(--xf-u)); width: 70%; border-radius: 4px; background: var(--xeplr-border-secondary, #ecebe4); }
|
|
93
|
+
.xeplr-factory-list .xeplr-table-row.xeplr-factory-row-open td,
|
|
94
|
+
.xeplr-factory-list tr.xeplr-factory-row-open td { background: color-mix(in srgb, var(--xf-accent) 10%, transparent); }
|
|
78
95
|
|
|
79
96
|
/* ── the builder ────────────────────────────────────────────────────────── */
|
|
80
97
|
.xeplr-factory-builder {
|
|
@@ -107,6 +124,8 @@
|
|
|
107
124
|
.xeplr-factory-save-error { padding: 6px 12px; color: var(--xf-danger); background: color-mix(in srgb, var(--xf-danger) 7%, transparent); font-size: 12px; }
|
|
108
125
|
|
|
109
126
|
.xeplr-factory-body { flex: 1; min-height: 0; display: flex; }
|
|
127
|
+
.xeplr-factory-bar-status--saved { color: var(--xeplr-status-good, #1d7a4c); }
|
|
128
|
+
.xeplr-factory-bar-status--invalid, .xeplr-factory-bar-status--error { color: var(--xf-danger); }
|
|
110
129
|
.xeplr-factory-preview { flex: 1; min-height: 0; overflow: auto; padding: 16px; background: var(--xeplr-plane, #f7f7f5); }
|
|
111
130
|
.xeplr-factory-preview > .xeplr-factory-screen { max-width: 960px; margin: 0 auto; background: var(--xeplr-surface, #fff); border: 1px solid var(--xf-border); border-radius: 8px; }
|
|
112
131
|
|
|
@@ -123,11 +142,13 @@
|
|
|
123
142
|
.xeplr-factory-palette-icon { width: 14px; height: 10px; border: 1.5px solid var(--xf-muted); border-radius: 2px; flex: 0 0 auto; }
|
|
124
143
|
.xeplr-factory-palette-icon--checkbox { width: 10px; }
|
|
125
144
|
.xeplr-factory-palette-icon--label { border: none; border-top: 2px solid var(--xf-muted); border-bottom: 2px solid var(--xf-muted); height: 6px; }
|
|
126
|
-
.xeplr-factory-palette-icon--
|
|
145
|
+
.xeplr-factory-palette-icon--list { height: 12px; background: repeating-linear-gradient(to bottom, var(--xf-muted) 0 1.5px, transparent 1.5px 4px); border: none; }
|
|
127
146
|
.xeplr-factory-palette-icon--textarea { height: 14px; }
|
|
128
147
|
|
|
129
148
|
/* canvas */
|
|
130
|
-
.xeplr-factory-stage { flex: 1; min-width: 0; min-height: 0; display: flex; background: var(--xeplr-plane, #f7f7f5); }
|
|
149
|
+
.xeplr-factory-stage { flex: 1; min-width: 0; min-height: 0; display: flex; justify-content: center; padding: 12px; box-sizing: border-box; background: var(--xeplr-plane, #f7f7f5); }
|
|
150
|
+
/* Exactly the screen's design width, never wider: what you design is the size it is shown at. */
|
|
151
|
+
.xeplr-factory-frame { flex: 1; min-width: 0; height: 100%; display: flex; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
|
|
131
152
|
.xeplr-factory-canvas {
|
|
132
153
|
flex: 1; height: 100%;
|
|
133
154
|
background:
|
|
@@ -175,3 +196,34 @@
|
|
|
175
196
|
.xeplr-factory-option-error { grid-column: 1 / -1; margin-top: 0; }
|
|
176
197
|
.xeplr-factory-icon-button { border: none; background: none; cursor: pointer; font-size: 16px; color: var(--xf-muted); padding: 0; }
|
|
177
198
|
.xeplr-factory-add { font: inherit; font-size: 12px; background: none; border: 1px dashed var(--xf-border); border-radius: 5px; padding: 5px; width: 100%; cursor: pointer; color: var(--xf-accent); }
|
|
199
|
+
|
|
200
|
+
.xeplr-factory-color { display: grid; grid-template-columns: 32px 1fr 20px; gap: 4px; align-items: center; }
|
|
201
|
+
.xeplr-factory-color input[type="color"] { width: 32px; height: 28px; padding: 0; border: 1px solid var(--xf-border); border-radius: 5px; background: none; cursor: pointer; }
|
|
202
|
+
.xeplr-factory-checklist { display: flex; flex-direction: column; gap: 5px; }
|
|
203
|
+
.xeplr-factory-checklist .xeplr-factory-toggle { font-weight: 500; }
|
|
204
|
+
|
|
205
|
+
/* ── popup ──────────────────────────────────────────────────────────────── */
|
|
206
|
+
.xeplr-factory-modal-backdrop {
|
|
207
|
+
position: fixed; inset: 0; z-index: 10000; display: flex; align-items: flex-start; justify-content: center;
|
|
208
|
+
padding: 4vh 2vw; box-sizing: border-box; overflow: auto;
|
|
209
|
+
background: rgba(20, 20, 18, .45);
|
|
210
|
+
}
|
|
211
|
+
.xeplr-factory-modal {
|
|
212
|
+
max-width: 96vw; background: var(--xeplr-surface, #fff); color: var(--xeplr-text, #1a1a19);
|
|
213
|
+
border-radius: 10px; box-shadow: 0 12px 40px rgba(0, 0, 0, .25); outline: none;
|
|
214
|
+
}
|
|
215
|
+
.xeplr-factory-modal-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid var(--xeplr-border, #e2e1d9); }
|
|
216
|
+
.xeplr-factory-modal-title { font-weight: 700; font-size: 15px; }
|
|
217
|
+
.xeplr-factory-modal-close { border: none; background: none; font-size: 22px; line-height: 1; cursor: pointer; color: var(--xeplr-muted, #6b6a63); padding: 2px 6px; }
|
|
218
|
+
.xeplr-factory-modal-body { padding: 8px 24px 16px; }
|
|
219
|
+
|
|
220
|
+
/* ── publish result ─────────────────────────────────────────────────────── */
|
|
221
|
+
.xeplr-factory-publish { display: flex; align-items: flex-start; gap: 10px; padding: 8px 12px; font-size: 12.5px; border-bottom: 1px solid var(--xf-border); }
|
|
222
|
+
.xeplr-factory-publish > span { flex: 1; }
|
|
223
|
+
.xeplr-factory-publish.is-ok { color: var(--xeplr-status-good, #1d7a4c); background: color-mix(in srgb, var(--xeplr-status-good, #1d7a4c) 7%, transparent); }
|
|
224
|
+
.xeplr-factory-publish.is-error { color: var(--xf-danger); background: color-mix(in srgb, var(--xf-danger) 7%, transparent); flex-wrap: wrap; }
|
|
225
|
+
.xeplr-factory-publish-detail { flex-basis: 100%; order: 3; margin: 4px 0 0; padding: 8px; max-height: 220px; overflow: auto; font-size: 11.5px; color: var(--xeplr-text, #1a1a19); background: var(--xeplr-surface, #fff); border: 1px solid var(--xf-border); border-radius: 6px; white-space: pre; }
|
|
226
|
+
.xeplr-factory-publish.is-confirm { align-items: center; color: var(--xeplr-text, #1a1a19); background: color-mix(in srgb, var(--xeplr-status-warn, #a86a00) 10%, transparent); border-bottom-color: color-mix(in srgb, var(--xeplr-status-warn, #a86a00) 40%, transparent); }
|
|
227
|
+
.xeplr-factory-confirm-text { flex: 1; line-height: 1.5; }
|
|
228
|
+
.xeplr-factory-confirm-text ul { margin: 4px 0; padding-left: 18px; }
|
|
229
|
+
.xeplr-factory-confirm-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
package/src/designs/index.js
CHANGED
|
@@ -2,6 +2,8 @@ import './factory.css'
|
|
|
2
2
|
|
|
3
3
|
export { default as BuilderSample } from './BuilderSample.jsx'
|
|
4
4
|
export { default as ScreenSample } from './ScreenSample.jsx'
|
|
5
|
+
export { default as ListView } from './ListView.jsx'
|
|
6
|
+
export { default as ScreenModal } from './ScreenModal.jsx'
|
|
5
7
|
export { default as ControlView, VIEWS } from './ControlView.jsx'
|
|
6
8
|
export { default as Palette, DRAG_TYPE } from './Palette.jsx'
|
|
7
9
|
export { default as PropertyPanel, EDITORS } from './PropertyPanel.jsx'
|
|
@@ -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'
|