The flow editor can now change a flow's shape on the canvas, not only through the inspector's fields: - drag a box onto another to move it and its branch there; a drop the save would refuse shows the target dashed red and gives the reason; - a palette of node kinds adds a node below the picked box on click, or below any box it is dragged onto; it offers what the controller lists; - arrow keys walk the tree, M picks a box up and Enter drops it under the focused one, Alt+Up/Down reorder a node among its siblings, Delete removes a branch; focus follows the edit; - each box with siblings says when it runs, and the inspector can move it earlier or later, since the router runs children in edge order; - request_reviewer is a second leaf kind beside todo, with a reviewer field; - leaving with unsaved edits asks first, also for in-app links and the back button (a router hook runs navigation past a page's guard).
162 lines
4.8 KiB
JavaScript
162 lines
4.8 KiB
JavaScript
// Where the flow editor's canvas puts each node. Run by `npm test`.
|
|
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
import {
|
|
layoutFlow,
|
|
neighbour,
|
|
nodeAt,
|
|
NODE_HEIGHT,
|
|
NODE_WIDTH,
|
|
} from "../src/pages/notifications/FlowLayout.ts";
|
|
|
|
const match = (id) => ({ id, kind: "match", when: [] });
|
|
const source = { id: "s", kind: "source", events: ["issues"] };
|
|
const flow = (nodes, edges) => ({
|
|
id: "f",
|
|
name: "f",
|
|
enabled: true,
|
|
nodes,
|
|
edges: edges.map(([from, to]) => ({ from, to })),
|
|
});
|
|
const at = (layout, id) => layout.nodes.find((n) => n.id === id);
|
|
|
|
test("a lone source sits top left", () => {
|
|
const l = layoutFlow(flow([source], []));
|
|
assert.equal(l.nodes.length, 1);
|
|
assert.equal(at(l, "s").depth, 0);
|
|
assert.equal(l.edges.length, 0);
|
|
assert.ok(l.width > NODE_WIDTH && l.height > NODE_HEIGHT);
|
|
});
|
|
|
|
test("leaves take rows in edge order and a parent sits between its children", () => {
|
|
const l = layoutFlow(
|
|
flow(
|
|
[source, match("a"), match("b"), match("c")],
|
|
[
|
|
["s", "a"],
|
|
["s", "b"],
|
|
["a", "c"],
|
|
],
|
|
),
|
|
);
|
|
// c (under a) is the first leaf, b the second; a follows c, s the middle.
|
|
assert.ok(at(l, "c").y < at(l, "b").y);
|
|
assert.equal(at(l, "a").y, at(l, "c").y);
|
|
assert.equal(at(l, "s").y, (at(l, "a").y + at(l, "b").y) / 2);
|
|
assert.equal(at(l, "c").depth, 2);
|
|
assert.ok(at(l, "c").x > at(l, "a").x && at(l, "a").x > at(l, "s").x);
|
|
});
|
|
|
|
test("no two nodes overlap", () => {
|
|
const nodes = [source];
|
|
const edges = [];
|
|
for (let i = 0; i < 6; i += 1) {
|
|
nodes.push(match(`m${i}`), match(`n${i}`));
|
|
edges.push(["s", `m${i}`], [`m${i}`, `n${i}`]);
|
|
}
|
|
const l = layoutFlow(flow(nodes, edges));
|
|
for (const a of l.nodes) {
|
|
for (const b of l.nodes) {
|
|
if (a === b) continue;
|
|
const apart =
|
|
a.x + NODE_WIDTH <= b.x ||
|
|
b.x + NODE_WIDTH <= a.x ||
|
|
a.y + NODE_HEIGHT <= b.y ||
|
|
b.y + NODE_HEIGHT <= a.y;
|
|
assert.ok(apart, `${a.id} overlaps ${b.id}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("an edge runs from the parent's right side to the child's left", () => {
|
|
const l = layoutFlow(flow([source, match("a")], [["s", "a"]]));
|
|
const [e] = l.edges;
|
|
assert.equal(e.x1, at(l, "s").x + NODE_WIDTH);
|
|
assert.equal(e.x2, at(l, "a").x);
|
|
assert.equal(e.y1, at(l, "s").y + NODE_HEIGHT / 2);
|
|
assert.equal(e.y2, at(l, "a").y + NODE_HEIGHT / 2);
|
|
});
|
|
|
|
test("a node nothing reaches yet still gets a place, below the tree", () => {
|
|
const l = layoutFlow(flow([source, match("a"), match("lost")], [["s", "a"]]));
|
|
assert.equal(l.nodes.length, 3);
|
|
assert.ok(at(l, "lost").y > at(l, "a").y);
|
|
assert.equal(l.edges.length, 1);
|
|
});
|
|
|
|
test("edges to unknown nodes are left out, and a cycle terminates", () => {
|
|
const l = layoutFlow(
|
|
flow(
|
|
[source, match("a"), match("b")],
|
|
[
|
|
["s", "a"],
|
|
["a", "b"],
|
|
["b", "a"],
|
|
["s", "ghost"],
|
|
],
|
|
),
|
|
);
|
|
assert.equal(l.nodes.length, 3);
|
|
assert.ok(l.edges.every((e) => e.to !== "ghost"));
|
|
});
|
|
|
|
test("a node with two parents is placed once", () => {
|
|
const l = layoutFlow(
|
|
flow(
|
|
[source, match("a"), match("b"), match("x")],
|
|
[
|
|
["s", "a"],
|
|
["s", "b"],
|
|
["a", "x"],
|
|
["b", "x"],
|
|
],
|
|
),
|
|
);
|
|
assert.equal(l.nodes.length, 4);
|
|
const spots = new Set(l.nodes.map((n) => `${n.x},${n.y}`));
|
|
assert.equal(spots.size, 4, "no two nodes share a spot");
|
|
});
|
|
|
|
test("an empty flow has an empty layout", () => {
|
|
const l = layoutFlow(flow([], []));
|
|
assert.deepEqual(l.nodes, []);
|
|
assert.ok(l.width >= 0 && l.height >= 0);
|
|
});
|
|
|
|
test("nodeAt finds the box holding a point and nothing between boxes", () => {
|
|
const l = layoutFlow(flow([source, match("a")], [["s", "a"]]));
|
|
const a = at(l, "a");
|
|
assert.equal(nodeAt(l, a.x + 5, a.y + 5), "a");
|
|
assert.equal(
|
|
nodeAt(l, a.x + NODE_WIDTH, a.y + NODE_HEIGHT),
|
|
"a",
|
|
"the edge counts",
|
|
);
|
|
assert.equal(nodeAt(l, a.x - 1, a.y + 5), undefined);
|
|
assert.equal(nodeAt(l, 5000, 5000), undefined);
|
|
});
|
|
|
|
test("neighbour walks to the parent, the first child and the box above or below", () => {
|
|
const f = flow(
|
|
[source, match("a"), match("b"), match("c")],
|
|
[
|
|
["s", "a"],
|
|
["s", "b"],
|
|
["a", "c"],
|
|
],
|
|
);
|
|
const l = layoutFlow(f);
|
|
assert.equal(neighbour(l, f, "a", "left"), "s");
|
|
assert.equal(neighbour(l, f, "s", "right"), "a", "the first in edge order");
|
|
assert.equal(
|
|
neighbour(l, f, "s", "left"),
|
|
undefined,
|
|
"nothing left of the source",
|
|
);
|
|
assert.equal(neighbour(l, f, "c", "right"), undefined, "a leaf has no child");
|
|
// a sits above b in the same column.
|
|
assert.equal(neighbour(l, f, "a", "down"), "b");
|
|
assert.equal(neighbour(l, f, "b", "up"), "a");
|
|
assert.equal(neighbour(l, f, "a", "up"), undefined);
|
|
assert.equal(neighbour(l, f, "nope", "up"), undefined);
|
|
});
|