@xeplr/ui-factory 1.0.14 → 1.2.0
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 +29 -5
- package/README.md +167 -20
- package/package.json +7 -2
- package/src/controls.js +19 -13
- package/src/designs/BuilderSample.jsx +77 -19
- package/src/designs/ControlView.jsx +10 -1
- package/src/designs/FlowRunnerSample.jsx +6 -4
- package/src/designs/ListView.jsx +40 -21
- package/src/designs/Palette.jsx +2 -1
- package/src/designs/PropertyPanel.jsx +57 -1
- package/src/designs/ScreenEditorSample.jsx +97 -0
- package/src/designs/ScreenModal.jsx +2 -2
- package/src/designs/ScreenSample.jsx +92 -41
- package/src/designs/factory.css +170 -76
- package/src/designs/index.js +4 -0
- package/src/designs/styles.js +11 -1
- package/src/generate.js +35 -10
- package/src/hooks.js +1 -1
- package/src/index.js +6 -2
- package/src/model.js +2 -1
- package/src/pages.jsx +18 -3
- package/src/presets.js +324 -0
- package/src/remote.js +7 -1
- package/src/scaffold.js +2 -2
- package/src/screenSource.js +91 -0
- package/src/tableSchema.js +109 -6
- package/src/urlState.js +71 -0
- package/src/useFactoryBuilder.js +97 -25
- package/src/useFactoryScreen.js +85 -47
- package/src/useFlowBuilder.js +7 -4
- package/src/useScreenSource.js +81 -0
- package/src/useUrlPlaces.js +48 -0
- package/src/validateDocument.js +5 -1
- package/src/values.js +18 -5
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
2
|
import { XeplrCanvas } from '@xeplr/ui-canvas'
|
|
3
3
|
import ControlView from './ControlView.jsx'
|
|
4
4
|
import Palette, { DRAG_TYPE } from './Palette.jsx'
|
|
5
5
|
import PropertyPanel from './PropertyPanel.jsx'
|
|
6
6
|
import { screenStyle } from './styles.js'
|
|
7
|
+
import { presetFor } from '../presets.js'
|
|
7
8
|
|
|
8
9
|
// The builder, drawn: name and save status on top, controls on the left, the
|
|
9
10
|
// screen on the canvas in the middle, properties on the right — the selected
|
|
@@ -14,12 +15,15 @@ import { screenStyle } from './styles.js'
|
|
|
14
15
|
// units with pageAspect = the screen's aspect: the same geometry ScreenSample
|
|
15
16
|
// renders with, so the builder shows the screen at its real size.
|
|
16
17
|
|
|
18
|
+
// NOTHING SAVES ITSELF — see the note in useFactoryBuilder. These say what
|
|
19
|
+
// IS, not what is about to happen on its own: "unsaved" is a fact about the
|
|
20
|
+
// screen, and the Save button beside it is what changes it.
|
|
17
21
|
const STATUS = {
|
|
18
22
|
idle: '',
|
|
19
|
-
|
|
23
|
+
unsaved: 'Unsaved changes',
|
|
20
24
|
saving: 'Saving…',
|
|
21
|
-
saved: '
|
|
22
|
-
invalid: '
|
|
25
|
+
saved: 'Saved',
|
|
26
|
+
invalid: 'Fix the problems to save',
|
|
23
27
|
error: 'Not saved'
|
|
24
28
|
}
|
|
25
29
|
|
|
@@ -30,8 +34,10 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
|
|
|
30
34
|
|
|
31
35
|
function onDrop(e) {
|
|
32
36
|
const type = e.dataTransfer.getData(DRAG_TYPE) || e.dataTransfer.getData('text/plain')
|
|
33
|
-
const
|
|
34
|
-
|
|
37
|
+
const preset = presetFor(type)
|
|
38
|
+
const base = ctrl.controls[preset ? preset.control : type]
|
|
39
|
+
if (!base) return
|
|
40
|
+
const def = { defaultSize: { ...base.defaultSize, ...(preset && preset.size) } }
|
|
35
41
|
e.preventDefault()
|
|
36
42
|
const inner = e.currentTarget.querySelector('.xeplr-canvas-inner')
|
|
37
43
|
if (!inner) return ctrl.addControl(type)
|
|
@@ -45,6 +51,16 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
|
|
|
45
51
|
})
|
|
46
52
|
}
|
|
47
53
|
|
|
54
|
+
// WITH NOTHING SAVING ITSELF, closing the tab is how work is lost. The
|
|
55
|
+
// browser's own question is the only one that can be asked this late, and
|
|
56
|
+
// it is asked ONLY when there is something to lose.
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!ctrl.dirty) return undefined
|
|
59
|
+
function ask(e) { e.preventDefault(); e.returnValue = '' }
|
|
60
|
+
window.addEventListener('beforeunload', ask)
|
|
61
|
+
return () => window.removeEventListener('beforeunload', ask)
|
|
62
|
+
}, [ctrl.dirty])
|
|
63
|
+
|
|
48
64
|
return (
|
|
49
65
|
<div className={'xeplr-factory-builder' + (className ? ' ' + className : '')} style={style}>
|
|
50
66
|
<header className="xeplr-factory-bar">
|
|
@@ -63,8 +79,32 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
|
|
|
63
79
|
{problemCount} problem{problemCount === 1 ? '' : 's'}
|
|
64
80
|
</span>
|
|
65
81
|
)}
|
|
66
|
-
{
|
|
67
|
-
|
|
82
|
+
{/* Suggestions, not problems: they never stop a save or a publish.
|
|
83
|
+
Clicking one opens the field, where the panel offers the change. */}
|
|
84
|
+
{ctrl.hints.length > 0 && (
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
className="xeplr-factory-hints"
|
|
88
|
+
title={ctrl.hints.map((h) => h.message).join('\n')}
|
|
89
|
+
onClick={() => ctrl.setSelected(new Set([ctrl.hints[0].nodeId]))}
|
|
90
|
+
>
|
|
91
|
+
{ctrl.hints.length} suggestion{ctrl.hints.length === 1 ? '' : 's'}
|
|
92
|
+
</button>
|
|
93
|
+
)}
|
|
94
|
+
{/* SAVE IS AN ACT. It is offered whenever there is something to save
|
|
95
|
+
(and after a failure, which is the same thing), and it is the only
|
|
96
|
+
thing that writes the draft — trying a layout out and walking away
|
|
97
|
+
leaves the saved screen as it was. */}
|
|
98
|
+
{(ctrl.dirty || ctrl.status === 'error') && (
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
className="xeplr-factory-secondary"
|
|
102
|
+
onClick={ctrl.save}
|
|
103
|
+
disabled={ctrl.saving || !ctrl.validation.ok}
|
|
104
|
+
title={ctrl.validation.ok ? 'Save this design' : 'Fix the problems first'}
|
|
105
|
+
>
|
|
106
|
+
{ctrl.status === 'error' ? 'Try saving again' : ctrl.saving ? 'Saving…' : 'Save'}
|
|
107
|
+
</button>
|
|
68
108
|
)}
|
|
69
109
|
{renderPreview && (
|
|
70
110
|
<button type="button" className="xeplr-factory-secondary" aria-pressed={preview} onClick={() => setPreview((p) => !p)}>
|
|
@@ -79,20 +119,35 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
|
|
|
79
119
|
</header>
|
|
80
120
|
|
|
81
121
|
{ctrl.publishResult && ctrl.publishResult.confirm && (
|
|
82
|
-
<div className="xeplr-factory-publish is-confirm" role="alertdialog" aria-label="Confirm
|
|
122
|
+
<div className="xeplr-factory-publish is-confirm" role="alertdialog" aria-label="Confirm changes to saved data">
|
|
83
123
|
<div className="xeplr-factory-confirm-text">
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
124
|
+
{ctrl.publishResult.confirm.length > 0 && (
|
|
125
|
+
<>
|
|
126
|
+
<strong>Publishing removes {ctrl.publishResult.confirm.length === 1 ? 'a column' : `${ctrl.publishResult.confirm.length} columns`} and all the data in {ctrl.publishResult.confirm.length === 1 ? 'it' : 'them'}.</strong>
|
|
127
|
+
<ul>
|
|
128
|
+
{ctrl.publishResult.confirm.map((c) => (
|
|
129
|
+
<li key={c.column}><code>{c.column}</code> — {c.records} saved value{c.records === 1 ? '' : 's'}, for every company using this table</li>
|
|
130
|
+
))}
|
|
131
|
+
</ul>
|
|
132
|
+
This cannot be undone. To keep the data, put the field back before publishing.
|
|
133
|
+
</>
|
|
134
|
+
)}
|
|
135
|
+
{/* A field that changed kind: every saved value was checked and fits. */}
|
|
136
|
+
{(ctrl.publishResult.convert || []).length > 0 && (
|
|
137
|
+
<>
|
|
138
|
+
<strong>Publishing converts saved values to their field's new kind.</strong>
|
|
139
|
+
<ul>
|
|
140
|
+
{ctrl.publishResult.convert.map((c) => (
|
|
141
|
+
<li key={c.column}><code>{c.column}</code> — {c.from} → {c.to}: all {c.records} saved value{c.records === 1 ? '' : 's'} fit; blank ones become empty</li>
|
|
142
|
+
))}
|
|
143
|
+
</ul>
|
|
144
|
+
</>
|
|
145
|
+
)}
|
|
91
146
|
</div>
|
|
92
147
|
<div className="xeplr-factory-confirm-actions">
|
|
93
|
-
<button type="button" className=
|
|
94
|
-
onClick={() => ctrl.publish(ctrl.publishResult.confirm.map((c) => c.column))}>
|
|
95
|
-
{ctrl.publishing ? 'Publishing…' : 'Remove and publish'}
|
|
148
|
+
<button type="button" className={ctrl.publishResult.confirm.length ? 'xeplr-factory-danger' : 'xeplr-factory-primary'} disabled={ctrl.publishing}
|
|
149
|
+
onClick={() => ctrl.publish(ctrl.publishResult.confirm.map((c) => c.column), (ctrl.publishResult.convert || []).map((c) => c.column))}>
|
|
150
|
+
{ctrl.publishing ? 'Publishing…' : ctrl.publishResult.confirm.length ? 'Remove and publish' : 'Convert and publish'}
|
|
96
151
|
</button>
|
|
97
152
|
<button type="button" className="xeplr-factory-secondary" onClick={ctrl.clearPublishResult}>Cancel</button>
|
|
98
153
|
</div>
|
|
@@ -179,12 +234,15 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
|
|
|
179
234
|
doc={doc}
|
|
180
235
|
node={ctrl.selectedNode}
|
|
181
236
|
control={ctrl.selectedControl}
|
|
237
|
+
controls={ctrl.controls}
|
|
182
238
|
selectionCount={ctrl.selected.size}
|
|
183
239
|
errors={ctrl.selectedNode ? ctrl.errorsByNode[ctrl.selectedNode.id] : ctrl.errorsByNode._document}
|
|
184
240
|
tables={ctrl.tables}
|
|
185
241
|
screens={ctrl.screens}
|
|
186
242
|
lockedNames={ctrl.lockedNames}
|
|
187
243
|
onChange={(path, value) => ctrl.setProperty(ctrl.selectedNode.id, path, value)}
|
|
244
|
+
onConvert={(target) => ctrl.convertControl(ctrl.selectedNode.id, target)}
|
|
245
|
+
hint={ctrl.selectedNode ? ctrl.hints.find((h) => h.nodeId === ctrl.selectedNode.id) : null}
|
|
188
246
|
onScreenChange={ctrl.setScreen}
|
|
189
247
|
onRemove={ctrl.removeSelected}
|
|
190
248
|
/>
|
|
@@ -65,12 +65,21 @@ function inputProps(node, p, error, disabled, onBlur) {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// What each keyboard setting makes the box: its type (the phone keyboard) and
|
|
69
|
+
// what the browser's autofill offers.
|
|
70
|
+
const TEXT_INPUT = {
|
|
71
|
+
email: { type: 'email', autoComplete: 'email', inputMode: 'email' },
|
|
72
|
+
tel: { type: 'tel', autoComplete: 'tel', inputMode: 'tel' },
|
|
73
|
+
url: { type: 'url', autoComplete: 'url', inputMode: 'url' }
|
|
74
|
+
}
|
|
75
|
+
|
|
68
76
|
function TextView({ node, p, value, error, onChange, onBlur, disabled }) {
|
|
77
|
+
const kind = TEXT_INPUT[p.inputType] || { type: 'text' }
|
|
69
78
|
return (
|
|
70
79
|
<Field node={node} p={p} error={error}>
|
|
71
80
|
<input
|
|
72
81
|
className="xeplr-factory-input"
|
|
73
|
-
|
|
82
|
+
{...kind}
|
|
74
83
|
placeholder={p.placeholder || ''}
|
|
75
84
|
value={value ?? ''}
|
|
76
85
|
onChange={(e) => onChange(e.target.value)}
|
|
@@ -4,9 +4,9 @@ import ScreenSample from './ScreenSample.jsx'
|
|
|
4
4
|
// A flow, running: the screen the run is parked on, and one button forward.
|
|
5
5
|
//
|
|
6
6
|
// Presentation only — everything comes from useFlowRun and useFactoryScreen.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// after it. Nothing here knows the journey.
|
|
7
|
+
// "Next" saves the screen into its own table (one AJAX call, only if anything
|
|
8
|
+
// is unsaved), then hands the run what was filled in, and the run says which
|
|
9
|
+
// screen comes after it. Nothing here knows the journey.
|
|
10
10
|
|
|
11
11
|
export default function FlowRunnerSample({ run, screen, className, style, title, renderDone }) {
|
|
12
12
|
const values = useRef({})
|
|
@@ -47,7 +47,9 @@ export default function FlowRunnerSample({ run, screen, className, style, title,
|
|
|
47
47
|
...screen,
|
|
48
48
|
// The flow's forward button IS the screen's own, renamed: one button,
|
|
49
49
|
// not two that both look like the way on.
|
|
50
|
-
|
|
50
|
+
// The id comes from the save that Next just made when there was one:
|
|
51
|
+
// a new record's id has not reached the ref yet (that is a render away).
|
|
52
|
+
done: (r) => run.submit(values.current, r && r.record && r.record[screen.recordKey] != null ? r.record[screen.recordKey] : record.current),
|
|
51
53
|
doneLabel: run.sending ? 'Saving…' : 'Next'
|
|
52
54
|
}}
|
|
53
55
|
/>
|
package/src/designs/ListView.jsx
CHANGED
|
@@ -73,28 +73,47 @@ export default function ListView({ node, doc, design, list, onEdit, onDelete, on
|
|
|
73
73
|
rowActions.push({ key: 'extra-' + i + '-' + a.label, label: a.label, variant: a.variant, onClick: (row) => a.onClick(row.__record) })
|
|
74
74
|
})
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
76
|
+
const pageSize = p.pageSize || 10
|
|
77
|
+
const title = <span className="xeplr-factory-list-title">{p.title}</span>
|
|
78
|
+
const notes = (
|
|
79
|
+
<>
|
|
80
|
+
{list.loading && <span className="xeplr-factory-hint">Loading…</span>}
|
|
81
|
+
{list.error && <span className="xeplr-factory-error-inline" role="alert">{list.error}</span>}
|
|
82
|
+
</>
|
|
83
|
+
)
|
|
84
|
+
const newButton = actions.includes('new') && (
|
|
85
|
+
<button type="button" className="xeplr-factory-list-new" onClick={onNew}>+ New</button>
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
// ONE CARD. With rows, the title and + New ride in the table's own toolbar
|
|
89
|
+
// rather than in a header bar stacked on top of it — two bars, two frames,
|
|
90
|
+
// and a "Clear Sort" nobody asked for. Paging shows only when there is more
|
|
91
|
+
// than one page of it.
|
|
92
|
+
// Nothing to show yet: the header alone, over the empty state. (Loading and
|
|
93
|
+
// errors over rows already shown stay in the toolbar — the table does not
|
|
94
|
+
// blink away on every refresh.)
|
|
95
|
+
if (rows.length === 0) {
|
|
96
|
+
return (
|
|
97
|
+
<div className="xeplr-factory-list" style={style}>
|
|
98
|
+
<div className="xeplr-factory-list-head">{title}{notes}{newButton}</div>
|
|
99
|
+
{!list.loading && !list.error && <div className="xeplr-factory-list-empty">Nothing saved yet.</div>}
|
|
85
100
|
</div>
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className="xeplr-factory-list xeplr-factory-list--table" style={style}>
|
|
106
|
+
<XeplrTable
|
|
107
|
+
data={rows}
|
|
108
|
+
schema={{ 0: { key: listSource(doc, node) || 'records', columns: columns.map((c) => ({ accessor: c.field, header: c.label })) } }}
|
|
109
|
+
pageSize={pageSize}
|
|
110
|
+
enablePagination={rows.length > pageSize}
|
|
111
|
+
rowActions={rowActions.length ? rowActions : undefined}
|
|
112
|
+
rowClassName={(row) => (currentId !== null && currentId !== undefined && row.__record && row.__record[recordKey] === currentId ? 'xeplr-factory-row-open' : null)}
|
|
113
|
+
enableFiltering={rows.length > 5}
|
|
114
|
+
toolbarLeading={() => <>{title}{notes}</>}
|
|
115
|
+
toolbarActions={() => newButton || null}
|
|
116
|
+
/>
|
|
98
117
|
</div>
|
|
99
118
|
)
|
|
100
119
|
}
|
package/src/designs/Palette.jsx
CHANGED
|
@@ -23,7 +23,8 @@ export default function Palette({ groups, onAdd }) {
|
|
|
23
23
|
onClick={() => onAdd(c.type)}
|
|
24
24
|
title={`Drag onto the screen, or click to add ${c.label.toLowerCase()} at the bottom`}
|
|
25
25
|
>
|
|
26
|
-
|
|
26
|
+
{/* A ready-made field wears the icon of the control it is made of. */}
|
|
27
|
+
<span className={`xeplr-factory-palette-icon xeplr-factory-palette-icon--${c.icon || c.type}`} aria-hidden="true" />
|
|
27
28
|
{c.label}
|
|
28
29
|
</button>
|
|
29
30
|
))}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
1
2
|
import { getAtPath } from '../propertyPath.js'
|
|
3
|
+
import { FIELD_PRESETS, presetFor } from '../presets.js'
|
|
2
4
|
import { slugify, inputNodes } from '../document.js'
|
|
3
5
|
import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS, FILE_KINDS } from '../controls.js'
|
|
4
6
|
|
|
@@ -6,7 +8,57 @@ import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS, FILE_KINDS
|
|
|
6
8
|
// each field there names a path and an editor type, and this file owns only
|
|
7
9
|
// what each editor type looks like. A new property is a line in controls.js.
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
/**
|
|
12
|
+
* WHAT KIND OF FIELD THIS IS — at the top of every field's panel, and
|
|
13
|
+
* changeable at any time: a plain control (Text, Number…) or a ready-made one
|
|
14
|
+
* (Email, LinkedIn profile, Age…). Changing it keeps the label, name, position
|
|
15
|
+
* and required, and says what did not carry over.
|
|
16
|
+
*
|
|
17
|
+
* Under it, when the label reads like a ready-made field this is not ("LinkedIn
|
|
18
|
+
* URL" on a plain text box), the offer to make it one — or to stop asking.
|
|
19
|
+
*/
|
|
20
|
+
function FieldType({ node, controls, hint, onConvert, onKeepPlain }) {
|
|
21
|
+
const [note, setNote] = useState(null)
|
|
22
|
+
useEffect(() => { setNote(null) }, [node.id])
|
|
23
|
+
const registry = controls || {}
|
|
24
|
+
const plain = Object.values(registry).filter((c) => c.input)
|
|
25
|
+
const ready = FIELD_PRESETS.filter((p) => registry[p.control] && registry[p.control].input)
|
|
26
|
+
const current = presetFor(node.props && node.props.preset) ? 'preset:' + node.props.preset : node.type
|
|
27
|
+
const change = (target) => {
|
|
28
|
+
if (target === current) return
|
|
29
|
+
const dropped = onConvert(target) || []
|
|
30
|
+
const name = presetFor(target) ? presetFor(target).label : (registry[target] && registry[target].label)
|
|
31
|
+
setNote(dropped.length ? `Now ${name}. Not carried over: ${dropped.join(', ')}.` : `Now ${name}.`)
|
|
32
|
+
}
|
|
33
|
+
const suggested = hint && presetFor(hint.preset)
|
|
34
|
+
return (
|
|
35
|
+
<div className="xeplr-factory-fieldtype">
|
|
36
|
+
<label className="xeplr-factory-prop-label" htmlFor={`xf-type-${node.id}`}>Field type</label>
|
|
37
|
+
<select id={`xf-type-${node.id}`} className="xeplr-factory-prop-input" value={current} onChange={(e) => change(e.target.value)}>
|
|
38
|
+
<optgroup label="Controls">
|
|
39
|
+
{plain.map((c) => <option key={c.type} value={c.type}>{c.label}</option>)}
|
|
40
|
+
</optgroup>
|
|
41
|
+
{ready.length > 0 && (
|
|
42
|
+
<optgroup label="Ready-made fields">
|
|
43
|
+
{ready.map((p) => <option key={p.key} value={'preset:' + p.key}>{p.label}</option>)}
|
|
44
|
+
</optgroup>
|
|
45
|
+
)}
|
|
46
|
+
</select>
|
|
47
|
+
{note && <div className="xeplr-factory-prop-help" role="status">{note}</div>}
|
|
48
|
+
{suggested && (
|
|
49
|
+
<div className="xeplr-factory-suggest" role="note">
|
|
50
|
+
<span>{hint.message}.</span>
|
|
51
|
+
<div className="xeplr-factory-suggest-actions">
|
|
52
|
+
<button type="button" className="xeplr-factory-primary" onClick={() => change('preset:' + suggested.key)}>Make it {suggested.label}</button>
|
|
53
|
+
<button type="button" className="xeplr-factory-secondary" onClick={onKeepPlain}>Keep as is</button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default function PropertyPanel({ doc, node, control, controls, errors, tables, screens, lockedNames, onChange, onConvert, hint, onScreenChange, onRemove, selectionCount }) {
|
|
10
62
|
if (selectionCount > 1) {
|
|
11
63
|
return (
|
|
12
64
|
<aside className="xeplr-factory-panel">
|
|
@@ -30,6 +82,10 @@ export default function PropertyPanel({ doc, node, control, errors, tables, scre
|
|
|
30
82
|
<button type="button" className="xeplr-factory-link-danger" onClick={onRemove} title="Delete (Del)">Delete</button>
|
|
31
83
|
</header>
|
|
32
84
|
|
|
85
|
+
{control.input && onConvert && (
|
|
86
|
+
<FieldType node={node} controls={controls} hint={hint} onConvert={onConvert} onKeepPlain={() => onChange('props.preset', false)} />
|
|
87
|
+
)}
|
|
88
|
+
|
|
33
89
|
{control.properties.map((group) => (
|
|
34
90
|
<fieldset key={group.key} className="xeplr-factory-group">
|
|
35
91
|
<legend>{group.title}</legend>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// DESIGNING A FORM WHERE IT IS USED — the body of another package's modal.
|
|
2
|
+
//
|
|
3
|
+
// @xeplr/ui-workflow's designer offers a Screen step and a `screenEditor`
|
|
4
|
+
// render prop: it owns the window, the host owns what is inside it, because a
|
|
5
|
+
// screen belongs to the app and that package knows only a screen key. This is
|
|
6
|
+
// @xeplr/factory's answer to it, so that no application has to write one.
|
|
7
|
+
//
|
|
8
|
+
// Shaped to that contract exactly — ({ screenKey, onDone }) — but it is not
|
|
9
|
+
// about workflow: anything wanting "design this screen, tell me what it holds
|
|
10
|
+
// when it is saved" uses the same component. `screenKey` null means make one.
|
|
11
|
+
//
|
|
12
|
+
// PUBLISH IS WHAT COUNTS. A draft changes nothing anybody can run; publishing
|
|
13
|
+
// is what changes the table and makes the fields real, so that is the moment
|
|
14
|
+
// `onDone` fires and whoever is using the screen is told what it now holds.
|
|
15
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
16
|
+
import { describeScreen, keyFromName, editScreenOf } from '../screenSource.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @prop api a createFactoryApi() instance
|
|
20
|
+
* @prop screenKey the screen to design; null to make a new one
|
|
21
|
+
* @prop onDone ({ key, name, fields }) → void, on publish
|
|
22
|
+
* @prop labels optional copy overrides
|
|
23
|
+
*/
|
|
24
|
+
export function ScreenEditorSample({ api, screenKey, onDone, Builder, labels = {} }) {
|
|
25
|
+
const [current, setCurrent] = useState(null) // { key, document, lockedNames }
|
|
26
|
+
const [name, setName] = useState('')
|
|
27
|
+
const [busy, setBusy] = useState(false)
|
|
28
|
+
const [error, setError] = useState(null)
|
|
29
|
+
|
|
30
|
+
const open = useCallback(async (key) => {
|
|
31
|
+
// The draft if there is one, else what is published — with the names that
|
|
32
|
+
// are already columns and therefore cannot be renamed.
|
|
33
|
+
const row = await api.loadDraft(key)
|
|
34
|
+
setCurrent({ key, document: row.document, lockedNames: row.lockedNames || [] })
|
|
35
|
+
}, [api])
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!screenKey) return
|
|
39
|
+
open(screenKey).catch((e) => setError(e.message))
|
|
40
|
+
}, [screenKey, open])
|
|
41
|
+
|
|
42
|
+
// A NEW FORM IS A NEW ENTITY: createEntity makes both screens (the add/edit
|
|
43
|
+
// form and its list) as drafts. The form half is the one a step shows.
|
|
44
|
+
const create = async (e) => {
|
|
45
|
+
e.preventDefault()
|
|
46
|
+
const label = name.trim()
|
|
47
|
+
if (!label || busy) return
|
|
48
|
+
setBusy(true)
|
|
49
|
+
try {
|
|
50
|
+
const key = keyFromName(label)
|
|
51
|
+
const made = await api.createEntity({ entity: key })
|
|
52
|
+
await open(editScreenOf(made, key + '_edit'))
|
|
53
|
+
setError(null)
|
|
54
|
+
} catch (err) {
|
|
55
|
+
setError(err.message)
|
|
56
|
+
} finally {
|
|
57
|
+
setBusy(false)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const publish = useCallback(async (doc, options) => {
|
|
62
|
+
const result = await api.publish(doc, options)
|
|
63
|
+
const row = await api.loadDraft(doc.id)
|
|
64
|
+
setCurrent((c) => (c && c.key === doc.id ? { ...c, lockedNames: row.lockedNames || [] } : c))
|
|
65
|
+
if (onDone) onDone(describeScreen(doc))
|
|
66
|
+
return result
|
|
67
|
+
}, [api, onDone])
|
|
68
|
+
|
|
69
|
+
if (error) return <div className="xf-screen-editor-error">{error}</div>
|
|
70
|
+
|
|
71
|
+
if (!current) {
|
|
72
|
+
if (screenKey) return <p className="xf-screen-editor-wait">{labels.opening || 'Opening'} {screenKey}…</p>
|
|
73
|
+
return (
|
|
74
|
+
<form className="xf-screen-editor-new" onSubmit={create}>
|
|
75
|
+
<label>
|
|
76
|
+
<span>{labels.nameIt || 'What is this form called?'}</span>
|
|
77
|
+
<input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder={labels.placeholder || 'Leave request'} />
|
|
78
|
+
</label>
|
|
79
|
+
<button type="submit" disabled={!name.trim() || busy}>
|
|
80
|
+
{busy ? (labels.making || 'Making it…') : (labels.make || 'Make the form')}
|
|
81
|
+
</button>
|
|
82
|
+
</form>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<Builder
|
|
88
|
+
key={current.key}
|
|
89
|
+
{...api.builderProps}
|
|
90
|
+
document={current.document}
|
|
91
|
+
lockedNames={current.lockedNames}
|
|
92
|
+
onPublish={publish}
|
|
93
|
+
/>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default ScreenEditorSample
|
|
@@ -3,8 +3,8 @@ import { useEffect, useRef } from 'react'
|
|
|
3
3
|
// The popup a list's Edit / New opens its edit screen in.
|
|
4
4
|
//
|
|
5
5
|
// A real dialog: focus moves in, Escape and the backdrop close it, and focus
|
|
6
|
-
// returns to whatever opened it.
|
|
7
|
-
//
|
|
6
|
+
// returns to whatever opened it. Closing is the host's onClose, which asks
|
|
7
|
+
// before dropping unsaved changes (FactoryScreen in pages.jsx).
|
|
8
8
|
|
|
9
9
|
export default function ScreenModal({ title, width, onClose, children }) {
|
|
10
10
|
const dialogRef = useRef(null)
|