easy_flow 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/app/assets/builds/easy_flow/canvas.js +1 -0
- data/app/controllers/concerns/easy_flow/manage/authenticates_admin.rb +19 -0
- data/app/controllers/concerns/easy_flow/manage/draws_canvas.rb +34 -0
- data/app/controllers/easy_flow/application_controller.rb +32 -0
- data/app/controllers/easy_flow/flows_controller.rb +103 -0
- data/app/controllers/easy_flow/manage/base_controller.rb +14 -0
- data/app/controllers/easy_flow/manage/canvas_controller.rb +183 -0
- data/app/controllers/easy_flow/manage/definitions_controller.rb +15 -0
- data/app/controllers/easy_flow/manage/flows_controller.rb +55 -0
- data/app/controllers/easy_flow/manage/previews_controller.rb +38 -0
- data/app/controllers/easy_flow/manage/versions_controller.rb +22 -0
- data/app/javascript/easy_flow/canvas/Canvas.jsx +197 -0
- data/app/javascript/easy_flow/canvas/Connector.jsx +30 -0
- data/app/javascript/easy_flow/canvas/ConnectorLayer.jsx +31 -0
- data/app/javascript/easy_flow/canvas/Control.jsx +42 -0
- data/app/javascript/easy_flow/canvas/Inspector.jsx +44 -0
- data/app/javascript/easy_flow/canvas/Panel.jsx +61 -0
- data/app/javascript/easy_flow/canvas/Placeholder.jsx +24 -0
- data/app/javascript/easy_flow/canvas/Port.jsx +16 -0
- data/app/javascript/easy_flow/canvas/Records.jsx +34 -0
- data/app/javascript/easy_flow/canvas/StepCard.jsx +57 -0
- data/app/javascript/easy_flow/canvas/Toolbar.jsx +19 -0
- data/app/javascript/easy_flow/canvas/TypePicker.jsx +21 -0
- data/app/javascript/easy_flow/canvas/changes.js +5 -0
- data/app/javascript/easy_flow/canvas/choices.js +4 -0
- data/app/javascript/easy_flow/canvas/flow.js +7 -0
- data/app/javascript/easy_flow/canvas/flowSender.js +22 -0
- data/app/javascript/easy_flow/canvas/ids.js +7 -0
- data/app/javascript/easy_flow/canvas/rows.js +2 -0
- data/app/javascript/easy_flow/canvas/styles.js +3 -0
- data/app/javascript/easy_flow/canvas/useConnectors.js +75 -0
- data/app/javascript/easy_flow/canvas/useFlow.js +17 -0
- data/app/javascript/easy_flow/canvas.jsx +13 -0
- data/app/views/easy_flow/flows/complete.html.erb +22 -0
- data/app/views/easy_flow/flows/show.html.erb +17 -0
- data/app/views/easy_flow/flows/step.html.erb +27 -0
- data/app/views/easy_flow/manage/definitions/edit.html.erb +13 -0
- data/app/views/easy_flow/manage/flows/edit.html.erb +26 -0
- data/app/views/easy_flow/manage/flows/index.html.erb +32 -0
- data/app/views/easy_flow/manage/flows/show.html.erb +26 -0
- data/app/views/easy_flow/manage/versions/index.html.erb +41 -0
- data/app/views/easy_flow/steps/_choosing.html.erb +9 -0
- data/app/views/layouts/easy_flow/application.html.erb +15 -0
- data/config/routes.rb +32 -0
- data/lib/easy_flow/engine.rb +11 -0
- data/lib/easy_flow/version.rb +1 -1
- data/lib/easy_flow.rb +15 -0
- metadata +58 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from "react"
|
|
2
|
+
import StepCard from "./StepCard"
|
|
3
|
+
import Inspector from "./Inspector"
|
|
4
|
+
import Placeholder from "./Placeholder"
|
|
5
|
+
import { offeredTo } from "./choices"
|
|
6
|
+
import TypePicker from "./TypePicker"
|
|
7
|
+
import Toolbar from "./Toolbar"
|
|
8
|
+
import Panel from "./Panel"
|
|
9
|
+
import ConnectorLayer from "./ConnectorLayer"
|
|
10
|
+
import useFlow from "./useFlow"
|
|
11
|
+
import useConnectors from "./useConnectors"
|
|
12
|
+
import { CARD, GAP_X, GAP_Y } from "./styles"
|
|
13
|
+
import { nextId } from "./ids"
|
|
14
|
+
|
|
15
|
+
const page = { display: "flex", height: "100%", minHeight: 0, fontSize: 13 }
|
|
16
|
+
const scroll = { flex: 1, overflow: "auto", position: "relative" }
|
|
17
|
+
const waiting = { position: "absolute", top: 56, left: 16, zIndex: 5, width: CARD + 24, padding: 12, borderRadius: 8 }
|
|
18
|
+
|
|
19
|
+
const grid = { display: "grid", rowGap: GAP_Y, columnGap: 0, padding: 40, justifyContent: "center", position: "relative" }
|
|
20
|
+
const notice = { position: "sticky", zIndex: 8, top: 8, margin: "8px auto 0", width: "fit-content", padding: "6px 12px", borderRadius: 6, fontSize: 12 }
|
|
21
|
+
|
|
22
|
+
export const Choosing = ({ port, onCancel }) => (
|
|
23
|
+
<div className="bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-100" style={notice}>
|
|
24
|
+
Choose the step “{port || "next"}” should lead to — <button onClick={onCancel} className="text-accent-800 underline dark:text-accent-100" style={{ border: "none", background: "none", cursor: "pointer", fontSize: 12, padding: 0 }}>cancel</button>
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const Canvas = ({ base, token, initial }) => {
|
|
29
|
+
const { flow, error, notice, send } = useFlow(base, token, initial)
|
|
30
|
+
const [ selected, setSelected ] = useState(null)
|
|
31
|
+
const [ adding, setAdding ] = useState(null)
|
|
32
|
+
const [ armed, setArmed ] = useState(null)
|
|
33
|
+
const [ dragging, setDragging ] = useState(null)
|
|
34
|
+
const [ showing, setShowing ] = useState(false)
|
|
35
|
+
const cards = useRef({})
|
|
36
|
+
const surface = useRef(null)
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const open = () => { setSelected(null); setShowing(true) }
|
|
40
|
+
document.addEventListener("easy_flow:open-flow", open)
|
|
41
|
+
return () => document.removeEventListener("easy_flow:open-flow", open)
|
|
42
|
+
}, [])
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!showing) return
|
|
46
|
+
|
|
47
|
+
const away = (event) => {
|
|
48
|
+
if (!event.target.closest("[data-builder-panel], [data-open-panel]")) setShowing(false)
|
|
49
|
+
}
|
|
50
|
+
document.addEventListener("click", away)
|
|
51
|
+
return () => document.removeEventListener("click", away)
|
|
52
|
+
}, [ showing ])
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
document.dispatchEvent(new CustomEvent("easy_flow:flow-named", { detail: flow.flow?.title || flow.flow?.slug }))
|
|
56
|
+
}, [ flow.flow?.title, flow.flow?.slug ])
|
|
57
|
+
|
|
58
|
+
const byId = useMemo(() => Object.fromEntries(flow.nodes.map((node) => [ node.id, node ])), [ flow.nodes ])
|
|
59
|
+
const { links, extent } = useConnectors(flow, surface, cards, [ flow, selected, byId ])
|
|
60
|
+
|
|
61
|
+
const violationsFor = useMemo(() => {
|
|
62
|
+
const grouped = {}
|
|
63
|
+
flow.violations.forEach((violation) => (grouped[violation.node] ||= []).push(violation))
|
|
64
|
+
return grouped
|
|
65
|
+
}, [ flow.violations ])
|
|
66
|
+
|
|
67
|
+
const attached = flow.nodes.filter((node) => !node.loose)
|
|
68
|
+
const loose = flow.nodes.filter((node) => node.loose)
|
|
69
|
+
const rows = Math.max(0, ...attached.map((node) => node.row)) + 1
|
|
70
|
+
const columns = Math.max(0, ...attached.map((node) => node.column)) + 1
|
|
71
|
+
|
|
72
|
+
const connectTo = (target) => {
|
|
73
|
+
if (!armed) return
|
|
74
|
+
|
|
75
|
+
const [ source, port ] = armed
|
|
76
|
+
setArmed(null)
|
|
77
|
+
if (source !== target) send("/edges", "POST", { from: source, to: target, on: port || null })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const selectedNode = flow.nodes.find((node) => node.id === selected)
|
|
81
|
+
const entryFor = flow.palette.find((entry) => entry.type === selectedNode?.type)
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="text-gray-900 dark:text-gray-100" style={page} onMouseUp={() => setDragging(null)}
|
|
85
|
+
onKeyDown={(event) => { if (event.key === "Escape") { setSelected(null); setArmed(null); setAdding(null) } }}
|
|
86
|
+
tabIndex={-1}>
|
|
87
|
+
<div ref={surface} className="bg-surface-50 dark:bg-surface-950" style={scroll}
|
|
88
|
+
onClick={(event) => { if (event.target === surface.current) { setSelected(null); setArmed(null); setShowing(false) } }}>
|
|
89
|
+
{armed && (
|
|
90
|
+
<Choosing port={armed[1]} onCancel={() => setArmed(null)} />
|
|
91
|
+
)}
|
|
92
|
+
<Toolbar undoable={flow.undoable} redoable={flow.redoable}
|
|
93
|
+
onAdd={() => setAdding({ at: { x: 16, y: 52 } })}
|
|
94
|
+
onUndo={() => { setSelected(null); send("/undo", "POST") }}
|
|
95
|
+
onRedo={() => { setSelected(null); send("/redo", "POST") }} />
|
|
96
|
+
|
|
97
|
+
<ConnectorLayer links={links} extent={extent} dragging={dragging}
|
|
98
|
+
onInsert={(link) => setAdding({ from: link.source, to: link.target, at: { x: link.midX + 30, y: link.midY } })}
|
|
99
|
+
onRemove={(link) => { setSelected(null); send("/edges", "DELETE", { from: link.source, to: link.target }) }}
|
|
100
|
+
onDrop={(link) => { const held = dragging; setDragging(null); send("/steps/" + held + "/move", "PATCH", { from: link.source, to: link.target }) }} />
|
|
101
|
+
|
|
102
|
+
{loose.length > 0 && (
|
|
103
|
+
<div data-loose className="border border-dashed border-gray-300 bg-gray-50 dark:border-zinc-700 dark:bg-zinc-900" style={waiting}>
|
|
104
|
+
<div className="text-gray-500 dark:text-gray-400" style={{ fontSize: 11, marginBottom: 6 }}>Not in the flow yet — drag one onto a connection</div>
|
|
105
|
+
{loose.map((node) => (
|
|
106
|
+
<div key={node.id} style={{ marginBottom: 8 }}>
|
|
107
|
+
<StepCard
|
|
108
|
+
node={{
|
|
109
|
+
...node,
|
|
110
|
+
ref: (element) => { cards.current[node.id] = element },
|
|
111
|
+
violations: violationsFor[node.id] || [],
|
|
112
|
+
connected: []
|
|
113
|
+
}}
|
|
114
|
+
selected={selected === node.id}
|
|
115
|
+
armed={armed && armed[0] === node.id ? armed[1] : null}
|
|
116
|
+
connecting={Boolean(armed)}
|
|
117
|
+
onSelect={() => (armed ? connectTo(node.id) : setSelected(node.id))}
|
|
118
|
+
onArm={(port, event) => { event.stopPropagation(); setArmed([ node.id, port ]) }}
|
|
119
|
+
onDragStart={() => setDragging(node.id)}
|
|
120
|
+
onDragEnd={() => setDragging(null)} />
|
|
121
|
+
</div>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
<div style={{ ...grid, gridTemplateColumns: `repeat(${columns + 1}, ${(CARD + GAP_X) / 2}px)`, gridTemplateRows: `repeat(${rows}, auto)` }}>
|
|
127
|
+
{attached.map((node) => (
|
|
128
|
+
<div key={node.id} style={{ gridRow: node.row + 1, gridColumn: `${node.column + 1} / span 2`, zIndex: 2 }}>
|
|
129
|
+
{node.placeholder && (
|
|
130
|
+
<Placeholder node={{ ...node, ref: (element) => { cards.current[node.id] = element } }}
|
|
131
|
+
dragging={Boolean(dragging)}
|
|
132
|
+
onFill={() => setAdding({ from: node.from, on: node.on, at: { x: 16, y: 52 } })}
|
|
133
|
+
onDrop={() => {
|
|
134
|
+
const held = dragging
|
|
135
|
+
setDragging(null)
|
|
136
|
+
if (held) send("/edges", "POST", { from: node.from, to: held, on: node.on })
|
|
137
|
+
}} />
|
|
138
|
+
)}
|
|
139
|
+
{!node.placeholder && <StepCard
|
|
140
|
+
node={{
|
|
141
|
+
...node,
|
|
142
|
+
ref: (element) => { cards.current[node.id] = element },
|
|
143
|
+
violations: violationsFor[node.id] || [],
|
|
144
|
+
connected: flow.edges.filter((edge) => edge.source === node.id).map((edge) => edge.label || null)
|
|
145
|
+
}}
|
|
146
|
+
selected={node.id === selected}
|
|
147
|
+
armed={armed && armed[0] === node.id ? armed[1] : null}
|
|
148
|
+
connecting={Boolean(armed) && armed[0] !== node.id}
|
|
149
|
+
onSelect={() => (armed ? connectTo(node.id) : setSelected(node.id))}
|
|
150
|
+
onArm={(port, event) => {
|
|
151
|
+
const frame = surface.current.getBoundingClientRect()
|
|
152
|
+
setAdding({
|
|
153
|
+
from: node.id, on: port,
|
|
154
|
+
at: { x: event.clientX - frame.left + surface.current.scrollLeft + 8,
|
|
155
|
+
y: event.clientY - frame.top + surface.current.scrollTop + 8 }
|
|
156
|
+
})
|
|
157
|
+
}}
|
|
158
|
+
onDragEnd={() => setDragging(null)}
|
|
159
|
+
onDragStart={() => setDragging(node.id)}
|
|
160
|
+
/>}
|
|
161
|
+
</div>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
{adding && (
|
|
166
|
+
<TypePicker entries={flow.palette} at={adding.at} onDismiss={() => setAdding(null)}
|
|
167
|
+
onConnect={adding.on !== undefined ? () => { setArmed([ adding.from, adding.on ]); setAdding(null) } : null}
|
|
168
|
+
onPick={(entry) => {
|
|
169
|
+
const where = adding
|
|
170
|
+
setAdding(null)
|
|
171
|
+
send("/steps", "POST", { id: nextId(entry.type, flow.nodes.map((node) => node.id)), type: entry.type, from: where.from, to: where.to, on: where.on })
|
|
172
|
+
}} />
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
{showing && !selectedNode && (
|
|
177
|
+
<Panel flow={flow.flow || {}} changes={flow.changes || []} problems={flow.violations} refusal={error} notice={notice}
|
|
178
|
+
onClose={() => setShowing(false)}
|
|
179
|
+
onCreate={() => send("/versions", "POST")}
|
|
180
|
+
onPublish={() => send("/publish", "POST")}
|
|
181
|
+
onSaveDetails={(details) => send("/details", "PATCH", { flow: details })} />
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
{selectedNode && (
|
|
185
|
+
<Inspector node={selectedNode}
|
|
186
|
+
fields={entryFor?.fields || {}} holds={entryFor?.records || {}}
|
|
187
|
+
labels={entryFor?.labels || {}} recordLabels={entryFor?.record_labels || {}}
|
|
188
|
+
choices={offeredTo(selectedNode, entryFor)}
|
|
189
|
+
onClose={() => setSelected(null)}
|
|
190
|
+
onSave={(config) => send(`/steps/${selectedNode.id}`, "PATCH", { config })}
|
|
191
|
+
onDelete={() => { setSelected(null); send(`/steps/${selectedNode.id}`, "DELETE") }} />
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export default Canvas
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React, { useState } from "react"
|
|
2
|
+
import Button from "keystone_ui-react/src/Button.jsx"
|
|
3
|
+
const round = { width: 24, height: 24, padding: 0, fontSize: 15, lineHeight: "15px" }
|
|
4
|
+
|
|
5
|
+
const Connector = ({ link, onInsert, onRemove, onDrop, dragging }) => {
|
|
6
|
+
const [ over, setOver ] = useState(false)
|
|
7
|
+
const showing = over || dragging
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div data-connector={`${link.source}-${link.target}`}
|
|
11
|
+
style={{ position: "absolute", left: link.midX - 34, top: link.midY - 20, width: 68, height: 40, zIndex: 4 }}
|
|
12
|
+
onMouseEnter={() => setOver(true)} onMouseLeave={() => setOver(false)}
|
|
13
|
+
onDragEnter={() => setOver(true)} onDragLeave={() => setOver(false)}
|
|
14
|
+
onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move" }}
|
|
15
|
+
onDrop={(event) => { event.preventDefault(); setOver(false); onDrop() }}>
|
|
16
|
+
<div style={{ display: "flex", gap: 4, justifyContent: "center", alignItems: "center", height: "100%",
|
|
17
|
+
opacity: showing ? 1 : 0, transition: "opacity .12s" }}>
|
|
18
|
+
<Button variant="secondary" title={dragging ? "Move the step here" : "Insert a step here"} onClick={onInsert}
|
|
19
|
+
className="rounded-full data-[over=true]:ring-2 data-[over=true]:ring-accent-600"
|
|
20
|
+
data-over={Boolean(dragging && over)} style={round}>+</Button>
|
|
21
|
+
{!dragging && (
|
|
22
|
+
<Button variant="danger" title="Remove this connection" onClick={onRemove}
|
|
23
|
+
className="rounded-full" style={round}>×</Button>
|
|
24
|
+
)}
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default Connector
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import Connector from "./Connector"
|
|
3
|
+
|
|
4
|
+
const ConnectorLayer = ({ links, extent, dragging, onInsert, onRemove, onDrop }) => (
|
|
5
|
+
<>
|
|
6
|
+
<svg width={extent.width} height={extent.height}
|
|
7
|
+
style={{ position: "absolute", top: 0, left: 0, pointerEvents: "none", zIndex: 1 }}>
|
|
8
|
+
<defs>
|
|
9
|
+
<marker id="easy_flow-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto">
|
|
10
|
+
<path d="M 0 0 L 10 5 L 0 10 z" className="fill-gray-400 dark:fill-zinc-500" />
|
|
11
|
+
</marker>
|
|
12
|
+
</defs>
|
|
13
|
+
{links.map((link) => (
|
|
14
|
+
<path key={link.id} data-link={link.id} className="stroke-gray-400 dark:stroke-zinc-500" fill="none" strokeWidth="1.5" markerEnd="url(#easy_flow-arrow)" d={link.path} />
|
|
15
|
+
))}
|
|
16
|
+
</svg>
|
|
17
|
+
|
|
18
|
+
{links.filter((link) => link.label).map((link) => (
|
|
19
|
+
<div key={`${link.id}-label`} data-link-label={link.id} className="bg-surface-50 text-gray-500 dark:bg-surface-950 dark:text-gray-400"
|
|
20
|
+
style={{ position: "absolute", left: link.midX - 10, top: link.midY - 18, zIndex: 3,
|
|
21
|
+
fontSize: 11, padding: "0 3px" }}>{link.label}</div>
|
|
22
|
+
))}
|
|
23
|
+
|
|
24
|
+
{links.filter((link) => !link.placeholder).map((link) => (
|
|
25
|
+
<Connector key={link.id} link={link} dragging={Boolean(dragging)}
|
|
26
|
+
onInsert={() => onInsert(link)} onRemove={() => onRemove(link)} onDrop={() => onDrop(link)} />
|
|
27
|
+
))}
|
|
28
|
+
</>
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
export default ConnectorLayer
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { toggled } from "./choices"
|
|
3
|
+
import Input from "keystone_ui-react/src/Input.jsx"
|
|
4
|
+
import Select from "keystone_ui-react/src/Select.jsx"
|
|
5
|
+
import Checkbox from "keystone_ui-react/src/Checkbox.jsx"
|
|
6
|
+
|
|
7
|
+
const offered = (choices) =>
|
|
8
|
+
(choices || []).map((choice) => (typeof choice === "object" ? choice : { value: choice, label: choice }))
|
|
9
|
+
|
|
10
|
+
const Control = ({ type, value, choices, onChange, onSettle }) => {
|
|
11
|
+
if (type === "boolean") return <Checkbox checked={Boolean(value)} onChange={(e) => onSettle(e.target.checked)} />
|
|
12
|
+
|
|
13
|
+
if (type === "select" || type === "previous_step" || type === "from_step") {
|
|
14
|
+
return <Select className="mb-3" value={value ?? ""} onChange={(e) => onSettle(e.target.value)}>
|
|
15
|
+
<option value=""></option>
|
|
16
|
+
{offered(choices).map((choice) => <option key={choice.value} value={choice.value}>{choice.label}</option>)}
|
|
17
|
+
</Select>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (type === "multi_select") {
|
|
21
|
+
const chosen = Array.isArray(value) ? value : []
|
|
22
|
+
const toggle = (choice) => onSettle(toggled(chosen, choice))
|
|
23
|
+
return <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
|
24
|
+
{(choices || []).map((choice) => (
|
|
25
|
+
<label key={choice} style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
|
26
|
+
<Checkbox checked={chosen.includes(choice)} onChange={() => toggle(choice)} />
|
|
27
|
+
<span>{choice}</span>
|
|
28
|
+
</label>
|
|
29
|
+
))}
|
|
30
|
+
</div>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (type === "integer" || type === "float") {
|
|
34
|
+
return <Input className="mb-3" type="number" step={type === "integer" ? "1" : "any"} value={value ?? ""}
|
|
35
|
+
onChange={(e) => onChange(e.target.value)} onBlur={(e) => onSettle(e.target.value)} />
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return <Input className="mb-3" type="text" value={value ?? ""}
|
|
39
|
+
onChange={(e) => onChange(e.target.value)} onBlur={(e) => onSettle(e.target.value)} />
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default Control
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react"
|
|
2
|
+
import Control from "./Control"
|
|
3
|
+
import Records from "./Records"
|
|
4
|
+
import Panel from "keystone_ui-react/src/Panel.jsx"
|
|
5
|
+
import Button from "keystone_ui-react/src/Button.jsx"
|
|
6
|
+
import { Label } from "keystone_ui-react/src/FieldText.jsx"
|
|
7
|
+
|
|
8
|
+
const panel = { width: 280, padding: 20, overflowY: "auto" }
|
|
9
|
+
|
|
10
|
+
const Inspector = ({ node, fields, holds, labels, recordLabels, choices, onSave, onDelete, onClose }) => {
|
|
11
|
+
const [ draft, setDraft ] = useState(node.config)
|
|
12
|
+
useEffect(() => setDraft(node.config), [ node.id, node.config ])
|
|
13
|
+
|
|
14
|
+
const settle = (next) => {
|
|
15
|
+
setDraft(next)
|
|
16
|
+
if (JSON.stringify(next) !== JSON.stringify(node.config)) onSave(next)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Panel as="aside" style={panel} data-inspector>
|
|
21
|
+
<div style={{ display: "flex", alignItems: "start", justifyContent: "space-between", gap: 8 }}>
|
|
22
|
+
<h2 style={{ fontWeight: 600, marginBottom: 2 }}>{node.label}</h2>
|
|
23
|
+
<button title="Close" onClick={onClose} className="text-gray-500 dark:text-gray-400"
|
|
24
|
+
style={{ border: "none", background: "none", cursor: "pointer", fontSize: 18, lineHeight: 1 }}>×</button>
|
|
25
|
+
</div>
|
|
26
|
+
<p className="text-gray-500 dark:text-gray-400" style={{ fontSize: 11, marginBottom: 16 }}>{node.id} · {node.type}</p>
|
|
27
|
+
{Object.entries(fields).map(([ name, type ]) => (
|
|
28
|
+
<Label key={name}>
|
|
29
|
+
<span style={{ display: "block", marginBottom: 3 }}>{labels[name] || name}</span>
|
|
30
|
+
{type === "list"
|
|
31
|
+
? <Records holds={holds[name] || {}} labels={recordLabels[name] || {}} rows={draft[name]}
|
|
32
|
+
onChange={(next) => setDraft({ ...draft, [name]: next })}
|
|
33
|
+
onSettle={(next) => settle({ ...draft, [name]: next })} />
|
|
34
|
+
: <Control type={type} value={draft[name]} choices={choices[name]}
|
|
35
|
+
onChange={(next) => setDraft({ ...draft, [name]: next })}
|
|
36
|
+
onSettle={(next) => settle({ ...draft, [name]: next })} />}
|
|
37
|
+
</Label>
|
|
38
|
+
))}
|
|
39
|
+
<Button variant="danger" size="sm" className="w-full" onClick={onDelete}>Delete step</Button>
|
|
40
|
+
</Panel>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default Inspector
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import KeystonePanel from "keystone_ui-react/src/Panel.jsx"
|
|
3
|
+
import Button from "keystone_ui-react/src/Button.jsx"
|
|
4
|
+
import Input from "keystone_ui-react/src/Input.jsx"
|
|
5
|
+
import { Label } from "keystone_ui-react/src/FieldText.jsx"
|
|
6
|
+
import { worded } from "./changes"
|
|
7
|
+
import { standing } from "./flow"
|
|
8
|
+
|
|
9
|
+
const sheet = { width: 280, padding: 20, overflowY: "auto" }
|
|
10
|
+
const heading = { fontWeight: 600, marginBottom: 6, marginTop: 14 }
|
|
11
|
+
const item = { fontSize: 12, marginBottom: 4, lineHeight: 1.4 }
|
|
12
|
+
const caption = { display: "block", marginBottom: 3 }
|
|
13
|
+
|
|
14
|
+
const settling = (flow, name, onSaveDetails) => (event) => {
|
|
15
|
+
if (event.target.value !== (flow[name] ?? "")) onSaveDetails({ [name]: event.target.value })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const Panel = ({ flow, changes, problems, refusal, notice, onCreate, onPublish, onSaveDetails, onClose }) => (
|
|
19
|
+
<KeystonePanel as="aside" style={sheet} data-builder-panel>
|
|
20
|
+
<div style={{ display: "flex", alignItems: "start", justifyContent: "space-between", gap: 8 }}>
|
|
21
|
+
<h2 style={{ fontWeight: 600 }} data-flow-name>{flow.title || flow.slug}</h2>
|
|
22
|
+
<button title="Close" onClick={onClose} className="text-gray-500 dark:text-gray-400"
|
|
23
|
+
style={{ border: "none", background: "none", cursor: "pointer", fontSize: 18, lineHeight: 1 }}>×</button>
|
|
24
|
+
</div>
|
|
25
|
+
<a className="text-accent-600 dark:text-accent-400" style={{ fontSize: 12, display: "block" }} href={flow.history_url} data-history>{standing(flow)}</a>
|
|
26
|
+
|
|
27
|
+
<h2 style={heading}>Problems</h2>
|
|
28
|
+
{refusal && <p className="text-red-800 dark:text-red-400" style={{ ...item, fontWeight: 600 }} data-refusal>{refusal}</p>}
|
|
29
|
+
{notice && !refusal && <p className="text-emerald-800 dark:text-emerald-400" style={{ ...item, fontWeight: 600 }} data-notice>{notice}</p>}
|
|
30
|
+
{problems.length === 0 && !refusal
|
|
31
|
+
? <p className="text-gray-500 dark:text-gray-400" style={{ fontSize: 12 }}>Nothing wrong with this flow.</p>
|
|
32
|
+
: problems.map((problem) => (
|
|
33
|
+
<p key={`${problem.node}-${problem.problem}`} className="text-amber-700 dark:text-amber-400" style={item} data-problem>⚠ {worded(problem)}</p>
|
|
34
|
+
))}
|
|
35
|
+
|
|
36
|
+
<h2 style={heading}>Changes since the last version</h2>
|
|
37
|
+
{changes.length === 0
|
|
38
|
+
? <p className="text-gray-500 dark:text-gray-400" style={{ fontSize: 12 }}>Nothing has changed.</p>
|
|
39
|
+
: changes.map((change, at) => <p key={at} style={item} data-change>{change}</p>)}
|
|
40
|
+
|
|
41
|
+
<h2 style={heading}>Details</h2>
|
|
42
|
+
<Label>
|
|
43
|
+
<span style={caption}>Title</span>
|
|
44
|
+
<Input className="mb-3" defaultValue={flow.title ?? ""} onBlur={settling(flow, "title", onSaveDetails)} data-flow-title />
|
|
45
|
+
</Label>
|
|
46
|
+
|
|
47
|
+
<Label>
|
|
48
|
+
<span style={caption}>Start label</span>
|
|
49
|
+
<Input className="mb-3" defaultValue={flow.start_label ?? ""} onBlur={settling(flow, "start_label", onSaveDetails)} data-flow-start-label />
|
|
50
|
+
</Label>
|
|
51
|
+
|
|
52
|
+
<div style={{ marginTop: 16 }}>
|
|
53
|
+
<Button variant="secondary" size="sm" className="w-full mb-1.5" onClick={onCreate} data-create-version>Create version</Button>
|
|
54
|
+
<Button size="sm" className="w-full mb-1.5" onClick={onPublish} data-publish>Publish</Button>
|
|
55
|
+
<Button variant="secondary" size="sm" className="w-full mb-1.5" href={flow.definition_url} data-definition>Definition</Button>
|
|
56
|
+
<Button variant="secondary" size="sm" className="w-full mb-1.5" href={flow.details_url} data-details>Edit details</Button>
|
|
57
|
+
</div>
|
|
58
|
+
</KeystonePanel>
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
export default Panel
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { CARD } from "./styles"
|
|
3
|
+
|
|
4
|
+
const waiting = {
|
|
5
|
+
width: CARD, boxSizing: "border-box", padding: "10px 14px", borderRadius: 8, textAlign: "center", fontSize: 12, cursor: "pointer"
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const open = "border-2 border-dashed border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300"
|
|
9
|
+
const receiving = "border-2 border-dashed border-accent-600 bg-accent-50 text-accent-800 dark:bg-accent-950 dark:text-accent-200"
|
|
10
|
+
|
|
11
|
+
const Placeholder = ({ node, dragging, onFill, onDrop }) => (
|
|
12
|
+
<div ref={node.ref} data-placeholder={node.id}
|
|
13
|
+
title={`Choose the step “${node.label}” should lead to`}
|
|
14
|
+
onClick={onFill}
|
|
15
|
+
onDragOver={(event) => { if (dragging) { event.preventDefault(); event.dataTransfer.dropEffect = "move" } }}
|
|
16
|
+
onDrop={(event) => { event.preventDefault(); onDrop() }}
|
|
17
|
+
className={dragging ? receiving : open}
|
|
18
|
+
style={waiting}>
|
|
19
|
+
<div style={{ fontWeight: 600 }}>{node.label}</div>
|
|
20
|
+
<div style={{ fontSize: 11 }}>{dragging ? "drop a step here" : "leads nowhere yet — click to choose"}</div>
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
export default Placeholder
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
|
|
3
|
+
const open = "border border-amber-500 bg-white text-amber-700 dark:bg-zinc-900 dark:text-amber-400"
|
|
4
|
+
const joined = "border border-gray-300 bg-white text-gray-500 dark:border-zinc-600 dark:bg-zinc-900 dark:text-gray-400"
|
|
5
|
+
const choosing = "border border-accent-600 bg-accent-600 text-white"
|
|
6
|
+
|
|
7
|
+
const Port = ({ name, connected, armed, onArm, connecting }) => (
|
|
8
|
+
<button onClick={(event) => { if (connecting) return; event.stopPropagation(); onArm(event) }}
|
|
9
|
+
title={armed ? "Now choose a step to connect to" : "Connect this branch"}
|
|
10
|
+
className={armed ? choosing : connected ? joined : open}
|
|
11
|
+
style={{
|
|
12
|
+
padding: "1px 8px", marginRight: 4, borderRadius: 999, fontSize: 11, cursor: "pointer"
|
|
13
|
+
}}>{name || "next"}</button>
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
export default Port
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import Control from "./Control"
|
|
3
|
+
import { amended } from "./rows"
|
|
4
|
+
import Button from "keystone_ui-react/src/Button.jsx"
|
|
5
|
+
|
|
6
|
+
const Records = ({ holds, labels, rows, onChange, onSettle }) => {
|
|
7
|
+
const kept = Array.isArray(rows) ? rows : []
|
|
8
|
+
const amend = (index, name, next, settle) => {
|
|
9
|
+
const updated = amended(kept, index, name, next)
|
|
10
|
+
settle ? onSettle(updated) : onChange(updated)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div style={{ marginBottom: 12 }}>
|
|
15
|
+
{kept.map((row, index) => (
|
|
16
|
+
<div key={index} className="rounded-md border border-gray-200 dark:border-zinc-700" style={{ padding: "8px 8px 2px", marginBottom: 6 }}>
|
|
17
|
+
{Object.entries(holds).map(([ name, type ]) => (
|
|
18
|
+
<label key={name} style={{ display: "block" }}>
|
|
19
|
+
<span className="text-gray-500 dark:text-gray-400" style={{ display: "block", marginBottom: 2, fontSize: 11 }}>{(labels || {})[name] || name}</span>
|
|
20
|
+
<Control type={type} value={row[name]}
|
|
21
|
+
onChange={(next) => amend(index, name, next, false)}
|
|
22
|
+
onSettle={(next) => amend(index, name, next, true)} />
|
|
23
|
+
</label>
|
|
24
|
+
))}
|
|
25
|
+
<Button variant="secondary" size="sm" className="mb-1.5"
|
|
26
|
+
onClick={() => onSettle(kept.filter((_, at) => at !== index))}>Remove</Button>
|
|
27
|
+
</div>
|
|
28
|
+
))}
|
|
29
|
+
<Button variant="secondary" size="sm" className="w-full" onClick={() => onSettle([ ...kept, {} ])}>Add</Button>
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default Records
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import Port from "./Port"
|
|
3
|
+
import { CARD } from "./styles"
|
|
4
|
+
|
|
5
|
+
const card = { width: CARD, boxSizing: "border-box", borderRadius: 8 }
|
|
6
|
+
|
|
7
|
+
const bookendCard = { padding: "8px 14px", textAlign: "center", fontWeight: 600 }
|
|
8
|
+
|
|
9
|
+
const named = { fontWeight: 600, lineHeight: 1.3 }
|
|
10
|
+
|
|
11
|
+
const edge = (target, troubled, selected) => {
|
|
12
|
+
const lift = selected ? "ring-4 ring-accent-600/15" : "shadow-sm"
|
|
13
|
+
if (target || (selected && !troubled)) return `border-accent-600 ${lift}`
|
|
14
|
+
if (troubled) return `border-red-600 ${lift}`
|
|
15
|
+
return "border-gray-300 shadow-sm dark:border-zinc-700"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const StepCard = ({ node, selected, armed, connecting, onSelect, onArm, onDragEnd, onDragStart }) => {
|
|
19
|
+
const bookend = node.begins_here || node.ends_here
|
|
20
|
+
const pinned = node.begins_here
|
|
21
|
+
const ports = node.ends_here ? [] : node.ports
|
|
22
|
+
const target = connecting && !pinned
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div ref={node.ref}
|
|
26
|
+
data-step={node.id}
|
|
27
|
+
draggable={!pinned}
|
|
28
|
+
onDragStart={(event) => { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", node.id); onDragStart() }}
|
|
29
|
+
onDragEnd={onDragEnd}
|
|
30
|
+
onClick={onSelect}
|
|
31
|
+
className={`bg-white border-2 dark:bg-zinc-900 ${edge(target, node.violations.length > 0, selected)}`}
|
|
32
|
+
style={{
|
|
33
|
+
...card,
|
|
34
|
+
padding: bookend ? 0 : "12px 14px",
|
|
35
|
+
cursor: pinned ? "default" : connecting ? "crosshair" : "grab"
|
|
36
|
+
}}>
|
|
37
|
+
{bookend && <div className="text-gray-500 dark:text-gray-400" style={bookendCard}>{node.label}</div>}
|
|
38
|
+
{!bookend && <div style={named}>{node.label}</div>}
|
|
39
|
+
{!bookend && <div className="text-gray-500 dark:text-gray-400" style={{ fontSize: 11, marginTop: 2 }}>{node.type}</div>}
|
|
40
|
+
{node.violations.map((violation) => (
|
|
41
|
+
<div key={violation.problem + violation.detail} className="text-red-600 dark:text-red-400" style={{ fontSize: 11, padding: "0 14px 6px" }}>
|
|
42
|
+
{violation.problem.replace(/_/g, " ")}{violation.detail ? `: ${violation.detail}` : ""}
|
|
43
|
+
</div>
|
|
44
|
+
))}
|
|
45
|
+
{ports.length > 0 && (
|
|
46
|
+
<div style={{ padding: node.begins_here ? "0 14px 8px" : "10px 0 0" }}>
|
|
47
|
+
{ports.map((port) => (
|
|
48
|
+
<Port key={port} name={port} connected={node.connected.includes(port)}
|
|
49
|
+
connecting={connecting} armed={armed === port} onArm={(event) => onArm(port, event)} />
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default StepCard
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import Button from "keystone_ui-react/src/Button.jsx"
|
|
3
|
+
|
|
4
|
+
const round = { width: 24, height: 24, padding: 0, fontSize: 15, lineHeight: "15px" }
|
|
5
|
+
|
|
6
|
+
const Stepper = ({ label, title, idle, enabled, onUse }) => (
|
|
7
|
+
<Button variant="secondary" size="sm" title={enabled ? title : idle} disabled={!enabled} onClick={onUse}
|
|
8
|
+
style={{ opacity: enabled ? 1 : 0.4, cursor: enabled ? "pointer" : "default" }}>{label}</Button>
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
const Toolbar = ({ undoable, redoable, onAdd, onUndo, onRedo }) => (
|
|
12
|
+
<div style={{ position: "absolute", top: 16, left: 16, zIndex: 5, display: "flex", gap: 8 }}>
|
|
13
|
+
<Button variant="secondary" className="rounded-full" style={round} title="Add a step" onClick={onAdd}>+</Button>
|
|
14
|
+
<Stepper label="↶ Undo" title="Undo the last change" idle="Nothing to undo" enabled={undoable} onUse={onUndo} />
|
|
15
|
+
<Stepper label="↷ Redo" title="Redo the change you undid" idle="Nothing to redo" enabled={redoable} onUse={onRedo} />
|
|
16
|
+
</div>
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
export default Toolbar
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import Panel from "keystone_ui-react/src/Panel.jsx"
|
|
3
|
+
import Button from "keystone_ui-react/src/Button.jsx"
|
|
4
|
+
|
|
5
|
+
const TypePicker = ({ entries, at, onPick, onConnect, onDismiss }) => (
|
|
6
|
+
<Panel className="shadow-lg" style={{ position: "absolute", zIndex: 9, top: at.y, left: at.x, width: 210, padding: 8, borderRadius: 8 }}>
|
|
7
|
+
<p className="text-gray-500 dark:text-gray-400" style={{ margin: "0 0 6px", fontSize: 11 }}>Add a step</p>
|
|
8
|
+
{entries.map((entry) => (
|
|
9
|
+
<Button key={entry.type} variant="secondary" size="sm" className="w-full mb-1.5 flex-col" onClick={() => onPick(entry)}>
|
|
10
|
+
{entry.label}
|
|
11
|
+
<span className="opacity-75" style={{ display: "block", fontSize: 11 }}>{entry.type}</span>
|
|
12
|
+
</Button>
|
|
13
|
+
))}
|
|
14
|
+
{onConnect && (
|
|
15
|
+
<Button variant="secondary" size="sm" className="w-full mb-1.5" onClick={onConnect}>Connect to a step already here</Button>
|
|
16
|
+
)}
|
|
17
|
+
<Button variant="secondary" size="sm" className="w-full" onClick={onDismiss}>Cancel</Button>
|
|
18
|
+
</Panel>
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
export default TypePicker
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const standing = ({ version, published }) => {
|
|
2
|
+
if (!version) return "No version yet"
|
|
3
|
+
if (!published) return `Version ${version} · never published`
|
|
4
|
+
if (published === version) return `Version ${version} · live`
|
|
5
|
+
|
|
6
|
+
return `Version ${version} · visitors still on ${published}`
|
|
7
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const UNREACHABLE = "Your change was not saved because the server could not be reached."
|
|
2
|
+
|
|
3
|
+
export const createFlowSender = ({ base, token, fetch, onFlow, onError, onNotice }) => async (path, method, body) => {
|
|
4
|
+
const response = await fetch(base + path, {
|
|
5
|
+
method, headers: { "Content-Type": "application/json", "X-CSRF-Token": token }, body: body && JSON.stringify(body)
|
|
6
|
+
}).catch(() => null)
|
|
7
|
+
if (!response) {
|
|
8
|
+
onNotice(null)
|
|
9
|
+
return onError(UNREACHABLE)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const answered = await response.json().catch(() => ({}))
|
|
13
|
+
|
|
14
|
+
if (!response.ok) {
|
|
15
|
+
onNotice(null)
|
|
16
|
+
return onError(answered.error || "That change was refused")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
onError(null)
|
|
20
|
+
onNotice(answered.notice || null)
|
|
21
|
+
onFlow(await (await fetch(base + ".json", { headers: { Accept: "application/json" } })).json())
|
|
22
|
+
}
|