@xeplr/ui-factory 1.2.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.
@@ -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 id={id} field={field} value={getAtPath(doc, field.path)} onChange={(v) => onChange(field.path, v)} tables={tables} errors={[]} {...extra} />
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
- if (tables && tables.items.length > 0) {
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
- {tables.items.map((t) => <option key={String(t.id)} value={String(t.id)}>{t.name}</option>)}
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
- const from = doc && inputNodes(doc).length ? doc : editDoc
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) return <div className="xeplr-factory-prop-help">Add fields to the screen first — the list shows them as columns.</div>
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
  }
@@ -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; }
@@ -32,7 +32,7 @@ export function boxStyle(style) {
32
32
  fontStyle: s.fontStyle,
33
33
  textAlign: s.textAlign,
34
34
  color: s.color,
35
- background: s.background,
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 {