@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,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
|
+
}
|
|
@@ -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,30 @@
|
|
|
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; }
|
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'
|