@xeplr/ui-factory 1.1.0 → 1.3.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 +189 -2
- package/bin/xeplr-factory.js +2 -1
- package/package.json +7 -2
- package/src/controls.js +62 -2
- package/src/designs/BuilderSample.jsx +47 -1
- package/src/designs/ControlView.jsx +36 -0
- package/src/designs/ListView.jsx +3 -1
- package/src/designs/PropertyPanel.jsx +218 -7
- package/src/designs/factory.css +50 -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 +14 -1
- package/src/remote.js +22 -2
- package/src/screenSource.js +85 -2
- package/src/tableSchema.js +16 -0
- package/src/urlState.js +71 -0
- package/src/useFactoryBuilder.js +134 -10
- package/src/useFactoryScreen.js +45 -14
- package/src/useFlowBuilder.js +7 -4
- package/src/useFlowRun.js +6 -3
- package/src/useScreenSource.js +8 -4
- package/src/useUrlPlaces.js +48 -0
- package/src/validateDocument.js +67 -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) => (
|
|
@@ -523,6 +733,7 @@ export const EDITORS = {
|
|
|
523
733
|
table: TableEditor,
|
|
524
734
|
screen: ScreenEditor,
|
|
525
735
|
columns: ColumnsEditor,
|
|
736
|
+
listTable: ListTableEditor,
|
|
526
737
|
actions: ActionsEditor,
|
|
527
738
|
dataSource: DataSourceEditor
|
|
528
739
|
}
|
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
|
|
@@ -460,3 +485,28 @@
|
|
|
460
485
|
padding: 16px; border-radius: 8px; border: 1px solid var(--xf-border);
|
|
461
486
|
background: var(--xf-surface); color: var(--xf-ink);
|
|
462
487
|
}
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
/* ── a child table, designed where it was made ─────────────────────────── */
|
|
491
|
+
/* Wide, because what is inside is a whole designer: palette, canvas and
|
|
492
|
+
property panel. A form builder in a dialog box is a canvas nobody can
|
|
493
|
+
work on. */
|
|
494
|
+
.xeplr-factory-modal--wide {
|
|
495
|
+
width: min(1600px, 96vw);
|
|
496
|
+
height: min(1000px, 92vh);
|
|
497
|
+
display: flex;
|
|
498
|
+
flex-direction: column;
|
|
499
|
+
overflow: hidden;
|
|
500
|
+
}
|
|
501
|
+
.xeplr-factory-modal-head {
|
|
502
|
+
display: flex;
|
|
503
|
+
align-items: center;
|
|
504
|
+
gap: 10px;
|
|
505
|
+
padding: 10px 14px;
|
|
506
|
+
border-bottom: 1px solid var(--xf-border);
|
|
507
|
+
}
|
|
508
|
+
.xeplr-factory-modal-spacer { flex: 1; }
|
|
509
|
+
/* hidden, not auto: the builder inside manages its own panes */
|
|
510
|
+
.xeplr-factory-modal-body { flex: 1; min-height: 0; overflow: hidden; }
|
|
511
|
+
.xeplr-factory-modal-body > * { height: 100%; }
|
|
512
|
+
.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
|
|
package/src/masters.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// MAKING A MASTER — the one form shape every application repeats.
|
|
2
|
+
//
|
|
3
|
+
// Departments, statuses, countries, categories, reasons, priorities. They are
|
|
4
|
+
// all the same three things, and designing them one by one is drawing the same
|
|
5
|
+
// form over and over:
|
|
6
|
+
//
|
|
7
|
+
// name what it is called — what every picker shows
|
|
8
|
+
// sortOrder the order somebody wants to see them in, which is almost
|
|
9
|
+
// never alphabetical: Low / Medium / High, not High / Low /
|
|
10
|
+
// Medium
|
|
11
|
+
// parent the master it belongs to, when there is one — a state in a
|
|
12
|
+
// country, a sub-category in a category
|
|
13
|
+
//
|
|
14
|
+
// So a master is MADE, not designed: name it, say what it belongs to, and it
|
|
15
|
+
// exists. It is still an ordinary form afterwards — its design opens in the
|
|
16
|
+
// builder like any other, and a fourth field can be added to it — because a
|
|
17
|
+
// master is a flag on a form and not a kind of form (see screenSource.js).
|
|
18
|
+
//
|
|
19
|
+
// Pure except for `createMaster`, which is the one place that talks to the
|
|
20
|
+
// server; the spec it builds is a plain object anything can check.
|
|
21
|
+
|
|
22
|
+
import { screensFromSpec, entityNames } from './generate.js'
|
|
23
|
+
import { describeScreen, formKeyOf } from './screenSource.js'
|
|
24
|
+
|
|
25
|
+
/** The field every master is shown by. Said once, here and in labelField. */
|
|
26
|
+
export var MASTER_LABEL = 'name'
|
|
27
|
+
|
|
28
|
+
/** The field every master is ordered by. */
|
|
29
|
+
export var MASTER_ORDER = 'sortOrder'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* "Departments" → "department": what the ENTITY is called.
|
|
33
|
+
*
|
|
34
|
+
* A master is named in the plural, because it is a list — and an entity is
|
|
35
|
+
* named in the singular, because it is one row. entityNames does not know
|
|
36
|
+
* that, so "Departments" straight through makes a `departmentses` table and a
|
|
37
|
+
* screen called department**s**_edit. Nobody notices until the table is
|
|
38
|
+
* created, and then it cannot be renamed.
|
|
39
|
+
*
|
|
40
|
+
* Crude on purpose: enough for the words masters are actually called, and
|
|
41
|
+
* wrong quietly rather than cleverly — the key is shown before anything is
|
|
42
|
+
* made, and can be typed over.
|
|
43
|
+
*/
|
|
44
|
+
export function singularOf(label) {
|
|
45
|
+
var words = String(label || '').trim().split(/\s+/).filter(Boolean)
|
|
46
|
+
if (!words.length) return ''
|
|
47
|
+
var last = words[words.length - 1]
|
|
48
|
+
words[words.length - 1] = /ies$/i.test(last) ? last.slice(0, -3) + 'y'
|
|
49
|
+
: /(ss|us|is)$/i.test(last) ? last
|
|
50
|
+
: /s$/i.test(last) ? last.slice(0, -1)
|
|
51
|
+
: last
|
|
52
|
+
return words.join(' ')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The entity spec for a master.
|
|
57
|
+
*
|
|
58
|
+
* @param name what it is called, as a person writes it — "Departments"
|
|
59
|
+
* @param parent optional { table, label } — the master this one belongs to
|
|
60
|
+
* @param plural optional, when the plural is not name + s
|
|
61
|
+
*/
|
|
62
|
+
export function masterSpec({ name, parent, plural }) {
|
|
63
|
+
var label = String(name || '').trim()
|
|
64
|
+
if (!label) throw new Error('A master needs a name, e.g. "Departments"')
|
|
65
|
+
|
|
66
|
+
var fields = [
|
|
67
|
+
// Required, because a master row with no name is a blank line in every
|
|
68
|
+
// dropdown that points at it.
|
|
69
|
+
{ label: 'Name', type: 'text', required: true },
|
|
70
|
+
// Not required: most lists are fine alphabetical, and a number somebody
|
|
71
|
+
// is forced to invent for every row is a tax on adding one.
|
|
72
|
+
{ label: 'Sort order', type: 'number' }
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
if (parent && parent.table) {
|
|
76
|
+
fields.push({
|
|
77
|
+
label: parent.label || 'Belongs to',
|
|
78
|
+
type: 'dropdown',
|
|
79
|
+
table: parent.table,
|
|
80
|
+
required: true
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { entity: entityNames(singularOf(label), plural).singular, plural: plural, fields: fields }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Make one: both screens, marked as a master, published.
|
|
89
|
+
*
|
|
90
|
+
* PUBLISHED, because a master that is a draft has no table, so nothing can
|
|
91
|
+
* point at it and nobody can add a row — which is the whole of what a master
|
|
92
|
+
* is for. The one case where publishing on somebody's behalf is right: there
|
|
93
|
+
* is no design decision left to make.
|
|
94
|
+
*
|
|
95
|
+
* @param api a createFactoryApi() instance
|
|
96
|
+
* @param spec { name, parent, plural } — see masterSpec
|
|
97
|
+
* @returns the new master, as describeScreen reports it
|
|
98
|
+
*/
|
|
99
|
+
export async function createMaster(api, spec) {
|
|
100
|
+
var pair = screensFromSpec(masterSpec(spec))
|
|
101
|
+
|
|
102
|
+
// The FLAG goes on the edit screen: it is the one that describes the
|
|
103
|
+
// record, and labelField can only name a field that exists there.
|
|
104
|
+
var edit = Object.assign({}, pair.edit, { master: true, labelField: MASTER_LABEL })
|
|
105
|
+
var list = Object.assign({}, pair.list, { master: true })
|
|
106
|
+
|
|
107
|
+
// Drafts first, then published — the same two steps the builder takes, so a
|
|
108
|
+
// master is not a second way of writing a screen.
|
|
109
|
+
await api.saveDraft(edit)
|
|
110
|
+
await api.saveDraft(list)
|
|
111
|
+
await api.publish(edit)
|
|
112
|
+
await api.publish(list)
|
|
113
|
+
|
|
114
|
+
return describeScreen(edit)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A CHILD TABLE — rows that belong to one record of another form.
|
|
119
|
+
*
|
|
120
|
+
* This material's characteristics, this order's lines. Two fields and a link,
|
|
121
|
+
* which is the whole of what a sub-table is before somebody designs it:
|
|
122
|
+
*
|
|
123
|
+
* name what the row is
|
|
124
|
+
* value what it says — optional, but it is why most sub-tables exist
|
|
125
|
+
* <link> the record it belongs to, DECLARED AS AN INPUT so the list that
|
|
126
|
+
* opens it can supply it (see screenParams). Bound with
|
|
127
|
+
* {params.<link>} rather than typed, hidden when given and asked
|
|
128
|
+
* when not — the same field, whether it is opened from a material
|
|
129
|
+
* or on its own.
|
|
130
|
+
*
|
|
131
|
+
* @param name what the child is called — "Material characteristics"
|
|
132
|
+
* @param parent { table, label, link } — the form it belongs to, and the
|
|
133
|
+
* column pointing back at it
|
|
134
|
+
*/
|
|
135
|
+
export function childSpec({ name, parent, plural }) {
|
|
136
|
+
var label = String(name || '').trim()
|
|
137
|
+
if (!label) throw new Error('A table needs a name, e.g. "Characteristics"')
|
|
138
|
+
if (!parent || !parent.table || !parent.link) throw new Error('A child table needs the record it belongs to')
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
entity: entityNames(singularOf(label), plural).singular,
|
|
142
|
+
plural: plural,
|
|
143
|
+
fields: [
|
|
144
|
+
// The link first, because it is what the row IS before it is anything
|
|
145
|
+
// else — and hidden in practice, since the list supplies it.
|
|
146
|
+
{ label: parent.linkLabel || parent.label || 'Belongs to', name: parent.link,
|
|
147
|
+
type: 'dropdown', table: parent.table, required: true, default: '{params.' + parent.link + '}' },
|
|
148
|
+
{ label: 'Name', type: 'text', required: true },
|
|
149
|
+
{ label: 'Value', type: 'text' }
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Make one, published, with the link declared as an input.
|
|
156
|
+
*
|
|
157
|
+
* Published for the same reason a master is: a draft has no table, so the
|
|
158
|
+
* list that is about to point at it would have nothing to read.
|
|
159
|
+
*
|
|
160
|
+
* @returns { key, edit, list, link } — what the List control needs to be
|
|
161
|
+
* pointed at it.
|
|
162
|
+
*/
|
|
163
|
+
export async function createChildTable(api, spec) {
|
|
164
|
+
var pair = screensFromSpec(childSpec(spec))
|
|
165
|
+
var link = spec.parent.link
|
|
166
|
+
|
|
167
|
+
// The link is what this screen is CALLED WITH. Declared here rather than
|
|
168
|
+
// left as an ordinary field so the form knows not to ask for it when the
|
|
169
|
+
// list already said.
|
|
170
|
+
var edit = Object.assign({}, pair.edit, { params: [{ name: link }] })
|
|
171
|
+
|
|
172
|
+
await api.saveDraft(edit)
|
|
173
|
+
await api.saveDraft(pair.list)
|
|
174
|
+
await api.publish(edit)
|
|
175
|
+
await api.publish(pair.list)
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
key: formKeyOf(edit.id), edit: edit.id, list: pair.list.id, source: edit.source, link: link,
|
|
179
|
+
// What it holds, so a list pointed at it can show the right columns
|
|
180
|
+
// without reading the document back.
|
|
181
|
+
fields: describeScreen(edit).fields.map(function (f) { return { name: f.name, label: f.name } })
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The masters another master may belong to: every one except itself.
|
|
187
|
+
*
|
|
188
|
+
* A master that is its own parent is a row that can never be filled in, and
|
|
189
|
+
* the dropdown would offer the record being created.
|
|
190
|
+
*/
|
|
191
|
+
export function possibleParents(masters, exceptKey) {
|
|
192
|
+
return (masters || []).filter(function (m) { return m && m.key && m.key !== exceptKey })
|
|
193
|
+
}
|
package/src/model.js
CHANGED
|
@@ -8,6 +8,7 @@ export {
|
|
|
8
8
|
DOCUMENT_KIND, DOCUMENT_VERSION, DEFAULT_ASPECT, DEFAULT_WIDTH, MARGIN, GAP,
|
|
9
9
|
createScreen, renameScreen, setScreenProperty, addControl, moveNode, setNodeProperty, getNodeProperty, removeNodes,
|
|
10
10
|
inputNodes, readingOrder, contentBottom, camelName, slugify, uniqueFieldName, nextPosition,
|
|
11
|
+
screenParams, paramsText, setScreenParams,
|
|
11
12
|
steppers, stepsOf, stepOf, setNodeStep, nodesForSteps, resolveStep, reachableStep, nextOpenStep
|
|
12
13
|
} from './document.js'
|
|
13
14
|
export { validateDocument, assertValidDocument } from './validateDocument.js'
|
|
@@ -21,6 +22,10 @@ export {
|
|
|
21
22
|
columnForField, tableForScreen, columnSql, widening, diffTables, migrationFor, nextMigrationName,
|
|
22
23
|
createStatements, addStatements, alterStatement, dropStatement, columnFromDatabase, planTableChange, conversionFor
|
|
23
24
|
} from './tableSchema.js'
|
|
25
|
+
// A value with a {params.x} token is a binding; a plain value is a literal.
|
|
26
|
+
export { resolveBinding, isBinding } from './values.js'
|
|
27
|
+
// A code people can read out, unlike the record's own key.
|
|
28
|
+
export { codeFields, countedFields, generateCode } from './values.js'
|
|
24
29
|
export { FIELD_PRESETS, PATTERNS, INPUT_TYPES, presetFor, presetProps, suggestPreset, presetGroup, presetHints, convertField } from './presets.js'
|
|
25
30
|
export { createFlowsApi } from './flows.js'
|
|
26
31
|
export {
|