@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,13 +1,17 @@
|
|
|
1
|
+
import { LABEL_PRESETS } from '../controls.js'
|
|
2
|
+
import ListView from './ListView.jsx'
|
|
3
|
+
import { fieldStyle, labelStyle, boxStyle } from './styles.js'
|
|
4
|
+
|
|
1
5
|
// How each control LOOKS — the one renderer, used both on the builder's
|
|
2
6
|
// canvas (mode 'design': inert, so a press drags it) and on a live screen
|
|
3
7
|
// (mode 'live'). Sharing it is what makes the builder honest: what you place is
|
|
4
|
-
// what the form shows.
|
|
8
|
+
// what the form shows, fonts and colours included.
|
|
5
9
|
//
|
|
6
|
-
// Presentation only. Values, options and
|
|
10
|
+
// Presentation only. Values, options, errors and list data arrive as props.
|
|
7
11
|
|
|
8
12
|
const ids = (node) => `xeplr-factory-${node.id}`
|
|
9
13
|
|
|
10
|
-
export default function ControlView({ node, mode = 'live', value, error, options, onChange,
|
|
14
|
+
export default function ControlView({ node, mode = 'live', value, error, options, onChange, onBlur, disabled, list }) {
|
|
11
15
|
const View = VIEWS[node.type]
|
|
12
16
|
if (!View) {
|
|
13
17
|
return <div className="xeplr-factory-unknown">Unknown control “{node.type}”</div>
|
|
@@ -22,17 +26,18 @@ export default function ControlView({ node, mode = 'live', value, error, options
|
|
|
22
26
|
error={design ? null : error}
|
|
23
27
|
options={options || { loading: false, items: [], error: null }}
|
|
24
28
|
onChange={onChange || (() => {})}
|
|
25
|
-
|
|
29
|
+
onBlur={onBlur || (() => {})}
|
|
26
30
|
disabled={design || disabled}
|
|
31
|
+
list={list}
|
|
27
32
|
/>
|
|
28
33
|
)
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
function Field({ node, p, error, children, inline }) {
|
|
32
37
|
return (
|
|
33
|
-
<div className={`xeplr-factory-field${inline ? ' xeplr-factory-field--inline' : ''}${error ? ' has-error' : ''}`}>
|
|
38
|
+
<div className={`xeplr-factory-field${inline ? ' xeplr-factory-field--inline' : ''}${error ? ' has-error' : ''}`} style={fieldStyle(p.style)}>
|
|
34
39
|
{!inline && (
|
|
35
|
-
<label className="xeplr-factory-label" htmlFor={ids(node)}>
|
|
40
|
+
<label className="xeplr-factory-label" htmlFor={ids(node)} style={labelStyle(p.style)}>
|
|
36
41
|
{p.label}{p.required && <span className="xeplr-factory-required" aria-hidden="true"> *</span>}
|
|
37
42
|
</label>
|
|
38
43
|
)}
|
|
@@ -42,19 +47,21 @@ function Field({ node, p, error, children, inline }) {
|
|
|
42
47
|
)
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
function inputProps(node, p, error, disabled) {
|
|
50
|
+
function inputProps(node, p, error, disabled, onBlur) {
|
|
46
51
|
return {
|
|
47
52
|
id: ids(node),
|
|
48
53
|
name: p.name,
|
|
49
54
|
disabled,
|
|
50
|
-
required: undefined, // validation is ours, with our messages — not the browser's bubbles
|
|
51
55
|
'aria-invalid': error ? true : undefined,
|
|
52
56
|
'aria-describedby': error ? `${ids(node)}-error` : undefined,
|
|
53
|
-
|
|
57
|
+
'aria-required': p.required ? true : undefined,
|
|
58
|
+
tabIndex: disabled ? -1 : undefined,
|
|
59
|
+
onBlur,
|
|
60
|
+
style: boxStyle(p.style)
|
|
54
61
|
}
|
|
55
62
|
}
|
|
56
63
|
|
|
57
|
-
function TextView({ node, p, value, error, onChange, disabled }) {
|
|
64
|
+
function TextView({ node, p, value, error, onChange, onBlur, disabled }) {
|
|
58
65
|
return (
|
|
59
66
|
<Field node={node} p={p} error={error}>
|
|
60
67
|
<input
|
|
@@ -63,13 +70,13 @@ function TextView({ node, p, value, error, onChange, disabled }) {
|
|
|
63
70
|
placeholder={p.placeholder || ''}
|
|
64
71
|
value={value ?? ''}
|
|
65
72
|
onChange={(e) => onChange(e.target.value)}
|
|
66
|
-
{...inputProps(node, p, error, disabled)}
|
|
73
|
+
{...inputProps(node, p, error, disabled, onBlur)}
|
|
67
74
|
/>
|
|
68
75
|
</Field>
|
|
69
76
|
)
|
|
70
77
|
}
|
|
71
78
|
|
|
72
|
-
function TextareaView({ node, p, value, error, onChange, disabled }) {
|
|
79
|
+
function TextareaView({ node, p, value, error, onChange, onBlur, disabled }) {
|
|
73
80
|
return (
|
|
74
81
|
<Field node={node} p={p} error={error}>
|
|
75
82
|
<textarea
|
|
@@ -77,13 +84,13 @@ function TextareaView({ node, p, value, error, onChange, disabled }) {
|
|
|
77
84
|
placeholder={p.placeholder || ''}
|
|
78
85
|
value={value ?? ''}
|
|
79
86
|
onChange={(e) => onChange(e.target.value)}
|
|
80
|
-
{...inputProps(node, p, error, disabled)}
|
|
87
|
+
{...inputProps(node, p, error, disabled, onBlur)}
|
|
81
88
|
/>
|
|
82
89
|
</Field>
|
|
83
90
|
)
|
|
84
91
|
}
|
|
85
92
|
|
|
86
|
-
function NumberView({ node, p, value, error, onChange, disabled }) {
|
|
93
|
+
function NumberView({ node, p, value, error, onChange, onBlur, disabled }) {
|
|
87
94
|
const v = p.validation || {}
|
|
88
95
|
return (
|
|
89
96
|
<Field node={node} p={p} error={error}>
|
|
@@ -95,13 +102,13 @@ function NumberView({ node, p, value, error, onChange, disabled }) {
|
|
|
95
102
|
placeholder={p.placeholder || ''}
|
|
96
103
|
value={value ?? ''}
|
|
97
104
|
onChange={(e) => onChange(e.target.value)}
|
|
98
|
-
{...inputProps(node, p, error, disabled)}
|
|
105
|
+
{...inputProps(node, p, error, disabled, onBlur)}
|
|
99
106
|
/>
|
|
100
107
|
</Field>
|
|
101
108
|
)
|
|
102
109
|
}
|
|
103
110
|
|
|
104
|
-
function DateView({ node, p, value, error, onChange, disabled }) {
|
|
111
|
+
function DateView({ node, p, value, error, onChange, onBlur, disabled }) {
|
|
105
112
|
const v = p.validation || {}
|
|
106
113
|
return (
|
|
107
114
|
<Field node={node} p={p} error={error}>
|
|
@@ -112,21 +119,23 @@ function DateView({ node, p, value, error, onChange, disabled }) {
|
|
|
112
119
|
max={v.max}
|
|
113
120
|
value={value ?? ''}
|
|
114
121
|
onChange={(e) => onChange(e.target.value)}
|
|
115
|
-
{...inputProps(node, p, error, disabled)}
|
|
122
|
+
{...inputProps(node, p, error, disabled, onBlur)}
|
|
116
123
|
/>
|
|
117
124
|
</Field>
|
|
118
125
|
)
|
|
119
126
|
}
|
|
120
127
|
|
|
121
|
-
function CheckboxView({ node, p, value, error, onChange, disabled }) {
|
|
128
|
+
function CheckboxView({ node, p, value, error, onChange, onBlur, disabled }) {
|
|
129
|
+
const text = boxStyle(p.style)
|
|
122
130
|
return (
|
|
123
131
|
<Field node={node} p={p} error={error} inline>
|
|
124
|
-
<label className="xeplr-factory-check" htmlFor={ids(node)}>
|
|
132
|
+
<label className="xeplr-factory-check" htmlFor={ids(node)} style={{ fontSize: text.fontSize, fontWeight: text.fontWeight, fontStyle: text.fontStyle, color: text.color }}>
|
|
125
133
|
<input
|
|
126
134
|
type="checkbox"
|
|
127
135
|
checked={Boolean(value ?? p.default)}
|
|
128
136
|
onChange={(e) => onChange(e.target.checked)}
|
|
129
|
-
{...inputProps(node, p, error, disabled)}
|
|
137
|
+
{...inputProps(node, p, error, disabled, onBlur)}
|
|
138
|
+
style={undefined}
|
|
130
139
|
/>
|
|
131
140
|
<span>{p.label}{p.required && <span className="xeplr-factory-required" aria-hidden="true"> *</span>}</span>
|
|
132
141
|
</label>
|
|
@@ -134,7 +143,7 @@ function CheckboxView({ node, p, value, error, onChange, disabled }) {
|
|
|
134
143
|
)
|
|
135
144
|
}
|
|
136
145
|
|
|
137
|
-
function DropdownView({ node, p, design, value, error, options, onChange, disabled }) {
|
|
146
|
+
function DropdownView({ node, p, design, value, error, options, onChange, onBlur, disabled }) {
|
|
138
147
|
const data = p.data || {}
|
|
139
148
|
const items = data.source === 'static' ? (data.options || []) : options.items
|
|
140
149
|
return (
|
|
@@ -143,7 +152,7 @@ function DropdownView({ node, p, design, value, error, options, onChange, disabl
|
|
|
143
152
|
className="xeplr-factory-input xeplr-factory-select"
|
|
144
153
|
value={value === undefined || value === null ? '' : String(value)}
|
|
145
154
|
onChange={(e) => onChange(e.target.value)}
|
|
146
|
-
{...inputProps(node, p, error, disabled || (!design && options.loading))}
|
|
155
|
+
{...inputProps(node, p, error, disabled || (!design && options.loading), onBlur)}
|
|
147
156
|
>
|
|
148
157
|
<option value="">{!design && options.loading ? 'Loading…' : (p.placeholder || 'Select…')}</option>
|
|
149
158
|
{!design && items.map((o) => <option key={String(o.id)} value={String(o.id)}>{o.name}</option>)}
|
|
@@ -161,25 +170,23 @@ function DropdownView({ node, p, design, value, error, options, onChange, disabl
|
|
|
161
170
|
|
|
162
171
|
function LabelView({ p }) {
|
|
163
172
|
const variant = p.variant || 'text'
|
|
173
|
+
const preset = LABEL_PRESETS[variant] || LABEL_PRESETS.text
|
|
164
174
|
const Tag = variant === 'heading' ? 'h2' : variant === 'subheading' ? 'h3' : 'p'
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function ButtonView({ p, design, onAction, disabled }) {
|
|
169
|
-
const action = p.action || 'submit'
|
|
175
|
+
const s = { fontSize: preset.fontSize, fontWeight: preset.fontWeight, ...(p.style || {}) }
|
|
170
176
|
return (
|
|
171
|
-
<
|
|
172
|
-
className={`xeplr-factory-
|
|
173
|
-
|
|
174
|
-
disabled={disabled}
|
|
175
|
-
tabIndex={design ? -1 : undefined}
|
|
176
|
-
onClick={action === 'reset' && !design ? () => onAction('reset') : undefined}
|
|
177
|
+
<Tag
|
|
178
|
+
className={`xeplr-factory-text xeplr-factory-text--${variant}`}
|
|
179
|
+
style={{ ...fieldStyle(s), ...boxStyle(s) }}
|
|
177
180
|
>
|
|
178
|
-
{p.
|
|
179
|
-
</
|
|
181
|
+
{p.text}
|
|
182
|
+
</Tag>
|
|
180
183
|
)
|
|
181
184
|
}
|
|
182
185
|
|
|
186
|
+
function ListControl({ node, design, list }) {
|
|
187
|
+
return <ListView node={node} design={design} {...(list || {})} />
|
|
188
|
+
}
|
|
189
|
+
|
|
183
190
|
export const VIEWS = {
|
|
184
191
|
text: TextView,
|
|
185
192
|
textarea: TextareaView,
|
|
@@ -188,5 +195,5 @@ export const VIEWS = {
|
|
|
188
195
|
checkbox: CheckboxView,
|
|
189
196
|
dropdown: DropdownView,
|
|
190
197
|
label: LabelView,
|
|
191
|
-
|
|
198
|
+
list: ListControl
|
|
192
199
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { XeplrTable } from '@xeplr/ui-table'
|
|
2
|
+
import { displayValue, listColumns, listSource } from '../values.js'
|
|
3
|
+
import { boxStyle, fieldStyle } from './styles.js'
|
|
4
|
+
|
|
5
|
+
// A list of the saved records, in @xeplr/ui-table.
|
|
6
|
+
//
|
|
7
|
+
// On the canvas (design) it is a still picture of the columns — there are no
|
|
8
|
+
// records while designing. Live, rows come from the host's fetchRecords.
|
|
9
|
+
// Edit and New open the list's edit screen in a popup (`editScreen`), or, for a
|
|
10
|
+
// list on a form, load the row into the form's own fields. Delete removes it.
|
|
11
|
+
|
|
12
|
+
export default function ListView({ node, doc, design, list, onEdit, onDelete, onNew, currentId, recordKey = 'id', canDelete, fieldNodes, optionsFor }) {
|
|
13
|
+
const p = node.props || {}
|
|
14
|
+
const columns = listColumns(doc, node)
|
|
15
|
+
const actions = p.actions || ['new', 'edit', 'delete']
|
|
16
|
+
const style = { ...fieldStyle(p.style), ...boxStyle(p.style) }
|
|
17
|
+
|
|
18
|
+
if (design) {
|
|
19
|
+
return (
|
|
20
|
+
<div className="xeplr-factory-list xeplr-factory-list--design" style={style}>
|
|
21
|
+
<div className="xeplr-factory-list-head">
|
|
22
|
+
<span className="xeplr-factory-list-title">{p.title}</span>
|
|
23
|
+
<span className="xeplr-factory-hint">from {listSource(doc, node) || 'no table'}</span>
|
|
24
|
+
{actions.includes('new') && <span className="xeplr-factory-list-new">+ New</span>}
|
|
25
|
+
</div>
|
|
26
|
+
<table className="xeplr-factory-list-mock">
|
|
27
|
+
<thead>
|
|
28
|
+
<tr>
|
|
29
|
+
{columns.map((c) => <th key={c.field}>{c.label}</th>)}
|
|
30
|
+
{(actions.includes('edit') || actions.includes('delete')) && <th />}
|
|
31
|
+
</tr>
|
|
32
|
+
</thead>
|
|
33
|
+
<tbody>
|
|
34
|
+
{[0, 1, 2].map((i) => (
|
|
35
|
+
<tr key={i}>{columns.map((c) => <td key={c.field}><span className="xeplr-factory-list-bar" /></td>)}{(actions.includes('edit') || actions.includes('delete')) && <td />}</tr>
|
|
36
|
+
))}
|
|
37
|
+
</tbody>
|
|
38
|
+
</table>
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const byField = Object.fromEntries((fieldNodes || []).map((n) => [n.props.name, n]))
|
|
44
|
+
// What the table shows: a dropdown's NAME and Yes/No — the raw record is kept
|
|
45
|
+
// alongside, so Edit opens exactly what was saved.
|
|
46
|
+
const rows = (list.rows || []).map((rec) => {
|
|
47
|
+
const shown = { __record: rec }
|
|
48
|
+
columns.forEach((c) => {
|
|
49
|
+
const f = byField[c.field]
|
|
50
|
+
shown[c.field] = displayValue(f, rec[c.field], f && f.type === 'dropdown' ? optionsFor(f).items : undefined)
|
|
51
|
+
})
|
|
52
|
+
return shown
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const rowActions = []
|
|
56
|
+
if (actions.includes('edit')) {
|
|
57
|
+
rowActions.push({ key: 'edit', label: 'Edit', onClick: (row) => onEdit(row.__record) })
|
|
58
|
+
}
|
|
59
|
+
if (actions.includes('delete') && canDelete) {
|
|
60
|
+
rowActions.push({
|
|
61
|
+
key: 'delete',
|
|
62
|
+
label: 'Delete',
|
|
63
|
+
variant: 'danger',
|
|
64
|
+
onClick: (row) => {
|
|
65
|
+
// eslint-disable-next-line no-alert
|
|
66
|
+
if (typeof window === 'undefined' || window.confirm('Delete this record?')) onDelete(row.__record)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="xeplr-factory-list" style={style}>
|
|
73
|
+
<div className="xeplr-factory-list-head">
|
|
74
|
+
<span className="xeplr-factory-list-title">{p.title}</span>
|
|
75
|
+
{list.loading && <span className="xeplr-factory-hint">Loading…</span>}
|
|
76
|
+
{list.error && <span className="xeplr-factory-error-inline" role="alert">{list.error}</span>}
|
|
77
|
+
{actions.includes('new') && (
|
|
78
|
+
<button type="button" className="xeplr-factory-list-new" onClick={onNew}>+ New</button>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
{!list.loading && !list.error && rows.length === 0 ? (
|
|
82
|
+
<div className="xeplr-factory-list-empty">Nothing saved yet.</div>
|
|
83
|
+
) : (
|
|
84
|
+
<XeplrTable
|
|
85
|
+
data={rows}
|
|
86
|
+
schema={{ 0: { key: listSource(doc, node) || 'records', columns: columns.map((c) => ({ accessor: c.field, header: c.label })) } }}
|
|
87
|
+
pageSize={p.pageSize || 10}
|
|
88
|
+
rowActions={rowActions.length ? rowActions : undefined}
|
|
89
|
+
rowClassName={(row) => (currentId !== null && currentId !== undefined && row.__record && row.__record[recordKey] === currentId ? 'xeplr-factory-row-open' : null)}
|
|
90
|
+
enableFiltering={rows.length > 5}
|
|
91
|
+
/>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { getAtPath } from '../propertyPath.js'
|
|
2
|
-
import { slugify } from '../document.js'
|
|
2
|
+
import { slugify, inputNodes } from '../document.js'
|
|
3
|
+
import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS } from '../controls.js'
|
|
3
4
|
|
|
4
5
|
// The selected control's properties, GENERATED from its entry in controls.js:
|
|
5
6
|
// each field there names a path and an editor type, and this file owns only
|
|
6
7
|
// what each editor type looks like. A new property is a line in controls.js.
|
|
7
8
|
|
|
8
|
-
export default function PropertyPanel({ node, control, errors, tables, onChange, onRemove, selectionCount }) {
|
|
9
|
+
export default function PropertyPanel({ doc, node, control, errors, tables, screens, lockedNames, onChange, onScreenChange, onRemove, selectionCount }) {
|
|
9
10
|
if (selectionCount > 1) {
|
|
10
11
|
return (
|
|
11
12
|
<aside className="xeplr-factory-panel">
|
|
@@ -15,13 +16,7 @@ export default function PropertyPanel({ node, control, errors, tables, onChange,
|
|
|
15
16
|
)
|
|
16
17
|
}
|
|
17
18
|
if (!node || !control) {
|
|
18
|
-
return
|
|
19
|
-
<aside className="xeplr-factory-panel">
|
|
20
|
-
<p className="xeplr-factory-panel-empty">
|
|
21
|
-
Select a control to set its label, validation and options — or drag one in from the left.
|
|
22
|
-
</p>
|
|
23
|
-
</aside>
|
|
24
|
-
)
|
|
19
|
+
return <ScreenPanel doc={doc} errors={errors || []} tables={tables} onChange={onScreenChange} />
|
|
25
20
|
}
|
|
26
21
|
|
|
27
22
|
const nodeErrors = errors || []
|
|
@@ -41,21 +36,27 @@ export default function PropertyPanel({ node, control, errors, tables, onChange,
|
|
|
41
36
|
{group.fields.map((field) => {
|
|
42
37
|
const errs = errorsFor(field.path)
|
|
43
38
|
errs.forEach((e) => shown.add(e))
|
|
44
|
-
|
|
39
|
+
// A field name that is already a column cannot change — see setNodeProperty.
|
|
40
|
+
const locked = field.path === 'props.name' && (lockedNames || []).includes(node.props?.name)
|
|
41
|
+
const Editor = locked ? LockedEditor : (EDITORS[field.type] || TextEditor)
|
|
45
42
|
const id = `xf-prop-${node.id}-${field.path.replace(/\W+/g, '-')}`
|
|
46
43
|
return (
|
|
47
44
|
<div key={field.path} className={`xeplr-factory-prop${errs.length ? ' has-error' : ''}`}>
|
|
48
|
-
{field.type !== 'toggle' && <label className="xeplr-factory-prop-label" htmlFor={id}>{field.label}</label>}
|
|
45
|
+
{field.type !== 'toggle' && field.type !== 'toggleValue' && field.type !== 'columns' && field.type !== 'actions' && <label className="xeplr-factory-prop-label" htmlFor={id}>{field.label}</label>}
|
|
49
46
|
<Editor
|
|
50
47
|
id={id}
|
|
51
48
|
field={field}
|
|
52
49
|
value={getAtPath(node, field.path)}
|
|
53
50
|
onChange={(v) => onChange(field.path, v)}
|
|
54
51
|
tables={tables}
|
|
52
|
+
screens={screens}
|
|
55
53
|
node={node}
|
|
54
|
+
doc={doc}
|
|
56
55
|
errors={errs}
|
|
57
56
|
/>
|
|
58
|
-
{
|
|
57
|
+
{locked
|
|
58
|
+
? <div className="xeplr-factory-prop-help">Saved as a column of <code>{doc?.source}</code> — renaming it would leave its data behind. A rename is a migration.</div>
|
|
59
|
+
: field.help && <div className="xeplr-factory-prop-help">{field.help}</div>}
|
|
59
60
|
{errs.filter((e) => e.field === field.path).map((e, i) => (
|
|
60
61
|
<div key={i} className="xeplr-factory-prop-error">{e.message}</div>
|
|
61
62
|
))}
|
|
@@ -79,6 +80,51 @@ export default function PropertyPanel({ node, control, errors, tables, onChange,
|
|
|
79
80
|
)
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Nothing selected: the SCREEN's own properties — where records are saved,
|
|
85
|
+
* how wide it is designed, and the font and colours every control inherits.
|
|
86
|
+
*/
|
|
87
|
+
function ScreenPanel({ doc, errors, tables, onChange }) {
|
|
88
|
+
const errorFor = (path) => errors.filter((e) => e.path === path || e.path.startsWith(path + '.')).map((e) => e.message)[0]
|
|
89
|
+
const styleFields = SCREEN_STYLE_KEYS.map((k) => ({ path: `style.${k}`, label: k === 'fontSize' ? 'Base font size (px)' : STYLE_KEYS[k].label, ...STYLE_KEYS[k] }))
|
|
90
|
+
const row = (field, Editor, extra) => {
|
|
91
|
+
const id = `xf-screen-${field.path.replace(/\W+/g, '-')}`
|
|
92
|
+
const msg = errorFor(field.path)
|
|
93
|
+
return (
|
|
94
|
+
<div key={field.path} className={`xeplr-factory-prop${msg ? ' has-error' : ''}`}>
|
|
95
|
+
{field.type !== 'toggle' && field.type !== 'toggleValue' && <label className="xeplr-factory-prop-label" htmlFor={id}>{field.label}</label>}
|
|
96
|
+
<Editor id={id} field={field} value={getAtPath(doc, field.path)} onChange={(v) => onChange(field.path, v)} tables={tables} errors={[]} {...extra} />
|
|
97
|
+
{field.help && <div className="xeplr-factory-prop-help">{field.help}</div>}
|
|
98
|
+
{msg && <div className="xeplr-factory-prop-error">{msg}</div>}
|
|
99
|
+
</div>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
return (
|
|
103
|
+
<aside className="xeplr-factory-panel" aria-label="Screen properties">
|
|
104
|
+
<header className="xeplr-factory-panel-head">
|
|
105
|
+
<span className="xeplr-factory-panel-type">Screen</span>
|
|
106
|
+
</header>
|
|
107
|
+
<p className="xeplr-factory-panel-empty">Select a control to change it, or drag one in from the left.</p>
|
|
108
|
+
<fieldset className="xeplr-factory-group">
|
|
109
|
+
<legend>Data</legend>
|
|
110
|
+
{row({ path: 'source', label: 'Saves to', type: 'table', help: 'The table this screen\'s records are saved in' }, TableEditor)}
|
|
111
|
+
</fieldset>
|
|
112
|
+
<fieldset className="xeplr-factory-group">
|
|
113
|
+
<legend>Size</legend>
|
|
114
|
+
{row({ path: 'width', label: 'Width (px)', type: 'number', help: 'The width it is designed at. Narrower screens scale it down; wider ones never stretch it.' }, NumberEditor)}
|
|
115
|
+
</fieldset>
|
|
116
|
+
<fieldset className="xeplr-factory-group">
|
|
117
|
+
<legend>Text</legend>
|
|
118
|
+
{styleFields.map((f) => row(f, EDITORS[f.type] || TextEditor))}
|
|
119
|
+
</fieldset>
|
|
120
|
+
</aside>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function LockedEditor({ id, value }) {
|
|
125
|
+
return <input id={id} className="xeplr-factory-prop-input xeplr-factory-mono" type="text" value={value ?? ''} readOnly aria-readonly="true" />
|
|
126
|
+
}
|
|
127
|
+
|
|
82
128
|
function TextEditor({ id, value, onChange }) {
|
|
83
129
|
return <input id={id} className="xeplr-factory-prop-input" type="text" value={value ?? ''} onChange={(e) => onChange(e.target.value)} />
|
|
84
130
|
}
|
|
@@ -106,6 +152,121 @@ function DateEditor({ id, value, onChange }) {
|
|
|
106
152
|
return <input id={id} className="xeplr-factory-prop-input" type="date" value={value ?? ''} onChange={(e) => onChange(e.target.value)} />
|
|
107
153
|
}
|
|
108
154
|
|
|
155
|
+
/** A font family: the common ones, or anything typed. */
|
|
156
|
+
function FontEditor({ id, value, onChange }) {
|
|
157
|
+
const known = FONT_FAMILIES.some((f) => f.value === value)
|
|
158
|
+
return (
|
|
159
|
+
<select id={id} className="xeplr-factory-prop-input" value={value == null ? '' : known ? value : '__custom'} onChange={(e) => {
|
|
160
|
+
const v = e.target.value
|
|
161
|
+
if (v === '__custom') return
|
|
162
|
+
onChange(v === '' ? undefined : v)
|
|
163
|
+
}} style={{ fontFamily: value || undefined }}>
|
|
164
|
+
<option value="">Same as the screen</option>
|
|
165
|
+
{FONT_FAMILIES.map((f) => <option key={f.label} value={f.value} style={{ fontFamily: f.value }}>{f.label}</option>)}
|
|
166
|
+
{!known && value && <option value="__custom">{value}</option>}
|
|
167
|
+
</select>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A colour: a swatch, and the hex beside it. Clearing it goes back to the default. */
|
|
172
|
+
function ColorEditor({ id, value, onChange }) {
|
|
173
|
+
const hex = typeof value === 'string' && /^#([0-9a-f]{6})$/i.test(value) ? value
|
|
174
|
+
: typeof value === 'string' && /^#([0-9a-f]{3})$/i.test(value) ? '#' + value.slice(1).split('').map((c) => c + c).join('')
|
|
175
|
+
: '#000000'
|
|
176
|
+
return (
|
|
177
|
+
<div className="xeplr-factory-color">
|
|
178
|
+
<input type="color" aria-label="Pick colour" value={hex} onChange={(e) => onChange(e.target.value)} />
|
|
179
|
+
<input id={id} className="xeplr-factory-prop-input xeplr-factory-mono" type="text" placeholder="default" value={value ?? ''} onChange={(e) => onChange(e.target.value.trim())} />
|
|
180
|
+
{value && <button type="button" className="xeplr-factory-icon-button" aria-label="Reset colour" onClick={() => onChange(undefined)}>×</button>}
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A style that is either on (e.g. italic) or not set. */
|
|
186
|
+
function ToggleValueEditor({ id, field, value, onChange }) {
|
|
187
|
+
return (
|
|
188
|
+
<label className="xeplr-factory-toggle" htmlFor={id}>
|
|
189
|
+
<input id={id} type="checkbox" checked={value === field.on} onChange={(e) => onChange(e.target.checked ? field.on : undefined)} />
|
|
190
|
+
<span>{field.label}</span>
|
|
191
|
+
</label>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A table name: picked from the app's tables when it lists them, typed otherwise. */
|
|
196
|
+
function TableEditor({ id, value, onChange, tables }) {
|
|
197
|
+
if (tables && tables.items.length > 0) {
|
|
198
|
+
return (
|
|
199
|
+
<select id={id} className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) => onChange(e.target.value || undefined)}>
|
|
200
|
+
<option value="">{tables.loading ? 'Loading tables…' : '—'}</option>
|
|
201
|
+
{tables.items.map((t) => <option key={String(t.id)} value={String(t.id)}>{t.name}</option>)}
|
|
202
|
+
</select>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
return <input id={id} className="xeplr-factory-prop-input" type="text" placeholder={tables?.loading ? 'Loading tables…' : 'table name'} value={value ?? ''} onChange={(e) => onChange(e.target.value.trim() || undefined)} />
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A list's columns: tick which fields to show, in reading order. The fields
|
|
210
|
+
* are this screen's own — or, for a list screen, those of the screen it edits
|
|
211
|
+
* in. Nothing ticked means all of them.
|
|
212
|
+
*/
|
|
213
|
+
function ColumnsEditor({ value, onChange, doc, node, screens }) {
|
|
214
|
+
const editDoc = node && node.props.editScreen ? (screens || []).find((s) => s.id === node.props.editScreen)?.document : null
|
|
215
|
+
const from = doc && inputNodes(doc).length ? doc : editDoc
|
|
216
|
+
const fields = from
|
|
217
|
+
? inputNodes(from).map((n) => ({ field: n.props.name, label: n.props.label || n.props.name }))
|
|
218
|
+
: (Array.isArray(value) ? value : [])
|
|
219
|
+
const chosen = Array.isArray(value) ? value : null
|
|
220
|
+
const isOn = (f) => (chosen ? chosen.some((c) => c.field === f.field) : true)
|
|
221
|
+
const toggle = (f) => {
|
|
222
|
+
const current = chosen || fields
|
|
223
|
+
const next = isOn(f) ? current.filter((c) => c.field !== f.field) : fields.filter((x) => x.field === f.field || current.some((c) => c.field === x.field))
|
|
224
|
+
// All of them again is the same as "not set" — keep the document clean.
|
|
225
|
+
onChange(next.length === fields.length || next.length === 0 ? undefined : next.map((c) => ({ field: c.field, label: (current.find((x) => x.field === c.field) || c).label })))
|
|
226
|
+
}
|
|
227
|
+
if (!fields.length) return <div className="xeplr-factory-prop-help">Add fields to the screen first — the list shows them as columns.</div>
|
|
228
|
+
return (
|
|
229
|
+
<div className="xeplr-factory-checklist">
|
|
230
|
+
{fields.map((f) => (
|
|
231
|
+
<label key={f.field} className="xeplr-factory-toggle">
|
|
232
|
+
<input type="checkbox" checked={isOn(f)} onChange={() => toggle(f)} />
|
|
233
|
+
<span>{f.label}</span>
|
|
234
|
+
</label>
|
|
235
|
+
))}
|
|
236
|
+
</div>
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Another screen, by id: picked from the ones the app lists, typed otherwise. */
|
|
241
|
+
function ScreenEditor({ id, value, onChange, screens, doc }) {
|
|
242
|
+
const others = (screens || []).filter((s) => !doc || s.id !== doc.id)
|
|
243
|
+
if (others.length) {
|
|
244
|
+
return (
|
|
245
|
+
<select id={id} className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) => onChange(e.target.value || undefined)}>
|
|
246
|
+
<option value="">— open rows in this screen's fields</option>
|
|
247
|
+
{others.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
|
248
|
+
</select>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
return <input id={id} className="xeplr-factory-prop-input xeplr-factory-mono" type="text" placeholder="employee_edit" value={value ?? ''} onChange={(e) => onChange(e.target.value.trim() || undefined)} />
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const ACTION_LABELS = { new: 'New — add a record', edit: 'Edit — open a row for editing', delete: 'Delete — remove a row' }
|
|
255
|
+
|
|
256
|
+
function ActionsEditor({ value, onChange }) {
|
|
257
|
+
const on = Array.isArray(value) ? value : LIST_ACTIONS
|
|
258
|
+
return (
|
|
259
|
+
<div className="xeplr-factory-checklist">
|
|
260
|
+
{LIST_ACTIONS.map((a) => (
|
|
261
|
+
<label key={a} className="xeplr-factory-toggle">
|
|
262
|
+
<input type="checkbox" checked={on.includes(a)} onChange={(e) => onChange(e.target.checked ? LIST_ACTIONS.filter((x) => x === a || on.includes(x)) : on.filter((x) => x !== a))} />
|
|
263
|
+
<span>{ACTION_LABELS[a]}</span>
|
|
264
|
+
</label>
|
|
265
|
+
))}
|
|
266
|
+
</div>
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
109
270
|
function ToggleEditor({ id, field, value, onChange }) {
|
|
110
271
|
return (
|
|
111
272
|
<label className="xeplr-factory-toggle" htmlFor={id}>
|
|
@@ -116,8 +277,14 @@ function ToggleEditor({ id, field, value, onChange }) {
|
|
|
116
277
|
}
|
|
117
278
|
|
|
118
279
|
function SelectEditor({ id, field, value, onChange }) {
|
|
280
|
+
// Style selects may be unset ("inherit"); numeric option values stay numbers.
|
|
281
|
+
const optional = String(field.path || '').includes('.style.') || String(field.path || '').startsWith('style.')
|
|
119
282
|
return (
|
|
120
|
-
<select id={id} className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) =>
|
|
283
|
+
<select id={id} className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) => {
|
|
284
|
+
const hit = field.options.find((o) => String(o.value) === e.target.value)
|
|
285
|
+
onChange(hit ? hit.value : undefined)
|
|
286
|
+
}}>
|
|
287
|
+
{optional && <option value="">Default</option>}
|
|
121
288
|
{field.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
122
289
|
</select>
|
|
123
290
|
)
|
|
@@ -223,6 +390,13 @@ export const EDITORS = {
|
|
|
223
390
|
number: NumberEditor,
|
|
224
391
|
date: DateEditor,
|
|
225
392
|
toggle: ToggleEditor,
|
|
393
|
+
toggleValue: ToggleValueEditor,
|
|
226
394
|
select: SelectEditor,
|
|
395
|
+
font: FontEditor,
|
|
396
|
+
color: ColorEditor,
|
|
397
|
+
table: TableEditor,
|
|
398
|
+
screen: ScreenEditor,
|
|
399
|
+
columns: ColumnsEditor,
|
|
400
|
+
actions: ActionsEditor,
|
|
227
401
|
dataSource: DataSourceEditor
|
|
228
402
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
// The popup a list's Edit / New opens its edit screen in.
|
|
4
|
+
//
|
|
5
|
+
// A real dialog: focus moves in, Escape and the backdrop close it, and focus
|
|
6
|
+
// returns to whatever opened it. There is nothing to confirm on close — the
|
|
7
|
+
// screen inside has already saved itself.
|
|
8
|
+
|
|
9
|
+
export default function ScreenModal({ title, width, onClose, children }) {
|
|
10
|
+
const dialogRef = useRef(null)
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const opener = typeof document !== 'undefined' ? document.activeElement : null
|
|
14
|
+
dialogRef.current?.focus()
|
|
15
|
+
function onKey(e) { if (e.key === 'Escape') onClose() }
|
|
16
|
+
window.addEventListener('keydown', onKey)
|
|
17
|
+
return () => {
|
|
18
|
+
window.removeEventListener('keydown', onKey)
|
|
19
|
+
if (opener && opener.focus) opener.focus()
|
|
20
|
+
}
|
|
21
|
+
}, [onClose])
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="xeplr-factory-modal-backdrop" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose() }}>
|
|
25
|
+
<div
|
|
26
|
+
ref={dialogRef}
|
|
27
|
+
className="xeplr-factory-modal"
|
|
28
|
+
role="dialog"
|
|
29
|
+
aria-modal="true"
|
|
30
|
+
aria-label={title}
|
|
31
|
+
tabIndex={-1}
|
|
32
|
+
style={{ width: width ? `min(${width + 48}px, 96vw)` : undefined }}
|
|
33
|
+
>
|
|
34
|
+
<header className="xeplr-factory-modal-head">
|
|
35
|
+
<span className="xeplr-factory-modal-title">{title}</span>
|
|
36
|
+
<button type="button" className="xeplr-factory-modal-close" onClick={onClose} aria-label="Close">×</button>
|
|
37
|
+
</header>
|
|
38
|
+
<div className="xeplr-factory-modal-body">{children}</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|