@xeplr/ui-factory 1.0.14 → 1.1.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 +140 -18
- package/package.json +1 -1
- 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 +15 -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/useFactoryBuilder.js +65 -16
- package/src/useFactoryScreen.js +74 -39
- package/src/useScreenSource.js +81 -0
- package/src/validateDocument.js +5 -1
- package/src/values.js +18 -5
|
@@ -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)
|
|
@@ -11,23 +11,39 @@ import { CHOICE_TYPES } from '../controls.js'
|
|
|
11
11
|
// The screen is never wider than the width it was designed at, so what was
|
|
12
12
|
// designed at 800px is shown at 800px, and only a narrower screen scales it.
|
|
13
13
|
//
|
|
14
|
-
//
|
|
14
|
+
// SAVE IS A BUTTON, AND AN AJAX CALL — never a form submit. The <form> below
|
|
15
|
+
// exists for semantics only: its submit is swallowed, so Enter in a field
|
|
16
|
+
// posts nothing and saves nothing. Only the Save button saves.
|
|
17
|
+
//
|
|
18
|
+
// ONE FOOTER. Back, "Step 2 of 3" with the save status under it, then Next,
|
|
19
|
+
// Cancel and Save — a single bar under the fields, inset by the screen's
|
|
20
|
+
// margin so its edges line up with theirs.
|
|
15
21
|
|
|
16
22
|
const STATUS = {
|
|
17
23
|
idle: '',
|
|
18
|
-
pending: 'Unsaved changes
|
|
24
|
+
pending: 'Unsaved changes',
|
|
19
25
|
saving: 'Saving…',
|
|
20
|
-
saved: '
|
|
21
|
-
incomplete: 'Fill in the required fields
|
|
26
|
+
saved: 'Saved',
|
|
27
|
+
incomplete: 'Fill in the required fields',
|
|
22
28
|
invalid: 'Fix the highlighted fields to save'
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
/** What the background save is doing, in words. */
|
|
32
|
+
function SaveStatus({ ctrl }) {
|
|
33
|
+
const text = ctrl.status === 'error' ? `Not saved — ${ctrl.saveError}` : STATUS[ctrl.status]
|
|
34
|
+
return (
|
|
35
|
+
<span className={`xeplr-factory-footer-status xeplr-factory-footer-status--${ctrl.status}`} role="status" aria-live="polite">
|
|
36
|
+
{text}
|
|
37
|
+
</span>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
/**
|
|
26
42
|
* Back and Next, under the page. Next holds at a step whose own fields are not
|
|
27
43
|
* filled in — and shows why, rather than moving on and leaving a problem
|
|
28
44
|
* behind on a step nobody is looking at.
|
|
29
45
|
*/
|
|
30
|
-
function StepNav({ ctrl, node, children }) {
|
|
46
|
+
function StepNav({ ctrl, node, status, children }) {
|
|
31
47
|
const at = ctrl.steps.active(node.id)
|
|
32
48
|
const total = ctrl.steps.count(node.id)
|
|
33
49
|
const off = ctrl.steps.disabled(node.id)
|
|
@@ -51,29 +67,60 @@ function StepNav({ ctrl, node, children }) {
|
|
|
51
67
|
const handsOn = Boolean(children) && Boolean(ctrl.doneLabel)
|
|
52
68
|
const lastStep = forward === null
|
|
53
69
|
return (
|
|
54
|
-
|
|
70
|
+
// Moving between steps (Back, Next) sits on the left as quiet buttons;
|
|
71
|
+
// Save — the one thing that writes — is the only filled button, on the
|
|
72
|
+
// right. In a flow, Next IS the way forward, so there it stays filled.
|
|
73
|
+
<div className="xeplr-factory-footer xeplr-factory-stepnav">
|
|
55
74
|
<button type="button" className="xeplr-factory-secondary" onClick={() => go(back, 'back')} disabled={back === null}>Back</button>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
75
|
+
{!handsOn && (
|
|
76
|
+
<button type="button" className="xeplr-factory-secondary" onClick={() => go(forward, 'next')} disabled={lastStep}>Next</button>
|
|
77
|
+
)}
|
|
78
|
+
<div className="xeplr-factory-footer-mid">
|
|
79
|
+
<span className="xeplr-factory-stepnav-where">Step {place} of {live}</span>
|
|
80
|
+
{status}
|
|
81
|
+
</div>
|
|
82
|
+
{handsOn && !lastStep && (
|
|
83
|
+
<button type="button" className="xeplr-factory-primary" onClick={() => go(forward, 'next')}>Next</button>
|
|
59
84
|
)}
|
|
60
85
|
{(!handsOn || lastStep) && children}
|
|
61
86
|
</div>
|
|
62
87
|
)
|
|
63
88
|
}
|
|
64
89
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
90
|
+
/**
|
|
91
|
+
* The ways out, at the end of the footer.
|
|
92
|
+
*
|
|
93
|
+
* Save one AJAX call. On a page or in a popup (the host gave onDone) it
|
|
94
|
+
* then goes back; a form embedded in a page stays, showing "Saved".
|
|
95
|
+
* Cancel goes back without saving — asking first if anything is unsaved.
|
|
96
|
+
* Only where there is somewhere to go back to.
|
|
97
|
+
*
|
|
98
|
+
* Inside a flow the flow names the button (doneLabel, "Next"): it saves what
|
|
99
|
+
* is unsaved, then moves the journey on — one button, not two.
|
|
100
|
+
*/
|
|
101
|
+
function SaveButtons({ ctrl }) {
|
|
102
|
+
const saving = ctrl.status === 'saving'
|
|
103
|
+
if (ctrl.doneLabel) {
|
|
104
|
+
return (
|
|
105
|
+
<button type="button" className="xeplr-factory-primary" disabled={saving}
|
|
106
|
+
onClick={() => ctrl.flush().then((r) => { if (r && r.ok) ctrl.done(r) })}>
|
|
107
|
+
{ctrl.doneLabel}
|
|
108
|
+
</button>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
const save = () => ctrl.save().then((r) => { if (r && r.ok && ctrl.done) ctrl.done() })
|
|
68
112
|
return (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
113
|
+
<>
|
|
114
|
+
{ctrl.done && (
|
|
115
|
+
<button type="button" className="xeplr-factory-secondary" disabled={saving}
|
|
116
|
+
onClick={() => { if (ctrl.confirmDiscard()) ctrl.done() }}>
|
|
117
|
+
Cancel
|
|
118
|
+
</button>
|
|
119
|
+
)}
|
|
120
|
+
<button type="button" className="xeplr-factory-primary" disabled={saving} onClick={save}>
|
|
121
|
+
{saving ? 'Saving…' : 'Save'}
|
|
122
|
+
</button>
|
|
123
|
+
</>
|
|
77
124
|
)
|
|
78
125
|
}
|
|
79
126
|
|
|
@@ -97,26 +144,22 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
|
|
|
97
144
|
const shown = ctrl.visibleNodes && ctrl.visibleNodes.length ? ctrl.visibleNodes : doc.nodes
|
|
98
145
|
const pageHeight = (contentBottom(doc, shown) + MARGIN) * aspect * 100
|
|
99
146
|
const fieldNodes = inputNodes(doc)
|
|
100
|
-
|
|
101
|
-
//
|
|
147
|
+
// A screen with no fields of its own (a list screen) has nothing to save —
|
|
148
|
+
// and no sheet: its list is the card (factory.css, --bare).
|
|
102
149
|
const hasFields = fieldNodes.length > 0
|
|
150
|
+
const status = hasFields ? <SaveStatus ctrl={ctrl} /> : null
|
|
151
|
+
const steppers = ctrl.steps.nodes
|
|
103
152
|
|
|
104
153
|
return (
|
|
105
154
|
<div
|
|
106
|
-
className={'xeplr-factory-screen' + (className ? ' ' + className : '')}
|
|
107
|
-
style={{ maxWidth: doc.width, ...screenStyle(doc), ...style }}
|
|
155
|
+
className={'xeplr-factory-screen' + (hasFields ? '' : ' xeplr-factory-screen--bare') + (className ? ' ' + className : '')}
|
|
156
|
+
style={{ maxWidth: doc.width, '--xf-margin': `${MARGIN * 100}%`, ...screenStyle(doc), ...style }}
|
|
108
157
|
>
|
|
109
|
-
{hasFields && (
|
|
110
|
-
<div className={`xeplr-factory-status xeplr-factory-status--${ctrl.status}`} role="status" aria-live="polite">
|
|
111
|
-
<span>{ctrl.recordId !== null && ctrl.recordId !== undefined ? 'Editing record' : 'New record'}</span>
|
|
112
|
-
<span>{statusText}</span>
|
|
113
|
-
</div>
|
|
114
|
-
)}
|
|
115
158
|
<form
|
|
116
159
|
className="xeplr-factory-page"
|
|
117
160
|
style={{ height: `${pageHeight}cqw` }}
|
|
118
|
-
// Enter
|
|
119
|
-
onSubmit={(e) =>
|
|
161
|
+
// Never a submit: Enter posts nothing and saves nothing. Save is a button.
|
|
162
|
+
onSubmit={(e) => e.preventDefault()}
|
|
120
163
|
noValidate
|
|
121
164
|
aria-label={doc.name}
|
|
122
165
|
>
|
|
@@ -156,22 +199,30 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
|
|
|
156
199
|
extraActions: ctrl.actionsFor ? ctrl.actionsFor(node) : [],
|
|
157
200
|
// A list with an edit screen opens it in a popup; one on a form
|
|
158
201
|
// opens the row in the form's own fields.
|
|
159
|
-
|
|
202
|
+
// A list on the form itself swaps the record in the fields — so
|
|
203
|
+
// unsaved changes to the one showing are asked about first.
|
|
204
|
+
onEdit: (rec) => (node.props.editScreen ? ctrl.openEditor(node, rec) : (ctrl.confirmDiscard() && ctrl.openRecord(rec))),
|
|
160
205
|
// eslint-disable-next-line no-alert
|
|
161
206
|
onDelete: (rec) => ctrl.deleteRecord(node, rec).catch((err) => window.alert(err.message || 'Could not delete')),
|
|
162
|
-
onNew: () => (node.props.editScreen ? ctrl.openEditor(node, null) : ctrl.newRecord())
|
|
207
|
+
onNew: () => (node.props.editScreen ? ctrl.openEditor(node, null) : (ctrl.confirmDiscard() && ctrl.newRecord()))
|
|
163
208
|
} : undefined}
|
|
164
209
|
/>
|
|
165
210
|
</div>
|
|
166
211
|
))}
|
|
167
212
|
</form>
|
|
168
|
-
{
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
213
|
+
{steppers.map((node, i) => {
|
|
214
|
+
const last = i === steppers.length - 1
|
|
215
|
+
return (
|
|
216
|
+
<StepNav key={node.id} ctrl={ctrl} node={node} status={last ? status : null}>
|
|
217
|
+
{last && (hasFields || ctrl.doneLabel) && <SaveButtons ctrl={ctrl} />}
|
|
218
|
+
</StepNav>
|
|
219
|
+
)
|
|
220
|
+
})}
|
|
221
|
+
{!steppers.length && (hasFields || ctrl.doneLabel) && (
|
|
222
|
+
<div className="xeplr-factory-footer">
|
|
223
|
+
<div className="xeplr-factory-footer-mid">{status}</div>
|
|
224
|
+
<SaveButtons ctrl={ctrl} />
|
|
225
|
+
</div>
|
|
175
226
|
)}
|
|
176
227
|
{ctrl.popup && renderPopup && renderPopup(ctrl.popup, ctrl)}
|
|
177
228
|
</div>
|