+
built-in flows
+ {view.builtins.map((flow) => {
+ const on = !view.disabled_builtins.includes(flow.id);
+ return (
+
+
+ {flow.name}
+ setBuiltin(flow.id, checked)}
+ />
+
+
+
+ );
+ })}
+
custom flows
+ {view.flows.length === 0 ?
no custom flows
: null}
+ {view.flows.map((flow) => (
+
+
+ {flow.name}
+ {flow.enabled ? "on" : "off"}
+
+
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx
index 77a989aa..16076647 100644
--- a/frontend/packages/swarm-ui/src/shell/Shell.tsx
+++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx
@@ -56,6 +56,7 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
{ href: "/schedules", label: "schedules", accent: "var(--cyan)" },
{ href: "/broadcast", label: "broadcast", accent: "var(--blue)" },
{ href: "/messages", label: "messages", accent: "var(--purple)" },
+ { href: "/notifications", label: "notifications", accent: "var(--yellow)" },
{ href: "/issues", label: "issues", accent: "var(--yellow)" },
{ href: "/review", label: "review", accent: "var(--amber)" },
{ href: "/builds", label: "builds", accent: "var(--green)" },
diff --git a/frontend/packages/swarm-ui/test/flow-types.test.mjs b/frontend/packages/swarm-ui/test/flow-types.test.mjs
new file mode 100644
index 00000000..0a8319fe
--- /dev/null
+++ b/frontend/packages/swarm-ui/test/flow-types.test.mjs
@@ -0,0 +1,80 @@
+// The notifications page's tree and wording helpers. Run by `npm test`.
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ describeNode,
+ treeRows,
+ withBuiltin,
+} from "../src/pages/notifications/FlowTypes.ts";
+
+const flow = {
+ id: "f",
+ name: "f",
+ enabled: true,
+ nodes: [
+ { id: "s", kind: "source", events: ["issues", "issue_assign"] },
+ { id: "m", kind: "match", when: [{ field: "action", equals: "closed" }] },
+ { id: "m2", kind: "match", when: [] },
+ {
+ id: "t",
+ kind: "todo",
+ to: { field: "todo_holders" },
+ op: "clear",
+ key: "forge:{repo}#{number}:",
+ prefix: true,
+ text: "",
+ },
+ {
+ id: "u",
+ kind: "todo",
+ to: { agent: "triage" },
+ op: "upsert",
+ key: "k",
+ prefix: false,
+ text: "look",
+ },
+ ],
+ edges: [
+ { from: "s", to: "m" },
+ { from: "m", to: "t" },
+ { from: "s", to: "m2" },
+ { from: "m2", to: "u" },
+ ],
+};
+
+test("treeRows: each subtree follows its parent, with its depth", () => {
+ assert.deepEqual(
+ treeRows(flow).map((r) => [r.node.id, r.depth]),
+ [
+ ["s", 0],
+ ["m", 1],
+ ["t", 2],
+ ["m2", 1],
+ ["u", 2],
+ ],
+ );
+});
+
+test("treeRows: a cycle ends the walk and a flow with no source is empty", () => {
+ const cyclic = { ...flow, edges: [...flow.edges, { from: "t", to: "s" }] };
+ assert.equal(treeRows(cyclic).length, 5);
+ assert.deepEqual(treeRows({ ...flow, nodes: [] }), []);
+});
+
+test("describeNode: says what each kind does", () => {
+ assert.equal(describeNode(flow.nodes[0]), "forge issues, issue_assign");
+ assert.equal(describeNode(flow.nodes[1]), "action = closed");
+ assert.equal(describeNode(flow.nodes[2]), "match anything");
+ assert.equal(
+ describeNode(flow.nodes[3]),
+ "clear todos forge:{repo}#{number}:* of {todo_holders}",
+ );
+ assert.equal(describeNode(flow.nodes[4]), "todo to triage, key k: look");
+});
+
+test("withBuiltin: switching off adds once, switching on removes", () => {
+ assert.deepEqual(withBuiltin([], "b", false), ["b"]);
+ assert.deepEqual(withBuiltin(["b"], "b", false), ["b"]);
+ assert.deepEqual(withBuiltin(["c"], "a", false), ["a", "c"]);
+ assert.deepEqual(withBuiltin(["a", "b"], "a", true), ["b"]);
+});