discourse/plugins/discourse-workflows/test/javascripts/unit/lib/workflows-graph-utils-test.js
Joffrey JAFFEUX df73c14cee
FEATURE: simplify and enable merge node (#40805)
The merged node was in the codebase but it was not enabled. This commit
removes a large part of the complexity of this node and simplifies it to
the append only operation for now. On top of this it now automatically
computes the different inputs without having to create one socket per
input.

The new code:
- Allows multiple inputs to be represented as one socket in the workflow
editor.
- Ensures we don't lose data when waiting in a workflow. Eg: you trigger
a workflow, you compute a first node and then you have a wait node
before other computes. The moment you wait the workflow will be paused
and the result of the first node which was in memory was lost, changes
in the executor ensures we save this partial state.
- Handles loop over items node as a special case

<img width="965" height="495" alt="Screenshot 2026-06-11 at 21 27 22"
src="https://github.com/user-attachments/assets/288cf407-7a03-4f1d-82a1-e78325ae26b9"
/>
2026-06-12 09:59:58 +02:00

531 lines
14 KiB
JavaScript
Vendored

import { module, test } from "qunit";
import {
normalizeConnectionsForNodes,
normalizeNodeConfiguration,
removeNodesFromGraph,
} from "discourse/plugins/discourse-workflows/admin/components/workflows/editor/graph-utils";
function sortByKey(items, keyFn) {
return [...items].sort((a, b) => keyFn(a).localeCompare(keyFn(b)));
}
function normalizeGraph(graph) {
return {
nodes: sortByKey(graph.nodes, (node) => node.clientId).map((node) => ({
clientId: node.clientId,
type: node.type,
})),
connections: sortByKey(
graph.connections,
(connection) =>
`${connection.sourceClientId}::${connection.sourceOutput || "main"}::${connection.targetClientId}`
).map((connection) => ({
sourceClientId: connection.sourceClientId,
sourceOutput: connection.sourceOutput || "main",
targetClientId: connection.targetClientId,
})),
};
}
module("Unit | Utility | workflows graph utils", function () {
test("reconnects a regular node with one incoming and one outgoing edge", function (assert) {
const graph = removeNodesFromGraph(
[
{ clientId: "trigger", type: "trigger:manual" },
{ clientId: "action", type: "action:set_fields" },
{ clientId: "done", type: "action:topic_tags" },
],
[
{
sourceClientId: "trigger",
sourceOutput: "main",
targetClientId: "action",
},
{
sourceClientId: "action",
sourceOutput: "main",
targetClientId: "done",
},
],
["action"]
);
assert.deepEqual(normalizeGraph(graph), {
nodes: [
{ clientId: "done", type: "action:topic_tags" },
{ clientId: "trigger", type: "trigger:manual" },
],
connections: [
{
sourceClientId: "trigger",
sourceOutput: "main",
targetClientId: "done",
},
],
});
});
test("removing a loop node keeps the former body node standalone and reconnects the done path", function (assert) {
const graph = removeNodesFromGraph(
[
{ clientId: "trigger", type: "trigger:manual" },
{ clientId: "loop", type: "flow:loop_over_items" },
{ clientId: "body", type: "action:set_fields" },
{ clientId: "done", type: "action:topic_tags" },
],
[
{
sourceClientId: "trigger",
sourceOutput: "main",
targetClientId: "loop",
},
{
sourceClientId: "loop",
sourceOutput: "loop",
targetClientId: "loop",
},
{
sourceClientId: "loop",
sourceOutput: "loop",
targetClientId: "body",
},
{
sourceClientId: "body",
sourceOutput: "main",
targetClientId: "loop",
},
{
sourceClientId: "loop",
sourceOutput: "done",
targetClientId: "done",
},
],
["loop"]
);
assert.deepEqual(normalizeGraph(graph), {
nodes: [
{ clientId: "body", type: "action:set_fields" },
{ clientId: "done", type: "action:topic_tags" },
{ clientId: "trigger", type: "trigger:manual" },
],
connections: [
{
sourceClientId: "trigger",
sourceOutput: "main",
targetClientId: "done",
},
],
});
});
test("removing a loop node handles index-only loop connections", function (assert) {
const graph = removeNodesFromGraph(
[
{ clientId: "trigger", type: "trigger:manual" },
{ clientId: "loop", type: "flow:loop_over_items" },
{ clientId: "body", type: "action:set_fields" },
{ clientId: "done", type: "action:topic_tags" },
],
[
{
sourceClientId: "trigger",
sourceOutputIndex: 0,
targetClientId: "loop",
targetInputIndex: 0,
},
{
sourceClientId: "loop",
sourceOutputIndex: 1,
targetClientId: "body",
targetInputIndex: 0,
},
{
sourceClientId: "body",
sourceOutputIndex: 0,
targetClientId: "loop",
targetInputIndex: 0,
},
{
sourceClientId: "loop",
sourceOutputIndex: 0,
targetClientId: "done",
targetInputIndex: 0,
},
],
["loop"]
);
assert.deepEqual(normalizeGraph(graph), {
nodes: [
{ clientId: "body", type: "action:set_fields" },
{ clientId: "done", type: "action:topic_tags" },
{ clientId: "trigger", type: "trigger:manual" },
],
connections: [
{
sourceClientId: "trigger",
sourceOutput: "main",
targetClientId: "done",
},
],
});
});
test("removing the only loop body preserves the loop self-connection", function (assert) {
const graph = removeNodesFromGraph(
[
{ clientId: "trigger", type: "trigger:manual" },
{ clientId: "loop", type: "flow:loop_over_items" },
{ clientId: "body", type: "action:set_fields" },
{ clientId: "done", type: "action:topic_tags" },
],
[
{
sourceClientId: "trigger",
sourceOutput: "main",
targetClientId: "loop",
},
{
sourceClientId: "loop",
sourceOutput: "loop",
targetClientId: "loop",
},
{
sourceClientId: "loop",
sourceOutput: "loop",
targetClientId: "body",
},
{
sourceClientId: "body",
sourceOutput: "main",
targetClientId: "loop",
},
{
sourceClientId: "loop",
sourceOutput: "done",
targetClientId: "done",
},
],
["body"]
);
assert.deepEqual(normalizeGraph(graph), {
nodes: [
{ clientId: "done", type: "action:topic_tags" },
{ clientId: "loop", type: "flow:loop_over_items" },
{ clientId: "trigger", type: "trigger:manual" },
],
connections: [
{
sourceClientId: "loop",
sourceOutput: "done",
targetClientId: "done",
},
{
sourceClientId: "loop",
sourceOutput: "loop",
targetClientId: "loop",
},
{
sourceClientId: "trigger",
sourceOutput: "main",
targetClientId: "loop",
},
],
});
});
test("removing a loop node preserves the internal body subgraph", function (assert) {
const graph = removeNodesFromGraph(
[
{ clientId: "trigger", type: "trigger:manual" },
{ clientId: "loop", type: "flow:loop_over_items" },
{ clientId: "branch", type: "condition:if" },
{ clientId: "true_body", type: "action:set_fields" },
{ clientId: "false_body", type: "action:topic_tags" },
{ clientId: "done", type: "action:http_request" },
],
[
{
sourceClientId: "trigger",
sourceOutput: "main",
targetClientId: "loop",
},
{
sourceClientId: "loop",
sourceOutput: "loop",
targetClientId: "branch",
},
{
sourceClientId: "branch",
sourceOutput: "true",
targetClientId: "true_body",
},
{
sourceClientId: "branch",
sourceOutput: "false",
targetClientId: "false_body",
},
{
sourceClientId: "true_body",
sourceOutput: "main",
targetClientId: "loop",
},
{
sourceClientId: "false_body",
sourceOutput: "main",
targetClientId: "loop",
},
{
sourceClientId: "loop",
sourceOutput: "done",
targetClientId: "done",
},
],
["loop"]
);
assert.deepEqual(normalizeGraph(graph), {
nodes: [
{ clientId: "branch", type: "condition:if" },
{ clientId: "done", type: "action:http_request" },
{ clientId: "false_body", type: "action:topic_tags" },
{ clientId: "trigger", type: "trigger:manual" },
{ clientId: "true_body", type: "action:set_fields" },
],
connections: [
{
sourceClientId: "branch",
sourceOutput: "false",
targetClientId: "false_body",
},
{
sourceClientId: "branch",
sourceOutput: "true",
targetClientId: "true_body",
},
{
sourceClientId: "trigger",
sourceOutput: "main",
targetClientId: "done",
},
],
});
});
test("bulk loop deletion does not reconnect surviving body nodes to the done path", function (assert) {
const graph = removeNodesFromGraph(
[
{ clientId: "trigger", type: "trigger:manual" },
{ clientId: "loop", type: "flow:loop_over_items" },
{ clientId: "body_1", type: "action:set_fields" },
{ clientId: "body_2", type: "action:topic_tags" },
{ clientId: "done", type: "action:http_request" },
],
[
{
sourceClientId: "trigger",
sourceOutput: "main",
targetClientId: "loop",
},
{
sourceClientId: "loop",
sourceOutput: "loop",
targetClientId: "body_1",
},
{
sourceClientId: "body_1",
sourceOutput: "main",
targetClientId: "body_2",
},
{
sourceClientId: "body_2",
sourceOutput: "main",
targetClientId: "loop",
},
{
sourceClientId: "loop",
sourceOutput: "done",
targetClientId: "done",
},
],
["loop", "body_1"]
);
assert.deepEqual(normalizeGraph(graph), {
nodes: [
{ clientId: "body_2", type: "action:topic_tags" },
{ clientId: "done", type: "action:http_request" },
{ clientId: "trigger", type: "trigger:manual" },
],
connections: [
{
sourceClientId: "trigger",
sourceOutput: "main",
targetClientId: "done",
},
],
});
});
test("normalizes indexed target inputs as hidden indexes", function (assert) {
const indexedInputNodeType = {
identifier: "flow:indexed_input",
inputs: [
{
key: "main",
multiple: true,
},
],
};
const nodes = [
{
clientId: "indexed",
type: "flow:indexed_input",
},
];
const nodeTypeForNode = () => indexedInputNodeType;
const connections = [
{
sourceClientId: "a",
sourceOutput: "main",
targetClientId: "indexed",
targetInput: "main",
},
{
sourceClientId: "b",
sourceOutput: "main",
targetClientId: "indexed",
targetInput: "main",
},
{
sourceClientId: "c",
sourceOutput: "main",
targetClientId: "indexed",
targetInput: "main",
},
];
assert.deepEqual(
normalizeConnectionsForNodes(connections, nodes, nodeTypeForNode).map(
(connection) => ({
targetInput: connection.targetInput,
targetInputIndex: connection.targetInputIndex,
})
),
[
{ targetInput: "main", targetInputIndex: 0 },
{ targetInput: "main", targetInputIndex: 1 },
{ targetInput: "main", targetInputIndex: 2 },
]
);
assert.deepEqual(
normalizeConnectionsForNodes(
[
{
sourceClientId: "a",
sourceOutput: "main",
targetClientId: "indexed",
targetInput: "main",
},
{
sourceClientId: "b",
sourceOutput: "main",
targetClientId: "indexed",
targetInput: "main",
},
],
nodes,
nodeTypeForNode
).map((connection) => ({
targetInput: connection.targetInput,
targetInputIndex: connection.targetInputIndex,
})),
[
{ targetInput: "main", targetInputIndex: 0 },
{ targetInput: "main", targetInputIndex: 1 },
]
);
assert.deepEqual(
normalizeConnectionsForNodes(
[
{
sourceClientId: "a",
sourceOutput: "main",
targetClientId: "indexed",
targetInputIndex: 1,
},
{
sourceClientId: "b",
sourceOutput: "main",
targetClientId: "indexed",
targetInput: "main",
},
],
nodes,
nodeTypeForNode
).map((connection) => ({
targetInput: connection.targetInput,
targetInputIndex: connection.targetInputIndex,
})),
[
{ targetInput: "main", targetInputIndex: 1 },
{ targetInput: "main", targetInputIndex: 0 },
]
);
});
test("normalizes configuration from node type metadata", function (assert) {
assert.deepEqual(
normalizeNodeConfiguration(
{
type: "flow:no_configuration",
configuration: {
mode: "append",
notes: "Keep this visible on the canvas",
notesInFlow: true,
},
},
{
identifier: "flow:no_configuration",
properties: {},
credentials: [],
}
),
{
type: "flow:no_configuration",
configuration: {
notes: "Keep this visible on the canvas",
notesInFlow: true,
},
}
);
assert.deepEqual(
normalizeNodeConfiguration(
{
type: "action:configured",
configuration: {
operation: "list",
},
},
{
identifier: "action:configured",
properties: {
operation: {
type: "options",
},
},
}
),
{
type: "action:configured",
configuration: {
operation: "list",
},
}
);
});
});