@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.
@@ -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
- pending: 'Unsaved changes…',
23
+ unsaved: 'Unsaved changes',
20
24
  saving: 'Saving…',
21
- saved: 'All changes saved',
22
- invalid: 'Not saved — fix the problems',
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 def = ctrl.controls[type]
34
- if (!def) return
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
- {ctrl.status === 'error' && (
67
- <button type="button" className="xeplr-factory-secondary" onClick={ctrl.save}>Retry</button>
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 removing columns">
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
- <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>
85
- <ul>
86
- {ctrl.publishResult.confirm.map((c) => (
87
- <li key={c.column}><code>{c.column}</code> — {c.records} saved value{c.records === 1 ? '' : 's'}, for every company using this table</li>
88
- ))}
89
- </ul>
90
- This cannot be undone. To keep the data, put the field back before publishing.
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="xeplr-factory-danger" disabled={ctrl.publishing}
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
- type="text"
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
- // 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)