Watch
0
0
Fork
You've already forked hyperhive
0

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:
iris 2026-10-24 14:49:48 +02:00 • committed by mara
commit 68e289fff5
16 changed files with 1072 additions and 133 deletions

View file

@ -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");
});