swarm-ui: rewire a notification flow by drag or keyboard
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).
This commit is contained in:
parent
e01770c056
commit
68e289fff5
16 changed files with 1072 additions and 133 deletions
|
|
@ -9,14 +9,18 @@ import {
|
|||
duplicateFlow,
|
||||
heightBelow,
|
||||
isDirty,
|
||||
isLeafKind,
|
||||
limitsOf,
|
||||
moveAmongSiblings,
|
||||
newFlow,
|
||||
newNodeId,
|
||||
paletteKinds,
|
||||
removeNode,
|
||||
reparent,
|
||||
replaceNode,
|
||||
saveOutcome,
|
||||
saveRequest,
|
||||
siblingPosition,
|
||||
slugId,
|
||||
subtreeIds,
|
||||
withFlow,
|
||||
|
|
@ -254,3 +258,96 @@ test("limitsOf takes the controller's caps and falls back to today's", () => {
|
|||
);
|
||||
assert.deepEqual(limitsOf(undefined), DEFAULT_LIMITS);
|
||||
});
|
||||
|
||||
const reviewer = (id) => ({ id, kind: "request_reviewer", reviewer: "argus" });
|
||||
|
||||
test("a request_reviewer ends a branch like a todo", () => {
|
||||
assert.ok(isLeafKind("todo") && isLeafKind("request_reviewer"));
|
||||
assert.ok(!isLeafKind("match") && !isLeafKind("source"));
|
||||
const f = flow();
|
||||
const r = addChild(f, "c", reviewer("r1"));
|
||||
assert.ok(r.ok);
|
||||
assert.equal(
|
||||
addChild(r.flow, "r1", todo("t2")).ok,
|
||||
false,
|
||||
"nothing under it",
|
||||
);
|
||||
assert.equal(reparent(r.flow, "b", "r1").ok, false, "no move under it");
|
||||
assert.equal(
|
||||
replaceNode(f, reviewer("a")).ok,
|
||||
false,
|
||||
"a node with children cannot become one",
|
||||
);
|
||||
assert.ok(replaceNode(f, reviewer("c")).ok, "a leaf may");
|
||||
});
|
||||
|
||||
test("a new reviewer request is blank and its id reads reviewer1", () => {
|
||||
const f = flow();
|
||||
const n = blankNode(f, "request_reviewer");
|
||||
assert.deepEqual(n, {
|
||||
id: "reviewer1",
|
||||
kind: "request_reviewer",
|
||||
reviewer: "",
|
||||
});
|
||||
const r = addChild(f, "c", n);
|
||||
assert.ok(r.ok);
|
||||
assert.equal(blankNode(r.flow, "request_reviewer").id, "reviewer2");
|
||||
});
|
||||
|
||||
test("the palette offers what the controller lists, and the old two without a schema", () => {
|
||||
const schema = (kinds) => ({
|
||||
node_kinds: kinds.map((kind) => ({
|
||||
kind,
|
||||
description: kind + "!",
|
||||
fields: [],
|
||||
})),
|
||||
});
|
||||
assert.deepEqual(
|
||||
paletteKinds(schema(["source", "match", "todo", "request_reviewer"])).map(
|
||||
(p) => p.kind,
|
||||
),
|
||||
["match", "todo", "request_reviewer"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
paletteKinds(schema(["source", "match", "todo"])).map((p) => p.kind),
|
||||
["match", "todo"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
paletteKinds(schema([])).map((p) => p.kind),
|
||||
["match", "todo"],
|
||||
);
|
||||
assert.equal(paletteKinds(schema(["match"]))[0].description, "match!");
|
||||
});
|
||||
|
||||
test("reparent refuses a move under the node's own parent", () => {
|
||||
const r = reparent(flow(), "b", "a");
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(r.reason, /already under/);
|
||||
});
|
||||
|
||||
test("siblingPosition says where a branch runs among its siblings", () => {
|
||||
const f = flow();
|
||||
assert.deepEqual(siblingPosition(f, "a"), { index: 0, count: 2 });
|
||||
assert.deepEqual(siblingPosition(f, "c"), { index: 1, count: 2 });
|
||||
assert.deepEqual(siblingPosition(f, "b"), { index: 0, count: 1 });
|
||||
assert.deepEqual(siblingPosition(f, "source"), { index: 0, count: 1 });
|
||||
});
|
||||
|
||||
test("moveAmongSiblings swaps two branches and leaves other edges alone", () => {
|
||||
const f = flow();
|
||||
const later = moveAmongSiblings(f, "a", 1);
|
||||
assert.ok(later.ok);
|
||||
assert.deepEqual(
|
||||
later.flow.edges.filter((e) => e.from === "source").map((e) => e.to),
|
||||
["c", "a"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
later.flow.edges.filter((e) => e.from !== "source"),
|
||||
f.edges.filter((e) => e.from !== "source"),
|
||||
);
|
||||
const back = moveAmongSiblings(later.flow, "a", -1);
|
||||
assert.deepEqual(back.flow.edges, f.edges);
|
||||
assert.equal(moveAmongSiblings(f, "a", -1).ok, false, "already first");
|
||||
assert.equal(moveAmongSiblings(f, "c", 1).ok, false, "already last");
|
||||
assert.equal(moveAmongSiblings(f, "source", 1).ok, false, "the source");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ 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";
|
||||
|
|
@ -120,3 +122,41 @@ test("an empty flow has an empty layout", () => {
|
|||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
describeNode,
|
||||
ellipsize,
|
||||
kindValues,
|
||||
ordinal,
|
||||
treeRows,
|
||||
withBuiltin,
|
||||
} from "../src/pages/notifications/FlowTypes.ts";
|
||||
|
|
@ -105,3 +106,31 @@ test("kindValues lists the values a schema allows for a field", () => {
|
|||
assert.deepEqual(kindValues(schema, "match", "when"), []);
|
||||
assert.deepEqual(kindValues({ node_kinds: [] }, "todo", "op"), []);
|
||||
});
|
||||
|
||||
test("ordinal reads 1st, 2nd, 3rd, 4th and the teens as th", () => {
|
||||
assert.deepEqual([1, 2, 3, 4, 11, 12, 13, 21, 22, 101, 111].map(ordinal), [
|
||||
"1st",
|
||||
"2nd",
|
||||
"3rd",
|
||||
"4th",
|
||||
"11th",
|
||||
"12th",
|
||||
"13th",
|
||||
"21st",
|
||||
"22nd",
|
||||
"101st",
|
||||
"111th",
|
||||
]);
|
||||
});
|
||||
|
||||
test("describeNode says what a reviewer request and an unknown kind are", () => {
|
||||
assert.equal(
|
||||
describeNode({ id: "r", kind: "request_reviewer", reviewer: "argus" }),
|
||||
"request a review from argus",
|
||||
);
|
||||
assert.match(
|
||||
describeNode({ id: "r", kind: "request_reviewer", reviewer: "" }),
|
||||
/nobody yet/,
|
||||
);
|
||||
assert.match(describeNode({ id: "x", kind: "future" }), /future/);
|
||||
});
|
||||
|
|
|
|||
28
frontend/packages/swarm-ui/test/nav-guard.test.mjs
Normal file
28
frontend/packages/swarm-ui/test/nav-guard.test.mjs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// The guard a page holding unsaved work puts in front of in-app navigation.
|
||||
// Run by `npm test`.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { mayNavigate, setNavGuard } from "../src/shell/navGuard.ts";
|
||||
|
||||
test("with no guard every move goes ahead", () => {
|
||||
setNavGuard(null);
|
||||
assert.equal(mayNavigate("/alerts"), true);
|
||||
});
|
||||
|
||||
test("a guard that says no stops the move and is told where it was headed", () => {
|
||||
const asked = [];
|
||||
setNavGuard((to) => {
|
||||
asked.push(to);
|
||||
return false;
|
||||
});
|
||||
assert.equal(mayNavigate("/alerts"), false);
|
||||
assert.deepEqual(asked, ["/alerts"]);
|
||||
setNavGuard(null);
|
||||
assert.equal(mayNavigate("/alerts"), true, "removing it lets moves through");
|
||||
});
|
||||
|
||||
test("a guard that says yes lets the move through", () => {
|
||||
setNavGuard(() => true);
|
||||
assert.equal(mayNavigate("/jobs"), true);
|
||||
setNavGuard(null);
|
||||
});
|
||||
Loading…
Reference in a new issue