@xeplr/ui-factory 1.2.0 → 1.4.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/README.md +201 -0
- package/bin/xeplr-factory.js +2 -1
- package/package.json +1 -1
- package/src/controls.js +97 -2
- package/src/designs/BuilderSample.jsx +47 -1
- package/src/designs/ControlView.jsx +73 -2
- package/src/designs/ListView.jsx +3 -1
- package/src/designs/PropertyPanel.jsx +237 -7
- package/src/designs/ScreenSample.jsx +15 -9
- package/src/designs/factory.css +64 -0
- package/src/designs/styles.js +1 -1
- package/src/document.js +51 -0
- package/src/index.js +3 -1
- package/src/masters.js +193 -0
- package/src/model.js +5 -0
- package/src/pages.jsx +37 -1
- package/src/remote.js +22 -2
- package/src/screenSource.js +85 -2
- package/src/tableSchema.js +16 -0
- package/src/useFactoryBuilder.js +103 -2
- package/src/useFactoryScreen.js +202 -29
- package/src/useFlowRun.js +6 -3
- package/src/useScreenSource.js +8 -4
- package/src/validateDocument.js +82 -1
- package/src/values.js +118 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
2
|
import { getAtPath } from '../propertyPath.js'
|
|
3
3
|
import { FIELD_PRESETS, presetFor } from '../presets.js'
|
|
4
|
-
import { slugify, inputNodes } from '../document.js'
|
|
4
|
+
import { slugify, inputNodes, paramsText } from '../document.js'
|
|
5
5
|
import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS, FILE_KINDS } from '../controls.js'
|
|
6
6
|
|
|
7
7
|
// The selected control's properties, GENERATED from its entry in controls.js:
|
|
@@ -58,7 +58,7 @@ function FieldType({ node, controls, hint, onConvert, onKeepPlain }) {
|
|
|
58
58
|
)
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
export default function PropertyPanel({ doc, node, control, controls, errors, tables, screens, lockedNames, onChange, onConvert, hint, onScreenChange, onRemove, selectionCount }) {
|
|
61
|
+
export default function PropertyPanel({ doc, node, control, controls, errors, tables, screens, lockedNames, onChange, onConvert, hint, onScreenChange, onRemove, onMakeChildTable, selectionCount }) {
|
|
62
62
|
if (selectionCount > 1) {
|
|
63
63
|
return (
|
|
64
64
|
<aside className="xeplr-factory-panel">
|
|
@@ -109,6 +109,7 @@ export default function PropertyPanel({ doc, node, control, controls, errors, ta
|
|
|
109
109
|
node={node}
|
|
110
110
|
doc={doc}
|
|
111
111
|
errors={errs}
|
|
112
|
+
onMakeChildTable={onMakeChildTable}
|
|
112
113
|
/>
|
|
113
114
|
{locked
|
|
114
115
|
? <div className="xeplr-factory-prop-help">Saved as a column of <code>{doc?.source}</code> — renaming it would leave its data behind. A rename is a migration.</div>
|
|
@@ -149,7 +150,15 @@ function ScreenPanel({ doc, errors, tables, onChange }) {
|
|
|
149
150
|
return (
|
|
150
151
|
<div key={field.path} className={`xeplr-factory-prop${msg ? ' has-error' : ''}`}>
|
|
151
152
|
{field.type !== 'toggle' && field.type !== 'toggleValue' && <label className="xeplr-factory-prop-label" htmlFor={id}>{field.label}</label>}
|
|
152
|
-
<Editor
|
|
153
|
+
<Editor
|
|
154
|
+
id={id}
|
|
155
|
+
field={field}
|
|
156
|
+
value={field.path === 'paramsLine' ? paramsText(doc) : getAtPath(doc, field.path)}
|
|
157
|
+
onChange={(v) => onChange(field.path, v)}
|
|
158
|
+
tables={tables}
|
|
159
|
+
errors={[]}
|
|
160
|
+
{...extra}
|
|
161
|
+
/>
|
|
153
162
|
{field.help && <div className="xeplr-factory-prop-help">{field.help}</div>}
|
|
154
163
|
{msg && <div className="xeplr-factory-prop-error">{msg}</div>}
|
|
155
164
|
</div>
|
|
@@ -161,9 +170,48 @@ function ScreenPanel({ doc, errors, tables, onChange }) {
|
|
|
161
170
|
<span className="xeplr-factory-panel-type">Screen</span>
|
|
162
171
|
</header>
|
|
163
172
|
<p className="xeplr-factory-panel-empty">Select a control to change it, or drag one in from the left.</p>
|
|
173
|
+
<fieldset className="xeplr-factory-group">
|
|
174
|
+
<legend>About</legend>
|
|
175
|
+
{/* WHAT IT IS FOR. When a lot of people can make forms, a lot of
|
|
176
|
+
people make the same one — and the fields say what a form holds,
|
|
177
|
+
never what it was for or whether this is the one still in use. */}
|
|
178
|
+
{row({
|
|
179
|
+
path: 'description',
|
|
180
|
+
label: 'What this form is for',
|
|
181
|
+
type: 'textarea',
|
|
182
|
+
help: 'One sentence. It is what tells this apart from the other three called the same thing.'
|
|
183
|
+
}, TextareaEditor)}
|
|
184
|
+
</fieldset>
|
|
164
185
|
<fieldset className="xeplr-factory-group">
|
|
165
186
|
<legend>Data</legend>
|
|
166
187
|
{row({ path: 'source', label: 'Saves to', type: 'table', help: 'The table this screen\'s records are saved in' }, TableEditor)}
|
|
188
|
+
{/* A MASTER is a list other forms point at — departments, statuses,
|
|
189
|
+
countries. A flag on an ordinary form, not a kind of its own. */}
|
|
190
|
+
{row({
|
|
191
|
+
path: 'master',
|
|
192
|
+
label: 'This is a master',
|
|
193
|
+
type: 'toggle',
|
|
194
|
+
help: 'A list other forms choose from. Masters are kept together in the admin section.'
|
|
195
|
+
}, EDITORS.toggle || TextEditor)}
|
|
196
|
+
{doc.master && row({
|
|
197
|
+
path: 'labelField',
|
|
198
|
+
label: 'Shown as',
|
|
199
|
+
type: 'select',
|
|
200
|
+
options: [{ value: '', label: '— choose a field —' }].concat(
|
|
201
|
+
inputNodes(doc).map((n) => ({ value: n.props.name, label: n.props.label || n.props.name }))
|
|
202
|
+
),
|
|
203
|
+
help: 'What a picker shows for a record. Said once here, not on every dropdown.'
|
|
204
|
+
}, EDITORS.select || TextEditor)}
|
|
205
|
+
{/* WHAT THIS FORM IS CALLED WITH. One line, because that is the whole
|
|
206
|
+
question — "this form needs contentId, age, name". A field then
|
|
207
|
+
binds to one with {params.contentId} as its default, the same
|
|
208
|
+
brace used in a workflow step or a report. */}
|
|
209
|
+
{row({
|
|
210
|
+
path: 'paramsLine',
|
|
211
|
+
label: 'Inputs',
|
|
212
|
+
type: 'text',
|
|
213
|
+
help: 'Names, comma separated. A field takes one with {params.name} as its default.'
|
|
214
|
+
}, ListLineEditor)}
|
|
167
215
|
</fieldset>
|
|
168
216
|
<fieldset className="xeplr-factory-group">
|
|
169
217
|
<legend>Size</legend>
|
|
@@ -181,6 +229,46 @@ function LockedEditor({ id, value }) {
|
|
|
181
229
|
return <input id={id} className="xeplr-factory-prop-input xeplr-factory-mono" type="text" value={value ?? ''} readOnly aria-readonly="true" />
|
|
182
230
|
}
|
|
183
231
|
|
|
232
|
+
/**
|
|
233
|
+
* A LIST TYPED AS A LINE — the screen's inputs.
|
|
234
|
+
*
|
|
235
|
+
* Two bugs live here, and this shape is what avoids both.
|
|
236
|
+
*
|
|
237
|
+
* It cannot be a plain TextEditor: what is STORED is a list and what is SHOWN
|
|
238
|
+
* is that list joined again, so a comma is parsed away the moment it is typed
|
|
239
|
+
* ("a," → ["a"] → "a") and the box refuses to let anybody start a second name.
|
|
240
|
+
*
|
|
241
|
+
* And it cannot commit on BLUR, which was the first fix: pressing Publish is
|
|
242
|
+
* one gesture, so the blur and the click race, and a name typed just before
|
|
243
|
+
* publishing was not in the document the publish read. The screen went out
|
|
244
|
+
* with no inputs and nothing said why.
|
|
245
|
+
*
|
|
246
|
+
* So the text is kept HERE for display, and the document is told on every
|
|
247
|
+
* keystroke. Nothing is ever waiting to be committed, and the line somebody
|
|
248
|
+
* is typing is never rewritten under their fingers.
|
|
249
|
+
*/
|
|
250
|
+
function ListLineEditor({ id, value, onChange }) {
|
|
251
|
+
const [text, setText] = useState(value ?? '')
|
|
252
|
+
const [typing, setTyping] = useState(false)
|
|
253
|
+
|
|
254
|
+
// Taken from the document again when it changes underneath — a different
|
|
255
|
+
// screen, an undo — unless the box is being typed in right now, which is
|
|
256
|
+
// the only thing this local copy exists for.
|
|
257
|
+
useEffect(() => { if (!typing) setText(value ?? '') }, [value, typing])
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<input
|
|
261
|
+
id={id}
|
|
262
|
+
className="xeplr-factory-prop-input"
|
|
263
|
+
type="text"
|
|
264
|
+
value={text}
|
|
265
|
+
onFocus={() => setTyping(true)}
|
|
266
|
+
onChange={(e) => { setText(e.target.value); onChange(e.target.value) }}
|
|
267
|
+
onBlur={(e) => { setTyping(false); onChange(e.target.value) }}
|
|
268
|
+
/>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
184
272
|
function TextEditor({ id, value, onChange }) {
|
|
185
273
|
return <input id={id} className="xeplr-factory-prop-input" type="text" value={value ?? ''} onChange={(e) => onChange(e.target.value)} />
|
|
186
274
|
}
|
|
@@ -282,12 +370,113 @@ function ToggleValueEditor({ id, field, value, onChange }) {
|
|
|
282
370
|
}
|
|
283
371
|
|
|
284
372
|
/** A table name: picked from the app's tables when it lists them, typed otherwise. */
|
|
373
|
+
/**
|
|
374
|
+
* WHERE A DROPDOWN'S OPTIONS COME FROM.
|
|
375
|
+
*
|
|
376
|
+
* MASTERS FIRST, by the name a person knows them as — "Departments", not
|
|
377
|
+
* "departments" — because those are the lists that exist to be pointed at. A
|
|
378
|
+
* flat list of thirty raw table names is one nobody can read, and nothing in
|
|
379
|
+
* it says which tables were meant for this.
|
|
380
|
+
*
|
|
381
|
+
* Everything else is still underneath. A dropdown may legitimately read from
|
|
382
|
+
* any table a published screen reads, and hiding those would replace a
|
|
383
|
+
* confusing list with a dead end.
|
|
384
|
+
*
|
|
385
|
+
* Two groups in a plain <select>, not a combobox: typing jumps to an option
|
|
386
|
+
* in every browser, which is the searching somebody actually wants here, and
|
|
387
|
+
* a widget of our own would have to reimplement the keyboard to match.
|
|
388
|
+
*/
|
|
389
|
+
/**
|
|
390
|
+
* The same picker, plus a way out of the dead end.
|
|
391
|
+
*
|
|
392
|
+
* A sub-table points at a table that usually does not exist yet, and the
|
|
393
|
+
* honest answer to "which table?" at that moment is "the one I am about to
|
|
394
|
+
* make". Without this the designer has to be left, a form built, and the
|
|
395
|
+
* thought recovered on the way back.
|
|
396
|
+
*
|
|
397
|
+
* Only on a LIST, and only when the host can make one: it is a server act.
|
|
398
|
+
*/
|
|
399
|
+
function ListTableEditor({ id, value, onChange, tables, node, doc, onMakeChildTable }) {
|
|
400
|
+
const [making, setMaking] = useState(false)
|
|
401
|
+
const [name, setName] = useState('')
|
|
402
|
+
const [busy, setBusy] = useState(false)
|
|
403
|
+
const [error, setError] = useState(null)
|
|
404
|
+
const canMake = Boolean(onMakeChildTable && doc && doc.source && node)
|
|
405
|
+
|
|
406
|
+
const make = async () => {
|
|
407
|
+
if (!name.trim() || busy) return
|
|
408
|
+
setBusy(true)
|
|
409
|
+
try {
|
|
410
|
+
await onMakeChildTable(node.id, name.trim())
|
|
411
|
+
setMaking(false)
|
|
412
|
+
setName('')
|
|
413
|
+
setError(null)
|
|
414
|
+
} catch (err) {
|
|
415
|
+
setError(err.message)
|
|
416
|
+
} finally {
|
|
417
|
+
setBusy(false)
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (making) {
|
|
422
|
+
return (
|
|
423
|
+
<div>
|
|
424
|
+
<input
|
|
425
|
+
id={id}
|
|
426
|
+
className="xeplr-factory-prop-input"
|
|
427
|
+
autoFocus
|
|
428
|
+
value={name}
|
|
429
|
+
placeholder="Characteristics"
|
|
430
|
+
onChange={(e) => setName(e.target.value)}
|
|
431
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); make() } }}
|
|
432
|
+
/>
|
|
433
|
+
<div className="xeplr-factory-prop-help">
|
|
434
|
+
Makes a table of rows belonging to this one, and points this list at it.
|
|
435
|
+
</div>
|
|
436
|
+
{error && <div className="xeplr-factory-prop-error">{error}</div>}
|
|
437
|
+
<button type="button" className="xeplr-factory-primary" disabled={!name.trim() || busy} onClick={make}>
|
|
438
|
+
{busy ? 'Making it…' : 'Make the table'}
|
|
439
|
+
</button>
|
|
440
|
+
<button type="button" className="xeplr-factory-secondary" onClick={() => { setMaking(false); setError(null) }}>Cancel</button>
|
|
441
|
+
</div>
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return (
|
|
446
|
+
<div>
|
|
447
|
+
<TableEditor id={id} value={value} onChange={onChange} tables={tables} />
|
|
448
|
+
{canMake && (
|
|
449
|
+
<button type="button" className="xeplr-factory-secondary" onClick={() => setMaking(true)}>
|
|
450
|
+
New table…
|
|
451
|
+
</button>
|
|
452
|
+
)}
|
|
453
|
+
</div>
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
|
|
285
457
|
function TableEditor({ id, value, onChange, tables }) {
|
|
286
|
-
|
|
458
|
+
const items = (tables && tables.items) || []
|
|
459
|
+
if (items.length > 0) {
|
|
460
|
+
const masters = items.filter((t) => t.master)
|
|
461
|
+
const rest = items.filter((t) => !t.master)
|
|
462
|
+
// A SELECT WHOSE VALUE IS NOT AN OPTION SHOWS NOTHING, which reads as the
|
|
463
|
+
// setting having been lost. It has not: a table made a moment ago is not
|
|
464
|
+
// in a list fetched when the page opened, and a table whose screens were
|
|
465
|
+
// deleted is still what this list names.
|
|
466
|
+
const known = items.some((t) => String(t.id) === String(value || ''))
|
|
467
|
+
const stray = value && !known ? { id: value, name: value } : null
|
|
468
|
+
const option = (t) => <option key={String(t.id)} value={String(t.id)}>{t.name}</option>
|
|
469
|
+
|
|
287
470
|
return (
|
|
288
471
|
<select id={id} className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) => onChange(e.target.value || undefined)}>
|
|
289
472
|
<option value="">{tables.loading ? 'Loading tables…' : '—'}</option>
|
|
290
|
-
{
|
|
473
|
+
{stray && <option value={String(stray.id)}>{stray.name}</option>}
|
|
474
|
+
{masters.length > 0 && <optgroup label="Masters">{masters.map(option)}</optgroup>}
|
|
475
|
+
{/* Named as what they are, so it reads as "the rest" rather than as a
|
|
476
|
+
second list of the same kind of thing. */}
|
|
477
|
+
{rest.length > 0 && (
|
|
478
|
+
<optgroup label={masters.length > 0 ? 'Other tables' : 'Tables'}>{rest.map(option)}</optgroup>
|
|
479
|
+
)}
|
|
291
480
|
</select>
|
|
292
481
|
)
|
|
293
482
|
}
|
|
@@ -301,7 +490,17 @@ function TableEditor({ id, value, onChange, tables }) {
|
|
|
301
490
|
*/
|
|
302
491
|
function ColumnsEditor({ value, onChange, doc, node, screens }) {
|
|
303
492
|
const editDoc = node && node.props.editScreen ? (screens || []).find((s) => s.id === node.props.editScreen)?.document : null
|
|
304
|
-
|
|
493
|
+
|
|
494
|
+
// A LIST OF ANOTHER TABLE IS NOT DESCRIBED BY THIS SCREEN. Pointing a list
|
|
495
|
+
// at material_characteristics and being offered the MATERIAL's fields is
|
|
496
|
+
// worse than being offered nothing: every one of them ticks a column that
|
|
497
|
+
// will be blank in every row.
|
|
498
|
+
//
|
|
499
|
+
// The edit screen for those rows is the only description to hand here — a
|
|
500
|
+
// host that did not provide it leaves this empty, and the list falls back
|
|
501
|
+
// to the columns the rows themselves have (see listColumns).
|
|
502
|
+
const foreign = node && node.props.source && doc && node.props.source !== doc.source
|
|
503
|
+
const from = foreign ? editDoc : (doc && inputNodes(doc).length ? doc : editDoc)
|
|
305
504
|
const fields = from
|
|
306
505
|
? inputNodes(from).map((n) => ({ field: n.props.name, label: n.props.label || n.props.name }))
|
|
307
506
|
: (Array.isArray(value) ? value : [])
|
|
@@ -313,7 +512,18 @@ function ColumnsEditor({ value, onChange, doc, node, screens }) {
|
|
|
313
512
|
// All of them again is the same as "not set" — keep the document clean.
|
|
314
513
|
onChange(next.length === fields.length || next.length === 0 ? undefined : next.map((c) => ({ field: c.field, label: (current.find((x) => x.field === c.field) || c).label })))
|
|
315
514
|
}
|
|
316
|
-
if (!fields.length)
|
|
515
|
+
if (!fields.length) {
|
|
516
|
+
// Two different nothings, and saying "add fields to this screen" to
|
|
517
|
+
// somebody pointing a list at another table sends them to fix the wrong
|
|
518
|
+
// thing.
|
|
519
|
+
return (
|
|
520
|
+
<div className="xeplr-factory-prop-help">
|
|
521
|
+
{foreign
|
|
522
|
+
? 'Every column of ' + node.props.source + '. Set "Edit in" to that table\'s form to choose.'
|
|
523
|
+
: 'Add fields to the screen first — the list shows them as columns.'}
|
|
524
|
+
</div>
|
|
525
|
+
)
|
|
526
|
+
}
|
|
317
527
|
return (
|
|
318
528
|
<div className="xeplr-factory-checklist">
|
|
319
529
|
{fields.map((f) => (
|
|
@@ -340,6 +550,24 @@ function ScreenEditor({ id, value, onChange, screens, doc }) {
|
|
|
340
550
|
return <input id={id} className="xeplr-factory-prop-input xeplr-factory-mono" type="text" placeholder="employee_edit" value={value ?? ''} onChange={(e) => onChange(e.target.value.trim() || undefined)} />
|
|
341
551
|
}
|
|
342
552
|
|
|
553
|
+
/**
|
|
554
|
+
* A form to draw inside this one: the screens with fields of their own (a list
|
|
555
|
+
* screen has nothing to fill in), never this one — a form cannot hold itself.
|
|
556
|
+
*/
|
|
557
|
+
function FormEditor({ id, value, onChange, screens, doc }) {
|
|
558
|
+
const forms = (screens || []).filter((s) => (!doc || s.id !== doc.id)
|
|
559
|
+
&& (!s.document || (s.document.nodes || []).some((n) => n.props && n.props.name)))
|
|
560
|
+
if (forms.length) {
|
|
561
|
+
return (
|
|
562
|
+
<select id={id} className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) => onChange(e.target.value || undefined)}>
|
|
563
|
+
<option value="">— choose a form</option>
|
|
564
|
+
{forms.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
|
565
|
+
</select>
|
|
566
|
+
)
|
|
567
|
+
}
|
|
568
|
+
return <input id={id} className="xeplr-factory-prop-input xeplr-factory-mono" type="text" placeholder="bank_details_edit" value={value ?? ''} onChange={(e) => onChange(e.target.value.trim() || undefined)} />
|
|
569
|
+
}
|
|
570
|
+
|
|
343
571
|
const ACTION_LABELS = { new: 'New — add a record', edit: 'Edit — open a row for editing', delete: 'Delete — remove a row' }
|
|
344
572
|
|
|
345
573
|
function ActionsEditor({ value, onChange }) {
|
|
@@ -522,7 +750,9 @@ export const EDITORS = {
|
|
|
522
750
|
color: ColorEditor,
|
|
523
751
|
table: TableEditor,
|
|
524
752
|
screen: ScreenEditor,
|
|
753
|
+
form: FormEditor,
|
|
525
754
|
columns: ColumnsEditor,
|
|
755
|
+
listTable: ListTableEditor,
|
|
526
756
|
actions: ActionsEditor,
|
|
527
757
|
dataSource: DataSourceEditor
|
|
528
758
|
}
|
|
@@ -124,7 +124,7 @@ function SaveButtons({ ctrl }) {
|
|
|
124
124
|
)
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
export default function ScreenSample({ ctrl, className, style, renderPopup }) {
|
|
127
|
+
export default function ScreenSample({ ctrl, className, style, renderPopup, renderSubform }) {
|
|
128
128
|
const { document: doc } = ctrl
|
|
129
129
|
|
|
130
130
|
if (ctrl.documentErrors.length) {
|
|
@@ -147,20 +147,23 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
|
|
|
147
147
|
// A screen with no fields of its own (a list screen) has nothing to save —
|
|
148
148
|
// and no sheet: its list is the card (factory.css, --bare).
|
|
149
149
|
const hasFields = fieldNodes.length > 0
|
|
150
|
-
|
|
150
|
+
// A form inside another has no footer of its own: the host's Save saves it.
|
|
151
|
+
const footer = !ctrl.embedded
|
|
152
|
+
const status = hasFields && footer ? <SaveStatus ctrl={ctrl} /> : null
|
|
153
|
+
// Nor a <form> of its own — one form inside another is not valid HTML.
|
|
154
|
+
const Page = ctrl.embedded ? 'div' : 'form'
|
|
151
155
|
const steppers = ctrl.steps.nodes
|
|
152
156
|
|
|
153
157
|
return (
|
|
154
158
|
<div
|
|
155
|
-
className={'xeplr-factory-screen' + (hasFields ? '' : ' xeplr-factory-screen--bare') + (className ? ' ' + className : '')}
|
|
159
|
+
className={'xeplr-factory-screen' + (hasFields ? '' : ' xeplr-factory-screen--bare') + (ctrl.embedded ? ' xeplr-factory-screen--embedded' : '') + (className ? ' ' + className : '')}
|
|
156
160
|
style={{ maxWidth: doc.width, '--xf-margin': `${MARGIN * 100}%`, ...screenStyle(doc), ...style }}
|
|
157
161
|
>
|
|
158
|
-
<
|
|
162
|
+
<Page
|
|
159
163
|
className="xeplr-factory-page"
|
|
160
164
|
style={{ height: `${pageHeight}cqw` }}
|
|
161
165
|
// Never a submit: Enter posts nothing and saves nothing. Save is a button.
|
|
162
|
-
|
|
163
|
-
noValidate
|
|
166
|
+
{...(ctrl.embedded ? { role: 'group' } : { onSubmit: (e) => e.preventDefault(), noValidate: true })}
|
|
164
167
|
aria-label={doc.name}
|
|
165
168
|
>
|
|
166
169
|
{[...shown].sort((a, b) => (a.z || 0) - (b.z || 0)).map((node) => (
|
|
@@ -206,19 +209,22 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
|
|
|
206
209
|
onDelete: (rec) => ctrl.deleteRecord(node, rec).catch((err) => window.alert(err.message || 'Could not delete')),
|
|
207
210
|
onNew: () => (node.props.editScreen ? ctrl.openEditor(node, null) : (ctrl.confirmDiscard() && ctrl.newRecord()))
|
|
208
211
|
} : undefined}
|
|
212
|
+
// A form inside this one: the page draws it (renderSubform).
|
|
213
|
+
subform={node.type === 'subform' ? { sub: ctrl.subformFor(node), render: renderSubform } : undefined}
|
|
209
214
|
/>
|
|
210
215
|
</div>
|
|
211
216
|
))}
|
|
212
|
-
</
|
|
217
|
+
</Page>
|
|
213
218
|
{steppers.map((node, i) => {
|
|
214
219
|
const last = i === steppers.length - 1
|
|
215
220
|
return (
|
|
221
|
+
// An embedded form keeps its Back and Next; only Save is the host's.
|
|
216
222
|
<StepNav key={node.id} ctrl={ctrl} node={node} status={last ? status : null}>
|
|
217
|
-
{last && (hasFields || ctrl.doneLabel) && <SaveButtons ctrl={ctrl} />}
|
|
223
|
+
{last && footer && (hasFields || ctrl.doneLabel) && <SaveButtons ctrl={ctrl} />}
|
|
218
224
|
</StepNav>
|
|
219
225
|
)
|
|
220
226
|
})}
|
|
221
|
-
{!steppers.length && (hasFields || ctrl.doneLabel) && (
|
|
227
|
+
{footer && !steppers.length && (hasFields || ctrl.doneLabel) && (
|
|
222
228
|
<div className="xeplr-factory-footer">
|
|
223
229
|
<div className="xeplr-factory-footer-mid">{status}</div>
|
|
224
230
|
<SaveButtons ctrl={ctrl} />
|
package/src/designs/factory.css
CHANGED
|
@@ -98,6 +98,31 @@
|
|
|
98
98
|
.xeplr-factory-input:hover { border-color: color-mix(in srgb, var(--xf-accent) 45%, var(--xf-field-border)); }
|
|
99
99
|
.xeplr-factory-textarea { padding-block: calc(8 * var(--xf-u)); resize: none; }
|
|
100
100
|
.xeplr-factory-input:focus { outline: none; border-color: var(--xf-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--xf-accent) 25%, transparent); }
|
|
101
|
+
|
|
102
|
+
/* ── Dropdowns ─────────────────────────────────────────────────────────────
|
|
103
|
+
Every <select> the factory draws — on a screen, in the builder's panels, in
|
|
104
|
+
the flow designer — gets our own chevron, inset from the edge with room
|
|
105
|
+
kept for it. The browser's own sat against the border and in a different
|
|
106
|
+
place per platform. Drawn with two gradients rather than an SVG, so it is
|
|
107
|
+
the theme's muted ink and follows a dark theme. Only background-image and
|
|
108
|
+
its longhands are set: each box keeps its own colour, and a control's
|
|
109
|
+
Background (boxStyle) is a background-COLOR so it cannot wipe the arrow. */
|
|
110
|
+
:is(.xeplr-factory-screen, .xeplr-factory-canvas, .xeplr-factory-flow-builder, .xeplr-factory-builder, .xeplr-factory-modal-backdrop) select:not([multiple]) {
|
|
111
|
+
-webkit-appearance: none;
|
|
112
|
+
appearance: none;
|
|
113
|
+
/* Two thin diagonals side by side — each corner to corner of its own small
|
|
114
|
+
square — meet at the bottom as a ∨. */
|
|
115
|
+
background-image:
|
|
116
|
+
linear-gradient(45deg, transparent 42%, var(--xf-muted) 42%, var(--xf-muted) 58%, transparent 58%),
|
|
117
|
+
linear-gradient(135deg, transparent 42%, var(--xf-muted) 42%, var(--xf-muted) 58%, transparent 58%);
|
|
118
|
+
background-position: calc(100% - 16px) 50%, calc(100% - 10px) 50%;
|
|
119
|
+
background-size: 6px 6px, 6px 6px;
|
|
120
|
+
background-repeat: no-repeat;
|
|
121
|
+
padding-right: 30px;
|
|
122
|
+
text-overflow: ellipsis;
|
|
123
|
+
cursor: pointer;
|
|
124
|
+
}
|
|
125
|
+
:is(.xeplr-factory-screen, .xeplr-factory-canvas, .xeplr-factory-flow-builder, .xeplr-factory-builder, .xeplr-factory-modal-backdrop) select:not([multiple]):disabled { cursor: default; opacity: .6; }
|
|
101
126
|
.xeplr-factory-field.has-error .xeplr-factory-input { border-color: var(--xf-danger); }
|
|
102
127
|
.xeplr-factory-error {
|
|
103
128
|
/* In the gap BELOW the field, out of flow: a message appearing must not
|
|
@@ -216,6 +241,19 @@
|
|
|
216
241
|
.xeplr-factory-text { margin: 0; color: var(--xf-ink); height: 100%; overflow: hidden; line-height: 1.25; box-sizing: border-box; }
|
|
217
242
|
.xeplr-factory-text--text { color: var(--xf-muted); }
|
|
218
243
|
|
|
244
|
+
/* ── a form inside a form ────────────────────────────────────────────────
|
|
245
|
+
The inner form is drawn as itself, without its own sheet or footer: it is
|
|
246
|
+
part of this page, and its Save is this page's Save. Its box scrolls when
|
|
247
|
+
the form is taller than the space it was given. */
|
|
248
|
+
.xeplr-factory-subform { display: flex; flex-direction: column; gap: 8px; width: 100%; height: 100%; box-sizing: border-box; overflow: auto; }
|
|
249
|
+
.xeplr-factory-subform-title { font-weight: 600; font-size: 1.05em; color: var(--xf-ink); }
|
|
250
|
+
.xeplr-factory-screen--embedded { border: none; border-radius: 0; background: none; }
|
|
251
|
+
.xeplr-factory-subform--design {
|
|
252
|
+
justify-content: center; align-items: center; text-align: center; padding: 12px;
|
|
253
|
+
border: 1px dashed var(--xf-border); border-radius: 10px; color: var(--xf-muted);
|
|
254
|
+
}
|
|
255
|
+
.xeplr-factory-subform-what code { color: var(--xf-ink); }
|
|
256
|
+
|
|
219
257
|
/* ── list ───────────────────────────────────────────────────────────────── */
|
|
220
258
|
.xeplr-factory-list {
|
|
221
259
|
display: flex; flex-direction: column; width: 100%; box-sizing: border-box; overflow: auto;
|
|
@@ -310,6 +348,7 @@
|
|
|
310
348
|
.xeplr-factory-palette-icon--file { border-radius: 2px; border-top: 3px solid var(--xf-muted); height: 12px; width: 10px; }
|
|
311
349
|
.xeplr-factory-palette-icon--stepper { height: 8px; border-radius: 999px; background: linear-gradient(to right, var(--xf-muted) 0 30%, transparent 30%); border: none; }
|
|
312
350
|
.xeplr-factory-palette-icon--label { border: none; border-top: 2px solid var(--xf-muted); border-bottom: 2px solid var(--xf-muted); height: 6px; }
|
|
351
|
+
.xeplr-factory-palette-icon--subform { border-style: double; border-width: 3px; height: 11px; }
|
|
313
352
|
.xeplr-factory-palette-icon--list { height: 12px; background: repeating-linear-gradient(to bottom, var(--xf-muted) 0 1.5px, transparent 1.5px 4px); border: none; }
|
|
314
353
|
.xeplr-factory-palette-icon--textarea { height: 14px; }
|
|
315
354
|
|
|
@@ -460,3 +499,28 @@
|
|
|
460
499
|
padding: 16px; border-radius: 8px; border: 1px solid var(--xf-border);
|
|
461
500
|
background: var(--xf-surface); color: var(--xf-ink);
|
|
462
501
|
}
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
/* ── a child table, designed where it was made ─────────────────────────── */
|
|
505
|
+
/* Wide, because what is inside is a whole designer: palette, canvas and
|
|
506
|
+
property panel. A form builder in a dialog box is a canvas nobody can
|
|
507
|
+
work on. */
|
|
508
|
+
.xeplr-factory-modal--wide {
|
|
509
|
+
width: min(1600px, 96vw);
|
|
510
|
+
height: min(1000px, 92vh);
|
|
511
|
+
display: flex;
|
|
512
|
+
flex-direction: column;
|
|
513
|
+
overflow: hidden;
|
|
514
|
+
}
|
|
515
|
+
.xeplr-factory-modal-head {
|
|
516
|
+
display: flex;
|
|
517
|
+
align-items: center;
|
|
518
|
+
gap: 10px;
|
|
519
|
+
padding: 10px 14px;
|
|
520
|
+
border-bottom: 1px solid var(--xf-border);
|
|
521
|
+
}
|
|
522
|
+
.xeplr-factory-modal-spacer { flex: 1; }
|
|
523
|
+
/* hidden, not auto: the builder inside manages its own panes */
|
|
524
|
+
.xeplr-factory-modal-body { flex: 1; min-height: 0; overflow: hidden; }
|
|
525
|
+
.xeplr-factory-modal-body > * { height: 100%; }
|
|
526
|
+
.xeplr-factory-builder--nested { min-height: 0; }
|
package/src/designs/styles.js
CHANGED
|
@@ -32,7 +32,7 @@ export function boxStyle(style) {
|
|
|
32
32
|
fontStyle: s.fontStyle,
|
|
33
33
|
textAlign: s.textAlign,
|
|
34
34
|
color: s.color,
|
|
35
|
-
|
|
35
|
+
backgroundColor: s.background,
|
|
36
36
|
borderColor: s.borderColor,
|
|
37
37
|
borderWidth: s.borderWidth != null ? `${s.borderWidth}px` : undefined,
|
|
38
38
|
borderStyle: s.borderWidth != null ? 'solid' : undefined,
|
package/src/document.js
CHANGED
|
@@ -33,6 +33,53 @@ export const MARGIN = 0.04
|
|
|
33
33
|
export const GAP = 0.025
|
|
34
34
|
|
|
35
35
|
/** A new, empty screen. */
|
|
36
|
+
/**
|
|
37
|
+
* WHAT A SCREEN IS CALLED WITH.
|
|
38
|
+
*
|
|
39
|
+
* A form can need things it must not ask for: a containment needs the
|
|
40
|
+
* complaint it belongs to, and the person filling it in does not know a
|
|
41
|
+
* twenty-five character id. So a screen declares its INPUTS the way an action
|
|
42
|
+
* declares its inputSchema — one named list, at the screen — and a field
|
|
43
|
+
* binds to one with the same `{token}` this product uses everywhere else:
|
|
44
|
+
*
|
|
45
|
+
* props.default = '{params.complaintId}'
|
|
46
|
+
*
|
|
47
|
+
* A value with a token is a binding; a plain value is a literal. Nothing is
|
|
48
|
+
* marked on the field, because nothing needs to be: the brace already says
|
|
49
|
+
* which it is.
|
|
50
|
+
*
|
|
51
|
+
* Written as a plain list of names, the shortest thing that answers the
|
|
52
|
+
* question — "this form needs contentId, age, name".
|
|
53
|
+
*/
|
|
54
|
+
export function screenParams(doc) {
|
|
55
|
+
var raw = (doc && doc.params) || []
|
|
56
|
+
if (typeof raw === 'string') raw = raw.split(',')
|
|
57
|
+
return raw
|
|
58
|
+
.map(function (p) { return typeof p === 'string' ? { name: p.trim() } : p })
|
|
59
|
+
.filter(function (p) { return p && p.name })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The list as one line, which is how it is typed. */
|
|
63
|
+
export function paramsText(doc) {
|
|
64
|
+
return screenParams(doc).map(function (p) { return p.name }).join(', ')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One line back to the list, for the panel. */
|
|
68
|
+
export function setScreenParams(doc, text) {
|
|
69
|
+
var names = String(text == null ? '' : text)
|
|
70
|
+
.split(',')
|
|
71
|
+
.map(function (n) { return n.trim() })
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
var seen = {}
|
|
74
|
+
var params = []
|
|
75
|
+
names.forEach(function (n) {
|
|
76
|
+
if (seen[n]) return
|
|
77
|
+
seen[n] = true
|
|
78
|
+
params.push({ name: n })
|
|
79
|
+
})
|
|
80
|
+
return Object.assign({}, doc, { params: params })
|
|
81
|
+
}
|
|
82
|
+
|
|
36
83
|
export function createScreen({ name, id, aspect, width, source, style } = {}) {
|
|
37
84
|
const title = String(name || 'Untitled screen').trim() || 'Untitled screen'
|
|
38
85
|
const doc = {
|
|
@@ -60,6 +107,10 @@ export function renameScreen(doc, name) {
|
|
|
60
107
|
*/
|
|
61
108
|
export function setScreenProperty(doc, path, value) {
|
|
62
109
|
if (path === 'nodes' || path.startsWith('nodes.')) throw new Error('setScreenProperty cannot change nodes')
|
|
110
|
+
// The inputs are TYPED as one line and STORED as a list, so the document
|
|
111
|
+
// stays self-describing — the server, the CLI and a flow read names, not a
|
|
112
|
+
// string somebody has to split again.
|
|
113
|
+
if (path === 'paramsLine') return setScreenParams(doc, value)
|
|
63
114
|
return setAtPath(doc, path, value)
|
|
64
115
|
}
|
|
65
116
|
|
package/src/index.js
CHANGED
|
@@ -14,7 +14,9 @@ export { useFactoryScreen, normaliseOptions } from './useFactoryScreen.js'
|
|
|
14
14
|
// This app's screens, shaped for another package's designer — see
|
|
15
15
|
// useScreenSource.js. Two props instead of a page of glue per application.
|
|
16
16
|
export { useScreenSource } from './useScreenSource.js'
|
|
17
|
-
export { describeScreen, screenStub, loadScreens, keyFromName, editScreenOf } from './screenSource.js'
|
|
17
|
+
export { describeScreen, screenStub, loadScreens, keyFromName, editScreenOf, masterForms, formKeyOf, deleteForm } from './screenSource.js'
|
|
18
|
+
// Making a master: the one form shape every application repeats.
|
|
19
|
+
export { masterSpec, createMaster, possibleParents, singularOf, MASTER_LABEL, MASTER_ORDER, childSpec, createChildTable } from './masters.js'
|
|
18
20
|
export { useFlowRun } from './useFlowRun.js'
|
|
19
21
|
export { useFlowBuilder, FLOW_AUTOSAVE_DELAY } from './useFlowBuilder.js'
|
|
20
22
|
|