@xeplr/ui-factory 1.0.13 → 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.
@@ -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
- // The screen saves itself into its own table exactly as it does anywhere else;
8
- // "Next" hands the run what was filled in, and the run says which screen comes
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
- done: () => run.submit(values.current, record.current),
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
  />
@@ -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
- return (
77
- <div className="xeplr-factory-list" style={style}>
78
- <div className="xeplr-factory-list-head">
79
- <span className="xeplr-factory-list-title">{p.title}</span>
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
- {actions.includes('new') && (
83
- <button type="button" className="xeplr-factory-list-new" onClick={onNew}>+ New</button>
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
- {!list.loading && !list.error && rows.length === 0 ? (
87
- <div className="xeplr-factory-list-empty">Nothing saved yet.</div>
88
- ) : (
89
- <XeplrTable
90
- data={rows}
91
- schema={{ 0: { key: listSource(doc, node) || 'records', columns: columns.map((c) => ({ accessor: c.field, header: c.label })) } }}
92
- pageSize={p.pageSize || 10}
93
- rowActions={rowActions.length ? rowActions : undefined}
94
- rowClassName={(row) => (currentId !== null && currentId !== undefined && row.__record && row.__record[recordKey] === currentId ? 'xeplr-factory-row-open' : null)}
95
- enableFiltering={rows.length > 5}
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
  }
@@ -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
- <span className={`xeplr-factory-palette-icon xeplr-factory-palette-icon--${c.type}`} aria-hidden="true" />
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
- export default function PropertyPanel({ doc, node, control, errors, tables, screens, lockedNames, onChange, onScreenChange, onRemove, selectionCount }) {
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. There is nothing to confirm on close — the
7
- // screen inside has already saved itself.
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
- // NO SUBMIT. The status line says what the background save is doing.
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: 'All changes saved',
21
- incomplete: 'Fill in the required fields to save',
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)
@@ -44,28 +60,67 @@ function StepNav({ ctrl, node, children }) {
44
60
  // "Step 2 of 4" counts the steps that apply, not the ones ruled out.
45
61
  const live = total - off.length
46
62
  const place = at - off.filter((i) => i < at).length + 1
63
+ // A screen that moves on to something else when it is finished — the next
64
+ // screen of a flow — has ONE way forward: this form's own steps first, then
65
+ // that. Two buttons both called Next, one of which skips the steps still to
66
+ // do, is how a journey gets left half filled in.
67
+ const handsOn = Boolean(children) && Boolean(ctrl.doneLabel)
68
+ const lastStep = forward === null
47
69
  return (
48
- <div className="xeplr-factory-stepnav">
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">
49
74
  <button type="button" className="xeplr-factory-secondary" onClick={() => go(back, 'back')} disabled={back === null}>Back</button>
50
- <span className="xeplr-factory-stepnav-where">Step {place} of {live}</span>
51
- <button type="button" className="xeplr-factory-primary" onClick={() => go(forward, 'next')} disabled={forward === null}>Next</button>
52
- {children}
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>
84
+ )}
85
+ {(!handsOn || lastStep) && children}
53
86
  </div>
54
87
  )
55
88
  }
56
89
 
57
- /** Leaves the page — after whatever is still being saved has gone out. */
58
- function DoneButton({ ctrl }) {
59
- const saving = ctrl.status === 'pending' || ctrl.status === 'saving'
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() })
60
112
  return (
61
- <button
62
- type="button"
63
- className={ctrl.doneLabel ? 'xeplr-factory-primary' : 'xeplr-factory-secondary'}
64
- disabled={!ctrl.canSave && ctrl.status === 'invalid'}
65
- onClick={() => ctrl.flush().then(ctrl.done, ctrl.done)}
66
- >
67
- {saving ? 'Saving…' : (ctrl.doneLabel || 'Done')}
68
- </button>
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
+ </>
69
124
  )
70
125
  }
71
126
 
@@ -89,26 +144,22 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
89
144
  const shown = ctrl.visibleNodes && ctrl.visibleNodes.length ? ctrl.visibleNodes : doc.nodes
90
145
  const pageHeight = (contentBottom(doc, shown) + MARGIN) * aspect * 100
91
146
  const fieldNodes = inputNodes(doc)
92
- const statusText = ctrl.status === 'error' ? `Not saved — ${ctrl.saveError}` : STATUS[ctrl.status]
93
- // A screen with no fields of its own (a list screen) has nothing to save.
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).
94
149
  const hasFields = fieldNodes.length > 0
150
+ const status = hasFields ? <SaveStatus ctrl={ctrl} /> : null
151
+ const steppers = ctrl.steps.nodes
95
152
 
96
153
  return (
97
154
  <div
98
- className={'xeplr-factory-screen' + (className ? ' ' + className : '')}
99
- 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 }}
100
157
  >
101
- {hasFields && (
102
- <div className={`xeplr-factory-status xeplr-factory-status--${ctrl.status}`} role="status" aria-live="polite">
103
- <span>{ctrl.recordId !== null && ctrl.recordId !== undefined ? 'Editing record' : 'New record'}</span>
104
- <span>{statusText}</span>
105
- </div>
106
- )}
107
158
  <form
108
159
  className="xeplr-factory-page"
109
160
  style={{ height: `${pageHeight}cqw` }}
110
- // Enter in a field saves now rather than posting a page.
111
- onSubmit={(e) => { e.preventDefault(); ctrl.saveNow() }}
161
+ // Never a submit: Enter posts nothing and saves nothing. Save is a button.
162
+ onSubmit={(e) => e.preventDefault()}
112
163
  noValidate
113
164
  aria-label={doc.name}
114
165
  >
@@ -148,22 +199,30 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
148
199
  extraActions: ctrl.actionsFor ? ctrl.actionsFor(node) : [],
149
200
  // A list with an edit screen opens it in a popup; one on a form
150
201
  // opens the row in the form's own fields.
151
- onEdit: (rec) => (node.props.editScreen ? ctrl.openEditor(node, rec) : ctrl.openRecord(rec)),
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))),
152
205
  // eslint-disable-next-line no-alert
153
206
  onDelete: (rec) => ctrl.deleteRecord(node, rec).catch((err) => window.alert(err.message || 'Could not delete')),
154
- onNew: () => (node.props.editScreen ? ctrl.openEditor(node, null) : ctrl.newRecord())
207
+ onNew: () => (node.props.editScreen ? ctrl.openEditor(node, null) : (ctrl.confirmDiscard() && ctrl.newRecord()))
155
208
  } : undefined}
156
209
  />
157
210
  </div>
158
211
  ))}
159
212
  </form>
160
- {ctrl.steps.nodes.map((node, i) => (
161
- <StepNav key={node.id} ctrl={ctrl} node={node}>
162
- {ctrl.done && i === ctrl.steps.nodes.length - 1 && <DoneButton ctrl={ctrl} />}
163
- </StepNav>
164
- ))}
165
- {ctrl.done && !ctrl.steps.nodes.length && (
166
- <div className="xeplr-factory-done"><DoneButton ctrl={ctrl} /></div>
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>
167
226
  )}
168
227
  {ctrl.popup && renderPopup && renderPopup(ctrl.popup, ctrl)}
169
228
  </div>