@xeplr/ui-factory 1.8.0 → 1.8.2
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 +16 -0
- package/package.json +1 -1
- package/src/controls.js +53 -0
- package/src/designs/ControlView.jsx +63 -2
- package/src/designs/FlowRunnerSample.jsx +7 -3
- package/src/designs/ScreenSample.jsx +23 -1
- package/src/designs/factory.css +9 -0
- package/src/flows.js +3 -1
- package/src/useFactoryScreen.js +12 -2
- package/src/useFlowRun.js +8 -5
- package/src/values.js +12 -3
package/README.md
CHANGED
|
@@ -573,6 +573,21 @@ run is only what the arrows need and the id of the row that was written
|
|
|
573
573
|
that exists, arrows that point somewhere, one "otherwise" at the end, and
|
|
574
574
|
nothing unreachable.
|
|
575
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
|
+
|
|
576
591
|
### A flow and a stepper
|
|
577
592
|
|
|
578
593
|
Both walk someone through something, and they answer different questions:
|
|
@@ -602,6 +617,7 @@ Both walk someone through something, and they answer different questions:
|
|
|
602
617
|
| `stepper` | — | a journey across the top; every other control says which step it is on |
|
|
603
618
|
| `label` | — | text with a heading, subheading or text preset |
|
|
604
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 |
|
|
605
621
|
| `subform` | — | another form drawn inside this one (palette: **Form**), saving into its own table — see below |
|
|
606
622
|
|
|
607
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.8.
|
|
3
|
+
"version": "1.8.2",
|
|
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
|
@@ -576,6 +576,59 @@ CONTROLS.list = {
|
|
|
576
576
|
]
|
|
577
577
|
}
|
|
578
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
|
+
|
|
579
632
|
// A FORM INSIDE THIS ONE — Access's subform. Another published form, drawn
|
|
580
633
|
// inline with its own fields, saving into ITS OWN table: this employee's bank
|
|
581
634
|
// details. One record of it belongs to each record of this one, through
|
|
@@ -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} />
|
|
@@ -30,7 +30,12 @@ const STATUS = {
|
|
|
30
30
|
|
|
31
31
|
/** What the background save is doing, in words. */
|
|
32
32
|
function SaveStatus({ ctrl }) {
|
|
33
|
-
|
|
33
|
+
// A screen with no table of its own (a review in a workflow) saves nothing,
|
|
34
|
+
// so "Unsaved changes" and "Saved" would describe something that is not
|
|
35
|
+
// happening. What still matters there is a field left wrong.
|
|
36
|
+
const keeps = Boolean(ctrl.document && ctrl.document.source)
|
|
37
|
+
const quiet = !keeps && (ctrl.status === 'pending' || ctrl.status === 'saved' || ctrl.status === 'saving')
|
|
38
|
+
const text = quiet ? '' : ctrl.status === 'error' ? `Not saved — ${ctrl.saveError}` : STATUS[ctrl.status]
|
|
34
39
|
return (
|
|
35
40
|
<span className={`xeplr-factory-footer-status xeplr-factory-footer-status--${ctrl.status}`} role="status" aria-live="polite">
|
|
36
41
|
{text}
|
|
@@ -100,6 +105,22 @@ function StepNav({ ctrl, node, status, children }) {
|
|
|
100
105
|
*/
|
|
101
106
|
function SaveButtons({ ctrl }) {
|
|
102
107
|
const saving = ctrl.status === 'saving'
|
|
108
|
+
// A WORKFLOW STEP'S OWN BUTTONS (Approve / Send back): each saves what is
|
|
109
|
+
// unsaved, then moves the run on, saying which was pressed — its arrows
|
|
110
|
+
// branch on it. The first is the main one.
|
|
111
|
+
if (ctrl.doneButtons && ctrl.doneButtons.length) {
|
|
112
|
+
return (
|
|
113
|
+
<>
|
|
114
|
+
{ctrl.doneButtons.slice().reverse().map((b, i, list) => (
|
|
115
|
+
<button key={b.key} type="button" disabled={saving || ctrl.sending}
|
|
116
|
+
className={i === list.length - 1 ? 'xeplr-factory-primary' : 'xeplr-factory-secondary'}
|
|
117
|
+
onClick={() => ctrl.flush().then((r) => { if (r && r.ok) ctrl.done(r, b.key) })}>
|
|
118
|
+
{b.label}
|
|
119
|
+
</button>
|
|
120
|
+
))}
|
|
121
|
+
</>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
103
124
|
if (ctrl.doneLabel) {
|
|
104
125
|
return (
|
|
105
126
|
<button type="button" className="xeplr-factory-primary" disabled={saving}
|
|
@@ -184,6 +205,7 @@ export default function ScreenSample({ ctrl, className, style, renderPopup, rend
|
|
|
184
205
|
error={node.props?.name ? ctrl.errors[node.props.name] : undefined}
|
|
185
206
|
options={CHOICE_TYPES.includes(node.type) ? ctrl.optionsFor(node) : undefined}
|
|
186
207
|
upload={node.type === 'file' ? ctrl.upload : undefined}
|
|
208
|
+
rows={node.type === 'table' && ctrl.rowsFor ? ctrl.rowsFor(node) : undefined}
|
|
187
209
|
stepper={node.type === 'stepper' ? {
|
|
188
210
|
active: ctrl.steps.active(node.id),
|
|
189
211
|
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/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
|
|
|
@@ -338,6 +338,13 @@ export function useFactoryScreen({
|
|
|
338
338
|
return out
|
|
339
339
|
}, [doc.id])
|
|
340
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
|
+
|
|
341
348
|
const optionsFor = useCallback((node) => {
|
|
342
349
|
if (node.props.data?.source === 'static') return { loading: false, items: node.props.data.options, error: null }
|
|
343
350
|
return options[node.id] || { loading: false, items: [], error: null }
|
|
@@ -396,7 +403,9 @@ export function useFactoryScreen({
|
|
|
396
403
|
if (!subsOk) { setStatus('invalid'); return { ok: false } }
|
|
397
404
|
const customSave = hooksRef.current && typeof hooksRef.current.save === 'function'
|
|
398
405
|
const writing = changes.current
|
|
399
|
-
|
|
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 } }
|
|
400
409
|
|
|
401
410
|
setStatus('saving')
|
|
402
411
|
setSaveError(null)
|
|
@@ -782,6 +791,7 @@ export function useFactoryScreen({
|
|
|
782
791
|
setValue,
|
|
783
792
|
touch,
|
|
784
793
|
optionsFor,
|
|
794
|
+
rowsFor,
|
|
785
795
|
done: onDone || null,
|
|
786
796
|
steps: stepControl,
|
|
787
797
|
visibleNodes,
|
package/src/useFlowRun.js
CHANGED
|
@@ -16,7 +16,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
16
16
|
* @param runId an existing run to continue — omit to start one
|
|
17
17
|
* @param startRun async (flowKey) → { runId, status, stepKey, screen }
|
|
18
18
|
* @param loadRun async (runId) → { runId, status, stepKey, screen, values, given, previous }
|
|
19
|
-
* @param submitRun async (runId, values, recordId) → { status, stepKey, screen }
|
|
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, previous: 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 }
|
|
@@ -45,7 +46,9 @@ export function useFlowRun({ flowKey, runId, startRun, loadRun, submitRun, loadS
|
|
|
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 —
|
|
47
48
|
// what {previous.<screen>.values.<field>} defaults fill from.
|
|
48
|
-
document: doc, values: run.values || null, given: run.given || null, previous: run.previous || null
|
|
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
|
|
49
52
|
})
|
|
50
53
|
if (live.current.onStep) live.current.onStep(run)
|
|
51
54
|
}, [])
|
|
@@ -73,11 +76,11 @@ export function useFlowRun({ flowKey, runId, startRun, loadRun, submitRun, loadS
|
|
|
73
76
|
* next; `recordId` is the row the screen just saved, so later steps and
|
|
74
77
|
* their arrows can refer to it.
|
|
75
78
|
*/
|
|
76
|
-
const submit = useCallback(async (values, recordId) => {
|
|
79
|
+
const submit = useCallback(async (values, recordId, action) => {
|
|
77
80
|
if (!live.current.submitRun) throw new Error('No submitRun was provided')
|
|
78
81
|
setSending(true)
|
|
79
82
|
try {
|
|
80
|
-
const next = await live.current.submitRun(state.runId, values || {}, recordId)
|
|
83
|
+
const next = await live.current.submitRun(state.runId, values || {}, recordId, action)
|
|
81
84
|
await open({ runId: state.runId, ...next })
|
|
82
85
|
} catch (err) {
|
|
83
86
|
setState((s) => ({ ...s, error: err.message || 'That could not be sent' }))
|
package/src/values.js
CHANGED
|
@@ -101,10 +101,10 @@ export function initialValues(doc, provided = {}, controls = CONTROLS, previous
|
|
|
101
101
|
if (resolved !== undefined) out[name] = resolved
|
|
102
102
|
else if (!isBinding(node.props.default)) out[name] = node.props.default
|
|
103
103
|
else if (node.type === 'checkbox') out[name] = false
|
|
104
|
-
else if (node.type === 'multiselect') out[name] = []
|
|
104
|
+
else if (node.type === 'multiselect' || node.type === 'table') out[name] = []
|
|
105
105
|
}
|
|
106
106
|
else if (node.type === 'checkbox') out[name] = false
|
|
107
|
-
else if (node.type === 'multiselect') out[name] = []
|
|
107
|
+
else if (node.type === 'multiselect' || node.type === 'table') out[name] = []
|
|
108
108
|
})
|
|
109
109
|
return out
|
|
110
110
|
}
|
|
@@ -117,6 +117,7 @@ export function initialValues(doc, provided = {}, controls = CONTROLS, previous
|
|
|
117
117
|
export function parseInput(node, raw) {
|
|
118
118
|
if (node.type === 'checkbox') return Boolean(raw)
|
|
119
119
|
if (node.type === 'multiselect') return Array.isArray(raw) ? raw : fromDbValue(node, raw) || []
|
|
120
|
+
if (node.type === 'table') return Array.isArray(raw) ? raw : []
|
|
120
121
|
if (raw === undefined || raw === null || raw === '') return undefined
|
|
121
122
|
if (node.type === 'number') {
|
|
122
123
|
const n = typeof raw === 'number' ? raw : Number(String(raw).trim())
|
|
@@ -157,6 +158,14 @@ export function fieldError(node, value, loadedOptions) {
|
|
|
157
158
|
if (node.type === 'checkbox') {
|
|
158
159
|
return p.required && value !== true ? `${label} must be ticked` : null
|
|
159
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
|
+
}
|
|
160
169
|
if (node.type === 'multiselect') {
|
|
161
170
|
const chosen = Array.isArray(value) ? value : []
|
|
162
171
|
if (!chosen.length) return p.required ? `${label} is required` : null
|
|
@@ -277,7 +286,7 @@ export function recordValues(doc, record, controls = CONTROLS) {
|
|
|
277
286
|
const v = record ? record[name] : undefined
|
|
278
287
|
if (v !== undefined && v !== null) out[name] = fromDbValue(node, v)
|
|
279
288
|
else if (node.type === 'checkbox') out[name] = false
|
|
280
|
-
else if (node.type === 'multiselect') out[name] = []
|
|
289
|
+
else if (node.type === 'multiselect' || node.type === 'table') out[name] = []
|
|
281
290
|
})
|
|
282
291
|
return out
|
|
283
292
|
}
|