swarm-ui: edit a notification flow on a canvas
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.
This commit is contained in:
parent
c00fd99112
commit
f28e1e9063
13 changed files with 1209 additions and 6 deletions
|
|
@ -4,10 +4,12 @@ import { test } from "node:test";
|
|||
import {
|
||||
addChild,
|
||||
blankNode,
|
||||
DEFAULT_LIMITS,
|
||||
depthOf,
|
||||
duplicateFlow,
|
||||
heightBelow,
|
||||
isDirty,
|
||||
limitsOf,
|
||||
newFlow,
|
||||
newNodeId,
|
||||
removeNode,
|
||||
|
|
@ -233,3 +235,11 @@ test("saveOutcome tells a stale revision from a refused document", () => {
|
|||
assert.equal(saveOutcome(503), "error");
|
||||
assert.equal(saveOutcome(500), "error");
|
||||
});
|
||||
|
||||
test("limitsOf takes the controller's caps and falls back to today's", () => {
|
||||
assert.deepEqual(
|
||||
limitsOf({ limits: { max_nodes: 5, max_depth: 3, max_flows: 9 } }),
|
||||
{ maxNodes: 5, maxDepth: 3, maxFlows: 9 },
|
||||
);
|
||||
assert.deepEqual(limitsOf(undefined), DEFAULT_LIMITS);
|
||||
});
|
||||
|
|
|
|||
122
frontend/packages/swarm-ui/test/flow-layout.test.mjs
Normal file
122
frontend/packages/swarm-ui/test/flow-layout.test.mjs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// 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,
|
||||
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);
|
||||
});
|
||||
|
|
@ -3,6 +3,8 @@ import assert from "node:assert/strict";
|
|||
import { test } from "node:test";
|
||||
import {
|
||||
describeNode,
|
||||
ellipsize,
|
||||
kindValues,
|
||||
treeRows,
|
||||
withBuiltin,
|
||||
} from "../src/pages/notifications/FlowTypes.ts";
|
||||
|
|
@ -78,3 +80,28 @@ test("withBuiltin: switching off adds once, switching on removes", () => {
|
|||
assert.deepEqual(withBuiltin(["c"], "a", false), ["a", "c"]);
|
||||
assert.deepEqual(withBuiltin(["a", "b"], "a", true), ["b"]);
|
||||
});
|
||||
|
||||
test("ellipsize cuts long text and leaves short text alone", () => {
|
||||
assert.equal(ellipsize("short", 10), "short");
|
||||
assert.equal(ellipsize("exactly10!", 10), "exactly10!");
|
||||
assert.equal(ellipsize("a rather long line", 10), "a rather …");
|
||||
assert.equal(ellipsize("a rather long line", 10).length, 10);
|
||||
});
|
||||
|
||||
test("kindValues lists the values a schema allows for a field", () => {
|
||||
const schema = {
|
||||
node_kinds: [
|
||||
{
|
||||
kind: "todo",
|
||||
fields: [
|
||||
{ name: "op", values: [{ name: "upsert" }, { name: "clear" }] },
|
||||
{ name: "key", values: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.deepEqual(kindValues(schema, "todo", "op"), ["upsert", "clear"]);
|
||||
assert.deepEqual(kindValues(schema, "todo", "key"), []);
|
||||
assert.deepEqual(kindValues(schema, "match", "when"), []);
|
||||
assert.deepEqual(kindValues({ node_kinds: [] }, "todo", "op"), []);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue