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.
245 lines
8.1 KiB
JavaScript
245 lines
8.1 KiB
JavaScript
// The notification flow editor's draft edits. Run by `npm test`.
|
|
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
import {
|
|
addChild,
|
|
blankNode,
|
|
DEFAULT_LIMITS,
|
|
depthOf,
|
|
duplicateFlow,
|
|
heightBelow,
|
|
isDirty,
|
|
limitsOf,
|
|
newFlow,
|
|
newNodeId,
|
|
removeNode,
|
|
reparent,
|
|
replaceNode,
|
|
saveOutcome,
|
|
saveRequest,
|
|
slugId,
|
|
subtreeIds,
|
|
withFlow,
|
|
withoutFlow,
|
|
} from "../src/pages/notifications/FlowDraft.ts";
|
|
|
|
const todo = (id) => ({
|
|
id,
|
|
kind: "todo",
|
|
to: { agent: "triage" },
|
|
op: "upsert",
|
|
key: "k",
|
|
prefix: false,
|
|
text: "look",
|
|
});
|
|
|
|
// source -> a -> b -> t1, source -> c
|
|
const flow = () => ({
|
|
id: "f",
|
|
name: "f",
|
|
enabled: true,
|
|
nodes: [
|
|
{ id: "source", kind: "source", events: ["issues"] },
|
|
{ id: "a", kind: "match", when: [] },
|
|
{ id: "b", kind: "match", when: [] },
|
|
{ id: "c", kind: "match", when: [] },
|
|
todo("t1"),
|
|
],
|
|
edges: [
|
|
{ from: "source", to: "a" },
|
|
{ from: "a", to: "b" },
|
|
{ from: "b", to: "t1" },
|
|
{ from: "source", to: "c" },
|
|
],
|
|
});
|
|
|
|
const ids = (f) => f.nodes.map((n) => n.id).sort();
|
|
|
|
test("depth, height and subtree", () => {
|
|
const f = flow();
|
|
assert.equal(depthOf(f, "source"), 0);
|
|
assert.equal(depthOf(f, "t1"), 3);
|
|
assert.equal(heightBelow(f, "source"), 3);
|
|
assert.equal(heightBelow(f, "c"), 0);
|
|
assert.deepEqual(subtreeIds(f, "a").sort(), ["a", "b", "t1"]);
|
|
});
|
|
|
|
test("a cycle in the data cannot hang the walks", () => {
|
|
const f = flow();
|
|
f.edges.push({ from: "t1", to: "a" });
|
|
assert.ok(depthOf(f, "b") < 10);
|
|
assert.ok(heightBelow(f, "a") < 10);
|
|
assert.ok(subtreeIds(f, "a").length <= f.nodes.length);
|
|
});
|
|
|
|
test("addChild adds the node and its edge", () => {
|
|
const r = addChild(flow(), "c", todo("t2"));
|
|
assert.ok(r.ok);
|
|
assert.ok(r.flow.nodes.some((n) => n.id === "t2"));
|
|
assert.deepEqual(r.flow.edges.at(-1), { from: "c", to: "t2" });
|
|
});
|
|
|
|
test("addChild refuses what a save would", () => {
|
|
const f = flow();
|
|
assert.equal(addChild(f, "t1", todo("t2")).ok, false, "under a todo");
|
|
assert.equal(addChild(f, "nope", todo("t2")).ok, false, "no parent");
|
|
assert.equal(addChild(f, "c", todo("a")).ok, false, "id in use");
|
|
assert.equal(addChild(f, "c", todo("bad id")).ok, false, "bad id");
|
|
assert.equal(
|
|
addChild(f, "c", { id: "s2", kind: "source", events: ["issues"] }).ok,
|
|
false,
|
|
"second source",
|
|
);
|
|
const small = { maxNodes: 5, maxDepth: 16, maxFlows: 100 };
|
|
assert.equal(addChild(f, "c", todo("t2"), small).ok, false, "node cap");
|
|
const shallow = { maxNodes: 64, maxDepth: 2, maxFlows: 100 };
|
|
assert.equal(addChild(f, "b", todo("t2"), shallow).ok, false, "depth cap");
|
|
assert.ok(addChild(f, "a", todo("t2"), shallow).ok, "right at the cap");
|
|
});
|
|
|
|
test("removeNode takes the branch and keeps the rest", () => {
|
|
const r = removeNode(flow(), "a");
|
|
assert.ok(r.ok);
|
|
assert.deepEqual(ids(r.flow), ["c", "source"]);
|
|
assert.deepEqual(r.flow.edges, [{ from: "source", to: "c" }]);
|
|
assert.equal(removeNode(flow(), "source").ok, false);
|
|
assert.equal(removeNode(flow(), "nope").ok, false);
|
|
});
|
|
|
|
test("reparent moves a branch and keeps one parent per node", () => {
|
|
const r = reparent(flow(), "b", "c");
|
|
assert.ok(r.ok);
|
|
assert.equal(r.flow.edges.filter((e) => e.to === "b").length, 1);
|
|
assert.ok(r.flow.edges.some((e) => e.from === "c" && e.to === "b"));
|
|
assert.ok(r.flow.edges.some((e) => e.from === "b" && e.to === "t1"));
|
|
assert.equal(r.flow.nodes.length, 5);
|
|
});
|
|
|
|
test("reparent refuses a cycle, a todo parent and the source", () => {
|
|
const f = flow();
|
|
assert.equal(reparent(f, "a", "b").ok, false, "under its own child");
|
|
assert.equal(reparent(f, "a", "a").ok, false, "under itself");
|
|
assert.equal(reparent(f, "c", "t1").ok, false, "under a todo");
|
|
assert.equal(reparent(f, "source", "c").ok, false, "the source");
|
|
assert.equal(reparent(f, "nope", "c").ok, false, "unknown node");
|
|
});
|
|
|
|
test("reparent counts the whole branch against the depth cap", () => {
|
|
const limits = { maxNodes: 64, maxDepth: 4, maxFlows: 100 };
|
|
// a (1) -> b -> t1 is a branch of height 2; under b's child would be deeper.
|
|
const f = flow();
|
|
// c is at depth 1; moving a (height 2) under c puts t1 at depth 4.
|
|
assert.ok(reparent(f, "a", "c", limits).ok);
|
|
const tight = { ...limits, maxDepth: 3 };
|
|
assert.equal(reparent(f, "a", "c", tight).ok, false);
|
|
});
|
|
|
|
test("replaceNode keeps the place and the kind class", () => {
|
|
const f = flow();
|
|
const r = replaceNode(f, {
|
|
id: "a",
|
|
kind: "match",
|
|
when: [{ field: "action", equals: "x" }],
|
|
});
|
|
assert.ok(r.ok);
|
|
assert.equal(r.flow.nodes.find((n) => n.id === "a").when.length, 1);
|
|
assert.equal(replaceNode(f, { ...todo("a") }).ok, false, "a has children");
|
|
assert.ok(replaceNode(f, { ...todo("c") }).ok, "a leaf may become a todo");
|
|
assert.equal(
|
|
replaceNode(f, { id: "source", kind: "match", when: [] }).ok,
|
|
false,
|
|
);
|
|
assert.equal(
|
|
replaceNode(f, { id: "c", kind: "source", events: ["issues"] }).ok,
|
|
false,
|
|
);
|
|
assert.equal(replaceNode(f, todo("nope")).ok, false);
|
|
});
|
|
|
|
test("new ids do not collide", () => {
|
|
const f = flow();
|
|
assert.equal(newNodeId(f, "match"), "match1");
|
|
const r = addChild(f, "c", blankNode(f, "match"));
|
|
assert.ok(r.ok);
|
|
assert.equal(newNodeId(r.flow, "match"), "match2");
|
|
assert.equal(blankNode(f, "todo").kind, "todo");
|
|
});
|
|
|
|
test("slugId builds an id the controller accepts and avoids taken ones", () => {
|
|
assert.equal(slugId("My Flow!", []), "my-flow");
|
|
assert.equal(slugId("My Flow!", ["my-flow"]), "my-flow-2");
|
|
assert.equal(slugId("My Flow!", ["my-flow", "my-flow-2"]), "my-flow-3");
|
|
assert.equal(slugId("!!!", []), "flow");
|
|
assert.ok(/^[A-Za-z0-9_-]{1,64}$/.test(slugId("x".repeat(200), [])));
|
|
});
|
|
|
|
test("newFlow is one source; duplicateFlow copies without sharing", () => {
|
|
const fresh = newFlow("Triage", ["triage"]);
|
|
assert.equal(fresh.id, "triage-2");
|
|
assert.equal(fresh.nodes.length, 1);
|
|
assert.equal(fresh.nodes[0].kind, "source");
|
|
assert.ok(fresh.enabled);
|
|
|
|
const src = flow();
|
|
const copy = duplicateFlow(src, "f copy", ["f"]);
|
|
assert.equal(copy.id, "f-copy");
|
|
assert.equal(copy.nodes.length, src.nodes.length);
|
|
copy.nodes[1].when.push({ field: "action", equals: "x" });
|
|
assert.equal(src.nodes[1].when.length, 0, "the original is untouched");
|
|
const off = { ...src, enabled: false };
|
|
assert.ok(duplicateFlow(off, "x", []).enabled, "a copy is on");
|
|
});
|
|
|
|
test("withFlow replaces by id or appends; withoutFlow drops", () => {
|
|
const a = { ...flow(), id: "a" };
|
|
const b = { ...flow(), id: "b" };
|
|
assert.deepEqual(
|
|
withFlow([a], b).map((f) => f.id),
|
|
["a", "b"],
|
|
);
|
|
const renamed = { ...a, name: "renamed" };
|
|
assert.equal(withFlow([a, b], renamed)[0].name, "renamed");
|
|
assert.deepEqual(
|
|
withoutFlow([a, b], "a").map((f) => f.id),
|
|
["b"],
|
|
);
|
|
});
|
|
|
|
test("isDirty compares the draft with what was read", () => {
|
|
const view = {
|
|
revision: 3,
|
|
disabled_builtins: ["x"],
|
|
builtins: [],
|
|
flows: [flow()],
|
|
};
|
|
assert.equal(isDirty(view, [flow()], ["x"]), false);
|
|
assert.equal(isDirty(view, [], ["x"]), true);
|
|
assert.equal(isDirty(view, [flow()], []), true);
|
|
});
|
|
|
|
test("saveRequest sends the whole document at the revision read", () => {
|
|
const view = { revision: 7, disabled_builtins: [], builtins: [], flows: [] };
|
|
const req = saveRequest(view, [flow()], ["b1"]);
|
|
assert.equal(req.method, "PUT");
|
|
const body = JSON.parse(req.body);
|
|
assert.equal(body.revision, 7);
|
|
assert.deepEqual(body.disabled_builtins, ["b1"]);
|
|
assert.equal(body.flows[0].id, "f");
|
|
assert.equal("builtins" in body, false, "built-ins are never sent");
|
|
});
|
|
|
|
test("saveOutcome tells a stale revision from a refused document", () => {
|
|
assert.equal(saveOutcome(200), "saved");
|
|
assert.equal(saveOutcome(409), "conflict");
|
|
assert.equal(saveOutcome(400), "invalid");
|
|
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);
|
|
});
|