@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.
package/src/pages.jsx CHANGED
@@ -28,6 +28,9 @@ export function FactoryBuilder(props) {
28
28
  ctrl={ctrl}
29
29
  className={props.className}
30
30
  style={props.style}
31
+ // So a child table made here can be designed here — see the modal in
32
+ // BuilderSample. Supplied by createFactoryApi's builderProps.
33
+ screenApi={props.screenApi}
31
34
  renderPreview={(doc) => (
32
35
  // Preview never writes: onSave/onDelete are left out on purpose.
33
36
  <FactoryScreen
@@ -91,6 +94,10 @@ export function FactoryScreen(props) {
91
94
  key={`${popup.screenId}:${popup.record ? popup.record[ctrl.recordKey] : 'new'}`}
92
95
  document={popup.document}
93
96
  record={popup.record}
97
+ // What the sub-table calls it with: the column pointing back at
98
+ // the record this list is on. Hidden on the form when the
99
+ // screen says a field is filled in by whoever opens it.
100
+ params={popup.params || undefined}
94
101
  recordKey={props.recordKey}
95
102
  onSave={ctrl.popupSave}
96
103
  fetchOptions={props.fetchOptions}
@@ -134,7 +141,10 @@ export function FlowRunner(props) {
134
141
  const screen = useFactoryScreen({
135
142
  ...props,
136
143
  document: run.document || EMPTY_SCREEN,
137
- record: run.values || undefined
144
+ record: run.values || undefined,
145
+ // What the flow called this screen with — its step's own input, resolved
146
+ // by the engine. A field bound to {params.x} is filled from here.
147
+ params: run.given || undefined
138
148
  })
139
149
  return (
140
150
  <FlowRunnerSample
package/src/remote.js CHANGED
@@ -13,6 +13,8 @@
13
13
  * @param options.fetch a fetch that adds auth and tenant headers (e.g. @xeplr/ui-account's authFetch)
14
14
  * @param options.base prefix before /factory, if the router is mounted elsewhere (default '')
15
15
  */
16
+ import { createChildTable } from './masters.js'
17
+
16
18
  export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
17
19
  if (typeof doFetch !== 'function') throw new Error('createFactoryApi: pass { fetch } — usually authFetch from @xeplr/ui-account')
18
20
 
@@ -95,11 +97,23 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
95
97
  confirmConvert: (options && options.confirmConvert) || []
96
98
  })),
97
99
  listTables: () => call('GET', '/tables'),
100
+ // DELETE A FORM'S SCREEN. The design always goes; the table only when it
101
+ // is empty and nothing else describes it — the answer says which
102
+ // happened, and how many records kept it. Refused while another
103
+ // published screen chooses from this form's table.
104
+ deleteScreen: async (key) => one(await call('DELETE', `/screens/${enc(key)}`)),
98
105
  // A new form from its name: { entity, plural? } → { entity, name, source, edit, list } — both screens as drafts.
99
106
  createEntity: async (spec) => one(await call('POST', '/entities', spec)),
100
107
 
101
108
  // records
102
- fetchRecords: ({ screen }) => call('GET', `/records/${enc(screen)}`),
109
+ // `belongsTo` + `belongsToId`: a sub-table on a form asks for the rows of
110
+ // another table that belong to THIS record. Without an owner the server
111
+ // answers with none — a record that has not been saved yet owns nothing,
112
+ // and showing the whole table there is how somebody edits another
113
+ // record's lines.
114
+ fetchRecords: ({ screen, belongsTo, belongsToId }) => call('GET', belongsTo
115
+ ? `/records/${enc(screen)}?belongsTo=${enc(belongsTo)}&belongsToId=${enc(belongsToId == null ? '' : belongsToId)}`
116
+ : `/records/${enc(screen)}`),
103
117
  // One record, through the screen's get hooks — what Edit opens.
104
118
  fetchRecord: async ({ screen, id }) => one(await call('GET', `/records/${enc(screen)}/${enc(id)}`)),
105
119
  onSave: async (values, { id, screen }) => one(await call('POST', `/records/${enc(screen)}/save`, { id, values })),
@@ -116,6 +130,12 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
116
130
  fileUrl: (path) => `${base}/factory/files/${String(path).split('/').map(enc).join('/')}`
117
131
  }
118
132
  api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions, uploadFile: api.uploadFile, fileUrl: api.fileUrl }
119
- api.builderProps = { onSave: api.saveDraft, onPublish: api.publish, listTables: api.listTables, fetchOptions: api.fetchOptions, fetchRecords: api.fetchRecords }
133
+ // makeChildTable: a list on a form can make the table it is about to point
134
+ // at — rows belonging to this record — without leaving the designer. A
135
+ // server act, which is why the builder is given it rather than doing it.
136
+ api.builderProps = { onSave: api.saveDraft, onPublish: api.publish, listTables: api.listTables, fetchOptions: api.fetchOptions, fetchRecords: api.fetchRecords, makeChildTable: function (spec) { return createChildTable(api, spec) },
137
+ // The builder designs a child table in place, which needs the same calls
138
+ // this api already makes — handed over rather than rebuilt.
139
+ screenApi: api }
120
140
  return api
121
141
  }
@@ -15,7 +15,7 @@
15
15
  //
16
16
  // Pure: no React. The hook around it is useScreenSource.js.
17
17
 
18
- import { inputNodes } from './document.js'
18
+ import { inputNodes, screenParams } from './document.js'
19
19
 
20
20
  /**
21
21
  * A screen document → what the designer needs.
@@ -29,12 +29,65 @@ export function describeScreen(doc) {
29
29
  return {
30
30
  key: doc.id,
31
31
  name: doc.name || doc.id,
32
+ description: doc.description || null,
32
33
  fields: inputNodes(doc).map(function (n) {
33
34
  return { name: n.id, type: n.type }
34
- })
35
+ }),
36
+ // WHAT THIS SCREEN IS CALLED WITH — the inputs it declares, the way an
37
+ // action declares its inputSchema. A flow's Screen step asks for exactly
38
+ // these; a screen that declares none is asked for nothing.
39
+ inputs: screenParams(doc).map(function (p) {
40
+ return { name: p.name, label: p.label || p.name, type: p.type || 'string', required: Boolean(p.required) }
41
+ }),
42
+ // A MASTER is a list other forms point at. Reported here so a picker can
43
+ // offer "Departments" before it offers thirty raw table names, and so the
44
+ // admin section can gather them without reading every document again.
45
+ master: Boolean(doc.master),
46
+ labelField: doc.labelField || null,
47
+ source: doc.source || null
35
48
  }
36
49
  }
37
50
 
51
+ /** The form a screen belongs to: task_edit and task_list are one form. */
52
+ export function formKeyOf(screenKey) {
53
+ var m = /^(.*)_(edit|list)$/.exec(String(screenKey || ''))
54
+ return m ? m[1] : String(screenKey || '')
55
+ }
56
+
57
+ /**
58
+ * THE MASTERS, gathered from the screens an app has.
59
+ *
60
+ * A master is a property of the FORM, and a form is two screens — the edit
61
+ * screen is where it is declared, because that is the one that describes the
62
+ * record. The list screen is what the admin section opens, because looking at
63
+ * a master means looking at its rows.
64
+ *
65
+ * Returns [{ key, name, labelField, edit, list, source }], by name.
66
+ */
67
+ export function masterForms(screens) {
68
+ var byForm = {}
69
+ ;(screens || []).forEach(function (sc) {
70
+ if (!sc || !sc.key) return
71
+ var form = formKeyOf(sc.key)
72
+ var entry = byForm[form] || (byForm[form] = { key: form, name: null, labelField: null, edit: null, list: null, source: null })
73
+ if (/_list$/.test(sc.key)) entry.list = sc.key
74
+ else entry.edit = sc.key
75
+ if (sc.master) {
76
+ entry.master = true
77
+ entry.name = entry.name || sc.name
78
+ entry.labelField = entry.labelField || sc.labelField
79
+ entry.source = entry.source || sc.source
80
+ }
81
+ // A name to show even when the flag is on the other half of the pair.
82
+ if (!entry.name && !/_list$/.test(sc.key)) entry.name = sc.name
83
+ })
84
+
85
+ return Object.keys(byForm)
86
+ .map(function (k) { return byForm[k] })
87
+ .filter(function (f) { return f.master })
88
+ .sort(function (a, b) { return String(a.name).localeCompare(String(b.name)) })
89
+ }
90
+
38
91
  /** A listing row → the same shape, before its document has been read. */
39
92
  export function screenStub(row) {
40
93
  if (!row) return null
@@ -69,6 +122,36 @@ export async function loadScreens(api, onList) {
69
122
  }))
70
123
  }
71
124
 
125
+ /**
126
+ * DELETE A FORM — both of its screens.
127
+ *
128
+ * The list first, then the edit screen: while the list still exists the table
129
+ * is still described, so the edit screen's delete is the one that can find
130
+ * the table unused and empty. The other order leaves the table behind.
131
+ *
132
+ * @returns what became of the table: { table, tableDropped, records }
133
+ */
134
+ export async function deleteForm(api, form) {
135
+ if (!form || !form.key) throw new Error('Which form?')
136
+
137
+ // A HALF OF A FORM IS A KEY OR THE ROW THAT CARRIES ONE. Callers group
138
+ // screens for their own listing and hold whatever was convenient — a key
139
+ // here, a listing row there — and a row put into a URL reads as
140
+ // "[object Object]", which is a 404 naming a screen nobody could have
141
+ // asked for.
142
+ var half = function (x) {
143
+ if (!x) return null
144
+ return typeof x === 'string' ? x : (x.screenKey || x.id || x.key || null)
145
+ }
146
+
147
+ var list = half(form.list)
148
+ var edit = half(form.edit) || form.key
149
+
150
+ if (list) await api.deleteScreen(list)
151
+ var last = await api.deleteScreen(edit)
152
+ return last || { table: null, tableDropped: false, records: null }
153
+ }
154
+
72
155
  /** "Leave request" → "leave_request": what a new form is keyed by. */
73
156
  export function keyFromName(label) {
74
157
  return String(label == null ? '' : label)
@@ -55,6 +55,8 @@ export const MAX_IDENTIFIER = 63
55
55
 
56
56
  const TEXT_DEFAULT_LENGTH = 255
57
57
  const OPTION_MIN_LENGTH = 50
58
+ // prefix (8) + separator + six characters, with room to spare.
59
+ const ID_CODE_LENGTH = 24
58
60
  const FILE_PATH_LENGTH = 255
59
61
 
60
62
  /**
@@ -68,6 +70,15 @@ export function columnForField(node) {
68
70
  switch (node.type) {
69
71
  case 'text':
70
72
  return { ...base, type: 'varchar', length: v.maxLength || TEXT_DEFAULT_LENGTH }
73
+ // AN IDENTIFIER. The kind decides the column, which is the point of it
74
+ // being a type: a counted number is the database's to assign (identity,
75
+ // so an insert that does not mention it still gets one), and a code is a
76
+ // short string the server writes. Unique either way — an identifier two
77
+ // rows share is not one.
78
+ case 'id':
79
+ return p.idKind === 'auto'
80
+ ? { ...base, type: 'integer', identity: true, notNull: true, unique: true }
81
+ : { ...base, type: 'varchar', length: ID_CODE_LENGTH, unique: true }
71
82
  case 'textarea':
72
83
  return { ...base, type: 'text' }
73
84
  case 'number':
@@ -114,8 +125,13 @@ export function columnSql(col) {
114
125
  const type = col.type === 'varchar' ? `varchar(${col.length})` : col.type
115
126
  const parts = [type]
116
127
  if (col.references) parts.push(`REFERENCES ${q(col.references)}("id")`)
128
+ // BY DEFAULT, not ALWAYS: the database counts, and an import carrying real
129
+ // numbers can still write its own. ALWAYS would refuse them outright.
130
+ if (col.identity) parts.push('GENERATED BY DEFAULT AS IDENTITY')
117
131
  if (col.notNull) parts.push('NOT NULL')
118
132
  if (col.default !== undefined) parts.push(`DEFAULT ${col.default}`)
133
+ // An identifier two rows share is not one.
134
+ if (col.unique) parts.push('UNIQUE')
119
135
  return parts.join(' ')
120
136
  }
121
137
 
@@ -42,7 +42,22 @@ import { selectionPlace } from './urlState.js'
42
42
  * `<urlKey>Selected` and `<urlKey>Steps`, so a refresh or a shared
43
43
  * link lands on the same control. Omitted → kept in memory.
44
44
  */
45
- export function useFactoryBuilder({ document: given, name, onSave, onChange, onPublish, listTables, screens, lockedNames, controls = CONTROLS, autosaveDelay = 800, urlKey } = {}) {
45
+ /**
46
+ * What the link column on a child is called: materials → materialId.
47
+ *
48
+ * From the parent's TABLE rather than its name, because the table is what the
49
+ * column points at and is the thing that cannot be renamed later.
50
+ */
51
+ function linkNameFor(doc) {
52
+ var table = String((doc && doc.source) || 'parent').replace(/[^A-Za-z0-9]+/g, '_').replace(/_+$/, '')
53
+ var singular = /ies$/.test(table) ? table.slice(0, -3) + 'y'
54
+ : /(ss|us|is)$/.test(table) ? table
55
+ : /s$/.test(table) ? table.slice(0, -1)
56
+ : table
57
+ return singular.replace(/_([a-z])/g, function (_, c) { return c.toUpperCase() }) + 'Id'
58
+ }
59
+
60
+ export function useFactoryBuilder({ document: given, name, onSave, onChange, onPublish, listTables, makeChildTable, screens, lockedNames, controls = CONTROLS, autosaveDelay = 800, urlKey } = {}) {
46
61
  const [doc, setDoc] = useState(() => given || createScreen({ name }))
47
62
 
48
63
  // ── selection ─────────────────────────────────────────────────────────
@@ -105,6 +120,11 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
105
120
  const [tables, setTables] = useState({ loading: Boolean(listTables), items: [], error: null })
106
121
  const listTablesRef = useRef(listTables); listTablesRef.current = listTables
107
122
  const hasListTables = Boolean(listTables)
123
+ // Bumped when this designer MAKES a table. The list is otherwise fetched
124
+ // once, which was right while tables only ever appeared elsewhere — and
125
+ // wrong the moment "New table…" existed: the table was made, the list was
126
+ // pointed at it, and the picker had never heard of it, so it showed blank.
127
+ const [tableRound, setTableRound] = useState(0)
108
128
  useEffect(() => {
109
129
  if (!hasListTables) return undefined
110
130
  let cancelled = false
@@ -117,7 +137,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
117
137
  })
118
138
  .catch((err) => { if (!cancelled) setTables({ loading: false, items: [], error: err.message || 'Could not list tables' }) })
119
139
  return () => { cancelled = true }
120
- }, [hasListTables])
140
+ }, [hasListTables, tableRound])
121
141
 
122
142
  // ── edits ─────────────────────────────────────────────────────────────
123
143
  const rename = useCallback((value) => update((d) => renameScreen(d, value)), [update])
@@ -276,6 +296,8 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
276
296
  // Deliberate, unlike saving: a half-finished design must not reach everyone
277
297
  // filling the form in.
278
298
  const onPublishRef = useRef(onPublish); onPublishRef.current = onPublish
299
+ const makeChildTableRef = useRef(makeChildTable); makeChildTableRef.current = makeChildTable
300
+ const [designingChild, setDesigningChild] = useState(null)
279
301
  const [publishing, setPublishing] = useState(false)
280
302
  const [publishResult, setPublishResult] = useState(null) // { ok, message, version?, detail? }
281
303
  /**
@@ -379,6 +401,85 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
379
401
  },
380
402
  visibleNodes,
381
403
  setProperty,
404
+ /**
405
+ * A list's table, and the columns that only made sense for the old one.
406
+ *
407
+ * Chosen columns are field names. Point the list at another table and
408
+ * every one of them is a name that table has never heard of — so the grid
409
+ * keeps drawing the old headers over blank cells, which reads as the
410
+ * panel not having noticed the change. Clearing them falls back to "all
411
+ * the columns the rows have", which is right for whatever it now points
412
+ * at.
413
+ *
414
+ * The link goes too: it belonged to the old table's shape.
415
+ */
416
+ setListSource: function (nodeId, table) {
417
+ update(function (d) {
418
+ var node = d.nodes.find(function (n) { return n.id === nodeId })
419
+ var was = node && node.props ? node.props.source : undefined
420
+ var next = setNodeProperty(d, nodeId, 'props.source', table || undefined)
421
+ if ((was || '') === (table || '')) return next
422
+ next = setNodeProperty(next, nodeId, 'props.columns', undefined)
423
+ return setNodeProperty(next, nodeId, 'props.belongsTo', undefined)
424
+ })
425
+ },
426
+ /**
427
+ * Make the table a list is about to point at, and point it there.
428
+ *
429
+ * TWO PROPERTIES AT ONCE, which is why it is here rather than a button in
430
+ * the panel: a table without its link is a list of everybody's rows, and
431
+ * a link without a table is nothing at all. Neither half is a state worth
432
+ * being able to reach.
433
+ *
434
+ * The host does the making (it is a server act, and this package does not
435
+ * know where the server is) and answers with what the list needs.
436
+ */
437
+ // The child just made, waiting to be designed — { nodeId, screenKey }.
438
+ // Nothing is forced: closing it leaves a table with the two fields it was
439
+ // made with, which is a working sub-table.
440
+ designingChild: designingChild,
441
+ closeChild: function (screen) {
442
+ // Designed and published: its fields are the list's columns now. A
443
+ // close with nothing keeps what was set when it was made.
444
+ if (screen && Array.isArray(screen.fields) && designingChild) {
445
+ var link = designingChild.link
446
+ var columns = screen.fields
447
+ .filter(function (f) { return f.name !== link })
448
+ .map(function (f) { return { field: f.name, label: f.label || f.name } })
449
+ if (columns.length) {
450
+ var nodeId = designingChild.nodeId
451
+ update(function (d) { return setNodeProperty(d, nodeId, 'props.columns', columns) })
452
+ }
453
+ }
454
+ setDesigningChild(null)
455
+ },
456
+
457
+ makeChildTable: makeChildTableRef.current ? async function (nodeId, name) {
458
+ var made = await makeChildTableRef.current({
459
+ name: name,
460
+ parent: { table: docRef.current.source, label: docRef.current.name, link: linkNameFor(docRef.current) }
461
+ })
462
+ if (!made || !made.source) throw new Error('Could not make that table')
463
+ // So the picker knows the table this list is about to name.
464
+ setTableRound(function (n) { return n + 1 })
465
+ update(function (d) {
466
+ var withTable = setNodeProperty(d, nodeId, 'props.source', made.source)
467
+ var linked = setNodeProperty(withTable, nodeId, 'props.belongsTo', made.link)
468
+ // The child's own columns, which are known because it was just made
469
+ // — minus the link, which is the list itself and not a column anybody
470
+ // asked to see.
471
+ var columns = (made.fields || [])
472
+ .filter(function (f) { return f.name !== made.link })
473
+ .map(function (f) { return { field: f.name, label: f.label || f.name } })
474
+ return columns.length ? setNodeProperty(linked, nodeId, 'props.columns', columns) : linked
475
+ })
476
+ // STRAIGHT INTO ITS DESIGNER. A sub-table is made for the fields it is
477
+ // going to hold, and "Name" and "Value" are a guess at those — so the
478
+ // form opens where it was made, rather than being something to go and
479
+ // find afterwards with the thought already lost.
480
+ setDesigningChild({ nodeId: nodeId, screenKey: made.edit, link: made.link, name: name })
481
+ return made
482
+ } : null,
382
483
  convertControl,
383
484
  hints,
384
485
  removeSelected,
@@ -52,9 +52,16 @@ import { useUrlSteps } from './useUrlPlaces.js'
52
52
  * @param urlKey where the open step of each stepper lives in the URL (`<urlKey>Steps`),
53
53
  * so a refresh or a shared link lands on the same step. Omitted → in memory.
54
54
  */
55
+ /**
56
+ * @param params what this screen was CALLED WITH — { name: value } for the
57
+ * inputs it declares. A flow's Screen step supplies them; somebody opening
58
+ * the form from a list supplies nothing. A field whose default is
59
+ * `{params.x}` is filled from here, and left empty (for the person to fill
60
+ * in) when nobody said.
61
+ */
55
62
  export function useFactoryScreen({
56
63
  document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl,
57
- onOpenRecord, onDone, onDirtyChange, urlKey,
64
+ onOpenRecord, onDone, onDirtyChange, params, urlKey,
58
65
  controls = CONTROLS
59
66
  } = {}) {
60
67
  const check = useMemo(() => validateDocument(doc, controls), [doc, controls])
@@ -100,9 +107,13 @@ export function useFactoryScreen({
100
107
  }, [check.ok, doc, inputs, fieldsFor])
101
108
  const source = check.ok ? doc.source || null : null
102
109
 
110
+ // WHAT IT WAS CALLED WITH, then what was actually saved: a record being
111
+ // reopened wins, because the row is the truth once there is one.
103
112
  const startValues = useCallback(
104
- (rec) => (rec ? { ...initialValues(doc, {}, controls), ...recordValues(doc, rec, controls) } : initialValues(doc, {}, controls)),
105
- [doc, controls]
113
+ (rec) => (rec
114
+ ? { ...initialValues(doc, params || {}, controls), ...recordValues(doc, rec, controls) }
115
+ : initialValues(doc, params || {}, controls)),
116
+ [doc, controls, params]
106
117
  )
107
118
 
108
119
  const [values, setValues] = useState(() => (check.ok ? startValues(record) : {}))
@@ -484,7 +495,16 @@ export function useFactoryScreen({
484
495
  defaults: {
485
496
  get: () => {
486
497
  if (!fetchRecordsRef.current) throw new Error(`No fetchRecords was provided to list "${src}"`)
487
- return fetchRecordsRef.current({ source: src, screen: doc.id, node })
498
+ // A SUB-TABLE IS THIS RECORD'S. `belongsTo` names the column on
499
+ // those rows that points back here, and the id is this record's
500
+ // — which is nothing until it has been saved, and the server
501
+ // answers with no rows rather than with everybody's.
502
+ var link = node.props && node.props.belongsTo
503
+ return fetchRecordsRef.current({
504
+ source: src, screen: doc.id, node,
505
+ belongsTo: link || undefined,
506
+ belongsToId: link ? live.current.recordId : undefined
507
+ })
488
508
  }
489
509
  }
490
510
  }))
@@ -545,12 +565,20 @@ export function useFactoryScreen({
545
565
  const openRecordRef = useRef(onOpenRecord); openRecordRef.current = onOpenRecord
546
566
  const openEditor = useCallback(async (node, rec) => {
547
567
  const screenId = node.props.editScreen
568
+
569
+ // WHAT THE CHILD IS CALLED WITH. A sub-table's row belongs to THIS
570
+ // record, so the link column is handed to the screen that adds one — the
571
+ // same `params` a flow's Screen step passes. The person never sees it,
572
+ // and never has to know which material they are adding a line to.
573
+ const link = node.props.belongsTo
574
+ const given = link && live.current.recordId ? { [link]: live.current.recordId } : null
575
+
548
576
  if (node.props.openIn === 'page') {
549
577
  if (!openRecordRef.current) throw new Error('This list opens records on a page, but no onOpenRecord was provided to go there')
550
578
  const id = rec ? rec[live.current.recordKey] : null
551
- return openRecordRef.current({ screen: screenId, id, record: rec || null, source: listSource(doc, node), node })
579
+ return openRecordRef.current({ screen: screenId, id, record: rec || null, source: listSource(doc, node), node, params: given })
552
580
  }
553
- const base = { node, screenId, record: rec || null, document: null, loading: true, error: null }
581
+ const base = { node, screenId, record: rec || null, document: null, loading: true, error: null, params: given }
554
582
  setPopup(base)
555
583
  try {
556
584
  let found = screensRef.current && screensRef.current[screenId]
package/src/useFlowRun.js CHANGED
@@ -15,14 +15,17 @@ import { useCallback, useEffect, useRef, useState } from 'react'
15
15
  * @param flowKey which flow to run (starting one, or picking up a run)
16
16
  * @param runId an existing run to continue — omit to start one
17
17
  * @param startRun async (flowKey) → { runId, status, stepKey, screen }
18
- * @param loadRun async (runId) → { runId, status, stepKey, screen, values }
18
+ * @param loadRun async (runId) → { runId, status, stepKey, screen, values, given }
19
19
  * @param submitRun async (runId, values, recordId) → { status, stepKey, screen }
20
20
  * @param loadScreen async (key) → the screen document
21
21
  * @param onStep (step) → void — told each time the run moves, for the address bar
22
22
  * @param onFinish (run) → void — the journey is over
23
23
  */
24
24
  export function useFlowRun({ flowKey, runId, startRun, loadRun, submitRun, loadScreen, onStep, onFinish } = {}) {
25
- const [state, setState] = useState({ loading: true, error: null, runId: runId || null, status: null, stepKey: null, screen: null, document: null, values: null })
25
+ // `given` is what the flow called this screen with — the step's own input,
26
+ // resolved by the engine (see @xeplr/workflow's screen-show). A field bound
27
+ // to {params.x} is filled from it.
28
+ const [state, setState] = useState({ loading: true, error: null, runId: runId || null, status: null, stepKey: null, screen: null, document: null, values: null, given: null })
26
29
  const [sending, setSending] = useState(false)
27
30
  const live = useRef({})
28
31
  live.current = { startRun, loadRun, submitRun, loadScreen, onStep, onFinish, flowKey }
@@ -40,7 +43,7 @@ export function useFlowRun({ flowKey, runId, startRun, loadRun, submitRun, loadS
40
43
  setState({
41
44
  loading: false, error: null,
42
45
  runId: run.runId, status: run.status || 'running', stepKey: run.stepKey, screen: run.screen,
43
- document: doc, values: run.values || null
46
+ document: doc, values: run.values || null, given: run.given || null
44
47
  })
45
48
  if (live.current.onStep) live.current.onStep(run)
46
49
  }, [])
@@ -21,7 +21,7 @@
21
21
  // createElement so the file stays .js.
22
22
 
23
23
  import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
24
- import { loadScreens } from './screenSource.js'
24
+ import { loadScreens, masterForms } from './screenSource.js'
25
25
  import { ScreenEditorSample } from './designs/ScreenEditorSample.jsx'
26
26
 
27
27
  /**
@@ -29,7 +29,7 @@ import { ScreenEditorSample } from './designs/ScreenEditorSample.jsx'
29
29
  * @param opts { Builder, labels } — the builder component to draw, normally
30
30
  * FactoryBuilder. Passed in rather than imported so this hook does
31
31
  * not pull the whole builder into a bundle that never opens it.
32
- * @returns { screens, screenEditor, reload, loading, error }
32
+ * @returns { screens, masters, screenEditor, reload, loading, error }
33
33
  */
34
34
  export function useScreenSource(api, opts) {
35
35
  opts = opts || {}
@@ -74,8 +74,12 @@ export function useScreenSource(api, opts) {
74
74
  })
75
75
  }, [api, Builder, labels, reload])
76
76
 
77
+ // The lists other forms point at, one entry per FORM — what the admin
78
+ // section shows. Derived here so no application groups screens by hand.
79
+ const masters = useMemo(() => masterForms(screens), [screens])
80
+
77
81
  return useMemo(
78
- () => ({ screens, screenEditor, reload, loading, error }),
79
- [screens, screenEditor, reload, loading, error]
82
+ () => ({ screens, masters, screenEditor, reload, loading, error }),
83
+ [screens, masters, screenEditor, reload, loading, error]
80
84
  )
81
85
  }
@@ -18,7 +18,7 @@ import { FIELD_PRESETS, INPUT_TYPES, presetFor } from './presets.js'
18
18
  const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
19
19
  const TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_.$-]*$/
20
20
  const SCREEN_ID = /^[A-Za-z0-9_-]+$/
21
- const DOCUMENT_KEYS = ['kind', 'version', 'id', 'name', 'source', 'units', 'aspect', 'width', 'style', 'nodes']
21
+ const DOCUMENT_KEYS = ['kind', 'version', 'id', 'name', 'source', 'units', 'aspect', 'width', 'style', 'description', 'master', 'labelField', 'params', 'nodes']
22
22
  const COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i
23
23
 
24
24
  /**
@@ -41,6 +41,49 @@ export function validateDocument(doc, controls = CONTROLS) {
41
41
  if (!(typeof doc.aspect === 'number' && doc.aspect > 0)) err('aspect', 'must be a number greater than 0 (1 = page as tall as it is wide)')
42
42
  if (!(Number.isInteger(doc.width) && doc.width >= 320 && doc.width <= 1920)) err('width', 'must be a whole number of pixels from 320 to 1920 — the width the screen is designed at')
43
43
  if (doc.source !== undefined && !(nonEmptyString(doc.source) && TABLE_NAME.test(doc.source))) err('source', 'must name the table records are saved to, e.g. "employees"')
44
+
45
+ // WHAT THIS FORM IS FOR, in the maker's own words. Three forms called
46
+ // "Inspection" are told apart by this and by who made them — never by their
47
+ // fields, which say what a form holds and not what it was for.
48
+ if (doc.description !== undefined && doc.description !== null && doc.description !== '') {
49
+ if (typeof doc.description !== 'string') err('description', 'must be a sentence about what this form is for')
50
+ else if (doc.description.length > 500) err('description', 'must be under 500 characters — a sentence, not the manual')
51
+ }
52
+
53
+ // A MASTER is a form whose records other forms point at — departments,
54
+ // statuses, countries. It is a FLAG on an ordinary form, not a kind of its
55
+ // own: a master has a table, a list, an edit screen and permissions like
56
+ // anything else, and a separate type would be two things that behave the
57
+ // same until they do not, with every later feature asking which it has.
58
+ if (doc.master !== undefined && typeof doc.master !== 'boolean') {
59
+ err('master', 'must be true or false — whether other forms pick from this one')
60
+ }
61
+ // What a picker SHOWS for a record. Said once here rather than on every
62
+ // dropdown that points at this form.
63
+ if (doc.labelField !== undefined) {
64
+ if (!nonEmptyString(doc.labelField)) err('labelField', 'must name a field of this screen, e.g. "name"')
65
+ else if (!(doc.nodes || []).some((n) => n.props && n.props.name === doc.labelField)) {
66
+ err('labelField', `"${doc.labelField}" is not a field on this screen`)
67
+ }
68
+ }
69
+
70
+ // WHAT THIS SCREEN IS CALLED WITH. Names only — a field binds to one with
71
+ // {params.<name>} as its default, so a name that is not a plain identifier
72
+ // could never be referenced, and a duplicate would make the reference mean
73
+ // two things.
74
+ if (doc.params !== undefined) {
75
+ if (!Array.isArray(doc.params)) err('params', 'must be a list of inputs this screen is called with')
76
+ else {
77
+ const seen = new Set()
78
+ doc.params.forEach((p, i) => {
79
+ const name = p && p.name
80
+ if (!nonEmptyString(name)) err(`params.${i}.name`, 'must be a name, e.g. "complaintId"')
81
+ else if (!/^[A-Za-z_][\w]*$/.test(name)) err(`params.${i}.name`, `"${name}" must be letters, digits and _, starting with a letter`)
82
+ else if (seen.has(name)) err(`params.${i}.name`, `"${name}" is declared twice — a field binding to it would mean two things`)
83
+ else seen.add(name)
84
+ })
85
+ }
86
+ }
44
87
  checkStyle(doc.style, SCREEN_STYLE_KEYS, 'style', err, 'the screen')
45
88
  Object.keys(doc).forEach((k) => {
46
89
  if (!DOCUMENT_KEYS.includes(k)) err(k, `is not a screen property — allowed: ${DOCUMENT_KEYS.join(', ')}`)
@@ -92,6 +135,18 @@ export function validateDocument(doc, controls = CONTROLS) {
92
135
  // Where the field came from (a ready-made field's key), or false: plain on purpose.
93
136
  if (props.preset !== undefined && props.preset !== false && !presetFor(props.preset)) err(`${at}.props.preset`, `"${props.preset}" is not a ready-made field — one of: ${FIELD_PRESETS.map((p) => p.key).join(', ')}, or false`)
94
137
  if (props.inputType !== undefined && !INPUT_TYPES.includes(props.inputType)) err(`${at}.props.inputType`, `must be one of: ${INPUT_TYPES.join(', ')}`)
138
+
139
+ // AN IDENTIFIER — the number or code this record is known by. How it is
140
+ // made decides the column, so it is checked here rather than discovered
141
+ // at publish time.
142
+ if (props.idKind !== undefined && props.idKind !== 'code' && props.idKind !== 'auto') {
143
+ err(`${at}.props.idKind`, 'must be "code" (CMP-7K3QF9, assigned on save) or "auto" (a number the database counts)')
144
+ }
145
+ if (props.codePrefix !== undefined && props.codePrefix !== '') {
146
+ if (typeof props.codePrefix !== 'string' || !/^[A-Za-z0-9]{1,8}$/.test(props.codePrefix)) {
147
+ err(`${at}.props.codePrefix`, 'must be up to 8 letters or digits, e.g. "CMP" — it is what the code starts with')
148
+ }
149
+ }
95
150
  checkDefault(node, def, at, err)
96
151
  checkValidation(node, def, at, err)
97
152
  }
@@ -330,6 +385,17 @@ function checkList(props, doc, at, err) {
330
385
  if (props.source !== undefined && !(nonEmptyString(props.source) && TABLE_NAME.test(props.source))) err(`${at}.props.source`, 'must name the table to list records from')
331
386
  if (props.source === undefined && doc.source === undefined) err(`${at}.props.source`, 'is required when the screen has no "source" — which table should the list read?')
332
387
  if (props.editScreen !== undefined && !(nonEmptyString(props.editScreen) && SCREEN_ID.test(props.editScreen))) err(`${at}.props.editScreen`, 'must be the id of the screen that edits a record, e.g. "employee_edit"')
388
+
389
+ // THE LINK BACK, for a sub-table on a form: this material's
390
+ // characteristics. A column name, checked as one — a value that could never
391
+ // be a column is a list that is always empty and nobody knows why.
392
+ if (props.belongsTo !== undefined && props.belongsTo !== '') {
393
+ if (!(nonEmptyString(props.belongsTo) && /^[A-Za-z_][\w]*$/.test(props.belongsTo))) {
394
+ err(`${at}.props.belongsTo`, 'must be a column name, e.g. "materialId"')
395
+ } else if (!props.source) {
396
+ err(`${at}.props.belongsTo`, 'only means something with "Records from" set — a list of this screen\'s own table already belongs to this record')
397
+ }
398
+ }
333
399
  if (props.pageSize !== undefined && !(Number.isInteger(props.pageSize) && props.pageSize >= 1 && props.pageSize <= 200)) err(`${at}.props.pageSize`, 'must be a whole number from 1 to 200')
334
400
  if (props.actions !== undefined) {
335
401
  if (!Array.isArray(props.actions)) err(`${at}.props.actions`, `must be an array of: ${LIST_ACTIONS.join(', ')}`)