@xeplr/ui-factory 1.7.3 → 1.8.1
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 +44 -0
- package/package.json +1 -1
- package/src/controls.js +57 -0
- package/src/designs/BuilderSample.jsx +4 -1
- package/src/designs/ControlView.jsx +63 -2
- package/src/designs/FlowRunnerSample.jsx +7 -3
- package/src/designs/PropertyPanel.jsx +49 -5
- package/src/designs/ScreenSample.jsx +17 -0
- package/src/designs/factory.css +9 -0
- package/src/flows.js +3 -1
- package/src/index.js +2 -0
- package/src/model.js +1 -1
- package/src/pages.jsx +8 -1
- package/src/useFactoryScreen.js +20 -6
- package/src/useFlowRun.js +11 -6
- package/src/useFormFields.js +37 -0
- package/src/validateDocument.js +5 -0
- package/src/values.js +37 -11
package/README.md
CHANGED
|
@@ -395,6 +395,34 @@ Controllers, for a design of your own: `useFlowBuilder` (the flow, plus
|
|
|
395
395
|
functions, `forEvent`). Model: `navigationOf`, `setNavigation`, `arrowTargets`,
|
|
396
396
|
`setEvent`, `functionsFor`.
|
|
397
397
|
|
|
398
|
+
## Filled in from a screen before — `{previous.<screen>.values.<field>}`
|
|
399
|
+
|
|
400
|
+
In a flow, what one screen saved is handed to the screens after it as plain
|
|
401
|
+
JSON, by SCREEN key (the workflow API's `previous` on every run view):
|
|
402
|
+
|
|
403
|
+
```json
|
|
404
|
+
{ "department_edit": { "values": { "id": "dep_42", "name": "Finance" } } }
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
A field fills itself from it with a default of
|
|
408
|
+
`{previous.department_edit.values.name}` — or `.id`, the row that screen
|
|
409
|
+
saved, to link to it. Pick it in the builder: **Fill in from** on any field
|
|
410
|
+
offers this screen's own inputs (`{params.x}`) and every form's fields, so
|
|
411
|
+
nothing is typed. Any kind of field may be filled this way, a number or a date
|
|
412
|
+
too.
|
|
413
|
+
|
|
414
|
+
- **No contract.** A screen not on this run's way here, or a field it does not
|
|
415
|
+
have, leaves the field empty — nothing errors. Making the names line up is
|
|
416
|
+
the flow's author's job; declared contracts between steps are the paid
|
|
417
|
+
workflow's.
|
|
418
|
+
- **By screen, not by step**, because the binding lives on the screen, and a
|
|
419
|
+
screen is the same form in every flow. A screen shown twice hands over its
|
|
420
|
+
latest.
|
|
421
|
+
- `FlowRunner` passes the run's `previous` to the screen; with your own design,
|
|
422
|
+
hand it to `useFactoryScreen({ previous })`. Model: `previousBinding`,
|
|
423
|
+
`resolveBinding(value, params, previous)`, `initialValues(doc, params,
|
|
424
|
+
controls, previous)`.
|
|
425
|
+
|
|
398
426
|
## Masters — the lists the rest of the app points at
|
|
399
427
|
|
|
400
428
|
Departments, statuses, countries, categories. Mark a form as a master in its
|
|
@@ -545,6 +573,21 @@ run is only what the arrows need and the id of the row that was written
|
|
|
545
573
|
that exists, arrows that point somewhere, one "otherwise" at the end, and
|
|
546
574
|
nothing unreachable.
|
|
547
575
|
|
|
576
|
+
### A review screen inside a workflow
|
|
577
|
+
|
|
578
|
+
A screen can also be one step of an ordinary Xeplr Workflow: the run parks on it until a person answers. The workflow step says what it is handed, who answers it and with which buttons:
|
|
579
|
+
|
|
580
|
+
```json
|
|
581
|
+
{ "actionName": "screen-show", "kind": "wait",
|
|
582
|
+
"values": { "screen": "unmatched_review", "values": { "lines": "{steps.match.output.rows}" },
|
|
583
|
+
"assignTo": "Accounts", "buttons": [{ "key": "approve", "label": "Approve" }, { "key": "send_back", "label": "Send back" }] } }
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
- **A `table` shows what it was handed** (`rows: '{params.lines}'`, a screen input), with a tick per row; the ticked rows are the field's value (`selected`).
|
|
587
|
+
- **The step's buttons replace Next** in `FlowRunner` (the first is the main one). The one pressed is sent as `action` and becomes the step's `output.action`, so its arrows branch on it.
|
|
588
|
+
- **A screen with no table of its own saves nothing**: a button goes straight to the run.
|
|
589
|
+
- `createFlowsApi(...).waiting()` lists every screen waiting for one of the signed-in person's roles (`GET /flows/waiting`).
|
|
590
|
+
|
|
548
591
|
### A flow and a stepper
|
|
549
592
|
|
|
550
593
|
Both walk someone through something, and they answer different questions:
|
|
@@ -574,6 +617,7 @@ Both walk someone through something, and they answer different questions:
|
|
|
574
617
|
| `stepper` | — | a journey across the top; every other control says which step it is on |
|
|
575
618
|
| `label` | — | text with a heading, subheading or text preset |
|
|
576
619
|
| `list` | — | saved records, with New / Edit / Delete |
|
|
620
|
+
| `table` | array of the rows ticked | `minItems`, `maxItems`; `required` = at least one. Rows the screen is **handed** (`rows: '{params.lines}'`), not a table it reads; `columns` (`ref, amount`) or every key of the first row; `selectable` (a tick per row, on by default). Adds no column — see below |
|
|
577
621
|
| `subform` | — | another form drawn inside this one (palette: **Form**), saving into its own table — see below |
|
|
578
622
|
|
|
579
623
|
A screen is a **sheet**, centred in the space it is given and shown at its
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/ui-factory",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
4
4
|
"description": "Design data-entry screens on @xeplr/ui-canvas, save them as JSON, run them as auto-saving forms with lists of saved records — built for Claude to draft and people to refine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/controls.js
CHANGED
|
@@ -95,6 +95,10 @@ const FIELD_BASICS = (extra) => ({
|
|
|
95
95
|
{ path: 'props.label', label: 'Label', type: 'text' },
|
|
96
96
|
{ path: 'props.name', label: 'Field name', type: 'text', help: 'The key this value is saved under — letters, digits and _' },
|
|
97
97
|
...(extra || []),
|
|
98
|
+
// FILLED IN WHEN THE SCREEN OPENS, from somewhere else — the same
|
|
99
|
+
// props.default, holding a binding instead of a typed value.
|
|
100
|
+
{ path: 'props.default', label: 'Fill in from', type: 'fillFrom',
|
|
101
|
+
help: 'What this screen is called with, or — in a flow — what a screen before this one saved. Nothing is checked: a screen not on the way here leaves it empty.' },
|
|
98
102
|
{ path: 'props.required', label: 'Required', type: 'toggle' }
|
|
99
103
|
]
|
|
100
104
|
})
|
|
@@ -572,6 +576,59 @@ CONTROLS.list = {
|
|
|
572
576
|
]
|
|
573
577
|
}
|
|
574
578
|
|
|
579
|
+
// A TABLE OF ROWS THE WORKFLOW HANDS THE SCREEN — the unmatched bank lines
|
|
580
|
+
// a person reviews — with a tick per row. Its value is the rows ticked, under
|
|
581
|
+
// its name (default `selected`), so the step's output.selected is what the
|
|
582
|
+
// person chose. The rows come from a binding to the screen's inputs
|
|
583
|
+
// (`rows: '{params.lines}'`), filled by the workflow step's `values`.
|
|
584
|
+
//
|
|
585
|
+
// Not a list: a list reads a database table; this shows what the run already
|
|
586
|
+
// has, and saves nothing of its own (no column: columnForField ignores it).
|
|
587
|
+
CONTROLS.table = {
|
|
588
|
+
type: 'table',
|
|
589
|
+
label: 'Table',
|
|
590
|
+
group: 'Data',
|
|
591
|
+
input: true,
|
|
592
|
+
valueType: 'array',
|
|
593
|
+
defaultSize: { w: 0.92, h: 0.42 },
|
|
594
|
+
defaults: { label: 'Rows', rows: '{params.rows}', selectable: true, columns: '' },
|
|
595
|
+
props: ['name', 'label', 'required', 'rows', 'columns', 'selectable', 'validation', 'style'],
|
|
596
|
+
validation: ['minItems', 'maxItems'],
|
|
597
|
+
styles: ['fontFamily', 'fontSize', 'color', 'background', 'borderColor', 'borderWidth', 'borderRadius'],
|
|
598
|
+
properties: [
|
|
599
|
+
FIELD_BASICS(),
|
|
600
|
+
{
|
|
601
|
+
key: 'table',
|
|
602
|
+
title: 'Rows',
|
|
603
|
+
fields: [
|
|
604
|
+
{ path: 'props.rows', label: 'Rows from', type: 'text',
|
|
605
|
+
help: 'One of this screen\'s inputs, e.g. {params.lines}. The workflow step fills it.' },
|
|
606
|
+
{ path: 'props.columns', label: 'Columns', type: 'text',
|
|
607
|
+
help: 'Which, in order, e.g. ref, amount, date. Blank: every column of the first row.' },
|
|
608
|
+
{ path: 'props.selectable', label: 'A tick per row', type: 'checkbox',
|
|
609
|
+
help: 'The ticked rows are what this field gives the workflow.' }
|
|
610
|
+
]
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
key: 'validation',
|
|
614
|
+
title: 'Validation',
|
|
615
|
+
fields: [
|
|
616
|
+
{ path: 'props.validation.minItems', label: 'Tick at least', type: 'number' },
|
|
617
|
+
{ path: 'props.validation.maxItems', label: 'Tick at most', type: 'number' }
|
|
618
|
+
]
|
|
619
|
+
},
|
|
620
|
+
...styleGroups(['fontFamily', 'fontSize', 'color', 'background', 'borderColor', 'borderWidth', 'borderRadius'])
|
|
621
|
+
]
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** The columns a table shows: its own list, else every key of the first row. */
|
|
625
|
+
export function tableColumns(p, rows) {
|
|
626
|
+
const listed = String((p && p.columns) || '').split(',').map((c) => c.trim()).filter(Boolean)
|
|
627
|
+
if (listed.length) return listed
|
|
628
|
+
const first = (rows || []).find((r) => r && typeof r === 'object')
|
|
629
|
+
return first ? Object.keys(first) : []
|
|
630
|
+
}
|
|
631
|
+
|
|
575
632
|
// A FORM INSIDE THIS ONE — Access's subform. Another published form, drawn
|
|
576
633
|
// inline with its own fields, saving into ITS OWN table: this employee's bank
|
|
577
634
|
// details. One record of it belongs to each record of this one, through
|
|
@@ -29,7 +29,9 @@ const STATUS = {
|
|
|
29
29
|
error: 'Not saved'
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
// screenApi: the app's factory API client, for designing a child table in place.
|
|
33
|
+
// forms: the app's forms and their fields (useFormFields), for "Fill in from".
|
|
34
|
+
export default function BuilderSample({ ctrl, renderPreview, className, style, screenApi, forms }) {
|
|
33
35
|
const { document: doc } = ctrl
|
|
34
36
|
const [preview, setPreview] = useState(false)
|
|
35
37
|
const problemCount = ctrl.validation.errors.length
|
|
@@ -272,6 +274,7 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
|
|
|
272
274
|
errors={ctrl.selectedNode ? ctrl.errorsByNode[ctrl.selectedNode.id] : ctrl.errorsByNode._document}
|
|
273
275
|
tables={ctrl.tables}
|
|
274
276
|
screens={ctrl.screens}
|
|
277
|
+
forms={forms}
|
|
275
278
|
lockedNames={ctrl.lockedNames}
|
|
276
279
|
onChange={(path, value) => (
|
|
277
280
|
// A list's table is not an ordinary property: changing it makes
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
|
-
import { LABEL_PRESETS } from '../controls.js'
|
|
2
|
+
import { tableColumns, LABEL_PRESETS } from '../controls.js'
|
|
3
3
|
import { stepsOf } from '../document.js'
|
|
4
4
|
import ListView from './ListView.jsx'
|
|
5
5
|
import { fieldStyle, labelStyle, boxStyle } from './styles.js'
|
|
@@ -13,7 +13,7 @@ import { fieldStyle, labelStyle, boxStyle } from './styles.js'
|
|
|
13
13
|
|
|
14
14
|
const ids = (node) => `xeplr-factory-${node.id}`
|
|
15
15
|
|
|
16
|
-
export default function ControlView({ node, mode = 'live', value, error, options, onChange, onBlur, disabled, list, upload, stepper, subform }) {
|
|
16
|
+
export default function ControlView({ node, mode = 'live', value, error, options, onChange, onBlur, disabled, list, upload, stepper, subform, rows }) {
|
|
17
17
|
const View = VIEWS[node.type]
|
|
18
18
|
if (!View) {
|
|
19
19
|
return <div className="xeplr-factory-unknown">Unknown control “{node.type}”</div>
|
|
@@ -34,6 +34,7 @@ export default function ControlView({ node, mode = 'live', value, error, options
|
|
|
34
34
|
upload={upload}
|
|
35
35
|
stepper={stepper}
|
|
36
36
|
subform={subform}
|
|
37
|
+
rows={rows}
|
|
37
38
|
/>
|
|
38
39
|
)
|
|
39
40
|
}
|
|
@@ -459,6 +460,65 @@ function SubformView({ p, node, design, subform }) {
|
|
|
459
460
|
)
|
|
460
461
|
}
|
|
461
462
|
|
|
463
|
+
// Rows the workflow handed the screen, a tick per row. The value is the rows
|
|
464
|
+
// ticked. In the designer: three placeholder rows, so it reads as a table.
|
|
465
|
+
function TableView({ node, p, design, value, error, onChange, disabled, rows }) {
|
|
466
|
+
const shown = design ? [{ ref: 'A1', amount: 500 }, { ref: 'A2', amount: 20 }, { ref: 'A3', amount: 75 }] : (rows || [])
|
|
467
|
+
const columns = tableColumns(p, shown)
|
|
468
|
+
const chosen = Array.isArray(value) ? value : []
|
|
469
|
+
const selectable = p.selectable !== false
|
|
470
|
+
const ticked = (row) => chosen.some((c) => c === row || JSON.stringify(c) === JSON.stringify(row))
|
|
471
|
+
const toggle = (row, on) => {
|
|
472
|
+
const rest = chosen.filter((c) => !(c === row || JSON.stringify(c) === JSON.stringify(row)))
|
|
473
|
+
onChange(on ? [...rest, row] : rest)
|
|
474
|
+
}
|
|
475
|
+
const all = shown.length > 0 && shown.every(ticked)
|
|
476
|
+
const text = boxStyle(p.style)
|
|
477
|
+
return (
|
|
478
|
+
<Field node={node} p={p} error={error}>
|
|
479
|
+
<div className="xeplr-factory-table-wrap" role="group" aria-labelledby={`${ids(node)}-label`}>
|
|
480
|
+
{shown.length === 0 ? (
|
|
481
|
+
<div className="xeplr-factory-hint">Nothing to show.</div>
|
|
482
|
+
) : (
|
|
483
|
+
<table className="xeplr-factory-table" style={{ fontSize: text.fontSize, color: text.color }}>
|
|
484
|
+
<thead>
|
|
485
|
+
<tr>
|
|
486
|
+
{selectable && (
|
|
487
|
+
<th className="xeplr-factory-table-tick">
|
|
488
|
+
<input type="checkbox" aria-label="Tick every row" checked={all} disabled={disabled}
|
|
489
|
+
onChange={(e) => onChange(e.target.checked ? shown.slice() : [])} />
|
|
490
|
+
</th>
|
|
491
|
+
)}
|
|
492
|
+
{columns.map((c) => <th key={c}>{c}</th>)}
|
|
493
|
+
</tr>
|
|
494
|
+
</thead>
|
|
495
|
+
<tbody>
|
|
496
|
+
{shown.map((row, i) => (
|
|
497
|
+
<tr key={i} className={selectable && ticked(row) ? 'is-ticked' : undefined}>
|
|
498
|
+
{selectable && (
|
|
499
|
+
<td className="xeplr-factory-table-tick">
|
|
500
|
+
<input type="checkbox" aria-label={'Row ' + (i + 1)} checked={ticked(row)} disabled={disabled}
|
|
501
|
+
onChange={(e) => toggle(row, e.target.checked)} />
|
|
502
|
+
</td>
|
|
503
|
+
)}
|
|
504
|
+
{columns.map((c) => {
|
|
505
|
+
const v = row ? row[c] : null
|
|
506
|
+
return <td key={c} className={typeof v === 'number' ? 'is-number' : undefined}>{v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v)}</td>
|
|
507
|
+
})}
|
|
508
|
+
</tr>
|
|
509
|
+
))}
|
|
510
|
+
</tbody>
|
|
511
|
+
</table>
|
|
512
|
+
)}
|
|
513
|
+
</div>
|
|
514
|
+
{!design && selectable && shown.length > 0 && (
|
|
515
|
+
<div className="xeplr-factory-hint">{chosen.length} of {shown.length} ticked</div>
|
|
516
|
+
)}
|
|
517
|
+
{design && <div className="xeplr-factory-hint">Rows from {p.rows || '— set "Rows from"'}</div>}
|
|
518
|
+
</Field>
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
|
|
462
522
|
function ListControl({ node, design, list }) {
|
|
463
523
|
return <ListView node={node} design={design} {...(list || {})} />
|
|
464
524
|
}
|
|
@@ -478,5 +538,6 @@ export const VIEWS = {
|
|
|
478
538
|
stepper: StepperView,
|
|
479
539
|
label: LabelView,
|
|
480
540
|
list: ListControl,
|
|
541
|
+
table: TableView,
|
|
481
542
|
subform: SubformView
|
|
482
543
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useRef } from 'react'
|
|
2
2
|
import ScreenSample from './ScreenSample.jsx'
|
|
3
3
|
|
|
4
|
-
// A flow, running: the screen the run is parked on, and
|
|
4
|
+
// A flow, running: the screen the run is parked on, and the way forward — one
|
|
5
|
+
// Next, or the step's own buttons (Approve / Send back), each saying which.
|
|
5
6
|
//
|
|
6
7
|
// Presentation only — everything comes from useFlowRun and useFactoryScreen.
|
|
7
8
|
// "Next" saves the screen into its own table (one AJAX call, only if anything
|
|
@@ -49,8 +50,11 @@ export default function FlowRunnerSample({ run, screen, className, style, title,
|
|
|
49
50
|
// not two that both look like the way on.
|
|
50
51
|
// The id comes from the save that Next just made when there was one:
|
|
51
52
|
// a new record's id has not reached the ref yet (that is a render away).
|
|
52
|
-
done: (r) => run.submit(values.current, r && r.record && r.record[screen.recordKey] != null ? r.record[screen.recordKey] : record.current),
|
|
53
|
-
doneLabel: run.sending ? 'Saving…' : 'Next'
|
|
53
|
+
done: (r, action) => run.submit(values.current, r && r.record && r.record[screen.recordKey] != null ? r.record[screen.recordKey] : record.current, action),
|
|
54
|
+
doneLabel: run.sending ? 'Saving…' : 'Next',
|
|
55
|
+
// The step's own buttons, when it has them: each is a way on.
|
|
56
|
+
doneButtons: run.buttons && run.buttons.length ? run.buttons : null,
|
|
57
|
+
sending: run.sending
|
|
54
58
|
}}
|
|
55
59
|
/>
|
|
56
60
|
<FlowValues screen={screen} values={values} record={record} />
|
|
@@ -1,7 +1,8 @@
|
|
|
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, paramsText } from '../document.js'
|
|
4
|
+
import { slugify, inputNodes, paramsText, screenParams } from '../document.js'
|
|
5
|
+
import { isBinding, previousBinding } from '../values.js'
|
|
5
6
|
import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS, FILE_KINDS } from '../controls.js'
|
|
6
7
|
|
|
7
8
|
// The selected control's properties, GENERATED from its entry in controls.js:
|
|
@@ -58,7 +59,7 @@ function FieldType({ node, controls, hint, onConvert, onKeepPlain }) {
|
|
|
58
59
|
)
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
export default function PropertyPanel({ doc, node, control, controls, errors, tables, screens, lockedNames, onChange, onConvert, hint, onScreenChange, onRemove, onMakeChildTable, selectionCount }) {
|
|
62
|
+
export default function PropertyPanel({ doc, node, control, controls, errors, tables, screens, forms, lockedNames, onChange, onConvert, hint, onScreenChange, onRemove, onMakeChildTable, selectionCount }) {
|
|
62
63
|
if (selectionCount > 1) {
|
|
63
64
|
return (
|
|
64
65
|
<aside className="xeplr-factory-panel">
|
|
@@ -95,17 +96,22 @@ export default function PropertyPanel({ doc, node, control, controls, errors, ta
|
|
|
95
96
|
// A field name that is already a column cannot change — see setNodeProperty.
|
|
96
97
|
const locked = field.path === 'props.name' && (lockedNames || []).includes(node.props?.name)
|
|
97
98
|
const Editor = locked ? LockedEditor : (EDITORS[field.type] || TextEditor)
|
|
98
|
-
const id = `xf-prop-${node.id}-${field.path.replace(/\W+/g, '-')}`
|
|
99
|
+
const id = `xf-prop-${node.id}-${field.path.replace(/\W+/g, '-')}-${field.type}`
|
|
100
|
+
// A default that is a BINDING belongs to "Fill in from"; the typed
|
|
101
|
+
// box beside it shows empty rather than the binding's braces.
|
|
102
|
+
const raw = getAtPath(node, field.path)
|
|
103
|
+
const value = field.path === 'props.default' && field.type !== 'fillFrom' && isBinding(raw) ? undefined : raw
|
|
99
104
|
return (
|
|
100
|
-
<div key={field.path} className={`xeplr-factory-prop${errs.length ? ' has-error' : ''}`}>
|
|
105
|
+
<div key={field.path + ':' + field.type} className={`xeplr-factory-prop${errs.length ? ' has-error' : ''}`}>
|
|
101
106
|
{field.type !== 'toggle' && field.type !== 'toggleValue' && field.type !== 'columns' && field.type !== 'actions' && <label className="xeplr-factory-prop-label" htmlFor={id}>{field.label}</label>}
|
|
102
107
|
<Editor
|
|
103
108
|
id={id}
|
|
104
109
|
field={field}
|
|
105
|
-
value={
|
|
110
|
+
value={value}
|
|
106
111
|
onChange={(v) => onChange(field.path, v)}
|
|
107
112
|
tables={tables}
|
|
108
113
|
screens={screens}
|
|
114
|
+
forms={forms}
|
|
109
115
|
node={node}
|
|
110
116
|
doc={doc}
|
|
111
117
|
errors={errs}
|
|
@@ -568,6 +574,43 @@ function FormEditor({ id, value, onChange, screens, doc }) {
|
|
|
568
574
|
return <input id={id} className="xeplr-factory-prop-input xeplr-factory-mono" type="text" placeholder="bank_details_edit" value={value ?? ''} onChange={(e) => onChange(e.target.value.trim() || undefined)} />
|
|
569
575
|
}
|
|
570
576
|
|
|
577
|
+
/**
|
|
578
|
+
* FILL IN FROM — a field's default as a BINDING, picked, never typed: what
|
|
579
|
+
* this screen is called with ({params.x}), or what a screen before it in a
|
|
580
|
+
* flow saved ({previous.<screen>.values.<field>}). Picking "nothing" clears
|
|
581
|
+
* only a binding; a typed default value is left alone.
|
|
582
|
+
*/
|
|
583
|
+
function FillFromEditor({ id, value, onChange, doc, forms }) {
|
|
584
|
+
const bound = isBinding(value) ? value : ''
|
|
585
|
+
const inputs = doc ? screenParams(doc) : []
|
|
586
|
+
const others = (forms || []).filter((f) => !doc || f.key !== doc.id)
|
|
587
|
+
const offered = new Set([
|
|
588
|
+
...inputs.map((p) => `{params.${p.name}}`),
|
|
589
|
+
...others.flatMap((f) => f.fields.map((x) => previousBinding(f.key, x.name)))
|
|
590
|
+
])
|
|
591
|
+
return (
|
|
592
|
+
<select
|
|
593
|
+
id={id}
|
|
594
|
+
className="xeplr-factory-prop-input"
|
|
595
|
+
value={bound}
|
|
596
|
+
onChange={(e) => { if (e.target.value || bound) onChange(e.target.value || undefined) }}
|
|
597
|
+
>
|
|
598
|
+
<option value="">Nothing — empty, or the default value</option>
|
|
599
|
+
{inputs.length > 0 && (
|
|
600
|
+
<optgroup label="What this screen is called with">
|
|
601
|
+
{inputs.map((p) => <option key={p.name} value={`{params.${p.name}}`}>{p.label || p.name}</option>)}
|
|
602
|
+
</optgroup>
|
|
603
|
+
)}
|
|
604
|
+
{others.map((f) => (
|
|
605
|
+
<optgroup key={f.key} label={`${f.name} — a screen before this one`}>
|
|
606
|
+
{f.fields.map((x) => <option key={x.name} value={previousBinding(f.key, x.name)}>{f.name} → {x.label}</option>)}
|
|
607
|
+
</optgroup>
|
|
608
|
+
))}
|
|
609
|
+
{bound && !offered.has(bound) && <option value={bound}>{bound}</option>}
|
|
610
|
+
</select>
|
|
611
|
+
)
|
|
612
|
+
}
|
|
613
|
+
|
|
571
614
|
const ACTION_LABELS = { new: 'New — add a record', edit: 'Edit — open a row for editing', delete: 'Delete — remove a row' }
|
|
572
615
|
|
|
573
616
|
function ActionsEditor({ value, onChange }) {
|
|
@@ -751,6 +794,7 @@ export const EDITORS = {
|
|
|
751
794
|
table: TableEditor,
|
|
752
795
|
screen: ScreenEditor,
|
|
753
796
|
form: FormEditor,
|
|
797
|
+
fillFrom: FillFromEditor,
|
|
754
798
|
columns: ColumnsEditor,
|
|
755
799
|
listTable: ListTableEditor,
|
|
756
800
|
actions: ActionsEditor,
|
|
@@ -100,6 +100,22 @@ function StepNav({ ctrl, node, status, children }) {
|
|
|
100
100
|
*/
|
|
101
101
|
function SaveButtons({ ctrl }) {
|
|
102
102
|
const saving = ctrl.status === 'saving'
|
|
103
|
+
// A WORKFLOW STEP'S OWN BUTTONS (Approve / Send back): each saves what is
|
|
104
|
+
// unsaved, then moves the run on, saying which was pressed — its arrows
|
|
105
|
+
// branch on it. The first is the main one.
|
|
106
|
+
if (ctrl.doneButtons && ctrl.doneButtons.length) {
|
|
107
|
+
return (
|
|
108
|
+
<>
|
|
109
|
+
{ctrl.doneButtons.slice().reverse().map((b, i, list) => (
|
|
110
|
+
<button key={b.key} type="button" disabled={saving || ctrl.sending}
|
|
111
|
+
className={i === list.length - 1 ? 'xeplr-factory-primary' : 'xeplr-factory-secondary'}
|
|
112
|
+
onClick={() => ctrl.flush().then((r) => { if (r && r.ok) ctrl.done(r, b.key) })}>
|
|
113
|
+
{b.label}
|
|
114
|
+
</button>
|
|
115
|
+
))}
|
|
116
|
+
</>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
103
119
|
if (ctrl.doneLabel) {
|
|
104
120
|
return (
|
|
105
121
|
<button type="button" className="xeplr-factory-primary" disabled={saving}
|
|
@@ -184,6 +200,7 @@ export default function ScreenSample({ ctrl, className, style, renderPopup, rend
|
|
|
184
200
|
error={node.props?.name ? ctrl.errors[node.props.name] : undefined}
|
|
185
201
|
options={CHOICE_TYPES.includes(node.type) ? ctrl.optionsFor(node) : undefined}
|
|
186
202
|
upload={node.type === 'file' ? ctrl.upload : undefined}
|
|
203
|
+
rows={node.type === 'table' && ctrl.rowsFor ? ctrl.rowsFor(node) : undefined}
|
|
187
204
|
stepper={node.type === 'stepper' ? {
|
|
188
205
|
active: ctrl.steps.active(node.id),
|
|
189
206
|
isDisabled: (i) => ctrl.steps.isDisabled(node.id, i),
|
package/src/designs/factory.css
CHANGED
|
@@ -636,3 +636,12 @@
|
|
|
636
636
|
.xeplr-factory-fd-branches p { margin: 0; color: var(--xf-muted); }
|
|
637
637
|
.xeplr-factory-fd-branches ul { margin: 0; padding-left: 18px; }
|
|
638
638
|
.xeplr-factory-fd-branches button { align-self: flex-start; }
|
|
639
|
+
|
|
640
|
+
/* A table of rows the workflow handed the screen, a tick per row. */
|
|
641
|
+
.xeplr-factory-table-wrap { overflow: auto; max-height: 100%; border: 1px solid var(--xf-border); border-radius: 6px; }
|
|
642
|
+
.xeplr-factory-table { width: 100%; border-collapse: collapse; font-size: max(11px, calc(12 * var(--xf-u))); }
|
|
643
|
+
.xeplr-factory-table th, .xeplr-factory-table td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--xf-border); white-space: nowrap; }
|
|
644
|
+
.xeplr-factory-table th { position: sticky; top: 0; background: var(--xeplr-bg-secondary, #fff); color: var(--xf-muted); font-weight: 600; }
|
|
645
|
+
.xeplr-factory-table td.is-number { text-align: right; font-variant-numeric: tabular-nums; }
|
|
646
|
+
.xeplr-factory-table tr.is-ticked td { background: var(--xeplr-accent-soft, rgba(47, 91, 211, 0.07)); }
|
|
647
|
+
.xeplr-factory-table-tick { width: 28px; }
|
package/src/flows.js
CHANGED
|
@@ -101,7 +101,9 @@ export function createFlowsApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
101
101
|
startRun: async (key) => one(await call('POST', `/${enc(key)}/runs`)),
|
|
102
102
|
loadRun: async (runId) => one(await call('GET', `/runs/${enc(runId)}`)),
|
|
103
103
|
/** What the person filled in. Answers with the next screen, or that it is done. */
|
|
104
|
-
submitRun: async (runId, values, recordId) => one(await call('POST', `/runs/${enc(runId)}/submit`, { values, recordId })),
|
|
104
|
+
submitRun: async (runId, values, recordId, action) => one(await call('POST', `/runs/${enc(runId)}/submit`, action ? { values, recordId, action } : { values, recordId })),
|
|
105
|
+
/** Every screen waiting for one of the signed-in person's roles, in any workflow. */
|
|
106
|
+
waiting: () => call('GET', '/waiting'),
|
|
105
107
|
/** Journeys of this flow that are still going — "in progress". */
|
|
106
108
|
myRuns: (key) => call('GET', `/${enc(key)}/runs?mine=1`)
|
|
107
109
|
}
|
package/src/index.js
CHANGED
|
@@ -23,6 +23,8 @@ export { useFlowBuilder, FLOW_AUTOSAVE_DELAY } from './useFlowBuilder.js'
|
|
|
23
23
|
export { useScreenEvents } from './useScreenEvents.js'
|
|
24
24
|
export { useFunctionCatalog } from './useFunctionCatalog.js'
|
|
25
25
|
export { useUrlTab } from './useUrlPlaces.js'
|
|
26
|
+
// The app's forms and their fields — what "Fill in from" offers.
|
|
27
|
+
export { useFormFields } from './useFormFields.js'
|
|
26
28
|
|
|
27
29
|
// Designs
|
|
28
30
|
export { BuilderSample, ScreenSample, ListView, ScreenModal, FlowRunnerSample, FlowBuilderSample, FlowDesignerSample, FLOW_DESIGNER_TABS, ScreenEditorSample, ControlView, VIEWS, Palette, DRAG_TYPE, PropertyPanel, EDITORS } from './designs/index.js'
|
package/src/model.js
CHANGED
|
@@ -25,7 +25,7 @@ export {
|
|
|
25
25
|
createStatements, addStatements, alterStatement, dropStatement, columnFromDatabase, planTableChange, conversionFor
|
|
26
26
|
} from './tableSchema.js'
|
|
27
27
|
// A value with a {params.x} token is a binding; a plain value is a literal.
|
|
28
|
-
export { resolveBinding, isBinding } from './values.js'
|
|
28
|
+
export { resolveBinding, isBinding, previousBinding } from './values.js'
|
|
29
29
|
// A code people can read out, unlike the record's own key.
|
|
30
30
|
export { codeFields, countedFields, generateCode } from './values.js'
|
|
31
31
|
export { FIELD_PRESETS, PATTERNS, INPUT_TYPES, presetFor, presetProps, suggestPreset, presetGroup, presetHints, convertField } from './presets.js'
|
package/src/pages.jsx
CHANGED
|
@@ -3,6 +3,7 @@ import { useFactoryBuilder } from './useFactoryBuilder.js'
|
|
|
3
3
|
import { useFactoryScreen } from './useFactoryScreen.js'
|
|
4
4
|
import { useFlowRun } from './useFlowRun.js'
|
|
5
5
|
import { useFlowBuilder } from './useFlowBuilder.js'
|
|
6
|
+
import { useFormFields } from './useFormFields.js'
|
|
6
7
|
import { useScreenEvents } from './useScreenEvents.js'
|
|
7
8
|
import { useFunctionCatalog } from './useFunctionCatalog.js'
|
|
8
9
|
import { useUrlTab } from './useUrlPlaces.js'
|
|
@@ -27,9 +28,12 @@ import { BuilderSample, ScreenSample, ScreenModal, FlowRunnerSample, FlowBuilder
|
|
|
27
28
|
*/
|
|
28
29
|
export function FactoryBuilder(props) {
|
|
29
30
|
const ctrl = useFactoryBuilder(props)
|
|
31
|
+
// The app's forms and their fields, for a field's "Fill in from".
|
|
32
|
+
const forms = useFormFields(props.screenApi)
|
|
30
33
|
return (
|
|
31
34
|
<BuilderSample
|
|
32
35
|
ctrl={ctrl}
|
|
36
|
+
forms={forms}
|
|
33
37
|
className={props.className}
|
|
34
38
|
style={props.style}
|
|
35
39
|
// So a child table made here can be designed here — see the modal in
|
|
@@ -174,7 +178,10 @@ export function FlowRunner(props) {
|
|
|
174
178
|
record: run.values || undefined,
|
|
175
179
|
// What the flow called this screen with — its step's own input, resolved
|
|
176
180
|
// by the engine. A field bound to {params.x} is filled from here.
|
|
177
|
-
params: run.given || undefined
|
|
181
|
+
params: run.given || undefined,
|
|
182
|
+
// What the screens before this one saved, by screen key — a field bound to
|
|
183
|
+
// {previous.<screen>.values.<field>} is filled from here.
|
|
184
|
+
previous: run.previous || undefined
|
|
178
185
|
})
|
|
179
186
|
return (
|
|
180
187
|
<FlowRunnerSample
|
package/src/useFactoryScreen.js
CHANGED
|
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
2
2
|
import { CONTROLS, CHOICE_TYPES } from './controls.js'
|
|
3
3
|
import { inputNodes, nodesForSteps, steppers, stepsOf, stepOf, resolveStep, reachableStep, nextOpenStep } from './document.js'
|
|
4
4
|
import { validateDocument } from './validateDocument.js'
|
|
5
|
-
import { initialValues, parseInput, saveState, fieldError, optionValue, recordValues, listSource } from './values.js'
|
|
5
|
+
import { initialValues, parseInput, saveState, fieldError, optionValue, recordValues, listSource, resolveBinding } from './values.js'
|
|
6
6
|
import { hookMethod } from './hooks.js'
|
|
7
7
|
import { useUrlSteps } from './useUrlPlaces.js'
|
|
8
8
|
|
|
@@ -67,10 +67,14 @@ import { useUrlSteps } from './useUrlPlaces.js'
|
|
|
67
67
|
* the form from a list supplies nothing. A field whose default is
|
|
68
68
|
* `{params.x}` is filled from here, and left empty (for the person to fill
|
|
69
69
|
* in) when nobody said.
|
|
70
|
+
* @param previous in a flow, what the screens before this one saved, by
|
|
71
|
+
* screen key: { department_edit: { values: { id, name, … } } }. A field
|
|
72
|
+
* whose default is `{previous.department_edit.values.name}` is filled from
|
|
73
|
+
* here — empty when that screen was not on this run's path.
|
|
70
74
|
*/
|
|
71
75
|
export function useFactoryScreen({
|
|
72
76
|
document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl,
|
|
73
|
-
onOpenRecord, onDone, onDirtyChange, params, urlKey,
|
|
77
|
+
onOpenRecord, onDone, onDirtyChange, params, urlKey, previous,
|
|
74
78
|
link, onReady,
|
|
75
79
|
controls = CONTROLS
|
|
76
80
|
} = {}) {
|
|
@@ -134,9 +138,9 @@ export function useFactoryScreen({
|
|
|
134
138
|
// reopened wins, because the row is the truth once there is one.
|
|
135
139
|
const startValues = useCallback(
|
|
136
140
|
(rec) => (rec
|
|
137
|
-
? { ...initialValues(doc, params || {}, controls), ...recordValues(doc, rec, controls) }
|
|
138
|
-
: initialValues(doc, params || {}, controls)),
|
|
139
|
-
[doc, controls, params]
|
|
141
|
+
? { ...initialValues(doc, params || {}, controls, previous), ...recordValues(doc, rec, controls) }
|
|
142
|
+
: initialValues(doc, params || {}, controls, previous)),
|
|
143
|
+
[doc, controls, params, previous]
|
|
140
144
|
)
|
|
141
145
|
|
|
142
146
|
const [values, setValues] = useState(() => (check.ok ? startValues(record) : {}))
|
|
@@ -334,6 +338,13 @@ export function useFactoryScreen({
|
|
|
334
338
|
return out
|
|
335
339
|
}, [doc.id])
|
|
336
340
|
|
|
341
|
+
// A table's rows: its binding to this screen's inputs (`{params.lines}`),
|
|
342
|
+
// filled by whoever opened it — a workflow step's values.
|
|
343
|
+
const rowsFor = useCallback((node) => {
|
|
344
|
+
const rows = resolveBinding(node && node.props ? node.props.rows : undefined, params || {}, previous)
|
|
345
|
+
return Array.isArray(rows) ? rows : []
|
|
346
|
+
}, [params, previous])
|
|
347
|
+
|
|
337
348
|
const optionsFor = useCallback((node) => {
|
|
338
349
|
if (node.props.data?.source === 'static') return { loading: false, items: node.props.data.options, error: null }
|
|
339
350
|
return options[node.id] || { loading: false, items: [], error: null }
|
|
@@ -392,7 +403,9 @@ export function useFactoryScreen({
|
|
|
392
403
|
if (!subsOk) { setStatus('invalid'); return { ok: false } }
|
|
393
404
|
const customSave = hooksRef.current && typeof hooksRef.current.save === 'function'
|
|
394
405
|
const writing = changes.current
|
|
395
|
-
|
|
406
|
+
// Nothing to save to: no save given, or a screen with no table of its own
|
|
407
|
+
// (a review screen in a workflow, whose values go only to the run).
|
|
408
|
+
if ((!l.onSave || !l.source) && !customSave) { savedChanges.current = writing; setDirty(false); setStatus('saved'); return { ok: true } }
|
|
396
409
|
|
|
397
410
|
setStatus('saving')
|
|
398
411
|
setSaveError(null)
|
|
@@ -778,6 +791,7 @@ export function useFactoryScreen({
|
|
|
778
791
|
setValue,
|
|
779
792
|
touch,
|
|
780
793
|
optionsFor,
|
|
794
|
+
rowsFor,
|
|
781
795
|
done: onDone || null,
|
|
782
796
|
steps: stepControl,
|
|
783
797
|
visibleNodes,
|
package/src/useFlowRun.js
CHANGED
|
@@ -15,8 +15,9 @@ 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, given }
|
|
19
|
-
* @param submitRun async (runId, values, recordId) → { status, stepKey, screen }
|
|
18
|
+
* @param loadRun async (runId) → { runId, status, stepKey, screen, values, given, previous }
|
|
19
|
+
* @param submitRun async (runId, values, recordId, action) → { status, stepKey, screen }
|
|
20
|
+
* (action: the button pressed, when the step has buttons)
|
|
20
21
|
* @param loadScreen async (key) → the screen document
|
|
21
22
|
* @param onStep (step) → void — told each time the run moves, for the address bar
|
|
22
23
|
* @param onFinish (run) → void — the journey is over
|
|
@@ -25,7 +26,7 @@ export function useFlowRun({ flowKey, runId, startRun, loadRun, submitRun, loadS
|
|
|
25
26
|
// `given` is what the flow called this screen with — the step's own input,
|
|
26
27
|
// resolved by the engine (see @xeplr/workflow's screen-show). A field bound
|
|
27
28
|
// 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 })
|
|
29
|
+
const [state, setState] = useState({ loading: true, error: null, runId: runId || null, status: null, stepKey: null, screen: null, document: null, values: null, given: null, previous: null, buttons: null })
|
|
29
30
|
const [sending, setSending] = useState(false)
|
|
30
31
|
const live = useRef({})
|
|
31
32
|
live.current = { startRun, loadRun, submitRun, loadScreen, onStep, onFinish, flowKey }
|
|
@@ -43,7 +44,11 @@ export function useFlowRun({ flowKey, runId, startRun, loadRun, submitRun, loadS
|
|
|
43
44
|
setState({
|
|
44
45
|
loading: false, error: null,
|
|
45
46
|
runId: run.runId, status: run.status || 'running', stepKey: run.stepKey, screen: run.screen,
|
|
46
|
-
|
|
47
|
+
// previous: what the screens before this one saved, by screen key —
|
|
48
|
+
// what {previous.<screen>.values.<field>} defaults fill from.
|
|
49
|
+
document: doc, values: run.values || null, given: run.given || null, previous: run.previous || null,
|
|
50
|
+
// The step's buttons ([{ key, label }]); none is one Next.
|
|
51
|
+
buttons: Array.isArray(run.buttons) && run.buttons.length ? run.buttons : null
|
|
47
52
|
})
|
|
48
53
|
if (live.current.onStep) live.current.onStep(run)
|
|
49
54
|
}, [])
|
|
@@ -71,11 +76,11 @@ export function useFlowRun({ flowKey, runId, startRun, loadRun, submitRun, loadS
|
|
|
71
76
|
* next; `recordId` is the row the screen just saved, so later steps and
|
|
72
77
|
* their arrows can refer to it.
|
|
73
78
|
*/
|
|
74
|
-
const submit = useCallback(async (values, recordId) => {
|
|
79
|
+
const submit = useCallback(async (values, recordId, action) => {
|
|
75
80
|
if (!live.current.submitRun) throw new Error('No submitRun was provided')
|
|
76
81
|
setSending(true)
|
|
77
82
|
try {
|
|
78
|
-
const next = await live.current.submitRun(state.runId, values || {}, recordId)
|
|
83
|
+
const next = await live.current.submitRun(state.runId, values || {}, recordId, action)
|
|
79
84
|
await open({ runId: state.runId, ...next })
|
|
80
85
|
} catch (err) {
|
|
81
86
|
setState((s) => ({ ...s, error: err.message || 'That could not be sent' }))
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { inputNodes } from './document.js'
|
|
3
|
+
|
|
4
|
+
// THE APP'S FORMS AND THEIR FIELDS — what a field's "Fill in from" picker
|
|
5
|
+
// offers: in a flow, a screen before this one, and what it saved. No JSX.
|
|
6
|
+
//
|
|
7
|
+
// Every published screen that has fields of its own (a list screen has none),
|
|
8
|
+
// each with `id` — the row it saved — then its fields by the name they are
|
|
9
|
+
// saved under. Loaded once per builder, through the app's own API client.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param api a createFactoryApi() instance (builderProps.screenApi)
|
|
13
|
+
* @returns [{ key, name, fields: [{ name, label }] }]
|
|
14
|
+
*/
|
|
15
|
+
export function useFormFields(api) {
|
|
16
|
+
const [forms, setForms] = useState([])
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!api || !api.listScreens || !api.loadScreen) return undefined
|
|
19
|
+
let gone = false
|
|
20
|
+
Promise.resolve()
|
|
21
|
+
.then(() => api.listScreens())
|
|
22
|
+
.then((rows) => Promise.all((rows || []).filter((r) => r && r.version).map(async (r) => {
|
|
23
|
+
try {
|
|
24
|
+
const doc = await api.loadScreen(r.screenKey)
|
|
25
|
+
const fields = inputNodes(doc).map((n) => ({ name: n.props.name, label: n.props.label || n.props.name }))
|
|
26
|
+
if (!fields.length) return null
|
|
27
|
+
return { key: doc.id, name: doc.name || doc.id, fields: [{ name: 'id', label: 'the row it saved (id)' }, ...fields] }
|
|
28
|
+
} catch (err) {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
})))
|
|
32
|
+
.then((list) => { if (!gone) setForms(list.filter(Boolean)) })
|
|
33
|
+
.catch(() => { /* the picker then offers this screen's own inputs only */ })
|
|
34
|
+
return () => { gone = true }
|
|
35
|
+
}, [api])
|
|
36
|
+
return forms
|
|
37
|
+
}
|
package/src/validateDocument.js
CHANGED
|
@@ -15,6 +15,7 @@ import { CONTROLS, LABEL_VARIANTS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS,
|
|
|
15
15
|
import { DOCUMENT_KIND, DOCUMENT_VERSION } from './document.js'
|
|
16
16
|
import { RESERVED_COLUMNS, MAX_IDENTIFIER } from './tableSchema.js'
|
|
17
17
|
import { FIELD_PRESETS, INPUT_TYPES, presetFor } from './presets.js'
|
|
18
|
+
import { isBinding } from './values.js'
|
|
18
19
|
|
|
19
20
|
const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
20
21
|
const TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_.$-]*$/
|
|
@@ -210,6 +211,10 @@ function checkGeometry(node, at, err) {
|
|
|
210
211
|
function checkDefault(node, def, at, err) {
|
|
211
212
|
const v = node.props.default
|
|
212
213
|
if (v === undefined) return
|
|
214
|
+
// A BINDING fills the field when the screen opens — {params.x} or
|
|
215
|
+
// {previous.<screen>.values.<field>} — so it is text whatever the field
|
|
216
|
+
// holds. A date or number field may be filled from one as well.
|
|
217
|
+
if (isBinding(v)) return
|
|
213
218
|
const isDatetime = node.type === 'datetime'
|
|
214
219
|
const ok = {
|
|
215
220
|
string: () => typeof v === 'string',
|
package/src/values.js
CHANGED
|
@@ -61,33 +61,50 @@ export function formSchema(doc, controls = CONTROLS) {
|
|
|
61
61
|
* Whole-value only. `{params.a}-{params.b}` is not a value a form field takes,
|
|
62
62
|
* and splicing text is a formula's job, not a form's.
|
|
63
63
|
*/
|
|
64
|
-
export function resolveBinding(value, params) {
|
|
64
|
+
export function resolveBinding(value, params, previous) {
|
|
65
65
|
if (typeof value !== 'string') return value
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
return params ? params[
|
|
66
|
+
const v = value.trim()
|
|
67
|
+
const p = PARAM_BINDING.exec(v)
|
|
68
|
+
if (p) return params ? params[p[1]] : undefined
|
|
69
|
+
// WHAT A SCREEN BEFORE THIS ONE SAVED, in a flow — by that screen's key, the
|
|
70
|
+
// saved row as `id`. Handed over as plain JSON with no contract: a screen
|
|
71
|
+
// not reached on this run, or a field it does not have, is simply empty.
|
|
72
|
+
const q = PREVIOUS_BINDING.exec(v)
|
|
73
|
+
if (q) {
|
|
74
|
+
const screen = previous && previous[q[1]]
|
|
75
|
+
return screen && screen.values ? screen.values[q[2]] : undefined
|
|
76
|
+
}
|
|
77
|
+
return value
|
|
69
78
|
}
|
|
70
79
|
|
|
71
|
-
|
|
80
|
+
const PARAM_BINDING = /^\{params\.([A-Za-z_][\w]*)\}$/
|
|
81
|
+
const PREVIOUS_BINDING = /^\{previous\.([A-Za-z][A-Za-z0-9_]*)\.values\.([A-Za-z_][\w]*)\}$/
|
|
82
|
+
|
|
83
|
+
/** Is this what somebody typed, or a reference — to an input, or to a screen before this one? */
|
|
72
84
|
export function isBinding(value) {
|
|
73
|
-
return typeof value === 'string' &&
|
|
85
|
+
return typeof value === 'string' && (PARAM_BINDING.test(value.trim()) || PREVIOUS_BINDING.test(value.trim()))
|
|
74
86
|
}
|
|
75
87
|
|
|
76
|
-
|
|
88
|
+
/** The binding to a field a screen before this one saved: `{previous.<screen>.values.<field>}`. */
|
|
89
|
+
export function previousBinding(screen, field) {
|
|
90
|
+
return `{previous.${screen}.values.${field}}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function initialValues(doc, provided = {}, controls = CONTROLS, previous = null) {
|
|
77
94
|
const out = {}
|
|
78
95
|
inputNodes(doc, controls).forEach((node) => {
|
|
79
96
|
const name = node.props.name
|
|
80
97
|
if (provided[name] !== undefined) out[name] = provided[name]
|
|
81
98
|
else if (node.props.default !== undefined) {
|
|
82
99
|
// A default may be a binding to one of the screen's own inputs.
|
|
83
|
-
const resolved = resolveBinding(node.props.default, provided)
|
|
100
|
+
const resolved = resolveBinding(node.props.default, provided, previous)
|
|
84
101
|
if (resolved !== undefined) out[name] = resolved
|
|
85
102
|
else if (!isBinding(node.props.default)) out[name] = node.props.default
|
|
86
103
|
else if (node.type === 'checkbox') out[name] = false
|
|
87
|
-
else if (node.type === 'multiselect') out[name] = []
|
|
104
|
+
else if (node.type === 'multiselect' || node.type === 'table') out[name] = []
|
|
88
105
|
}
|
|
89
106
|
else if (node.type === 'checkbox') out[name] = false
|
|
90
|
-
else if (node.type === 'multiselect') out[name] = []
|
|
107
|
+
else if (node.type === 'multiselect' || node.type === 'table') out[name] = []
|
|
91
108
|
})
|
|
92
109
|
return out
|
|
93
110
|
}
|
|
@@ -100,6 +117,7 @@ export function initialValues(doc, provided = {}, controls = CONTROLS) {
|
|
|
100
117
|
export function parseInput(node, raw) {
|
|
101
118
|
if (node.type === 'checkbox') return Boolean(raw)
|
|
102
119
|
if (node.type === 'multiselect') return Array.isArray(raw) ? raw : fromDbValue(node, raw) || []
|
|
120
|
+
if (node.type === 'table') return Array.isArray(raw) ? raw : []
|
|
103
121
|
if (raw === undefined || raw === null || raw === '') return undefined
|
|
104
122
|
if (node.type === 'number') {
|
|
105
123
|
const n = typeof raw === 'number' ? raw : Number(String(raw).trim())
|
|
@@ -140,6 +158,14 @@ export function fieldError(node, value, loadedOptions) {
|
|
|
140
158
|
if (node.type === 'checkbox') {
|
|
141
159
|
return p.required && value !== true ? `${label} must be ticked` : null
|
|
142
160
|
}
|
|
161
|
+
// A TABLE'S VALUE is the rows ticked: required is at least one.
|
|
162
|
+
if (node.type === 'table') {
|
|
163
|
+
const ticked = Array.isArray(value) ? value : []
|
|
164
|
+
if (!ticked.length) return p.required ? `${label}: tick at least one row` : null
|
|
165
|
+
if (v.minItems != null && ticked.length < v.minItems) return `${label}: tick at least ${v.minItems}`
|
|
166
|
+
if (v.maxItems != null && ticked.length > v.maxItems) return `${label}: tick at most ${v.maxItems}`
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
143
169
|
if (node.type === 'multiselect') {
|
|
144
170
|
const chosen = Array.isArray(value) ? value : []
|
|
145
171
|
if (!chosen.length) return p.required ? `${label} is required` : null
|
|
@@ -260,7 +286,7 @@ export function recordValues(doc, record, controls = CONTROLS) {
|
|
|
260
286
|
const v = record ? record[name] : undefined
|
|
261
287
|
if (v !== undefined && v !== null) out[name] = fromDbValue(node, v)
|
|
262
288
|
else if (node.type === 'checkbox') out[name] = false
|
|
263
|
-
else if (node.type === 'multiselect') out[name] = []
|
|
289
|
+
else if (node.type === 'multiselect' || node.type === 'table') out[name] = []
|
|
264
290
|
})
|
|
265
291
|
return out
|
|
266
292
|
}
|