A custom flow opens at /notifications/<id>: its nodes drawn left to right (source, then matches, then todos), and a panel to edit the picked node: the source's events, a match's conditions, a todo's recipients, op, key and text. Add a match or todo below a node, move a branch under another match, or remove it; an edit the controller would refuse (a todo with children, a second parent, a cycle, over the node or depth cap) is turned away with the reason. Edits stay in a draft until save, which sends the whole document at the revision read. A stale revision is told apart from a refused document: the editor offers to reload or to keep the draft on top of the latest. The form choices come from the controller's schema when it sends one. The list page gains edit and duplicate on custom flows, duplicate on the built-ins, and a new-flow box; a new flow's id avoids the built-ins' too.
74 lines
2.3 KiB
TypeScript
74 lines
2.3 KiB
TypeScript
// <FlowCanvas> — a flow drawn as boxes and lines: the source on the left, its
|
|
// branches to the right. Pick a node to edit it; the inspector beside the
|
|
// canvas does the editing.
|
|
import { layoutFlow, NODE_HEIGHT, NODE_WIDTH } from "./FlowLayout.js";
|
|
import { describeNode, ellipsize, type Flow } from "./FlowTypes.js";
|
|
|
|
const TEXT_CHARS = 30;
|
|
|
|
export function FlowCanvas({
|
|
flow,
|
|
selected,
|
|
onSelect,
|
|
}: {
|
|
flow: Flow;
|
|
selected: string | null;
|
|
onSelect: (id: string) => void;
|
|
}) {
|
|
const layout = layoutFlow(flow);
|
|
const byId = new Map(flow.nodes.map((n) => [n.id, n]));
|
|
return (
|
|
<div class="notify-canvas">
|
|
<svg
|
|
width={layout.width}
|
|
height={layout.height}
|
|
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
|
role="group"
|
|
aria-label={`${flow.name}, ${flow.nodes.length} nodes`}
|
|
>
|
|
{layout.edges.map((e) => {
|
|
const mid = (e.x1 + e.x2) / 2;
|
|
return (
|
|
<path
|
|
key={`${e.from}>${e.to}`}
|
|
class="notify-edge"
|
|
d={`M${e.x1} ${e.y1} C${mid} ${e.y1} ${mid} ${e.y2} ${e.x2} ${e.y2}`}
|
|
/>
|
|
);
|
|
})}
|
|
{layout.nodes.map((p) => {
|
|
const node = byId.get(p.id);
|
|
if (!node) return null;
|
|
const text = describeNode(node);
|
|
return (
|
|
<g
|
|
key={p.id}
|
|
class={`notify-box notify-box-${node.kind}${p.id === selected ? " notify-box-selected" : ""}`}
|
|
transform={`translate(${p.x} ${p.y})`}
|
|
tabIndex={0}
|
|
role="button"
|
|
aria-pressed={p.id === selected}
|
|
aria-label={`${node.kind} ${p.id}: ${text}`}
|
|
onClick={() => onSelect(p.id)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
onSelect(p.id);
|
|
}
|
|
}}
|
|
>
|
|
<title>{text}</title>
|
|
<rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" />
|
|
<text class="notify-box-kind" x="10" y="17">
|
|
{node.kind}
|
|
</text>
|
|
<text class="notify-box-text" x="10" y="35">
|
|
{ellipsize(text, TEXT_CHARS)}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
</svg>
|
|
</div>
|
|
);
|
|
}
|