mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
refactor(editor): Add per-node-id projections and remove workflowState store (no-changelog) (#31001)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
d461471436
commit
fad3ee23f6
@@ -25,6 +25,9 @@ import {
|
||||
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
|
||||
import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
|
||||
|
||||
const TEST_WORKFLOW_ID = 'test-workflow-id';
|
||||
const TEST_DOCUMENT_ID = createWorkflowDocumentId(TEST_WORKFLOW_ID);
|
||||
|
||||
describe(useNodeDirtiness, () => {
|
||||
let nodeTypeStore: ReturnType<typeof useNodeTypesStore>;
|
||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||
@@ -39,8 +42,6 @@ describe(useNodeDirtiness, () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const TEST_WORKFLOW_ID = 'test-workflow-id';
|
||||
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
nodeTypeStore = useNodeTypesStore();
|
||||
@@ -48,9 +49,7 @@ describe(useNodeDirtiness, () => {
|
||||
workflowsStore.setWorkflowId(TEST_WORKFLOW_ID);
|
||||
historyHelper = useHistoryHelper({} as RouteLocationNormalizedLoaded);
|
||||
|
||||
workflowDocumentStore = useWorkflowDocumentStore(
|
||||
createWorkflowDocumentId(TEST_WORKFLOW_ID),
|
||||
);
|
||||
workflowDocumentStore = useWorkflowDocumentStore(TEST_DOCUMENT_ID);
|
||||
provide(WorkflowDocumentStoreKey, shallowRef(workflowDocumentStore));
|
||||
|
||||
canvasOperations = useCanvasOperations();
|
||||
@@ -77,7 +76,7 @@ describe(useNodeDirtiness, () => {
|
||||
it('should be an empty object if no change has been made to the workflow', () => {
|
||||
setupTestWorkflow('a🚨✅, b✅, c✅');
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({});
|
||||
});
|
||||
|
||||
describe('injecting a node', () => {
|
||||
@@ -95,7 +94,7 @@ describe(useNodeDirtiness, () => {
|
||||
|
||||
await canvasOperations.addNodes([createTestNode({ name: 'c' })], { trackHistory: true });
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
|
||||
b: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
|
||||
});
|
||||
});
|
||||
@@ -111,7 +110,7 @@ describe(useNodeDirtiness, () => {
|
||||
trackHistory: true,
|
||||
}); // 'a' becomes new parent of 'c'
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
|
||||
c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
|
||||
});
|
||||
});
|
||||
@@ -125,7 +124,7 @@ describe(useNodeDirtiness, () => {
|
||||
trackHistory: true,
|
||||
}); // 'b' has no parent node anymore
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
|
||||
b: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
|
||||
});
|
||||
});
|
||||
@@ -137,7 +136,7 @@ describe(useNodeDirtiness, () => {
|
||||
|
||||
canvasOperations.setNodeParameters(workflowDocumentStore.nodesByName.b.id, { foo: 1 });
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
|
||||
b: CanvasNodeDirtiness.PARAMETERS_UPDATED,
|
||||
});
|
||||
});
|
||||
@@ -178,7 +177,7 @@ describe(useNodeDirtiness, () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({});
|
||||
});
|
||||
|
||||
it("should not update dirtiness if the node hasn't run yet", () => {
|
||||
@@ -186,7 +185,7 @@ describe(useNodeDirtiness, () => {
|
||||
|
||||
canvasOperations.setNodeParameters(workflowDocumentStore.nodesByName.b.id, { foo: 1 });
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({});
|
||||
});
|
||||
|
||||
it('should not update dirtiness when the notes field is updated', () => {
|
||||
@@ -198,7 +197,7 @@ describe(useNodeDirtiness, () => {
|
||||
value: 'test',
|
||||
});
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -216,7 +215,7 @@ describe(useNodeDirtiness, () => {
|
||||
{ trackHistory: true },
|
||||
);
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
|
||||
c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
|
||||
});
|
||||
});
|
||||
@@ -236,7 +235,7 @@ describe(useNodeDirtiness, () => {
|
||||
{ trackHistory: true },
|
||||
);
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -248,7 +247,7 @@ describe(useNodeDirtiness, () => {
|
||||
trackHistory: true,
|
||||
});
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
|
||||
c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
|
||||
});
|
||||
});
|
||||
@@ -260,7 +259,7 @@ describe(useNodeDirtiness, () => {
|
||||
trackHistory: true,
|
||||
});
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({});
|
||||
});
|
||||
|
||||
it('should restore original dirtiness after undoing a command', async () => {
|
||||
@@ -270,13 +269,13 @@ describe(useNodeDirtiness, () => {
|
||||
trackHistory: true,
|
||||
});
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
|
||||
c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
|
||||
});
|
||||
|
||||
await historyHelper.undo();
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,7 +291,7 @@ describe(useNodeDirtiness, () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({});
|
||||
});
|
||||
|
||||
it('should update dirtiness when pinned data is removed from a node with run data', async () => {
|
||||
@@ -306,7 +305,7 @@ describe(useNodeDirtiness, () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
|
||||
b: CanvasNodeDirtiness.PINNED_DATA_UPDATED,
|
||||
});
|
||||
});
|
||||
@@ -320,7 +319,7 @@ describe(useNodeDirtiness, () => {
|
||||
);
|
||||
workflowDocumentStore.touchPinnedDataLastUpdatedAt('b');
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
|
||||
// 'd' is not marked as pinned-data-updated because it has no run data.
|
||||
c: CanvasNodeDirtiness.PINNED_DATA_UPDATED,
|
||||
e: CanvasNodeDirtiness.PINNED_DATA_UPDATED,
|
||||
@@ -334,7 +333,7 @@ describe(useNodeDirtiness, () => {
|
||||
|
||||
canvasOperations.setNodeParameters(workflowDocumentStore.nodesByName.e.id, { foo: 1 });
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
|
||||
// 'e' itself is not marked as parameters-updated, because it has no run data.
|
||||
f: CanvasNodeDirtiness.UPSTREAM_DIRTY,
|
||||
b: CanvasNodeDirtiness.UPSTREAM_DIRTY,
|
||||
@@ -348,7 +347,7 @@ describe(useNodeDirtiness, () => {
|
||||
trackHistory: true,
|
||||
});
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
|
||||
b: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
|
||||
});
|
||||
});
|
||||
@@ -360,7 +359,7 @@ describe(useNodeDirtiness, () => {
|
||||
trackHistory: true,
|
||||
});
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
|
||||
b: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
|
||||
});
|
||||
});
|
||||
@@ -372,7 +371,7 @@ describe(useNodeDirtiness, () => {
|
||||
|
||||
canvasOperations.setNodeParameters(workflowDocumentStore.nodesByName.e.id, { foo: 1 });
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
|
||||
c: CanvasNodeDirtiness.UPSTREAM_DIRTY,
|
||||
e: CanvasNodeDirtiness.PARAMETERS_UPDATED,
|
||||
});
|
||||
@@ -383,7 +382,7 @@ describe(useNodeDirtiness, () => {
|
||||
|
||||
canvasOperations.setNodeParameters(workflowDocumentStore.nodesByName.c.id, { foo: 1 });
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
|
||||
c: CanvasNodeDirtiness.PARAMETERS_UPDATED,
|
||||
});
|
||||
});
|
||||
@@ -399,13 +398,13 @@ describe(useNodeDirtiness, () => {
|
||||
trackHistory: true,
|
||||
}); // 'a' becomes new parent of 'c'
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
|
||||
c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
|
||||
});
|
||||
|
||||
await canvasOperations.renameNode('c', 'd', { trackHistory: true });
|
||||
|
||||
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
|
||||
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
|
||||
d: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,14 +9,17 @@ import {
|
||||
} from '@/app/models/history';
|
||||
import { useHistoryStore } from '@/app/stores/history.store';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
|
||||
import {
|
||||
useWorkflowDocumentStore,
|
||||
type WorkflowDocumentId,
|
||||
} from '@/app/stores/workflowDocument.store';
|
||||
import {
|
||||
CanvasNodeDirtiness,
|
||||
type CanvasNodeDirtinessType,
|
||||
} from '@/features/workflows/canvas/canvas.types';
|
||||
import type { INodeConnections, NodeConnectionType } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import { computed } from 'vue';
|
||||
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
|
||||
|
||||
/**
|
||||
* Does the command make the given node dirty?
|
||||
@@ -116,13 +119,29 @@ function findLoop(
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the subgraph that is affected by changes made after the last (partial) execution
|
||||
* Determines the subgraph that is affected by changes made after the last (partial) execution.
|
||||
*
|
||||
* Takes the workflow document id (a value, ref, or getter) explicitly so it
|
||||
* can resolve the document store directly via `useWorkflowDocumentStore(id)`
|
||||
* instead of relying on `inject()`, which only resolves inside the
|
||||
* `WorkflowLayout` tree. This makes the composable safe to call from
|
||||
* off-layout contexts (e.g. the workflow-diff modal's `watchEffect`) without
|
||||
* the try/catch fallback `useWorkflowDocumentRenderData` previously needed.
|
||||
*
|
||||
* Passing a reactive id lets callers bound to a swappable injected document
|
||||
* store (e.g. push handlers that replace the current document while the host
|
||||
* component stays mounted) re-resolve the store reactively. The internal
|
||||
* computeds are built once and recompute against the active document, so the
|
||||
* dirtiness tracks the live document without callers having to recreate the
|
||||
* composable inside their own `computed`.
|
||||
*/
|
||||
export function useNodeDirtiness() {
|
||||
export function useNodeDirtiness(workflowDocumentId: MaybeRefOrGetter<WorkflowDocumentId>) {
|
||||
const historyStore = useHistoryStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
||||
const workflowDocumentStore = injectWorkflowDocumentStore();
|
||||
const workflowDocumentStore = computed(() =>
|
||||
useWorkflowDocumentStore(toValue(workflowDocumentId)),
|
||||
);
|
||||
|
||||
function getIncomingConnections(nodeName: string): INodeConnections {
|
||||
return workflowDocumentStore.value.incomingConnectionsByNodeName(nodeName);
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { ref } from 'vue';
|
||||
import { useHistoryStore } from '@/app/stores/history.store';
|
||||
import {
|
||||
CUSTOM_API_CALL_KEY,
|
||||
EnterpriseEditionFeature,
|
||||
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
||||
} from '@/app/constants';
|
||||
import { CUSTOM_API_CALL_KEY, EnterpriseEditionFeature } from '@/app/constants';
|
||||
|
||||
import {
|
||||
NodeHelpers,
|
||||
@@ -25,7 +21,6 @@ import type {
|
||||
IRunData,
|
||||
IBinaryKeyData,
|
||||
INode,
|
||||
INodePropertyOptions,
|
||||
INodeCredentialsDetails,
|
||||
INodeParameters,
|
||||
INodeTypeNameVersion,
|
||||
@@ -43,7 +38,8 @@ import type { WorkflowObjectAccessors } from '@/app/types/workflow';
|
||||
|
||||
import { isString } from '@/app/utils/typeGuards';
|
||||
import { isObject } from '@/app/utils/objectUtils';
|
||||
import { hasProxyAuth } from '@/app/utils/nodeTypesUtils';
|
||||
import { getNodeSubtitle, hasProxyAuth } from '@/app/utils/nodeTypesUtils';
|
||||
import { assignNodeId } from '@/app/utils/nodes/nodeTransforms';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
@@ -798,63 +794,6 @@ export function useNodeHelpers() {
|
||||
}
|
||||
}
|
||||
|
||||
function getNodeSubtitle(
|
||||
data: INode,
|
||||
nodeType: INodeTypeDescription,
|
||||
workflow: WorkflowObjectAccessors,
|
||||
): string | undefined {
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (data.notesInFlow) {
|
||||
return data.notes;
|
||||
}
|
||||
|
||||
if (nodeType?.subtitle !== undefined) {
|
||||
try {
|
||||
return workflow.expression.getSimpleParameterValue(
|
||||
data,
|
||||
nodeType.subtitle,
|
||||
'internal',
|
||||
{},
|
||||
undefined,
|
||||
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
||||
) as string | undefined;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.parameters.operation !== undefined) {
|
||||
const operation = data.parameters.operation as string;
|
||||
if (nodeType === null) {
|
||||
return operation;
|
||||
}
|
||||
|
||||
const operationData = nodeType.properties.find((property: INodeProperties) => {
|
||||
return property.name === 'operation';
|
||||
});
|
||||
if (operationData === undefined) {
|
||||
return operation;
|
||||
}
|
||||
|
||||
if (operationData.options === undefined) {
|
||||
return operation;
|
||||
}
|
||||
|
||||
const optionData = operationData.options.find((option) => {
|
||||
return (option as INodePropertyOptions).value === data.parameters.operation;
|
||||
});
|
||||
if (optionData === undefined) {
|
||||
return operation;
|
||||
}
|
||||
|
||||
return optionData.name;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function matchCredentials(node: INodeUi) {
|
||||
if (!node.credentials) {
|
||||
return;
|
||||
@@ -935,12 +874,6 @@ export function useNodeHelpers() {
|
||||
}
|
||||
}
|
||||
|
||||
function assignNodeId(node: INodeUi) {
|
||||
const id = window.crypto.randomUUID();
|
||||
node.id = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
function assignWebhookId(node: INodeUi) {
|
||||
const id = window.crypto.randomUUID();
|
||||
node.webhookId = id;
|
||||
|
||||
@@ -103,7 +103,7 @@ export function useRunWorkflow(useRunWorkflowOpts: {
|
||||
router: useRunWorkflowOpts.router,
|
||||
});
|
||||
const executionsStore = useExecutionsStore();
|
||||
const { dirtinessByName } = useNodeDirtiness();
|
||||
const { dirtinessByName } = useNodeDirtiness(() => workflowDocumentStore.value.documentId);
|
||||
const { startChat } = useCanvasOperations();
|
||||
const chatStore = useChat();
|
||||
|
||||
|
||||
@@ -70,8 +70,10 @@ vi.mock('@/app/composables/useCanvasOperations', () => ({
|
||||
useCanvasOperations: vi.fn(() => mockCanvasOperations),
|
||||
}));
|
||||
|
||||
// Mock nodeTypesUtils
|
||||
vi.mock('@/app/utils/nodeTypesUtils', () => ({
|
||||
// Mock nodeTypesUtils — keep real exports (e.g. getNodeSubtitle, used by
|
||||
// useNodeHelpers) and override only the auth helpers under test.
|
||||
vi.mock('@/app/utils/nodeTypesUtils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/app/utils/nodeTypesUtils')>()),
|
||||
getMainAuthField: vi.fn(),
|
||||
getAuthTypeForNodeCredential: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { setActivePinia, createPinia, getActivePinia } from 'pinia';
|
||||
import { nextTick } from 'vue';
|
||||
import { NodeConnectionTypes, SEND_AND_WAIT_OPERATION, WAIT_INDEFINITELY } from 'n8n-workflow';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import {
|
||||
useExecutionDataStore,
|
||||
createExecutionDataId,
|
||||
getExecutionDataStoreId,
|
||||
disposeExecutionDataStore,
|
||||
} from '@/app/stores/executionData.store';
|
||||
import { createTestWorkflowExecutionResponse } from '@/__tests__/mocks';
|
||||
import {
|
||||
CANVAS_EXECUTION_DATA_THROTTLE_DURATION,
|
||||
FORM_NODE_TYPE,
|
||||
WAIT_NODE_TYPE,
|
||||
} from '@/app/constants';
|
||||
import {
|
||||
createTestNode,
|
||||
createTestWorkflow,
|
||||
createTestWorkflowExecutionResponse,
|
||||
} from '@/__tests__/mocks';
|
||||
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
|
||||
|
||||
function createTestExecution(overrides: Partial<IExecutionResponse> = {}): IExecutionResponse {
|
||||
@@ -774,6 +786,447 @@ describe('executionData.store', () => {
|
||||
expect(store.executionIssuesByNodeName.get('NodeA')?.value).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Per-node-id projections (moved here from useCanvasMapping).
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Builds an execution whose embedded `workflowData.nodes` snapshot provides
|
||||
* the id↔name mapping the by-id projections key off, with `runData` keyed by
|
||||
* node name as the server produces it.
|
||||
*/
|
||||
function setExecutionWithSnapshot(
|
||||
store: ReturnType<typeof useExecutionDataStore>,
|
||||
{
|
||||
nodes,
|
||||
runData = {},
|
||||
lastNodeExecuted = '',
|
||||
...executionOverrides
|
||||
}: {
|
||||
nodes: INode[];
|
||||
runData?: Record<string, Array<Record<string, unknown>>>;
|
||||
lastNodeExecuted?: string;
|
||||
} & Partial<IExecutionResponse>,
|
||||
) {
|
||||
store.setExecution(
|
||||
createTestExecution({
|
||||
workflowData: createTestWorkflow({ nodes }),
|
||||
data: {
|
||||
resultData: { runData, lastNodeExecuted },
|
||||
} as never,
|
||||
...executionOverrides,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('executionStatusByNodeId', () => {
|
||||
const node = createTestNode({ id: 'node-1', name: 'Node 1' });
|
||||
|
||||
it('returns the last execution status when not canceled', () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
|
||||
setExecutionWithSnapshot(store, {
|
||||
nodes: [node],
|
||||
runData: {
|
||||
'Node 1': [{ executionStatus: 'success' }, { executionStatus: 'error' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.executionStatusByNodeId.get('node-1')?.value).toBe('error');
|
||||
});
|
||||
|
||||
it('returns the second-to-last status when the last task is canceled and multiple tasks exist', () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
|
||||
setExecutionWithSnapshot(store, {
|
||||
nodes: [node],
|
||||
runData: {
|
||||
'Node 1': [{ executionStatus: 'success' }, { executionStatus: 'canceled' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.executionStatusByNodeId.get('node-1')?.value).toBe('success');
|
||||
});
|
||||
|
||||
it('returns canceled when the only task is canceled', () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
|
||||
setExecutionWithSnapshot(store, {
|
||||
nodes: [node],
|
||||
runData: {
|
||||
'Node 1': [{ executionStatus: 'canceled' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.executionStatusByNodeId.get('node-1')?.value).toBe('canceled');
|
||||
});
|
||||
|
||||
it('returns new when the node has no tasks', () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
|
||||
setExecutionWithSnapshot(store, { nodes: [node], runData: {} });
|
||||
|
||||
expect(store.executionStatusByNodeId.get('node-1')?.value).toBe('new');
|
||||
});
|
||||
|
||||
it('has no entry for nodes absent from the workflow snapshot', () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
|
||||
setExecutionWithSnapshot(store, { nodes: [node] });
|
||||
|
||||
expect(store.executionStatusByNodeId.has('unknown-node')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executionWaitingByNodeId', () => {
|
||||
const waitTill = new Date('2025-05-05T12:00:00.000Z');
|
||||
|
||||
function setWaitingExecution(
|
||||
store: ReturnType<typeof useExecutionDataStore>,
|
||||
node: INode,
|
||||
overrides: Partial<IExecutionResponse> = {},
|
||||
) {
|
||||
setExecutionWithSnapshot(store, {
|
||||
nodes: [node],
|
||||
lastNodeExecuted: node.name,
|
||||
finished: false,
|
||||
...({ waitTill } as Partial<IExecutionResponse>),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it('returns the webhook message for a wait node resuming on webhook', () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
const node = createTestNode({
|
||||
id: 'wait-1',
|
||||
name: 'Wait',
|
||||
type: WAIT_NODE_TYPE,
|
||||
parameters: { resume: 'webhook' },
|
||||
});
|
||||
|
||||
setWaitingExecution(store, node);
|
||||
|
||||
expect(store.executionWaitingByNodeId.get('wait-1')?.value).toBe(
|
||||
'The node is waiting for an incoming webhook call',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the form message for a wait node resuming on form submission', () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
const node = createTestNode({
|
||||
id: 'wait-1',
|
||||
name: 'Wait',
|
||||
type: WAIT_NODE_TYPE,
|
||||
parameters: { resume: 'form' },
|
||||
});
|
||||
|
||||
setWaitingExecution(store, node);
|
||||
|
||||
expect(store.executionWaitingByNodeId.get('wait-1')?.value).toBe(
|
||||
'The node is waiting for a form submission',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the user-input message for sendAndWait operations', () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
const node = createTestNode({
|
||||
id: 'send-1',
|
||||
name: 'Send',
|
||||
parameters: { operation: SEND_AND_WAIT_OPERATION },
|
||||
});
|
||||
|
||||
setWaitingExecution(store, node);
|
||||
|
||||
expect(store.executionWaitingByNodeId.get('send-1')?.value).toBe(
|
||||
'The node is waiting for user input',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the form message for form nodes', () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
const node = createTestNode({ id: 'form-1', name: 'Form', type: FORM_NODE_TYPE });
|
||||
|
||||
setWaitingExecution(store, node);
|
||||
|
||||
expect(store.executionWaitingByNodeId.get('form-1')?.value).toBe(
|
||||
'The node is waiting for a form submission',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the indefinite message when waiting indefinitely', () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
const node = createTestNode({ id: 'wait-1', name: 'Wait', type: WAIT_NODE_TYPE });
|
||||
|
||||
setWaitingExecution(store, node, {
|
||||
...({ waitTill: WAIT_INDEFINITELY } as Partial<IExecutionResponse>),
|
||||
});
|
||||
|
||||
expect(store.executionWaitingByNodeId.get('wait-1')?.value).toBe(
|
||||
'The node is waiting for an incoming webhook call (indefinitely)',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a dated message for a timed wait', () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
const node = createTestNode({ id: 'wait-1', name: 'Wait', type: WAIT_NODE_TYPE });
|
||||
|
||||
setWaitingExecution(store, node);
|
||||
|
||||
expect(store.executionWaitingByNodeId.get('wait-1')?.value).toMatch(
|
||||
/^Node is waiting until /,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined when the execution is finished', () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
const node = createTestNode({ id: 'wait-1', name: 'Wait', type: WAIT_NODE_TYPE });
|
||||
|
||||
setWaitingExecution(store, node, { finished: true });
|
||||
|
||||
expect(store.executionWaitingByNodeId.get('wait-1')?.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the node is not the last node executed', () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
const node = createTestNode({ id: 'wait-1', name: 'Wait', type: WAIT_NODE_TYPE });
|
||||
|
||||
setWaitingExecution(store, node, { lastNodeExecuted: 'Another Node' } as never);
|
||||
|
||||
expect(store.executionWaitingByNodeId.get('wait-1')?.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the execution has no waitTill', () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
const node = createTestNode({ id: 'wait-1', name: 'Wait', type: WAIT_NODE_TYPE });
|
||||
|
||||
setExecutionWithSnapshot(store, {
|
||||
nodes: [node],
|
||||
lastNodeExecuted: node.name,
|
||||
finished: false,
|
||||
});
|
||||
|
||||
expect(store.executionWaitingByNodeId.get('wait-1')?.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('executionRunDataOutputMapByNodeId', () => {
|
||||
// The rebuild runs behind a throttledWatch whose leading slot is consumed
|
||||
// by the `immediate: true` run at store creation, so a setExecution right
|
||||
// after creation lands on the trailing edge. Fake timers let tests skip
|
||||
// the throttle window deterministically.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function flushOutputMapRebuild() {
|
||||
// Let the (pre-flush) watcher run, then fire the trailing throttle slot.
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(CANVAS_EXECUTION_DATA_THROTTLE_DURATION);
|
||||
}
|
||||
|
||||
function createTask(
|
||||
items: number,
|
||||
overrides: Record<string, unknown> = {},
|
||||
connectionType: string = NodeConnectionTypes.Main,
|
||||
) {
|
||||
return {
|
||||
startTime: 0,
|
||||
executionTime: 0,
|
||||
executionIndex: 0,
|
||||
source: [],
|
||||
data: {
|
||||
[connectionType]: [Array.from({ length: items }, () => ({ json: {} }))],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('is empty when there is no run data', () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
|
||||
expect(store.executionRunDataOutputMapByNodeId.size).toBe(0);
|
||||
});
|
||||
|
||||
it('calculates iterations and total for a single node', async () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
const node = createTestNode({ id: 'node-1', name: 'Node 1' });
|
||||
|
||||
setExecutionWithSnapshot(store, {
|
||||
nodes: [node],
|
||||
runData: { 'Node 1': [createTask(2)] },
|
||||
});
|
||||
await flushOutputMapRebuild();
|
||||
|
||||
expect(store.executionRunDataOutputMapByNodeId.get('node-1')).toEqual({
|
||||
[NodeConnectionTypes.Main]: { 0: { iterations: 1, total: 2 } },
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates multiple iterations', async () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
const node = createTestNode({ id: 'node-1', name: 'Node 1' });
|
||||
|
||||
setExecutionWithSnapshot(store, {
|
||||
nodes: [node],
|
||||
runData: {
|
||||
'Node 1': [
|
||||
createTask(1),
|
||||
createTask(3, { executionIndex: 1 }),
|
||||
createTask(2, { executionIndex: 2 }),
|
||||
],
|
||||
},
|
||||
});
|
||||
await flushOutputMapRebuild();
|
||||
|
||||
expect(store.executionRunDataOutputMapByNodeId.get('node-1')).toEqual({
|
||||
[NodeConnectionTypes.Main]: { 0: { iterations: 3, total: 6 } },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not count canceled iterations but still counts their data', async () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
const node = createTestNode({ id: 'node-1', name: 'Node 1' });
|
||||
|
||||
setExecutionWithSnapshot(store, {
|
||||
nodes: [node],
|
||||
runData: {
|
||||
'Node 1': [
|
||||
createTask(2, { executionStatus: 'success' }),
|
||||
createTask(3, { executionStatus: 'canceled', executionIndex: 1 }),
|
||||
createTask(1, { executionStatus: 'success', executionIndex: 2 }),
|
||||
],
|
||||
},
|
||||
});
|
||||
await flushOutputMapRebuild();
|
||||
|
||||
expect(store.executionRunDataOutputMapByNodeId.get('node-1')).toEqual({
|
||||
[NodeConnectionTypes.Main]: { 0: { iterations: 2, total: 6 } },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports zero iterations when all iterations are canceled', async () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
const node = createTestNode({ id: 'node-1', name: 'Node 1' });
|
||||
|
||||
setExecutionWithSnapshot(store, {
|
||||
nodes: [node],
|
||||
runData: {
|
||||
'Node 1': [
|
||||
createTask(2, { executionStatus: 'canceled' }),
|
||||
createTask(1, { executionStatus: 'canceled', executionIndex: 1 }),
|
||||
],
|
||||
},
|
||||
});
|
||||
await flushOutputMapRebuild();
|
||||
|
||||
expect(store.executionRunDataOutputMapByNodeId.get('node-1')).toEqual({
|
||||
[NodeConnectionTypes.Main]: { 0: { iterations: 0, total: 3 } },
|
||||
});
|
||||
});
|
||||
|
||||
it('populates byTarget per-target counts for non-main connections', async () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
const modelNode = createTestNode({ id: 'model-1', name: 'OpenAI Chat Model' });
|
||||
const agent1Node = createTestNode({ id: 'agent-1', name: 'AI Agent 1' });
|
||||
const agent2Node = createTestNode({ id: 'agent-2', name: 'AI Agent 2' });
|
||||
|
||||
setExecutionWithSnapshot(store, {
|
||||
nodes: [modelNode, agent1Node, agent2Node],
|
||||
runData: {
|
||||
'OpenAI Chat Model': [
|
||||
createTask(
|
||||
2,
|
||||
{ executionStatus: 'success', source: [{ previousNode: 'AI Agent 1' }] },
|
||||
NodeConnectionTypes.AiLanguageModel,
|
||||
),
|
||||
createTask(
|
||||
1,
|
||||
{
|
||||
executionStatus: 'success',
|
||||
executionIndex: 1,
|
||||
source: [{ previousNode: 'AI Agent 2' }],
|
||||
},
|
||||
NodeConnectionTypes.AiLanguageModel,
|
||||
),
|
||||
createTask(
|
||||
3,
|
||||
{
|
||||
executionStatus: 'success',
|
||||
executionIndex: 2,
|
||||
source: [{ previousNode: 'AI Agent 1' }],
|
||||
},
|
||||
NodeConnectionTypes.AiLanguageModel,
|
||||
),
|
||||
],
|
||||
},
|
||||
});
|
||||
await flushOutputMapRebuild();
|
||||
|
||||
const outputData =
|
||||
store.executionRunDataOutputMapByNodeId.get('model-1')?.[
|
||||
NodeConnectionTypes.AiLanguageModel
|
||||
]?.[0];
|
||||
|
||||
expect(outputData?.iterations).toBe(3);
|
||||
expect(outputData?.total).toBe(6);
|
||||
// Agent 1 was called twice with 2 + 3 = 5 items; agent 2 once with 1.
|
||||
expect(outputData?.byTarget?.['agent-1']).toEqual({ iterations: 2, total: 5 });
|
||||
expect(outputData?.byTarget?.['agent-2']).toEqual({ iterations: 1, total: 1 });
|
||||
});
|
||||
|
||||
it('counts items inside the response field for non-main connections', async () => {
|
||||
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
|
||||
const embeddingNode = createTestNode({ id: 'embed-1', name: 'Embeddings OpenAI' });
|
||||
const vectorStoreNode = createTestNode({ id: 'vector-1', name: 'Vector Store' });
|
||||
|
||||
setExecutionWithSnapshot(store, {
|
||||
nodes: [embeddingNode, vectorStoreNode],
|
||||
runData: {
|
||||
'Embeddings OpenAI': [
|
||||
{
|
||||
startTime: 0,
|
||||
executionTime: 0,
|
||||
executionIndex: 0,
|
||||
executionStatus: 'success',
|
||||
source: [{ previousNode: 'Vector Store' }],
|
||||
data: {
|
||||
[NodeConnectionTypes.AiEmbedding]: [
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: [
|
||||
{ embedding: [0.1, 0.2] },
|
||||
{ embedding: [0.3, 0.4] },
|
||||
{ embedding: [0.5, 0.6] },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await flushOutputMapRebuild();
|
||||
|
||||
const outputData =
|
||||
store.executionRunDataOutputMapByNodeId.get('embed-1')?.[
|
||||
NodeConnectionTypes.AiEmbedding
|
||||
]?.[0];
|
||||
|
||||
// Counts the 3 items inside `response`, not the 1 wrapper item — also
|
||||
// for the per-target counts.
|
||||
expect(outputData?.iterations).toBe(1);
|
||||
expect(outputData?.total).toBe(3);
|
||||
expect(outputData?.byTarget?.['vector-1']).toEqual({ iterations: 1, total: 3 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function flushPromises() {
|
||||
|
||||
@@ -2,18 +2,35 @@ import { defineStore, getActivePinia } from 'pinia';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import { computed, effectScope, inject, readonly, ref, shallowReactive } from 'vue';
|
||||
import type { ComputedRef } from 'vue';
|
||||
import { createEventHook } from '@vueuse/core';
|
||||
import { createEventHook, throttledWatch } from '@vueuse/core';
|
||||
import { structuralComputed } from '@n8n/composables/structuralComputed';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import type { ExecutionStatus, IRunData, IRunExecutionData, ITaskStartedData } from 'n8n-workflow';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
SEND_AND_WAIT_OPERATION,
|
||||
WAIT_INDEFINITELY,
|
||||
type ExecutionStatus,
|
||||
type INode,
|
||||
type IRunData,
|
||||
type IRunExecutionData,
|
||||
type ITaskData,
|
||||
type ITaskStartedData,
|
||||
} from 'n8n-workflow';
|
||||
import type { PushPayload } from '@n8n/api-types';
|
||||
import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
|
||||
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
|
||||
import { ExecutionDataStoreKey } from '@/app/constants/injectionKeys';
|
||||
import {
|
||||
CANVAS_EXECUTION_DATA_THROTTLE_DURATION,
|
||||
FORM_NODE_TYPE,
|
||||
WAIT_NODE_TYPE,
|
||||
} from '@/app/constants';
|
||||
import { getPairedItemsMapping } from '@/app/utils/pairedItemUtils';
|
||||
import { sanitizeHtml } from '@/app/utils/htmlUtils';
|
||||
import { CHANGE_ACTION } from './workflowDocument/types';
|
||||
import type { ChangeAction, ChangeEvent } from './workflowDocument/types';
|
||||
import type { ExecutionOutputMap } from '@/app/types/executionData';
|
||||
|
||||
export type ExecutionDataId = string;
|
||||
|
||||
@@ -57,6 +74,7 @@ export function getExecutionDataStoreId(id: ExecutionDataId) {
|
||||
*/
|
||||
export function useExecutionDataStore(id: ExecutionDataId) {
|
||||
return defineStore(getExecutionDataStoreId(id), () => {
|
||||
const i18n = useI18n();
|
||||
const execution = ref<IExecutionResponse | null>(null);
|
||||
const executionResultDataLastUpdate = ref<number>();
|
||||
const executionStartedData = ref<ExecutionStartedDataValue>();
|
||||
@@ -137,6 +155,264 @@ export function useExecutionDataStore(id: ExecutionDataId) {
|
||||
void onExecutionDataChange.on(reconcileExecutionIssuesFromRunData);
|
||||
reconcileExecutionIssuesFromRunData();
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Per-node-id projections of execution state.
|
||||
//
|
||||
// The store's primary key is node *name* (runData is keyed by name),
|
||||
// but consumers (canvas, NDV) need by-id lookup. The execution payload
|
||||
// embeds a `workflowData.nodes` snapshot, which is the authoritative
|
||||
// source for the name↔id mapping at execution time. `renameExecutionDataNode`
|
||||
// updates that snapshot in place, so renames propagate naturally.
|
||||
//
|
||||
// Each map uses the same shallowReactive<Map<id, ComputedRef<T>>>
|
||||
// pattern as `executionIssuesByNodeName`: per-entry structuralComputed,
|
||||
// reconciled against `workflowData.nodes` via `onExecutionDataChange`.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Lazy index of the embedded workflow snapshot, keyed by node id.
|
||||
// Materializes once per execution.value change and is shared across all
|
||||
// per-entry projections below — collapses id → node resolution from
|
||||
// O(N) per lookup to O(1).
|
||||
const executionNodeById = computed(() => {
|
||||
const map = new Map<string, INode>();
|
||||
const nodes = execution.value?.workflowData?.nodes;
|
||||
if (nodes) for (const n of nodes) map.set(n.id, n);
|
||||
return map;
|
||||
});
|
||||
|
||||
function getExecutionNodeById(nodeId: string): INode | undefined {
|
||||
return executionNodeById.value.get(nodeId);
|
||||
}
|
||||
|
||||
function getExecutionNodeIds(): string[] {
|
||||
return Array.from(executionNodeById.value.keys());
|
||||
}
|
||||
|
||||
const byIdScopes = new Map<string, () => void>();
|
||||
const executionStatusByNodeId = shallowReactive(
|
||||
new Map<string, ComputedRef<ExecutionStatus>>(),
|
||||
);
|
||||
const executionRunDataByNodeId = shallowReactive(
|
||||
new Map<string, ComputedRef<ITaskData[] | null>>(),
|
||||
);
|
||||
const executionWaitingByNodeId = shallowReactive(
|
||||
new Map<string, ComputedRef<string | undefined>>(),
|
||||
);
|
||||
|
||||
function computeExecutionStatus(nodeId: string): ExecutionStatus {
|
||||
const node = getExecutionNodeById(nodeId);
|
||||
if (!node) return 'new';
|
||||
const tasks = executionRunData.value?.[node.name] ?? [];
|
||||
// A canceled top-of-stack tends to mask the prior "real" status — peek
|
||||
// one task back so the UI shows the meaningful state.
|
||||
let status = tasks.at(-1)?.executionStatus;
|
||||
if (tasks.length > 1 && status === 'canceled') {
|
||||
status = tasks.at(-2)?.executionStatus;
|
||||
}
|
||||
return status ?? 'new';
|
||||
}
|
||||
|
||||
function computeExecutionRunData(nodeId: string): ITaskData[] | null {
|
||||
const node = getExecutionNodeById(nodeId);
|
||||
if (!node) return null;
|
||||
const tasks = executionRunData.value?.[node.name];
|
||||
return tasks ?? null;
|
||||
}
|
||||
|
||||
function computeExecutionWaiting(nodeId: string): string | undefined {
|
||||
const node = getExecutionNodeById(nodeId);
|
||||
if (!node) return undefined;
|
||||
const ex = execution.value;
|
||||
if (!ex || ex.finished) return undefined;
|
||||
// `waitTill` exists at runtime on resumed executions but isn't declared
|
||||
// on IExecutionResponse — narrow via the property check.
|
||||
const waitTill = (ex as IExecutionResponse & { waitTill?: Date | string }).waitTill;
|
||||
if (!waitTill) return undefined;
|
||||
const lastNodeExecuted = ex.data?.resultData?.lastNodeExecuted;
|
||||
if (node.name !== lastNodeExecuted) return undefined;
|
||||
|
||||
const resume = node.parameters?.resume as string | undefined;
|
||||
if (node.type === WAIT_NODE_TYPE && (resume === 'webhook' || resume === 'form')) {
|
||||
return resume === 'webhook'
|
||||
? i18n.baseText('node.theNodeIsWaitingWebhookCall')
|
||||
: i18n.baseText('node.theNodeIsWaitingFormCall');
|
||||
}
|
||||
if (node.parameters?.operation === SEND_AND_WAIT_OPERATION) {
|
||||
return i18n.baseText('node.theNodeIsWaitingUserInput');
|
||||
}
|
||||
if (node.type === FORM_NODE_TYPE) {
|
||||
return i18n.baseText('node.theNodeIsWaitingFormCall');
|
||||
}
|
||||
const waitDate = new Date(waitTill);
|
||||
if (waitDate.getTime() === WAIT_INDEFINITELY.getTime()) {
|
||||
return i18n.baseText('node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall');
|
||||
}
|
||||
return i18n.baseText('node.nodeIsWaitingTill', {
|
||||
interpolate: {
|
||||
date: waitDate.toLocaleDateString(),
|
||||
time: waitDate.toLocaleTimeString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function applyAddByIdEntry(nodeId: string) {
|
||||
if (byIdScopes.has(nodeId)) return;
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
executionStatusByNodeId.set(
|
||||
nodeId,
|
||||
structuralComputed(() => computeExecutionStatus(nodeId)),
|
||||
);
|
||||
// Plain `computed` (Object.is) rather than `structuralComputed(..., isEqual)`:
|
||||
// per-task data can be megabytes for nodes with large outputs, and
|
||||
// every push replaces the runData array reference with new content
|
||||
// — so an isEqual gate would deep-compare megabytes and never
|
||||
// short-circuit. Reference identity is the right gate here.
|
||||
executionRunDataByNodeId.set(
|
||||
nodeId,
|
||||
computed(() => computeExecutionRunData(nodeId)),
|
||||
);
|
||||
executionWaitingByNodeId.set(
|
||||
nodeId,
|
||||
structuralComputed(() => computeExecutionWaiting(nodeId)),
|
||||
);
|
||||
});
|
||||
byIdScopes.set(nodeId, () => scope.stop());
|
||||
}
|
||||
|
||||
function applyRemoveByIdEntry(nodeId: string) {
|
||||
byIdScopes.get(nodeId)?.();
|
||||
byIdScopes.delete(nodeId);
|
||||
executionStatusByNodeId.delete(nodeId);
|
||||
executionRunDataByNodeId.delete(nodeId);
|
||||
executionWaitingByNodeId.delete(nodeId);
|
||||
}
|
||||
|
||||
function applyReconcileByIdEntries(nodeIds: string[]) {
|
||||
const next = new Set(nodeIds);
|
||||
for (const old of byIdScopes.keys()) {
|
||||
if (!next.has(old)) applyRemoveByIdEntry(old);
|
||||
}
|
||||
for (const id of nodeIds) applyAddByIdEntry(id);
|
||||
}
|
||||
|
||||
function reconcileByIdEntries() {
|
||||
applyReconcileByIdEntries(getExecutionNodeIds());
|
||||
}
|
||||
|
||||
void onExecutionDataChange.on(reconcileByIdEntries);
|
||||
reconcileByIdEntries();
|
||||
|
||||
// Throttled per-node-id aggregation of run data into `ExecutionOutputMap`.
|
||||
// Aggregation cost scales with task list length and item counts; rebuilding
|
||||
// on every push during a fast execution would be expensive. The throttle
|
||||
// batches updates; consumers (label rendering, item counters) only invalidate
|
||||
// when their specific id slot changes, gated by `isEqual` per node.
|
||||
const executionRunDataOutputMapByNodeId = shallowReactive(
|
||||
new Map<string, ExecutionOutputMap>(),
|
||||
);
|
||||
|
||||
function rebuildExecutionRunDataOutputMap() {
|
||||
const runData = executionRunData.value;
|
||||
const snapshotNodes = execution.value?.workflowData?.nodes ?? [];
|
||||
if (!runData) {
|
||||
for (const k of Array.from(executionRunDataOutputMapByNodeId.keys())) {
|
||||
executionRunDataOutputMapByNodeId.delete(k);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nameToId = new Map(snapshotNodes.map((n) => [n.name, n.id]));
|
||||
const next = new Map<string, ExecutionOutputMap>();
|
||||
|
||||
for (const nodeName of Object.keys(runData)) {
|
||||
const nodeId = nameToId.get(nodeName);
|
||||
if (!nodeId) continue;
|
||||
|
||||
const agg: ExecutionOutputMap = {};
|
||||
const taskList = runData[nodeName] ?? [];
|
||||
|
||||
for (const runIteration of taskList) {
|
||||
const data = runIteration.data ?? {};
|
||||
for (const connectionType of Object.keys(data)) {
|
||||
const connectionTypeData = data[connectionType] ?? {};
|
||||
agg[connectionType] = agg[connectionType] ?? {};
|
||||
|
||||
for (const outputIndex of Object.keys(connectionTypeData)) {
|
||||
const parsedOutputIndex = parseInt(outputIndex, 10);
|
||||
const items = connectionTypeData[parsedOutputIndex] ?? [];
|
||||
|
||||
agg[connectionType][outputIndex] = agg[connectionType][outputIndex] ?? {
|
||||
total: 0,
|
||||
iterations: 0,
|
||||
...(connectionType !== NodeConnectionTypes.Main ? { byTarget: {} } : {}),
|
||||
};
|
||||
|
||||
// Non-main connections (AI tools/memory/embeddings) wrap items
|
||||
// in a `response` array; the apparent itemCount is the length
|
||||
// of that array, not the outer items list. Check only the first
|
||||
// item assuming uniform structure.
|
||||
let itemCount = items.length;
|
||||
if (connectionType !== NodeConnectionTypes.Main && items.length > 0) {
|
||||
const first = items[0];
|
||||
if (
|
||||
first?.json &&
|
||||
typeof first.json === 'object' &&
|
||||
'response' in first.json &&
|
||||
Array.isArray((first.json as { response: unknown[] }).response)
|
||||
) {
|
||||
itemCount = (first.json as { response: unknown[] }).response.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (runIteration.executionStatus !== 'canceled') {
|
||||
agg[connectionType][outputIndex].iterations += 1;
|
||||
}
|
||||
agg[connectionType][outputIndex].total += itemCount;
|
||||
|
||||
if (connectionType !== NodeConnectionTypes.Main) {
|
||||
const callingNodeName = runIteration.source?.[0]?.previousNode;
|
||||
if (callingNodeName) {
|
||||
const targetId = nameToId.get(callingNodeName);
|
||||
if (targetId) {
|
||||
const entry = agg[connectionType][outputIndex];
|
||||
entry.byTarget = entry.byTarget ?? {};
|
||||
entry.byTarget[targetId] = entry.byTarget[targetId] ?? {
|
||||
total: 0,
|
||||
iterations: 0,
|
||||
};
|
||||
if (runIteration.executionStatus !== 'canceled') {
|
||||
entry.byTarget[targetId].iterations += 1;
|
||||
}
|
||||
entry.byTarget[targetId].total += itemCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
next.set(nodeId, agg);
|
||||
}
|
||||
|
||||
// Reconcile to the shallowReactive map for per-id reactivity. isEqual
|
||||
// skips structurally-identical slots so downstream consumers don't
|
||||
// invalidate when their node's aggregation hasn't actually changed.
|
||||
for (const oldId of Array.from(executionRunDataOutputMapByNodeId.keys())) {
|
||||
if (!next.has(oldId)) executionRunDataOutputMapByNodeId.delete(oldId);
|
||||
}
|
||||
for (const [nodeId, value] of next.entries()) {
|
||||
const existing = executionRunDataOutputMapByNodeId.get(nodeId);
|
||||
if (!existing || !isEqual(existing, value)) {
|
||||
executionRunDataOutputMapByNodeId.set(nodeId, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throttledWatch(executionResultDataLastUpdate, rebuildExecutionRunDataOutputMap, {
|
||||
throttle: CANVAS_EXECUTION_DATA_THROTTLE_DURATION,
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
function fireChange(action: ChangeAction, nodeName?: string) {
|
||||
void onExecutionDataChange.trigger({
|
||||
action,
|
||||
@@ -415,6 +691,10 @@ export function useExecutionDataStore(id: ExecutionDataId) {
|
||||
executionRunData,
|
||||
executedNode,
|
||||
executionIssuesByNodeName,
|
||||
executionStatusByNodeId,
|
||||
executionRunDataByNodeId,
|
||||
executionWaitingByNodeId,
|
||||
executionRunDataOutputMapByNodeId,
|
||||
executionStartedData: readonly(executionStartedData),
|
||||
executionPairedItemMappings: readonly(executionPairedItemMappings),
|
||||
getExecutionRunDataByNodeName,
|
||||
|
||||
@@ -34,12 +34,11 @@ import { useWorkflowDocumentNodeGroups } from './workflowDocument/useWorkflowDoc
|
||||
import { CHANGE_ACTION } from './workflowDocument/types';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
|
||||
import { serializeNode } from '@/app/utils/nodes/nodeTransforms';
|
||||
import { assignNodeId, serializeNode } from '@/app/utils/nodes/nodeTransforms';
|
||||
import type { WorkflowObjectAccessors } from '../types';
|
||||
import type { IWorkflowDb } from '@/Interface';
|
||||
import type { INode, ProjectSharingData } from 'n8n-workflow';
|
||||
import { deepCopy } from 'n8n-workflow';
|
||||
import { deepCopy, nodeIssuesToString } from 'n8n-workflow';
|
||||
import type { WorkflowData } from '@n8n/rest-api-client/api/workflows';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import type { IUsedCredential } from '@/features/credentials/credentials.types';
|
||||
@@ -141,7 +140,6 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
|
||||
const [workflowId, workflowVersion] = id.split('@');
|
||||
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
|
||||
const { cloneWorkflowObject, createWorkflowObject, ...workflowDocumentWorkflowObject } =
|
||||
useWorkflowDocumentWorkflowObject({ workflowId });
|
||||
@@ -157,7 +155,6 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
|
||||
const workflowDocumentMeta = useWorkflowDocumentMeta();
|
||||
const workflowDocumentTags = useWorkflowDocumentTags();
|
||||
const workflowDocumentIsArchived = useWorkflowDocumentIsArchived();
|
||||
const workflowDocumentPinData = useWorkflowDocumentPinData();
|
||||
const workflowDocumentScopes = useWorkflowDocumentScopes();
|
||||
const workflowDocumentTimestamps = useWorkflowDocumentTimestamps();
|
||||
const workflowDocumentSettings = useWorkflowDocumentSettings({
|
||||
@@ -172,11 +169,16 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
|
||||
const { onStateDirty: onNodesStateDirty, ...workflowDocumentNodes } = useWorkflowDocumentNodes({
|
||||
getNodeType: (typeName, version) => nodeTypesStore.getNodeType(typeName, version),
|
||||
nodeMetadata: workflowDocumentNodeMetadata,
|
||||
assignNodeId: (node) => nodeHelpers.assignNodeId(node),
|
||||
assignNodeId,
|
||||
syncWorkflowObject: (nodes) => workflowDocumentWorkflowObject.syncWorkflowObjectNodes(nodes),
|
||||
unpinNodeData: (name) => workflowDocumentPinData.unpinNodeData(name),
|
||||
workflowObject: workflowDocumentWorkflowObject.workflowObject,
|
||||
});
|
||||
// pinData subscribes to nodes' `onNodesChange` and clears orphan pin data
|
||||
// on DELETE itself — so nodes doesn't need a reverse dep into pinData.
|
||||
const workflowDocumentPinData = useWorkflowDocumentPinData({
|
||||
nodesById: workflowDocumentNodes.nodesById,
|
||||
onNodesChange: workflowDocumentNodes.onNodesChange,
|
||||
});
|
||||
const { onStateDirty: onConnectionsStateDirty, ...workflowDocumentConnections } =
|
||||
useWorkflowDocumentConnections({
|
||||
getNodeById: (id) => workflowDocumentNodes.getNodeById(id),
|
||||
@@ -193,6 +195,9 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
|
||||
allNodes: workflowDocumentNodes.allNodes,
|
||||
outgoingConnectionsByNodeName: workflowDocumentConnections.outgoingConnectionsByNodeName,
|
||||
incomingConnectionsByNodeName: workflowDocumentConnections.incomingConnectionsByNodeName,
|
||||
nodesById: workflowDocumentNodes.nodesById,
|
||||
onNodesChange: workflowDocumentNodes.onNodesChange,
|
||||
nodeIssuesToString,
|
||||
});
|
||||
const { onStateDirty: onNodeGroupsStateDirty, ...workflowDocumentNodeGroups } =
|
||||
useWorkflowDocumentNodeGroups();
|
||||
|
||||
-1
@@ -46,7 +46,6 @@ function createNodesDeps(
|
||||
getNodeType: vi.fn().mockReturnValue(null),
|
||||
assignNodeId: vi.fn().mockReturnValue(''),
|
||||
syncWorkflowObject: vi.fn(),
|
||||
unpinNodeData: vi.fn(),
|
||||
nodeMetadata: useWorkflowDocumentNodeMetadata(),
|
||||
workflowObject: obj.workflowObject,
|
||||
};
|
||||
|
||||
+3
-27
@@ -46,7 +46,6 @@ function createDeps(overrides: Partial<WorkflowDocumentNodesDeps> = {}): Workflo
|
||||
getNodeType: vi.fn().mockReturnValue(null),
|
||||
assignNodeId: vi.fn().mockReturnValue(''),
|
||||
syncWorkflowObject: vi.fn(),
|
||||
unpinNodeData: vi.fn(),
|
||||
nodeMetadata: useWorkflowDocumentNodeMetadata(),
|
||||
workflowObject: ref(
|
||||
mock<Workflow>({ getNode: () => null }),
|
||||
@@ -588,32 +587,9 @@ describe('useWorkflowDocumentNodes', () => {
|
||||
expect(dirtySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removeNode calls unpinNodeData', () => {
|
||||
const node = createNode({ name: 'Target' });
|
||||
|
||||
const workflowDocumentNodes = useWorkflowDocumentNodes(deps);
|
||||
workflowDocumentNodes.setNodes([node]);
|
||||
workflowDocumentNodes.removeNode(node);
|
||||
|
||||
expect(deps.unpinNodeData).toHaveBeenCalledWith('Target');
|
||||
});
|
||||
|
||||
it('removeNodeById calls unpinNodeData', () => {
|
||||
const node = createNode({ name: 'Target' });
|
||||
|
||||
const workflowDocumentNodes = useWorkflowDocumentNodes(deps);
|
||||
workflowDocumentNodes.setNodes([node]);
|
||||
workflowDocumentNodes.removeNodeById(node.id);
|
||||
|
||||
expect(deps.unpinNodeData).toHaveBeenCalledWith('Target');
|
||||
});
|
||||
|
||||
it('removeNodeById does not call unpinNodeData when node not found', () => {
|
||||
const workflowDocumentNodes = useWorkflowDocumentNodes(deps);
|
||||
workflowDocumentNodes.removeNodeById('nonexistent');
|
||||
|
||||
expect(deps.unpinNodeData).not.toHaveBeenCalled();
|
||||
});
|
||||
// Orphan-pin-cleanup coverage (previously a direct dep of useWorkflowDocumentNodes
|
||||
// via `unpinNodeData`) now lives in useWorkflowDocumentPinData.test.ts —
|
||||
// pinData subscribes to onNodesChange and handles DELETE itself.
|
||||
|
||||
it('removeNodeById uses empty name when node not found', () => {
|
||||
const hookSpy = vi.fn();
|
||||
|
||||
-3
@@ -61,7 +61,6 @@ export interface WorkflowDocumentNodesDeps {
|
||||
getNodeType: (typeName: string, version?: number) => INodeTypeDescription | null;
|
||||
assignNodeId: (node: INodeUi) => string;
|
||||
syncWorkflowObject: (nodes: INodeUi[]) => void;
|
||||
unpinNodeData: (name: string) => void;
|
||||
nodeMetadata: ReturnType<typeof useWorkflowDocumentNodeMetadata>;
|
||||
workflowObject: Ref<Workflow>;
|
||||
}
|
||||
@@ -169,7 +168,6 @@ export function useWorkflowDocumentNodes(deps: WorkflowDocumentNodesDeps) {
|
||||
|
||||
deps.syncWorkflowObject(nodes.value);
|
||||
deps.nodeMetadata.removeNodeMetadata(node.name);
|
||||
deps.unpinNodeData(node.name);
|
||||
void onNodesChange.trigger({
|
||||
action: CHANGE_ACTION.DELETE,
|
||||
payload: { name: node.name, id: node.id },
|
||||
@@ -186,7 +184,6 @@ export function useWorkflowDocumentNodes(deps: WorkflowDocumentNodesDeps) {
|
||||
deps.syncWorkflowObject(nodes.value);
|
||||
if (node) {
|
||||
deps.nodeMetadata.removeNodeMetadata(node.name);
|
||||
deps.unpinNodeData(node.name);
|
||||
}
|
||||
void onNodesChange.trigger({
|
||||
action: CHANGE_ACTION.DELETE,
|
||||
|
||||
+4
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computed } from 'vue';
|
||||
import { computed, shallowRef } from 'vue';
|
||||
import { createTestNode } from '@/__tests__/mocks';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import type { INodeConnections } from 'n8n-workflow';
|
||||
@@ -21,6 +21,9 @@ function createDeps(nodes: INodeUi[], connected = true): WorkflowDocumentNodesIs
|
||||
allNodes: computed(() => nodes),
|
||||
outgoingConnectionsByNodeName: connected ? hasConnections : noConnections,
|
||||
incomingConnectionsByNodeName: noConnections,
|
||||
nodesById: shallowRef(new Map(nodes.map((n) => [n.id, n]))),
|
||||
onNodesChange: () => {},
|
||||
nodeIssuesToString: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+78
-2
@@ -1,11 +1,23 @@
|
||||
import { computed, type ComputedRef } from 'vue';
|
||||
import { computed, effectScope, shallowReactive, type ComputedRef, type ShallowRef } from 'vue';
|
||||
import { structuralComputed } from '@n8n/composables/structuralComputed';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import type { INodeUi, WorkflowValidationIssue } from '@/Interface';
|
||||
import type { INodeConnections } from 'n8n-workflow';
|
||||
import type { INodeConnections, INodeIssues, INode } from 'n8n-workflow';
|
||||
import { CHANGE_ACTION } from './types';
|
||||
import type {
|
||||
NodeAddedPayload,
|
||||
NodeRemovedPayload,
|
||||
NodesChangeEvent,
|
||||
NodesSetPayload,
|
||||
} from './useWorkflowDocumentNodes';
|
||||
|
||||
export type WorkflowDocumentNodesIssuesDeps = {
|
||||
allNodes: ComputedRef<INodeUi[]>;
|
||||
outgoingConnectionsByNodeName: (nodeName: string) => INodeConnections;
|
||||
incomingConnectionsByNodeName: (nodeName: string) => INodeConnections;
|
||||
nodesById: ShallowRef<Map<string, INodeUi>>;
|
||||
onNodesChange: (cb: (event: NodesChangeEvent) => void) => void;
|
||||
nodeIssuesToString: (issues: INodeIssues, node?: INode) => string[];
|
||||
};
|
||||
|
||||
export function useWorkflowDocumentNodesIssues(deps: WorkflowDocumentNodesIssuesDeps) {
|
||||
@@ -88,12 +100,76 @@ export function useWorkflowDocumentNodesIssues(deps: WorkflowDocumentNodesIssues
|
||||
return String(issue);
|
||||
}
|
||||
|
||||
// Per-node-id validation errors map. See useWorkflowDocumentRenderData
|
||||
// for an explanation of the shallowReactive + structuralComputed pattern.
|
||||
const validationErrorsByNodeId = shallowReactive(new Map<string, ComputedRef<string[]>>());
|
||||
const scopes = new Map<string, () => void>();
|
||||
|
||||
function computeValidationErrors(nodeId: string): string[] {
|
||||
const node = deps.nodesById.value.get(nodeId);
|
||||
if (!node?.issues) return [];
|
||||
return deps.nodeIssuesToString(node.issues, node);
|
||||
}
|
||||
|
||||
function applyAddEntry(nodeId: string) {
|
||||
if (scopes.has(nodeId)) return;
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
validationErrorsByNodeId.set(
|
||||
nodeId,
|
||||
structuralComputed(() => computeValidationErrors(nodeId), isEqual),
|
||||
);
|
||||
});
|
||||
scopes.set(nodeId, () => scope.stop());
|
||||
}
|
||||
|
||||
function applyRemoveEntry(nodeId: string) {
|
||||
scopes.get(nodeId)?.();
|
||||
scopes.delete(nodeId);
|
||||
validationErrorsByNodeId.delete(nodeId);
|
||||
}
|
||||
|
||||
function applyReconcileEntries(nodeIds: string[]) {
|
||||
const nextIds = new Set(nodeIds);
|
||||
for (const oldId of scopes.keys()) {
|
||||
if (!nextIds.has(oldId)) applyRemoveEntry(oldId);
|
||||
}
|
||||
for (const id of nodeIds) applyAddEntry(id);
|
||||
}
|
||||
|
||||
deps.onNodesChange((event) => {
|
||||
switch (event.action) {
|
||||
case CHANGE_ACTION.ADD: {
|
||||
const { node } = event.payload as NodeAddedPayload;
|
||||
applyAddEntry(node.id);
|
||||
break;
|
||||
}
|
||||
case CHANGE_ACTION.DELETE: {
|
||||
const payload = event.payload as NodeRemovedPayload;
|
||||
if (payload.id) {
|
||||
applyRemoveEntry(payload.id);
|
||||
} else {
|
||||
applyReconcileEntries([]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CHANGE_ACTION.SET: {
|
||||
const { nodeIds } = event.payload as NodesSetPayload;
|
||||
applyReconcileEntries(nodeIds);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
applyReconcileEntries(Array.from(deps.nodesById.value.keys()));
|
||||
|
||||
return {
|
||||
nodesWithValidationIssues,
|
||||
nodesWithValidationIssuesCount,
|
||||
hasNodeValidationIssues,
|
||||
hasPublishBlockingIssues,
|
||||
nodeValidationIssues,
|
||||
validationErrorsByNodeId,
|
||||
formatNodeIssueMessage,
|
||||
};
|
||||
}
|
||||
|
||||
+55
-1
@@ -1,13 +1,32 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { shallowRef } from 'vue';
|
||||
import type { INodeExecutionData, IPinData } from 'n8n-workflow';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import {
|
||||
useWorkflowDocumentPinData,
|
||||
getPinDataSize,
|
||||
pinDataToExecutionData,
|
||||
} from './useWorkflowDocumentPinData';
|
||||
import { CHANGE_ACTION } from './types';
|
||||
import type { NodesChangeEvent } from './useWorkflowDocumentNodes';
|
||||
|
||||
function createPinData() {
|
||||
return useWorkflowDocumentPinData();
|
||||
return useWorkflowDocumentPinData({
|
||||
nodesById: shallowRef(new Map<string, INodeUi>()),
|
||||
onNodesChange: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
function createPinDataWithNodesChange() {
|
||||
const subscribers: Array<(event: NodesChangeEvent) => void> = [];
|
||||
const pinData = useWorkflowDocumentPinData({
|
||||
nodesById: shallowRef(new Map<string, INodeUi>()),
|
||||
onNodesChange: (cb) => {
|
||||
subscribers.push(cb);
|
||||
},
|
||||
});
|
||||
const fire = (event: NodesChangeEvent) => subscribers.forEach((cb) => cb(event));
|
||||
return { pinData, fire };
|
||||
}
|
||||
|
||||
describe('useWorkflowDocumentPinData', () => {
|
||||
@@ -389,4 +408,39 @@ describe('getPinDataSize', () => {
|
||||
|
||||
expect(twoNodes).toBeGreaterThan(singleNode);
|
||||
});
|
||||
|
||||
describe('orphan pin cleanup on node removal', () => {
|
||||
it('clears pinned data when a DELETE event names the node', () => {
|
||||
const { pinData, fire } = createPinDataWithNodesChange();
|
||||
pinData.pinNodeData('Target', [{ json: { x: 1 } }] as INodeExecutionData[]);
|
||||
expect(pinData.pinnedDataByNodeName.value.Target).toBeDefined();
|
||||
|
||||
fire({ action: CHANGE_ACTION.DELETE, payload: { id: 'node-id-1', name: 'Target' } });
|
||||
|
||||
expect(pinData.pinnedDataByNodeName.value.Target).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves unrelated pinned data untouched', () => {
|
||||
const { pinData, fire } = createPinDataWithNodesChange();
|
||||
pinData.pinNodeData('Target', [{ json: { x: 1 } }] as INodeExecutionData[]);
|
||||
pinData.pinNodeData('Other', [{ json: { y: 2 } }] as INodeExecutionData[]);
|
||||
|
||||
fire({ action: CHANGE_ACTION.DELETE, payload: { id: 'node-id-1', name: 'Target' } });
|
||||
|
||||
expect(pinData.pinnedDataByNodeName.value.Target).toBeUndefined();
|
||||
expect(pinData.pinnedDataByNodeName.value.Other).toBeDefined();
|
||||
});
|
||||
|
||||
it('does nothing when DELETE has no node name (removeAllNodes)', () => {
|
||||
const { pinData, fire } = createPinDataWithNodesChange();
|
||||
pinData.pinNodeData('Target', [{ json: { x: 1 } }] as INodeExecutionData[]);
|
||||
|
||||
fire({ action: CHANGE_ACTION.DELETE, payload: {} });
|
||||
|
||||
// `removeAllNodes` clears pin data via the store-level `removeAllNodes`
|
||||
// orchestration, not via this subscriber — so this individual subscription
|
||||
// is a no-op for the bulk case.
|
||||
expect(pinData.pinnedDataByNodeName.value.Target).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+102
-2
@@ -1,9 +1,24 @@
|
||||
import { ref, readonly } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
effectScope,
|
||||
ref,
|
||||
readonly,
|
||||
shallowReactive,
|
||||
type ComputedRef,
|
||||
type ShallowRef,
|
||||
} from 'vue';
|
||||
import { createEventHook } from '@vueuse/core';
|
||||
import type { INodeExecutionData, IDataObject, IPinData } from 'n8n-workflow';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import { isJsonKeyObject, stringSizeInBytes } from '@/app/utils/typesUtils';
|
||||
import { CHANGE_ACTION } from './types';
|
||||
import type { ChangeAction, ChangeEvent } from './types';
|
||||
import type {
|
||||
NodeAddedPayload,
|
||||
NodeRemovedPayload,
|
||||
NodesChangeEvent,
|
||||
NodesSetPayload,
|
||||
} from './useWorkflowDocumentNodes';
|
||||
|
||||
export type PinDataNodePayload = {
|
||||
nodeName: string;
|
||||
@@ -66,7 +81,12 @@ export function getPinDataSize(
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function useWorkflowDocumentPinData() {
|
||||
export type WorkflowDocumentPinDataDeps = {
|
||||
nodesById: ShallowRef<Map<string, INodeUi>>;
|
||||
onNodesChange: (cb: (event: NodesChangeEvent) => void) => void;
|
||||
};
|
||||
|
||||
export function useWorkflowDocumentPinData(deps: WorkflowDocumentPinDataDeps) {
|
||||
const pinnedDataByNodeName = ref<IPinData>({});
|
||||
|
||||
const onPinnedDataChange = createEventHook<PinDataChangeEvent>();
|
||||
@@ -141,8 +161,88 @@ export function useWorkflowDocumentPinData() {
|
||||
return pinnedDataByNodeName.value[nodeName];
|
||||
}
|
||||
|
||||
// Per-node-id pin-data lookup. See useWorkflowDocumentRenderData for the
|
||||
// general shallowReactive + per-entry computed pattern.
|
||||
//
|
||||
// Note: deliberately uses plain `computed` (Object.is gate) rather than
|
||||
// `structuralComputed(..., isEqual)`. Pin data can reach ~10 MB per
|
||||
// workflow, and every mutation replaces the inner array reference — so an
|
||||
// isEqual gate would deep-compare megabytes on every change *and* never
|
||||
// short-circuit (the immutable update pattern means no mutation ever
|
||||
// produces a structurally-identical fresh array). Reference identity is
|
||||
// the right gate here.
|
||||
const pinnedDataByNodeId = shallowReactive(
|
||||
new Map<string, ComputedRef<INodeExecutionData[] | undefined>>(),
|
||||
);
|
||||
const scopes = new Map<string, () => void>();
|
||||
|
||||
function computePinnedData(nodeId: string): INodeExecutionData[] | undefined {
|
||||
const node = deps.nodesById.value.get(nodeId);
|
||||
if (!node) return undefined;
|
||||
return pinnedDataByNodeName.value[node.name];
|
||||
}
|
||||
|
||||
function applyAddPinEntry(nodeId: string) {
|
||||
if (scopes.has(nodeId)) return;
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
pinnedDataByNodeId.set(
|
||||
nodeId,
|
||||
computed(() => computePinnedData(nodeId)),
|
||||
);
|
||||
});
|
||||
scopes.set(nodeId, () => scope.stop());
|
||||
}
|
||||
|
||||
function applyRemovePinEntry(nodeId: string) {
|
||||
scopes.get(nodeId)?.();
|
||||
scopes.delete(nodeId);
|
||||
pinnedDataByNodeId.delete(nodeId);
|
||||
}
|
||||
|
||||
function applyReconcilePinEntries(nodeIds: string[]) {
|
||||
const nextIds = new Set(nodeIds);
|
||||
for (const oldId of scopes.keys()) {
|
||||
if (!nextIds.has(oldId)) applyRemovePinEntry(oldId);
|
||||
}
|
||||
for (const id of nodeIds) applyAddPinEntry(id);
|
||||
}
|
||||
|
||||
deps.onNodesChange((event) => {
|
||||
switch (event.action) {
|
||||
case CHANGE_ACTION.ADD: {
|
||||
const { node } = event.payload as NodeAddedPayload;
|
||||
applyAddPinEntry(node.id);
|
||||
break;
|
||||
}
|
||||
case CHANGE_ACTION.DELETE: {
|
||||
const payload = event.payload as NodeRemovedPayload;
|
||||
if (payload.id) {
|
||||
applyRemovePinEntry(payload.id);
|
||||
} else {
|
||||
applyReconcilePinEntries([]);
|
||||
}
|
||||
// Orphan pin cleanup: removing a node also clears any pinned data
|
||||
// keyed by its name. Previously this was a cross-cut owned by
|
||||
// useWorkflowDocumentNodes via an injected `unpinNodeData` dep;
|
||||
// pulling it into the pinData subscriber means nodes doesn't need
|
||||
// to know about pinData at all (breaks the construction cycle).
|
||||
if (payload.name) applyUnpin(payload.name);
|
||||
break;
|
||||
}
|
||||
case CHANGE_ACTION.SET: {
|
||||
const { nodeIds } = event.payload as NodesSetPayload;
|
||||
applyReconcilePinEntries(nodeIds);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
applyReconcilePinEntries(Array.from(deps.nodesById.value.keys()));
|
||||
|
||||
return {
|
||||
pinnedDataByNodeName: readonly(pinnedDataByNodeName),
|
||||
pinnedDataByNodeId,
|
||||
setPinData,
|
||||
pinNodeData,
|
||||
unpinNodeData,
|
||||
|
||||
+462
-39
@@ -1,70 +1,493 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { shallowReactive, type ComputedRef } from 'vue';
|
||||
import { computed, effectScope } from 'vue';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import type { IPinData } from 'n8n-workflow';
|
||||
import type { CanvasConnectionPort } from '@/features/workflows/canvas/canvas.types';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import {
|
||||
createTestNode,
|
||||
createTestWorkflow,
|
||||
createTestWorkflowExecutionResponse,
|
||||
} from '@/__tests__/mocks';
|
||||
import { STICKY_NODE_TYPE } from '@/app/constants';
|
||||
import {
|
||||
useWorkflowDocumentStore,
|
||||
createWorkflowDocumentId,
|
||||
type WorkflowDocumentId,
|
||||
} from '@/app/stores/workflowDocument.store';
|
||||
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
|
||||
import { useExecutionDataStore, createExecutionDataId } from '@/app/stores/executionData.store';
|
||||
import { useWorkflowDocumentRenderData } from './useWorkflowDocumentRenderData';
|
||||
|
||||
const nodeInputsByNodeId = shallowReactive(new Map<string, ComputedRef<CanvasConnectionPort[]>>());
|
||||
const nodeOutputsByNodeId = shallowReactive(new Map<string, ComputedRef<CanvasConnectionPort[]>>());
|
||||
const pinnedDataByNodeName = shallowReactive<IPinData>({});
|
||||
const executionIssuesByNodeName = new Map<string, ComputedRef<string[]>>();
|
||||
const TEST_TRIGGER_NODE_TYPE = 'n8n-nodes-base.testTrigger';
|
||||
|
||||
vi.mock('@/app/stores/workflowDocument.store', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/stores/workflowDocument.store')>();
|
||||
return {
|
||||
...actual,
|
||||
useWorkflowDocumentStore: vi.fn(() => ({
|
||||
workflowId: 'wf-1',
|
||||
nodeInputsByNodeId,
|
||||
nodeOutputsByNodeId,
|
||||
pinnedDataByNodeName,
|
||||
})),
|
||||
};
|
||||
});
|
||||
// External-surface mocks only — node-types and dirtiness pull in unrelated
|
||||
// stores that aren't needed to exercise renderData's wiring. The actual
|
||||
// workflowDocument / workflowExecutionState stores stay real so reconciliation
|
||||
// off `onNodesChange` is genuinely tested.
|
||||
vi.mock('@/app/composables/useNodeDirtiness', () => ({
|
||||
useNodeDirtiness: vi.fn(() => ({ dirtinessByName: computed(() => ({})) })),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/stores/workflowExecutionState.store', () => ({
|
||||
useWorkflowExecutionStateStore: vi.fn(() => ({
|
||||
activeExecutionIssuesByNodeName: executionIssuesByNodeName,
|
||||
vi.mock('@/app/stores/nodeTypes.store', () => ({
|
||||
useNodeTypesStore: vi.fn(() => ({
|
||||
isConfigNode: () => false,
|
||||
isConfigurableNode: () => false,
|
||||
isTriggerNode: (type: string) => type === 'n8n-nodes-base.testTrigger',
|
||||
getNodeType: (type: string) =>
|
||||
type === 'n8n-nodes-base.testTrigger'
|
||||
? {
|
||||
name: 'n8n-nodes-base.testTrigger',
|
||||
displayName: 'Test Trigger',
|
||||
description: '',
|
||||
version: 1,
|
||||
defaults: {},
|
||||
group: ['trigger'],
|
||||
inputs: [],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
}
|
||||
: null,
|
||||
communityNodeType: () => undefined,
|
||||
getAllNodeTypes: () => [],
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('useWorkflowDocumentRenderData', () => {
|
||||
function setupWorkflow(id: string, nodes: Array<Partial<INodeUi>> = []) {
|
||||
const docId = createWorkflowDocumentId(id, 'v1');
|
||||
const doc = useWorkflowDocumentStore(docId);
|
||||
doc.setNodes(
|
||||
nodes.map((n) =>
|
||||
createTestNode({
|
||||
id: n.id ?? `${n.name}-id`,
|
||||
name: n.name ?? 'Node',
|
||||
type: n.type ?? 'test',
|
||||
...n,
|
||||
} as Partial<INode>),
|
||||
),
|
||||
);
|
||||
return { docId, doc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an execution into a per-execution data store (with a workflowData
|
||||
* snapshot providing the id↔name mapping) and marks it active on the
|
||||
* document's execution state store, so the active-execution by-id projections
|
||||
* resolve through it.
|
||||
*/
|
||||
function setActiveExecution(
|
||||
docId: WorkflowDocumentId,
|
||||
nodes: INode[],
|
||||
runData: Record<string, Array<Record<string, unknown>>>,
|
||||
) {
|
||||
const executionId = `exec-${docId}`;
|
||||
const executionDataStore = useExecutionDataStore(createExecutionDataId(executionId));
|
||||
executionDataStore.setExecution(
|
||||
createTestWorkflowExecutionResponse({
|
||||
id: executionId,
|
||||
finished: false,
|
||||
status: 'running',
|
||||
workflowData: createTestWorkflow({ nodes }),
|
||||
data: { resultData: { runData, lastNodeExecuted: '' } } as never,
|
||||
}),
|
||||
);
|
||||
useWorkflowExecutionStateStore(docId).setActiveExecutionId(executionId);
|
||||
}
|
||||
|
||||
// The composable is side-effectful and registers `onScopeDispose`, so it must
|
||||
// run inside an `effectScope` (as it does in production). The returned `scope`
|
||||
// lets a test exercise teardown via `scope.stop()`.
|
||||
function createRenderData(docId: WorkflowDocumentId) {
|
||||
const scope = effectScope();
|
||||
const renderData = scope.run(() => useWorkflowDocumentRenderData(docId))!;
|
||||
return { renderData, scope };
|
||||
}
|
||||
|
||||
describe('useWorkflowDocumentRenderData — passthroughs', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.mocked(useWorkflowDocumentStore).mockClear();
|
||||
vi.mocked(useWorkflowExecutionStateStore).mockClear();
|
||||
});
|
||||
|
||||
it('passes through nodeInputsByNodeId and nodeOutputsByNodeId by reference', () => {
|
||||
const renderData = useWorkflowDocumentRenderData(createWorkflowDocumentId('wf-1'));
|
||||
it('re-exposes workflowDocument by-id projections by identity', () => {
|
||||
const { docId, doc } = setupWorkflow('wf-pass', [{ id: 'a', name: 'Alpha' }]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
expect(renderData.nodeInputsByNodeId).toBe(nodeInputsByNodeId);
|
||||
expect(renderData.nodeOutputsByNodeId).toBe(nodeOutputsByNodeId);
|
||||
expect(renderData.nodeInputsByNodeId).toBe(doc.nodeInputsByNodeId);
|
||||
expect(renderData.nodeOutputsByNodeId).toBe(doc.nodeOutputsByNodeId);
|
||||
expect(renderData.pinnedDataByNodeName).toBe(doc.pinnedDataByNodeName);
|
||||
expect(renderData.pinnedDataByNodeId).toBe(doc.pinnedDataByNodeId);
|
||||
expect(renderData.validationErrorsByNodeId).toBe(doc.validationErrorsByNodeId);
|
||||
});
|
||||
|
||||
it('passes through pinnedDataByNodeName by reference', () => {
|
||||
const renderData = useWorkflowDocumentRenderData(createWorkflowDocumentId('wf-1'));
|
||||
it('owns per-node-id node-type derivation maps locally', () => {
|
||||
const { docId } = setupWorkflow('wf-nodetype-info', [{ id: 'a', name: 'Alpha' }]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
expect(renderData.pinnedDataByNodeName).toBe(pinnedDataByNodeName);
|
||||
expect(renderData.nodeTypeDescriptionByNodeId.has('a')).toBe(true);
|
||||
expect(renderData.isTriggerByNodeId.has('a')).toBe(true);
|
||||
expect(renderData.subtitleByNodeId.has('a')).toBe(true);
|
||||
expect(renderData.simulatedNodeTypeDescriptionByNodeId.has('a')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes executionIssuesByNodeName resolved via the workflow execution state store', () => {
|
||||
const renderData = useWorkflowDocumentRenderData(createWorkflowDocumentId('wf-1'));
|
||||
describe('useWorkflowDocumentRenderData — fusion projections', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it('initializes fusion maps for nodes present at construction time', () => {
|
||||
const { docId } = setupWorkflow('wf-fusion-init', [
|
||||
{ id: 'a', name: 'Alpha' },
|
||||
{ id: 'b', name: 'Beta' },
|
||||
]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
expect(renderData.tooltipByNodeId.has('a')).toBe(true);
|
||||
expect(renderData.tooltipByNodeId.has('b')).toBe(true);
|
||||
expect(renderData.hasIssuesByNodeId.has('a')).toBe(true);
|
||||
expect(renderData.hasIssuesByNodeId.has('b')).toBe(true);
|
||||
expect(renderData.renderTypeByNodeId.has('a')).toBe(true);
|
||||
expect(renderData.renderTypeByNodeId.has('b')).toBe(true);
|
||||
});
|
||||
|
||||
it('reconciles fusion maps when a node is added', () => {
|
||||
const { docId, doc } = setupWorkflow('wf-fusion-add', [{ id: 'a', name: 'Alpha' }]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
doc.addNode(createTestNode({ id: 'b', name: 'Beta', type: 'test' }));
|
||||
|
||||
expect(renderData.tooltipByNodeId.has('b')).toBe(true);
|
||||
expect(renderData.hasIssuesByNodeId.has('b')).toBe(true);
|
||||
expect(renderData.renderTypeByNodeId.has('b')).toBe(true);
|
||||
});
|
||||
|
||||
it('reconciles fusion maps when a node is removed', () => {
|
||||
const { docId, doc } = setupWorkflow('wf-fusion-remove', [
|
||||
{ id: 'a', name: 'Alpha' },
|
||||
{ id: 'b', name: 'Beta' },
|
||||
]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
doc.removeNodeById('a');
|
||||
|
||||
expect(renderData.tooltipByNodeId.has('a')).toBe(false);
|
||||
expect(renderData.hasIssuesByNodeId.has('a')).toBe(false);
|
||||
expect(renderData.renderTypeByNodeId.has('a')).toBe(false);
|
||||
expect(renderData.tooltipByNodeId.has('b')).toBe(true);
|
||||
});
|
||||
|
||||
expect(renderData.executionIssuesByNodeName).toBe(executionIssuesByNodeName);
|
||||
it('keeps hasIssuesByNodeId false for clean nodes with no execution data', () => {
|
||||
const { docId } = setupWorkflow('wf-fusion-clean', [{ id: 'a', name: 'Alpha' }]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(false);
|
||||
});
|
||||
|
||||
it('returns undefined tooltip for non-trigger nodes', () => {
|
||||
const { docId } = setupWorkflow('wf-fusion-tooltip', [{ id: 'a', name: 'Alpha' }]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
expect(renderData.tooltipByNodeId.get('a')?.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the default render type for a generic node', () => {
|
||||
const { docId } = setupWorkflow('wf-fusion-default', [{ id: 'a', name: 'Alpha' }]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
const render = renderData.renderTypeByNodeId.get('a')?.value;
|
||||
expect(render?.type).toBe('default');
|
||||
});
|
||||
|
||||
it('returns a sticky-note render type for sticky nodes', () => {
|
||||
const { docId } = setupWorkflow('wf-fusion-sticky', [
|
||||
{ id: 's', name: 'Sticky', type: 'n8n-nodes-base.stickyNote' },
|
||||
]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
const render = renderData.renderTypeByNodeId.get('s')?.value;
|
||||
expect(render?.type).toBe('n8n-nodes-base.stickyNote');
|
||||
});
|
||||
|
||||
it('assigns z-index entries only for sticky notes via additionalPropertiesByNodeId', () => {
|
||||
const { docId } = setupWorkflow('wf-fusion-additional', [
|
||||
{ id: 's1', name: 'Sticky1', type: 'n8n-nodes-base.stickyNote' },
|
||||
{ id: 'n1', name: 'Node1', type: 'test' },
|
||||
]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
const props = renderData.additionalPropertiesByNodeId.value;
|
||||
expect(props.s1).toBeDefined();
|
||||
expect(props.n1).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useWorkflowDocumentRenderData — hasIssuesByNodeId precedence', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
const alpha = () => createTestNode({ id: 'a', name: 'Alpha' });
|
||||
|
||||
it('reports issues when the execution status is crashed', () => {
|
||||
const { docId } = setupWorkflow('wf-issues-crashed', [{ id: 'a', name: 'Alpha' }]);
|
||||
setActiveExecution(docId, [alpha()], { Alpha: [{ executionStatus: 'crashed' }] });
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(true);
|
||||
});
|
||||
|
||||
it('reports issues when the execution status is error', () => {
|
||||
const { docId } = setupWorkflow('wf-issues-error', [{ id: 'a', name: 'Alpha' }]);
|
||||
setActiveExecution(docId, [alpha()], { Alpha: [{ executionStatus: 'error' }] });
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no issues when the node has pinned data despite validation issues', () => {
|
||||
const { docId, doc } = setupWorkflow('wf-issues-pinned', [
|
||||
{ id: 'a', name: 'Alpha', issues: { typeUnknown: true } },
|
||||
]);
|
||||
doc.pinNodeData('Alpha', [{ json: {} }]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
// The node genuinely has validation errors — pinned data must still win.
|
||||
expect(renderData.validationErrorsByNodeId.get('a')?.value?.length).toBeGreaterThan(0);
|
||||
expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(false);
|
||||
});
|
||||
|
||||
it('reports issues for validation errors when there is no pinned data', () => {
|
||||
const { docId } = setupWorkflow('wf-issues-validation', [
|
||||
{ id: 'a', name: 'Alpha', issues: { typeUnknown: true } },
|
||||
]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(true);
|
||||
});
|
||||
|
||||
it('reports issues when the active execution reports execution errors', () => {
|
||||
const { docId } = setupWorkflow('wf-issues-execution', [{ id: 'a', name: 'Alpha' }]);
|
||||
setActiveExecution(docId, [alpha()], {
|
||||
Alpha: [{ executionStatus: 'error', error: { message: 'boom' } }],
|
||||
});
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no issues when a successful run follows an errored one', () => {
|
||||
const { docId } = setupWorkflow('wf-issues-retry', [{ id: 'a', name: 'Alpha' }]);
|
||||
setActiveExecution(docId, [alpha()], {
|
||||
Alpha: [
|
||||
{ executionStatus: 'error', error: { message: 'boom' } },
|
||||
{ executionStatus: 'success' },
|
||||
],
|
||||
});
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useWorkflowDocumentRenderData — trigger tooltip', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
function setupRunningWorkflow(id: string, nodes: Array<Partial<INodeUi>>) {
|
||||
const result = setupWorkflow(id, nodes);
|
||||
// `null` marks an execution as started with its id still pending, which
|
||||
// flips `isWorkflowRunning` without needing execution data.
|
||||
useWorkflowExecutionStateStore(result.docId).setActiveExecutionId(null);
|
||||
return result;
|
||||
}
|
||||
|
||||
it('shows the waiting tooltip on the single active trigger while the workflow is running', () => {
|
||||
const { docId } = setupRunningWorkflow('wf-tooltip-shown', [
|
||||
{ id: 't', name: 'Trigger', type: TEST_TRIGGER_NODE_TYPE },
|
||||
{ id: 'a', name: 'Alpha' },
|
||||
]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
expect(renderData.tooltipByNodeId.get('t')?.value).toBe(
|
||||
'Waiting for you to create an event in Test',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not show a tooltip when the trigger has pinned data', () => {
|
||||
const { docId, doc } = setupRunningWorkflow('wf-tooltip-pinned', [
|
||||
{ id: 't', name: 'Trigger', type: TEST_TRIGGER_NODE_TYPE },
|
||||
]);
|
||||
doc.pinNodeData('Trigger', [{ json: {} }]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
expect(renderData.tooltipByNodeId.get('t')?.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not show a tooltip when the workflow is not running', () => {
|
||||
const { docId } = setupWorkflow('wf-tooltip-idle', [
|
||||
{ id: 't', name: 'Trigger', type: TEST_TRIGGER_NODE_TYPE },
|
||||
]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
expect(renderData.tooltipByNodeId.get('t')?.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not show a tooltip on disabled triggers', () => {
|
||||
const { docId } = setupRunningWorkflow('wf-tooltip-disabled', [
|
||||
{ id: 't', name: 'Trigger', type: TEST_TRIGGER_NODE_TYPE, disabled: true },
|
||||
]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
expect(renderData.tooltipByNodeId.get('t')?.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('suppresses tooltips when multiple triggers are active and the driving trigger is unknown', () => {
|
||||
const { docId } = setupRunningWorkflow('wf-tooltip-multi', [
|
||||
{ id: 't1', name: 'Trigger 1', type: TEST_TRIGGER_NODE_TYPE },
|
||||
{ id: 't2', name: 'Trigger 2', type: TEST_TRIGGER_NODE_TYPE },
|
||||
]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
expect(renderData.tooltipByNodeId.get('t1')?.value).toBeUndefined();
|
||||
expect(renderData.tooltipByNodeId.get('t2')?.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useWorkflowDocumentRenderData — sticky z-index ordering', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
// Grid-aligned positions so the document store's snap-to-grid is a no-op
|
||||
// and the overlap geometry below is exact.
|
||||
function sticky(id: string, position: [number, number], size: number): Partial<INodeUi> {
|
||||
return {
|
||||
id,
|
||||
name: `Sticky ${id}`,
|
||||
type: STICKY_NODE_TYPE,
|
||||
position,
|
||||
parameters: { width: size, height: size },
|
||||
};
|
||||
}
|
||||
|
||||
it('assigns the base z-index to a single sticky', () => {
|
||||
const { docId } = setupWorkflow('wf-sticky-single', [sticky('s1', [0, 0], 100)]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
expect(renderData.additionalPropertiesByNodeId.value.s1).toEqual({
|
||||
style: { zIndex: -100 },
|
||||
});
|
||||
});
|
||||
|
||||
it('assigns sequential z-indexes to non-overlapping stickies', () => {
|
||||
const { docId } = setupWorkflow('wf-sticky-apart', [
|
||||
sticky('s1', [0, 0], 100),
|
||||
sticky('s2', [320, 320], 100),
|
||||
]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
const props = renderData.additionalPropertiesByNodeId.value;
|
||||
expect(props.s1).toEqual({ style: { zIndex: -100 } });
|
||||
expect(props.s2).toEqual({ style: { zIndex: -99 } });
|
||||
});
|
||||
|
||||
it('raises a smaller sticky above a larger overlapping one', () => {
|
||||
const { docId } = setupWorkflow('wf-sticky-overlap', [
|
||||
sticky('small', [48, 48], 100),
|
||||
sticky('large', [0, 0], 160),
|
||||
]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
const props = renderData.additionalPropertiesByNodeId.value;
|
||||
expect(props.large).toEqual({ style: { zIndex: -100 } });
|
||||
expect(props.small).toEqual({ style: { zIndex: -99 } });
|
||||
});
|
||||
|
||||
it('orders multiple overlapping stickies by area with smaller ones on top', () => {
|
||||
const { docId } = setupWorkflow('wf-sticky-multi', [
|
||||
sticky('s1', [0, 0], 100),
|
||||
sticky('s2', [32, 32], 50),
|
||||
sticky('s3', [48, 48], 100),
|
||||
]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
const props = renderData.additionalPropertiesByNodeId.value;
|
||||
expect(props.s1).toEqual({ style: { zIndex: -100 } });
|
||||
expect(props.s2).toEqual({ style: { zIndex: -98 } });
|
||||
expect(props.s3).toEqual({ style: { zIndex: -99 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useWorkflowDocumentRenderData — lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it('constructs without inject() warnings outside a component context', () => {
|
||||
// Production constructs this composable in detached effect scopes (watch
|
||||
// callbacks in WorkflowCanvas.vue / useWorkflowDiff.ts), where Vue's
|
||||
// inject() is unavailable. Nothing in the construction path may inject.
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
const { docId } = setupWorkflow('wf-inject', [{ id: 'a', name: 'Alpha' }]);
|
||||
createRenderData(docId);
|
||||
const warnings = warnSpy.mock.calls.flat().filter((arg) => typeof arg === 'string');
|
||||
expect(warnings.join('\n')).not.toContain('inject() can only be used');
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops reconciling once its scope is disposed', () => {
|
||||
const { docId, doc } = setupWorkflow('wf-teardown', [{ id: 'a', name: 'Alpha' }]);
|
||||
const { renderData, scope } = createRenderData(docId);
|
||||
|
||||
expect(renderData.renderTypeByNodeId.has('a')).toBe(true);
|
||||
|
||||
scope.stop();
|
||||
doc.addNode(createTestNode({ id: 'b', name: 'Beta', type: 'test' }));
|
||||
|
||||
// The `onNodesChange` subscription was removed on teardown, so the node
|
||||
// added after disposal is not reconciled into the maps.
|
||||
expect(renderData.renderTypeByNodeId.has('b')).toBe(false);
|
||||
expect(renderData.tooltipByNodeId.has('b')).toBe(false);
|
||||
expect(renderData.hasIssuesByNodeId.has('b')).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes active-execution projections as live getters, not captured snapshots', () => {
|
||||
const { docId } = setupWorkflow('wf-fresh', [{ id: 'a', name: 'Alpha' }]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
const executionStateStore = useWorkflowExecutionStateStore(docId);
|
||||
|
||||
// With no execution, the getter resolves to the store's current map.
|
||||
const emptyMap = renderData.executionStatusByNodeId;
|
||||
expect(emptyMap).toBe(executionStateStore.activeExecutionStatusByNodeId);
|
||||
|
||||
// Swapping the resolved execution changes the source map identity; the
|
||||
// getter must reflect the new map rather than a value captured at setup.
|
||||
executionStateStore.setActiveExecutionId('exec-fresh');
|
||||
expect(renderData.executionStatusByNodeId).toBe(
|
||||
executionStateStore.activeExecutionStatusByNodeId,
|
||||
);
|
||||
expect(renderData.executionStatusByNodeId).not.toBe(emptyMap);
|
||||
});
|
||||
|
||||
it('exposes pinnedDataByNodeName as a live getter that computeds track across pin/unpin', () => {
|
||||
const { docId, doc } = setupWorkflow('wf-pin-live', [{ id: 'a', name: 'Alpha' }]);
|
||||
const { renderData } = createRenderData(docId);
|
||||
|
||||
it('uses the exact workflow document id when resolving execution state', () => {
|
||||
const documentId = createWorkflowDocumentId('wf-1', 'ver-123');
|
||||
// Mirrors the canvas pin-badge computed in CanvasNodeStatusIcons /
|
||||
// CanvasNodeDefault: `!!renderData.value.pinnedDataByNodeName[name]`.
|
||||
const hasPinnedData = computed(() => !!renderData.pinnedDataByNodeName.Alpha);
|
||||
expect(hasPinnedData.value).toBe(false);
|
||||
|
||||
useWorkflowDocumentRenderData(documentId);
|
||||
// Pin mutations replace the store ref's value; a snapshot captured at
|
||||
// setup would keep returning the pre-pin object and the computed would
|
||||
// never invalidate.
|
||||
doc.pinNodeData('Alpha', [{ json: { value: 1 } }]);
|
||||
expect(hasPinnedData.value).toBe(true);
|
||||
|
||||
expect(useWorkflowExecutionStateStore).toHaveBeenCalledWith(documentId);
|
||||
doc.unpinNodeData('Alpha');
|
||||
expect(hasPinnedData.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+504
-15
@@ -1,33 +1,522 @@
|
||||
import { computed, effectScope, onScopeDispose, shallowReactive, type ComputedRef } from 'vue';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { structuralComputed } from '@n8n/composables/structuralComputed';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import {
|
||||
useWorkflowDocumentStore,
|
||||
type WorkflowDocumentId,
|
||||
} from '@/app/stores/workflowDocument.store';
|
||||
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useNodeDirtiness } from '@/app/composables/useNodeDirtiness';
|
||||
import { getNodeIconSource } from '@/app/utils/nodeIcon';
|
||||
import { getNodeSubtitle, getTriggerNodeServiceName } from '@/app/utils/nodeTypesUtils';
|
||||
import {
|
||||
CUSTOM_API_CALL_KEY,
|
||||
SIMULATE_NODE_TYPE,
|
||||
SIMULATE_TRIGGER_NODE_TYPE,
|
||||
STICKY_NODE_TYPE,
|
||||
} from '@/app/constants';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import { checkOverlap } from '@/features/workflows/canvas/canvas.utils';
|
||||
import type {
|
||||
BoundingBox,
|
||||
CanvasNode,
|
||||
CanvasNodeAddNodesRender,
|
||||
CanvasNodeChoicePromptRender,
|
||||
CanvasNodeData,
|
||||
CanvasNodeDefaultRender,
|
||||
CanvasNodeStickyNoteRender,
|
||||
} from '@/features/workflows/canvas/canvas.types';
|
||||
import { CanvasNodeRenderType } from '@/features/workflows/canvas/canvas.types';
|
||||
import { CHANGE_ACTION } from './types';
|
||||
import type {
|
||||
NodeAddedPayload,
|
||||
NodeRemovedPayload,
|
||||
NodesChangeEvent,
|
||||
NodesSetPayload,
|
||||
} from './useWorkflowDocumentNodes';
|
||||
|
||||
/**
|
||||
* Canvas render data accessor for a workflow document.
|
||||
*
|
||||
* Thin façade that re-exposes:
|
||||
* - the per-node port maps (`nodeInputsByNodeId`, `nodeOutputsByNodeId`)
|
||||
* owned by `useWorkflowDocumentNodes`,
|
||||
* - the pin data map (`pinnedDataByNodeName`) owned by
|
||||
* `useWorkflowDocumentPinData`, and
|
||||
* - the active execution's `executionIssuesByNodeName` map.
|
||||
* Provides a single object combining:
|
||||
* - by-node-id passthroughs from `workflowDocument` (port maps, pin data,
|
||||
* validation errors) and `workflowExecutionState` (status / run data /
|
||||
* output map / waiting / running / waiting-for-next / issues — each with
|
||||
* active/displayed-execution fallback);
|
||||
* - per-node-id derivations from the workflow document
|
||||
* (`nodeTypeDescriptionByNodeId`, `isTriggerByNodeId`, `subtitleByNodeId`,
|
||||
* `simulatedNodeTypeDescriptionByNodeId`);
|
||||
* - **fusion projections** that combine state from both stores into the
|
||||
* canvas-shaped outputs the renderer consumes: `tooltipByNodeId`,
|
||||
* `hasIssuesByNodeId`, `additionalPropertiesByNodeId`,
|
||||
* `renderTypeByNodeId`.
|
||||
*
|
||||
* Reactivity is owned by the underlying stores: the port maps are stable
|
||||
* references on the workflow document store, `pinnedDataByNodeName` is a
|
||||
* `shallowReactive` with per-key reactivity, and `executionIssuesByNodeName`
|
||||
* resolves through a `computed` on the workflow execution state store that
|
||||
* swaps between the active and displayed execution's per-execution maps.
|
||||
* Per-entry maps use a `shallowReactive<Map<id, ComputedRef<T>>>` pattern:
|
||||
* each entry runs in its own `effectScope` and `structuralComputed` gates
|
||||
* downstream propagation. Entry lifecycle is driven by the document store's
|
||||
* `onNodesChange` event, so add/remove are O(1) and updates re-evaluate
|
||||
* lazily.
|
||||
*
|
||||
* Lifecycle: this is side-effectful (subscribes to `onNodesChange`, creates
|
||||
* per-node scopes), so it must be invoked **once per document id inside an
|
||||
* `effectScope` the caller owns** — never inside a re-evaluating `computed`.
|
||||
* Stopping that scope runs the `onScopeDispose` teardown below. See
|
||||
* `WorkflowCanvas.vue` and `useWorkflowDiff.ts` for the call pattern.
|
||||
*/
|
||||
export function useWorkflowDocumentRenderData(workflowDocumentId: WorkflowDocumentId) {
|
||||
const workflowDocumentStore = useWorkflowDocumentStore(workflowDocumentId);
|
||||
const executionStateStore = useWorkflowExecutionStateStore(workflowDocumentId);
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const i18n = useI18n();
|
||||
const { dirtinessByName } = useNodeDirtiness(workflowDocumentId);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Per-entry maps reconciled off the document store's `onNodesChange`.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const nodeTypeDescriptionByNodeId = shallowReactive(
|
||||
new Map<string, ComputedRef<INodeTypeDescription | null>>(),
|
||||
);
|
||||
const isTriggerByNodeId = shallowReactive(new Map<string, ComputedRef<boolean>>());
|
||||
const subtitleByNodeId = shallowReactive(new Map<string, ComputedRef<string>>());
|
||||
const simulatedNodeTypeDescriptionByNodeId = shallowReactive(
|
||||
new Map<string, ComputedRef<INodeTypeDescription | null>>(),
|
||||
);
|
||||
const tooltipByNodeId = shallowReactive(new Map<string, ComputedRef<string | undefined>>());
|
||||
const hasIssuesByNodeId = shallowReactive(new Map<string, ComputedRef<boolean>>());
|
||||
const renderTypeByNodeId = shallowReactive(
|
||||
new Map<string, ComputedRef<CanvasNodeData['render']>>(),
|
||||
);
|
||||
const entryScopes = new Map<string, () => void>();
|
||||
|
||||
function getNode(nodeId: string): INodeUi | undefined {
|
||||
return workflowDocumentStore.nodesById.get(nodeId);
|
||||
}
|
||||
|
||||
// --- nodeTypeDescriptionByNodeId / isTriggerByNodeId / subtitleByNodeId / simulated ---
|
||||
|
||||
function computeNodeTypeDescription(nodeId: string): INodeTypeDescription | null {
|
||||
const node = getNode(nodeId);
|
||||
if (!node) return null;
|
||||
return (
|
||||
nodeTypesStore.getNodeType(node.type, node.typeVersion) ??
|
||||
nodeTypesStore.communityNodeType(node.type)?.nodeDescription ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function computeIsTrigger(nodeId: string): boolean {
|
||||
const node = getNode(nodeId);
|
||||
if (!node) return false;
|
||||
return nodeTypesStore.isTriggerNode(node.type);
|
||||
}
|
||||
|
||||
function computeSubtitle(nodeId: string): string {
|
||||
const node = getNode(nodeId);
|
||||
if (!node) return '';
|
||||
try {
|
||||
const nodeTypeDescription = computeNodeTypeDescription(nodeId);
|
||||
if (!nodeTypeDescription) return '';
|
||||
|
||||
const subtitle = getNodeSubtitle(
|
||||
node,
|
||||
nodeTypeDescription,
|
||||
workflowDocumentStore.getWorkflowObjectAccessorSnapshot(),
|
||||
);
|
||||
if (subtitle === undefined) return '';
|
||||
// Subtitles that resolve to the "custom API call" placeholder are noise — hide them.
|
||||
if (subtitle.includes(CUSTOM_API_CALL_KEY)) return '';
|
||||
return subtitle;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function computeSimulatedNodeTypeDescription(nodeId: string): INodeTypeDescription | null {
|
||||
const node = getNode(nodeId);
|
||||
if (!node) return null;
|
||||
if (node.type !== SIMULATE_NODE_TYPE && node.type !== SIMULATE_TRIGGER_NODE_TYPE) return null;
|
||||
|
||||
const icon = node.parameters?.icon as string;
|
||||
const iconValue = workflowDocumentStore
|
||||
.getExpressionHandler()
|
||||
.getSimpleParameterValue(node, icon, 'internal', {});
|
||||
if (iconValue && typeof iconValue === 'string') {
|
||||
return nodeTypesStore.getNodeType(iconValue) ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- tooltipByNodeId -----------------------------------------------------
|
||||
// Original: useCanvasMapping `nodeTooltipById`. Surfaces a "waiting for
|
||||
// trigger event" tooltip on the active trigger node while a workflow is
|
||||
// running. Hides itself when more than one trigger is active and the
|
||||
// driving trigger isn't identified yet, when the node is disabled, when
|
||||
// it has pinned data, or when it has already produced/errored output.
|
||||
function computeTooltip(nodeId: string): string | undefined {
|
||||
if (!executionStateStore.isWorkflowRunning) return undefined;
|
||||
const node = getNode(nodeId);
|
||||
if (!node) return undefined;
|
||||
|
||||
const isTrigger = isTriggerByNodeId.get(nodeId)?.value ?? false;
|
||||
const nodeTypeDescription = nodeTypeDescriptionByNodeId.get(nodeId)?.value ?? null;
|
||||
if (!isTrigger || !nodeTypeDescription) return undefined;
|
||||
|
||||
const triggerNodeName = executionStateStore.activeExecution?.triggerNode;
|
||||
// Count active (non-disabled) triggers; when ambiguous and the driving
|
||||
// trigger isn't named, suppress all tooltips.
|
||||
if (triggerNodeName === undefined) {
|
||||
let activeTriggerCount = 0;
|
||||
for (const [id] of isTriggerByNodeId) {
|
||||
const n = workflowDocumentStore.nodesById.get(id);
|
||||
if (!n) continue;
|
||||
if (isTriggerByNodeId.get(id)?.value && !n.disabled) {
|
||||
activeTriggerCount += 1;
|
||||
}
|
||||
}
|
||||
if (activeTriggerCount !== 1) return undefined;
|
||||
} else if (triggerNodeName !== node.name) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (node.disabled) return undefined;
|
||||
const status = executionStateStore.activeExecutionStatusByNodeId.get(nodeId)?.value ?? 'new';
|
||||
if (!['new', 'unknown', 'waiting'].includes(status)) return undefined;
|
||||
const pinned = workflowDocumentStore.pinnedDataByNodeId.get(nodeId)?.value;
|
||||
if (pinned) return undefined;
|
||||
|
||||
if (typeof nodeTypeDescription.eventTriggerDescription === 'string') {
|
||||
const nodeName = i18n.shortNodeType(nodeTypeDescription.name);
|
||||
return i18n
|
||||
.nodeText(nodeTypeDescription.name)
|
||||
.eventTriggerDescription(nodeName, nodeTypeDescription.eventTriggerDescription ?? '');
|
||||
}
|
||||
return i18n.baseText('node.waitingForYouToCreateAnEventIn', {
|
||||
interpolate: {
|
||||
nodeType: getTriggerNodeServiceName(nodeTypeDescription),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- hasIssuesByNodeId ---------------------------------------------------
|
||||
// Original: useCanvasMapping `nodeHasIssuesById`. Decides whether the node
|
||||
// renders with the "has issues" affordance. Hard-error execution statuses
|
||||
// (crashed/error) win first — a pinned node that crashed still shows issues.
|
||||
// Otherwise pinned data clears issues (false), then validation errors, the
|
||||
// execution-issue map, and finally a last-task `.error` check.
|
||||
function computeHasIssues(nodeId: string): boolean {
|
||||
const node = getNode(nodeId);
|
||||
if (!node) return false;
|
||||
|
||||
const status = executionStateStore.activeExecutionStatusByNodeId.get(nodeId)?.value ?? 'new';
|
||||
if (status === 'crashed' || status === 'error') return true;
|
||||
|
||||
const pinned = workflowDocumentStore.pinnedDataByNodeId.get(nodeId)?.value;
|
||||
if (pinned) return false;
|
||||
|
||||
const validationErrors =
|
||||
workflowDocumentStore.validationErrorsByNodeId.get(nodeId)?.value ?? [];
|
||||
if (validationErrors.length > 0) return true;
|
||||
|
||||
const executionIssues =
|
||||
executionStateStore.activeExecutionIssuesByNodeName.get(node.name)?.value ?? [];
|
||||
if (executionIssues.length > 0) return true;
|
||||
|
||||
const tasks = executionStateStore.activeExecutionRunDataByNodeId.get(nodeId)?.value ?? null;
|
||||
return Boolean(tasks?.at(-1)?.error);
|
||||
}
|
||||
|
||||
// --- renderTypeByNodeId --------------------------------------------------
|
||||
// Original: useCanvasMapping `renderTypeByNodeId` + create*RenderType
|
||||
// helpers. Returns the canvas render type/options the renderer reads.
|
||||
function createStickyNoteRenderType(node: INodeUi): CanvasNodeStickyNoteRender {
|
||||
return {
|
||||
type: CanvasNodeRenderType.StickyNote,
|
||||
options: {
|
||||
width: node.parameters.width as number,
|
||||
height: node.parameters.height as number,
|
||||
color: node.parameters.color as number,
|
||||
content: node.parameters.content as string,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAddNodesRenderType(): CanvasNodeAddNodesRender {
|
||||
return { type: CanvasNodeRenderType.AddNodes, options: {} };
|
||||
}
|
||||
|
||||
function createChoicePromptRenderType(): CanvasNodeChoicePromptRender {
|
||||
return { type: CanvasNodeRenderType.ChoicePrompt, options: {} };
|
||||
}
|
||||
|
||||
function createDefaultNodeRenderType(node: INodeUi): CanvasNodeDefaultRender {
|
||||
const nodeType = nodeTypeDescriptionByNodeId.get(node.id)?.value ?? null;
|
||||
const simulated = simulatedNodeTypeDescriptionByNodeId.get(node.id)?.value ?? null;
|
||||
const iconSource = simulated ?? nodeType ?? node.type;
|
||||
const icon = getNodeIconSource(iconSource, node, workflowDocumentStore.getExpressionHandler());
|
||||
|
||||
const isTrigger = isTriggerByNodeId.get(node.id)?.value ?? false;
|
||||
const tooltip = tooltipByNodeId.get(node.id)?.value;
|
||||
|
||||
// Snapshot the workflow object accessors per-call. Reads inside the
|
||||
// snapshot factory access the underlying stores' refs, so the
|
||||
// surrounding structuralComputed picks up dependencies and re-evaluates
|
||||
// when they change.
|
||||
const workflowAccessors = workflowDocumentStore.getWorkflowObjectAccessorSnapshot();
|
||||
|
||||
return {
|
||||
type: CanvasNodeRenderType.Default,
|
||||
options: {
|
||||
trigger: isTrigger,
|
||||
configuration: nodeTypesStore.isConfigNode(workflowAccessors, node, node.type),
|
||||
configurable: nodeTypesStore.isConfigurableNode(
|
||||
workflowAccessors,
|
||||
node,
|
||||
node.type,
|
||||
node.typeVersion,
|
||||
),
|
||||
tooltip,
|
||||
dirtiness: dirtinessByName.value[node.name],
|
||||
icon,
|
||||
placeholder: node.placeholder,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function computeRenderType(nodeId: string): CanvasNodeData['render'] {
|
||||
const node = getNode(nodeId);
|
||||
if (!node) return { type: CanvasNodeRenderType.Default, options: {} };
|
||||
switch (node.type) {
|
||||
case `${CanvasNodeRenderType.StickyNote}`:
|
||||
return createStickyNoteRenderType(node);
|
||||
case `${CanvasNodeRenderType.AddNodes}`:
|
||||
return createAddNodesRenderType();
|
||||
case `${CanvasNodeRenderType.ChoicePrompt}`:
|
||||
return createChoicePromptRenderType();
|
||||
default:
|
||||
return createDefaultNodeRenderType(node);
|
||||
}
|
||||
}
|
||||
|
||||
function applyAddEntry(nodeId: string) {
|
||||
if (entryScopes.has(nodeId)) return;
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
nodeTypeDescriptionByNodeId.set(
|
||||
nodeId,
|
||||
structuralComputed(() => computeNodeTypeDescription(nodeId), isEqual),
|
||||
);
|
||||
isTriggerByNodeId.set(
|
||||
nodeId,
|
||||
structuralComputed(() => computeIsTrigger(nodeId)),
|
||||
);
|
||||
subtitleByNodeId.set(
|
||||
nodeId,
|
||||
structuralComputed(() => computeSubtitle(nodeId)),
|
||||
);
|
||||
simulatedNodeTypeDescriptionByNodeId.set(
|
||||
nodeId,
|
||||
structuralComputed(() => computeSimulatedNodeTypeDescription(nodeId), isEqual),
|
||||
);
|
||||
tooltipByNodeId.set(
|
||||
nodeId,
|
||||
structuralComputed(() => computeTooltip(nodeId)),
|
||||
);
|
||||
hasIssuesByNodeId.set(
|
||||
nodeId,
|
||||
structuralComputed(() => computeHasIssues(nodeId)),
|
||||
);
|
||||
renderTypeByNodeId.set(
|
||||
nodeId,
|
||||
structuralComputed(() => computeRenderType(nodeId), isEqual),
|
||||
);
|
||||
});
|
||||
entryScopes.set(nodeId, () => scope.stop());
|
||||
}
|
||||
|
||||
function applyRemoveEntry(nodeId: string) {
|
||||
entryScopes.get(nodeId)?.();
|
||||
entryScopes.delete(nodeId);
|
||||
nodeTypeDescriptionByNodeId.delete(nodeId);
|
||||
isTriggerByNodeId.delete(nodeId);
|
||||
subtitleByNodeId.delete(nodeId);
|
||||
simulatedNodeTypeDescriptionByNodeId.delete(nodeId);
|
||||
tooltipByNodeId.delete(nodeId);
|
||||
hasIssuesByNodeId.delete(nodeId);
|
||||
renderTypeByNodeId.delete(nodeId);
|
||||
}
|
||||
|
||||
function applyReconcileEntries(nodeIds: string[]) {
|
||||
const next = new Set(nodeIds);
|
||||
for (const old of entryScopes.keys()) {
|
||||
if (!next.has(old)) applyRemoveEntry(old);
|
||||
}
|
||||
for (const id of nodeIds) applyAddEntry(id);
|
||||
}
|
||||
|
||||
let nodesChangeSubscription: { off: () => void } | undefined;
|
||||
if (typeof workflowDocumentStore.onNodesChange === 'function') {
|
||||
nodesChangeSubscription = workflowDocumentStore.onNodesChange((event: NodesChangeEvent) => {
|
||||
switch (event.action) {
|
||||
case CHANGE_ACTION.ADD: {
|
||||
const { node } = event.payload as NodeAddedPayload;
|
||||
applyAddEntry(node.id);
|
||||
break;
|
||||
}
|
||||
case CHANGE_ACTION.DELETE: {
|
||||
const payload = event.payload as NodeRemovedPayload;
|
||||
if (payload.id) applyRemoveEntry(payload.id);
|
||||
else applyReconcileEntries([]);
|
||||
break;
|
||||
}
|
||||
case CHANGE_ACTION.SET: {
|
||||
const { nodeIds } = event.payload as NodesSetPayload;
|
||||
applyReconcileEntries(nodeIds);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const initialIds = workflowDocumentStore.nodesById;
|
||||
if (initialIds && typeof initialIds.keys === 'function') {
|
||||
applyReconcileEntries(Array.from(initialIds.keys()));
|
||||
}
|
||||
|
||||
// Teardown. This composable is invoked once per document id inside a
|
||||
// caller-owned `effectScope` (see WorkflowCanvas.vue / useWorkflowDiff.ts).
|
||||
// When that scope stops — on document-id change or unmount — we remove the
|
||||
// `onNodesChange` subscription (otherwise the document store's event hook
|
||||
// keeps a closure referencing this instance's maps alive and keeps running
|
||||
// reconciliation) and stop every per-entry scope.
|
||||
onScopeDispose(() => {
|
||||
nodesChangeSubscription?.off();
|
||||
for (const stop of entryScopes.values()) stop();
|
||||
entryScopes.clear();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// `additionalPropertiesByNodeId` — sticky-note z-index overlap resolution.
|
||||
// Whole-list computation (changing one sticky can ripple to others via
|
||||
// overlap), so a single `computed` rather than per-entry maps. The result
|
||||
// is keyed by node id; consumers read individual slots.
|
||||
// -------------------------------------------------------------------------
|
||||
const additionalPropertiesByNodeId = computed(() => {
|
||||
type StickyBox = BoundingBox & { id: string; area: number; zIndex: number };
|
||||
const stickyZIndexBase = -100;
|
||||
|
||||
const stickyBoxes: StickyBox[] = [];
|
||||
for (const node of workflowDocumentStore.allNodes) {
|
||||
if (node.type !== STICKY_NODE_TYPE) continue;
|
||||
const width = node.parameters.width as number;
|
||||
const height = node.parameters.height as number;
|
||||
stickyBoxes.push({
|
||||
id: node.id,
|
||||
x: node.position[0],
|
||||
y: node.position[1],
|
||||
width,
|
||||
height,
|
||||
area: width * height,
|
||||
zIndex: stickyZIndexBase,
|
||||
});
|
||||
}
|
||||
|
||||
// Larger area first; assign baseline z-indexes in that order.
|
||||
stickyBoxes.sort((a, b) => b.area - a.area);
|
||||
stickyBoxes.forEach((box, idx) => {
|
||||
box.zIndex = stickyZIndexBase + idx;
|
||||
});
|
||||
|
||||
// Bump smaller overlapping stickies above their larger neighbours so
|
||||
// they remain interactive when stacked.
|
||||
for (let i = 0; i < stickyBoxes.length; i++) {
|
||||
for (let j = i + 1; j < stickyBoxes.length; j++) {
|
||||
const a = stickyBoxes[i];
|
||||
const b = stickyBoxes[j];
|
||||
if (!checkOverlap(a, b)) continue;
|
||||
if (a.area < b.area && a.zIndex <= b.zIndex) a.zIndex = b.zIndex + 1;
|
||||
else if (b.area < a.area && b.zIndex <= a.zIndex) b.zIndex = a.zIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const result: Record<string, Partial<CanvasNode>> = {};
|
||||
for (const box of stickyBoxes) {
|
||||
result[box.id] = { style: { zIndex: box.zIndex } };
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
return {
|
||||
nodeInputsByNodeId: workflowDocumentStore.nodeInputsByNodeId,
|
||||
nodeOutputsByNodeId: workflowDocumentStore.nodeOutputsByNodeId,
|
||||
pinnedDataByNodeName: workflowDocumentStore.pinnedDataByNodeName,
|
||||
executionIssuesByNodeName: executionStateStore.activeExecutionIssuesByNodeName,
|
||||
// --- workflowDocument projections ---
|
||||
// All exposed as getters, mirroring the execution-state block below.
|
||||
// `pinnedDataByNodeName` requires it: pin mutations *replace* the
|
||||
// underlying ref's value (immutable-update pattern), and this setup
|
||||
// runs once per document id in an untracked scope — a direct capture
|
||||
// would be a permanently stale snapshot. The getter re-resolves through
|
||||
// the store on each access, so consumer computeds track the ref and
|
||||
// invalidate on pin/unpin. The by-id maps are stable containers today,
|
||||
// but getters keep the store passthroughs uniform and stay correct if
|
||||
// any later becomes resolver-backed.
|
||||
get nodeInputsByNodeId() {
|
||||
return workflowDocumentStore.nodeInputsByNodeId;
|
||||
},
|
||||
get nodeOutputsByNodeId() {
|
||||
return workflowDocumentStore.nodeOutputsByNodeId;
|
||||
},
|
||||
get pinnedDataByNodeName() {
|
||||
return workflowDocumentStore.pinnedDataByNodeName;
|
||||
},
|
||||
get pinnedDataByNodeId() {
|
||||
return workflowDocumentStore.pinnedDataByNodeId;
|
||||
},
|
||||
get validationErrorsByNodeId() {
|
||||
return workflowDocumentStore.validationErrorsByNodeId;
|
||||
},
|
||||
|
||||
// --- node-type derivations (inlined) ---
|
||||
nodeTypeDescriptionByNodeId,
|
||||
isTriggerByNodeId,
|
||||
subtitleByNodeId,
|
||||
simulatedNodeTypeDescriptionByNodeId,
|
||||
|
||||
// --- workflowExecutionState projections ---
|
||||
// All exposed as getters. Setup runs once per document id, but the
|
||||
// `active*` maps swap identity when the active/displayed execution
|
||||
// changes, so they must re-resolve on access to stay reactive. The
|
||||
// running / waiting-for-next maps are stable today, but keeping every
|
||||
// execution-state passthrough a getter is uniform and stays correct if
|
||||
// any later becomes resolver-backed — and a getter over a stable ref
|
||||
// behaves identically to capturing it. (Consumers read these inside
|
||||
// their own computeds and none destructure them.)
|
||||
get executionIssuesByNodeName() {
|
||||
return executionStateStore.activeExecutionIssuesByNodeName;
|
||||
},
|
||||
get executionStatusByNodeId() {
|
||||
return executionStateStore.activeExecutionStatusByNodeId;
|
||||
},
|
||||
get executionRunDataByNodeId() {
|
||||
return executionStateStore.activeExecutionRunDataByNodeId;
|
||||
},
|
||||
get executionRunDataOutputMapByNodeId() {
|
||||
return executionStateStore.activeExecutionRunDataOutputMapByNodeId;
|
||||
},
|
||||
get executionWaitingByNodeId() {
|
||||
return executionStateStore.activeExecutionWaitingByNodeId;
|
||||
},
|
||||
get executionRunningByNodeId() {
|
||||
return executionStateStore.executionRunningByNodeId;
|
||||
},
|
||||
get executionWaitingForNextByNodeId() {
|
||||
return executionStateStore.executionWaitingForNextByNodeId;
|
||||
},
|
||||
|
||||
// --- multi-store fusion projections ---
|
||||
tooltipByNodeId,
|
||||
hasIssuesByNodeId,
|
||||
renderTypeByNodeId,
|
||||
additionalPropertiesByNodeId,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,18 @@
|
||||
import { defineStore, getActivePinia } from 'pinia';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import { computed, inject, readonly, ref, type ComputedRef } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
effectScope,
|
||||
inject,
|
||||
onScopeDispose,
|
||||
readonly,
|
||||
ref,
|
||||
shallowReactive,
|
||||
type ComputedRef,
|
||||
} from 'vue';
|
||||
import { createEventHook } from '@vueuse/core';
|
||||
import type { ExecutionSummary, IRunExecutionData } from 'n8n-workflow';
|
||||
import { structuralComputed } from '@n8n/composables/structuralComputed';
|
||||
import type { ExecutionStatus, ExecutionSummary, IRunExecutionData, ITaskData } from 'n8n-workflow';
|
||||
import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
|
||||
import type {
|
||||
IExecutionResponse,
|
||||
@@ -22,8 +32,19 @@ import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import { clearPopupWindowState } from '@/features/execution/executions/executions.utils';
|
||||
import { CHANGE_ACTION } from './workflowDocument/types';
|
||||
import type { ChangeAction, ChangeEvent } from './workflowDocument/types';
|
||||
import type {
|
||||
NodeAddedPayload,
|
||||
NodeRemovedPayload,
|
||||
NodesChangeEvent,
|
||||
NodesSetPayload,
|
||||
} from './workflowDocument/useWorkflowDocumentNodes';
|
||||
import type { ExecutionOutputMap } from '@/app/types/executionData';
|
||||
|
||||
const EMPTY_EXECUTION_ISSUES_BY_NODE_NAME = new Map<string, ComputedRef<string[]>>();
|
||||
const EMPTY_EXECUTION_STATUS_BY_NODE_ID = new Map<string, ComputedRef<ExecutionStatus>>();
|
||||
const EMPTY_EXECUTION_RUN_DATA_BY_NODE_ID = new Map<string, ComputedRef<ITaskData[] | null>>();
|
||||
const EMPTY_EXECUTION_RUN_DATA_OUTPUT_MAP_BY_NODE_ID = new Map<string, ExecutionOutputMap>();
|
||||
const EMPTY_EXECUTION_WAITING_BY_NODE_ID = new Map<string, ComputedRef<string | undefined>>();
|
||||
|
||||
export type WorkflowExecutionStateChangePayload = {
|
||||
documentId: WorkflowDocumentId;
|
||||
@@ -236,6 +257,38 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
|
||||
return EMPTY_EXECUTION_ISSUES_BY_NODE_NAME;
|
||||
});
|
||||
|
||||
// Active/displayed/pending fallback for the per-node-id execution data
|
||||
// projections. Resolves the backing execution id via
|
||||
// `getResolvedActiveExecutionId()` (string id → that execution, pending
|
||||
// `null` → IN_PROGRESS scaffold, else displayed id) so these stay
|
||||
// consistent with `activeExecutionRunData`; falls back to an empty Map
|
||||
// sentinel only when no execution is being tracked.
|
||||
|
||||
const activeExecutionStatusByNodeId = computed(() => {
|
||||
const executionId = getResolvedActiveExecutionId();
|
||||
if (!executionId) return EMPTY_EXECUTION_STATUS_BY_NODE_ID;
|
||||
return useExecutionDataStore(createExecutionDataId(executionId)).executionStatusByNodeId;
|
||||
});
|
||||
|
||||
const activeExecutionRunDataByNodeId = computed(() => {
|
||||
const executionId = getResolvedActiveExecutionId();
|
||||
if (!executionId) return EMPTY_EXECUTION_RUN_DATA_BY_NODE_ID;
|
||||
return useExecutionDataStore(createExecutionDataId(executionId)).executionRunDataByNodeId;
|
||||
});
|
||||
|
||||
const activeExecutionRunDataOutputMapByNodeId = computed(() => {
|
||||
const executionId = getResolvedActiveExecutionId();
|
||||
if (!executionId) return EMPTY_EXECUTION_RUN_DATA_OUTPUT_MAP_BY_NODE_ID;
|
||||
return useExecutionDataStore(createExecutionDataId(executionId))
|
||||
.executionRunDataOutputMapByNodeId;
|
||||
});
|
||||
|
||||
const activeExecutionWaitingByNodeId = computed(() => {
|
||||
const executionId = getResolvedActiveExecutionId();
|
||||
if (!executionId) return EMPTY_EXECUTION_WAITING_BY_NODE_ID;
|
||||
return useExecutionDataStore(createExecutionDataId(executionId)).executionWaitingByNodeId;
|
||||
});
|
||||
|
||||
const lastSuccessfulExecution = computed(() => {
|
||||
const lid = lastSuccessfulExecutionId.value;
|
||||
if (!lid) return null;
|
||||
@@ -255,6 +308,121 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
|
||||
return false;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Per-node-id "is this node mid-execution?" projections.
|
||||
//
|
||||
// Reconciled against the matching workflowDocument store's `onNodesChange`.
|
||||
// Each per-entry structuralComputed reads the `executingNode` refs
|
||||
// reactively, so add/remove calls invalidate only that entry — and only
|
||||
// when the *value* changes (gated by structural equality).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const documentStore = useWorkflowDocumentStore(documentId);
|
||||
|
||||
const executionRunningByNodeId = shallowReactive(new Map<string, ComputedRef<boolean>>());
|
||||
const executionWaitingForNextByNodeId = shallowReactive(
|
||||
new Map<string, ComputedRef<boolean>>(),
|
||||
);
|
||||
const runningScopes = new Map<string, () => void>();
|
||||
|
||||
function computeExecutionRunning(nodeId: string): boolean {
|
||||
// `nodesById` is a top-level shallowRef inside useWorkflowDocumentNodes;
|
||||
// Pinia unwraps it to a Map at the store boundary.
|
||||
const node = documentStore.nodesById.get(nodeId);
|
||||
if (!node) return false;
|
||||
return executingNode.isNodeExecuting(node.name);
|
||||
}
|
||||
|
||||
function computeExecutionWaitingForNext(nodeId: string): boolean {
|
||||
const node = documentStore.nodesById.get(nodeId);
|
||||
if (!node) return false;
|
||||
return (
|
||||
node.name === executingNode.lastAddedExecutingNode.value &&
|
||||
executingNode.executingNode.value.length === 0 &&
|
||||
isWorkflowRunning.value
|
||||
);
|
||||
}
|
||||
|
||||
function applyAddRunningEntry(nodeId: string) {
|
||||
if (runningScopes.has(nodeId)) return;
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
executionRunningByNodeId.set(
|
||||
nodeId,
|
||||
structuralComputed(() => computeExecutionRunning(nodeId)),
|
||||
);
|
||||
executionWaitingForNextByNodeId.set(
|
||||
nodeId,
|
||||
structuralComputed(() => computeExecutionWaitingForNext(nodeId)),
|
||||
);
|
||||
});
|
||||
runningScopes.set(nodeId, () => scope.stop());
|
||||
}
|
||||
|
||||
function applyRemoveRunningEntry(nodeId: string) {
|
||||
runningScopes.get(nodeId)?.();
|
||||
runningScopes.delete(nodeId);
|
||||
executionRunningByNodeId.delete(nodeId);
|
||||
executionWaitingForNextByNodeId.delete(nodeId);
|
||||
}
|
||||
|
||||
function applyReconcileRunningEntries(nodeIds: string[]) {
|
||||
const next = new Set(nodeIds);
|
||||
for (const old of runningScopes.keys()) {
|
||||
if (!next.has(old)) applyRemoveRunningEntry(old);
|
||||
}
|
||||
for (const id of nodeIds) applyAddRunningEntry(id);
|
||||
}
|
||||
|
||||
// Subscribe lazily and defensively. Some test files mock
|
||||
// `useWorkflowDocumentStore` with a partial object that lacks
|
||||
// `onNodesChange` / `nodesById`. The guard keeps the dependency soft for
|
||||
// tests that don't exercise the running maps; in production the document
|
||||
// store always provides the full surface.
|
||||
if (typeof documentStore.onNodesChange === 'function') {
|
||||
documentStore.onNodesChange((event: NodesChangeEvent) => {
|
||||
switch (event.action) {
|
||||
case CHANGE_ACTION.ADD: {
|
||||
const { node } = event.payload as NodeAddedPayload;
|
||||
applyAddRunningEntry(node.id);
|
||||
break;
|
||||
}
|
||||
case CHANGE_ACTION.DELETE: {
|
||||
const payload = event.payload as NodeRemovedPayload;
|
||||
if (payload.id) {
|
||||
applyRemoveRunningEntry(payload.id);
|
||||
} else {
|
||||
applyReconcileRunningEntries([]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CHANGE_ACTION.SET: {
|
||||
const { nodeIds } = event.payload as NodesSetPayload;
|
||||
applyReconcileRunningEntries(nodeIds);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const initialNodesById = documentStore.nodesById;
|
||||
if (initialNodesById && typeof initialNodesById.keys === 'function') {
|
||||
applyReconcileRunningEntries(Array.from(initialNodesById.keys()));
|
||||
}
|
||||
|
||||
// Scopes created from `onNodesChange` callbacks have no active parent
|
||||
// (event dispatch runs outside any scope), so `$dispose()` never
|
||||
// reaches them. Vue 3.5 computeds are not scope-owned and detach from
|
||||
// deps once unsubscribed, so this is deterministic cleanup hygiene
|
||||
// rather than leak prevention: stop the scopes and drop the per-node
|
||||
// entries when the store is disposed.
|
||||
onScopeDispose(() => {
|
||||
for (const stop of runningScopes.values()) stop();
|
||||
runningScopes.clear();
|
||||
executionRunningByNodeId.clear();
|
||||
executionWaitingForNextByNodeId.clear();
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolves the trigger node name driving the active execution.
|
||||
* Falls back to scanning runData keys for partial executions.
|
||||
@@ -669,6 +837,12 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
|
||||
getPastChatMessages,
|
||||
getActiveExecutionRunDataByNodeName,
|
||||
activeExecutionIssuesByNodeName,
|
||||
activeExecutionStatusByNodeId,
|
||||
activeExecutionRunDataByNodeId,
|
||||
activeExecutionRunDataOutputMapByNodeId,
|
||||
activeExecutionWaitingByNodeId,
|
||||
executionRunningByNodeId,
|
||||
executionWaitingForNextByNodeId,
|
||||
resolveExecutionTriggerNodeName,
|
||||
// Write API
|
||||
trackExecutionId,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Per-output aggregation of execution run data.
|
||||
*
|
||||
* `total` — items emitted across all iterations.
|
||||
* `iterations` — number of non-canceled task iterations contributing.
|
||||
* `byTarget` — present for non-main connection types (e.g. AI tools / memory /
|
||||
* embeddings). Tracks the same `{ total, iterations }` per target node id, so
|
||||
* label rendering can show counts per fan-out target. Keyed by node id.
|
||||
*/
|
||||
export type ExecutionOutputMapData = {
|
||||
total: number;
|
||||
iterations: number;
|
||||
byTarget?: {
|
||||
[targetNodeId: string]: {
|
||||
total: number;
|
||||
iterations: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Aggregated output stats for a single node, indexed first by connection type
|
||||
* (`main`, `ai_tool`, etc.) and then by output index (as a string).
|
||||
*/
|
||||
export type ExecutionOutputMap = {
|
||||
[connectionType: string]: {
|
||||
[outputIndex: string]: ExecutionOutputMapData;
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export type * from './executionData';
|
||||
export type * from './externalHooks';
|
||||
export type * from './pushConnection';
|
||||
export type * from './workflow';
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
MAIN_AUTH_FIELD_NAME,
|
||||
MAPPING_PARAMS,
|
||||
NON_ACTIVATABLE_TRIGGER_NODE_TYPES,
|
||||
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
||||
TEMPLATES_NODES_FILTER,
|
||||
} from '@/app/constants';
|
||||
import type { WorkflowObjectAccessors } from '@/app/types/workflow';
|
||||
import { i18n as locale } from '@n8n/i18n';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
@@ -19,9 +21,11 @@ import { isJsonKeyObject } from '@/app/utils/typesUtils';
|
||||
import {
|
||||
isResourceLocatorValue,
|
||||
type IDataObject,
|
||||
type INode,
|
||||
type INodeCredentialDescription,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
type INodePropertyOptions,
|
||||
type INodeTypeDescription,
|
||||
type NodeParameterValueType,
|
||||
type ResourceMapperField,
|
||||
@@ -62,6 +66,71 @@ export function getTriggerNodeServiceName(nodeType: INodeTypeDescription): strin
|
||||
return nodeType.displayName.replace(/ trigger/i, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the subtitle displayed under a node's name on the canvas.
|
||||
*
|
||||
* Pure function: reads only its parameters — it must not access stores or
|
||||
* `inject()`, because `useWorkflowDocumentRenderData` calls it from detached
|
||||
* effect scopes outside component setup (watch callbacks in
|
||||
* `WorkflowCanvas.vue` / `useWorkflowDiff.ts`).
|
||||
*/
|
||||
export function getNodeSubtitle(
|
||||
data: INode,
|
||||
nodeType: INodeTypeDescription,
|
||||
workflow: WorkflowObjectAccessors,
|
||||
): string | undefined {
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (data.notesInFlow) {
|
||||
return data.notes;
|
||||
}
|
||||
|
||||
if (nodeType?.subtitle !== undefined) {
|
||||
try {
|
||||
return workflow.expression.getSimpleParameterValue(
|
||||
data,
|
||||
nodeType.subtitle,
|
||||
'internal',
|
||||
{},
|
||||
undefined,
|
||||
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
||||
) as string | undefined;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.parameters.operation !== undefined) {
|
||||
const operation = data.parameters.operation as string;
|
||||
if (nodeType === null) {
|
||||
return operation;
|
||||
}
|
||||
|
||||
const operationData = nodeType.properties.find((property: INodeProperties) => {
|
||||
return property.name === 'operation';
|
||||
});
|
||||
if (operationData === undefined) {
|
||||
return operation;
|
||||
}
|
||||
|
||||
if (operationData.options === undefined) {
|
||||
return operation;
|
||||
}
|
||||
|
||||
const optionData = operationData.options.find((option) => {
|
||||
return (option as INodePropertyOptions).value === data.parameters.operation;
|
||||
});
|
||||
if (optionData === undefined) {
|
||||
return operation;
|
||||
}
|
||||
|
||||
return optionData.name;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getActivatableTriggerNodes(nodes: INodeUi[]) {
|
||||
return nodes.filter(
|
||||
(node: INodeUi) => !node.disabled && !NON_ACTIVATABLE_TRIGGER_NODE_TYPES.includes(node.type),
|
||||
|
||||
@@ -17,6 +17,20 @@ import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { getCredentialTypeName, isCredentialOnlyNodeType } from '@/app/utils/credentialOnlyNodes';
|
||||
import { hasProxyAuth } from '@/app/utils/nodeTypesUtils';
|
||||
|
||||
/**
|
||||
* Assigns a freshly generated id to the given node and returns it.
|
||||
*
|
||||
* Pure with respect to application state: it touches only its argument — it
|
||||
* must not access stores or `inject()`, because the workflow document store
|
||||
* wires it into `useWorkflowDocumentNodes` and may itself be constructed
|
||||
* outside component setup (e.g. from `useWorkflowDiff`'s watch effects).
|
||||
*/
|
||||
export function assignNodeId(node: INodeUi): string {
|
||||
const id = window.crypto.randomUUID();
|
||||
node.id = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the credentials that are displayable for the given node.
|
||||
*/
|
||||
|
||||
@@ -87,7 +87,7 @@ const workflowExecutionStateStore = computed(() =>
|
||||
const telemetry = useTelemetry();
|
||||
const i18n = useI18n();
|
||||
const activeNode = computed(() => ndvStore.value.activeNode);
|
||||
const { dirtinessByName } = useNodeDirtiness();
|
||||
const { dirtinessByName } = useNodeDirtiness(() => workflowDocumentStore.value.documentId);
|
||||
const uiStore = useUIStore();
|
||||
|
||||
// Composables
|
||||
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
CanvasNodeHandleInjectionData,
|
||||
CanvasNodeInjectionData,
|
||||
ConnectStartEvent,
|
||||
ExecutionOutputMapData,
|
||||
} from '@/features/workflows/canvas/canvas.types';
|
||||
import type { ExecutionOutputMapData } from '@/app/types/executionData';
|
||||
import {
|
||||
CanvasConnectionMode,
|
||||
CanvasNodeRenderType,
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { ComputedRef, Ref } from 'vue';
|
||||
import type { EventBus } from '@n8n/utils/event-bus';
|
||||
import type { CanvasLayoutSource } from '@/features/workflows/canvas/composables/useCanvasLayout';
|
||||
import type { NodeIconSource } from '@/app/utils/nodeIcon';
|
||||
import type { ExecutionOutputMap, ExecutionOutputMapData } from '@/app/types/executionData';
|
||||
|
||||
export const enum CanvasConnectionMode {
|
||||
Input = 'inputs',
|
||||
@@ -243,23 +244,6 @@ export type ConnectStartEvent = {
|
||||
|
||||
export type CanvasNodeMoveEvent = { id: string; position: CanvasNode['position'] };
|
||||
|
||||
export type ExecutionOutputMapData = {
|
||||
total: number;
|
||||
iterations: number;
|
||||
byTarget?: {
|
||||
[targetNodeId: string]: {
|
||||
total: number;
|
||||
iterations: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type ExecutionOutputMap = {
|
||||
[connectionType: string]: {
|
||||
[outputIndex: string]: ExecutionOutputMapData;
|
||||
};
|
||||
};
|
||||
|
||||
export type BoundingBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
INodeTypeDescription,
|
||||
NodeConnectionType,
|
||||
} from 'n8n-workflow';
|
||||
import type { Ref } from 'vue';
|
||||
import { computed, shallowReactive, type Ref } from 'vue';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import type {
|
||||
BoundingBox,
|
||||
@@ -67,6 +67,47 @@ export function injectCanvasRenderData(): Ref<CanvasRenderData> {
|
||||
return injectStrict(CanvasRenderDataKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an empty `CanvasRenderData` object.
|
||||
*
|
||||
* `CanvasRenderData` is a wide projection façade — production code populates
|
||||
* it via `useWorkflowDocumentRenderData(documentId)`. This helper exists for
|
||||
* the two cases that can't go through that path:
|
||||
* - placeholder values before the underlying workflow document is hydrated
|
||||
* (e.g. the workflow-diff side panels' initial render);
|
||||
* - test fixtures that only care about a few fields.
|
||||
*
|
||||
* Centralizing it here keeps the ~20+ consumers off the hook when new by-id
|
||||
* projections land — they update one default at a time, not 20 mock literals.
|
||||
*/
|
||||
export function createEmptyCanvasRenderData(
|
||||
overrides: Partial<CanvasRenderData> = {},
|
||||
): CanvasRenderData {
|
||||
return {
|
||||
nodeInputsByNodeId: shallowReactive(new Map()),
|
||||
nodeOutputsByNodeId: shallowReactive(new Map()),
|
||||
pinnedDataByNodeName: {},
|
||||
pinnedDataByNodeId: shallowReactive(new Map()),
|
||||
nodeTypeDescriptionByNodeId: shallowReactive(new Map()),
|
||||
isTriggerByNodeId: shallowReactive(new Map()),
|
||||
subtitleByNodeId: shallowReactive(new Map()),
|
||||
simulatedNodeTypeDescriptionByNodeId: shallowReactive(new Map()),
|
||||
validationErrorsByNodeId: shallowReactive(new Map()),
|
||||
executionIssuesByNodeName: shallowReactive(new Map()),
|
||||
executionStatusByNodeId: shallowReactive(new Map()),
|
||||
executionRunDataByNodeId: shallowReactive(new Map()),
|
||||
executionRunDataOutputMapByNodeId: shallowReactive(new Map()),
|
||||
executionWaitingByNodeId: shallowReactive(new Map()),
|
||||
executionRunningByNodeId: shallowReactive(new Map()),
|
||||
executionWaitingForNextByNodeId: shallowReactive(new Map()),
|
||||
tooltipByNodeId: shallowReactive(new Map()),
|
||||
hasIssuesByNodeId: shallowReactive(new Map()),
|
||||
renderTypeByNodeId: shallowReactive(new Map()),
|
||||
additionalPropertiesByNodeId: computed(() => ({})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps multiple legacy n8n connections to VueFlow connections
|
||||
*/
|
||||
|
||||
+33
-8
@@ -6,8 +6,19 @@ import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import type { ViewportTransform } from '@vue-flow/core';
|
||||
import { getRectOfNodes, useVueFlow } from '@vue-flow/core';
|
||||
import { throttledRef } from '@vueuse/core';
|
||||
import { computed, ref, useCssModule, useTemplateRef } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
effectScope,
|
||||
onScopeDispose,
|
||||
ref,
|
||||
shallowRef,
|
||||
useCssModule,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
type EffectScope,
|
||||
} from 'vue';
|
||||
import type { CanvasEventBusEvents } from '../canvas.types';
|
||||
import { createEmptyCanvasRenderData, type CanvasRenderData } from '../canvas.utils';
|
||||
import { useCanvasMapping } from '../composables/useCanvasMapping';
|
||||
import { mapGroupsToVueFlowNodes } from '../composables/useCanvasMapping.groups';
|
||||
import Canvas from './Canvas.vue';
|
||||
@@ -45,16 +56,31 @@ const props = withDefaults(
|
||||
const canvasRef = useTemplateRef('canvas');
|
||||
const $style = useCssModule();
|
||||
const workflowDocumentStore = injectWorkflowDocumentStore();
|
||||
const renderData = computed(() =>
|
||||
useWorkflowDocumentRenderData(workflowDocumentStore.value.documentId),
|
||||
|
||||
// `useWorkflowDocumentRenderData` is side-effectful (subscribes to the document
|
||||
// store and creates per-node effect scopes), so it must run once per document
|
||||
// id inside a scope we own — not inside a re-evaluating `computed`. We rebuild
|
||||
// it only when the document id actually changes, stopping the previous scope
|
||||
// (which runs the composable's teardown). The `watch` callback runs outside
|
||||
// reactive tracking, so the composable's internal reactive reads don't cause
|
||||
// re-invocation.
|
||||
const renderData = shallowRef<CanvasRenderData>(createEmptyCanvasRenderData());
|
||||
let renderDataScope: EffectScope | undefined;
|
||||
watch(
|
||||
() => workflowDocumentStore.value.documentId,
|
||||
(documentId) => {
|
||||
renderDataScope?.stop();
|
||||
renderDataScope = effectScope(true);
|
||||
renderDataScope.run(() => {
|
||||
renderData.value = useWorkflowDocumentRenderData(documentId);
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
onScopeDispose(() => renderDataScope?.stop());
|
||||
|
||||
const { onNodesInitialized, viewport, viewportRef, getNodes, fitBounds } = useVueFlow(props.id);
|
||||
|
||||
const workflowObject = computed(() =>
|
||||
workflowDocumentStore.value.getWorkflowObjectAccessorSnapshot(),
|
||||
);
|
||||
|
||||
const nodes = computed(() => {
|
||||
return props.showFallbackNodes
|
||||
? [...workflowDocumentStore.value.allNodes, ...props.fallbackNodes]
|
||||
@@ -75,7 +101,6 @@ const {
|
||||
} = useCanvasMapping({
|
||||
nodes,
|
||||
connections,
|
||||
workflowObject,
|
||||
renderData,
|
||||
isExperimentalNdvActive,
|
||||
});
|
||||
|
||||
+5
-7
@@ -1,6 +1,9 @@
|
||||
import { useVueFlow, type GraphNode, type VueFlowStore } from '@vue-flow/core';
|
||||
import { computed, ref, shallowRef } from 'vue';
|
||||
import type { CanvasRenderData } from '@/features/workflows/canvas/canvas.utils';
|
||||
import {
|
||||
createEmptyCanvasRenderData,
|
||||
type CanvasRenderData,
|
||||
} from '@/features/workflows/canvas/canvas.utils';
|
||||
import {
|
||||
createCanvasGraphEdge,
|
||||
createCanvasGraphNode,
|
||||
@@ -43,12 +46,7 @@ describe('useCanvasLayout', () => {
|
||||
const { layout } = useCanvasLayout(
|
||||
'test-canvas-id',
|
||||
computed(() => false),
|
||||
shallowRef<CanvasRenderData>({
|
||||
nodeInputsByNodeId: new Map(),
|
||||
nodeOutputsByNodeId: new Map(),
|
||||
pinnedDataByNodeName: {},
|
||||
executionIssuesByNodeName: new Map(),
|
||||
}),
|
||||
shallowRef<CanvasRenderData>(createEmptyCanvasRenderData()),
|
||||
);
|
||||
|
||||
return { layout };
|
||||
|
||||
+467
-3586
File diff suppressed because it is too large
Load Diff
+102
-631
@@ -4,566 +4,51 @@
|
||||
*/
|
||||
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
|
||||
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
|
||||
import type { CanvasRenderData } from '../canvas.utils';
|
||||
import type { Ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import type {
|
||||
BoundingBox,
|
||||
CanvasConnection,
|
||||
CanvasConnectionData,
|
||||
CanvasNode,
|
||||
CanvasNodeAddNodesRender,
|
||||
CanvasNodeChoicePromptRender,
|
||||
CanvasNodeData,
|
||||
CanvasNodeDefaultRender,
|
||||
CanvasNodeStickyNoteRender,
|
||||
ExecutionOutputMap,
|
||||
} from '../canvas.types';
|
||||
import { CanvasConnectionMode, CanvasNodeRenderType } from '../canvas.types';
|
||||
import {
|
||||
checkOverlap,
|
||||
computeNodeDisplaySize,
|
||||
mapLegacyConnectionsToCanvasConnections,
|
||||
parseCanvasConnectionHandleString,
|
||||
} from '../canvas.utils';
|
||||
import type {
|
||||
ExecutionStatus,
|
||||
ExecutionSummary,
|
||||
IConnections,
|
||||
INodeExecutionData,
|
||||
INodeTypeDescription,
|
||||
ITaskData,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, SEND_AND_WAIT_OPERATION, WAIT_INDEFINITELY } from 'n8n-workflow';
|
||||
import type { IConnections, ITaskData } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import {
|
||||
CANVAS_EXECUTION_DATA_THROTTLE_DURATION,
|
||||
CUSTOM_API_CALL_KEY,
|
||||
FORM_NODE_TYPE,
|
||||
SIMULATE_NODE_TYPE,
|
||||
SIMULATE_TRIGGER_NODE_TYPE,
|
||||
STICKY_NODE_TYPE,
|
||||
WAIT_NODE_TYPE,
|
||||
} from '@/app/constants';
|
||||
import { MarkerType } from '@vue-flow/core';
|
||||
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
|
||||
import { getTriggerNodeServiceName } from '@/app/utils/nodeTypesUtils';
|
||||
import { useNodeDirtiness } from '@/app/composables/useNodeDirtiness';
|
||||
import { getNodeIconSource } from '@/app/utils/nodeIcon';
|
||||
import * as workflowUtils from 'n8n-workflow/common';
|
||||
import { throttledWatch } from '@vueuse/core';
|
||||
import type { WorkflowObjectAccessors } from '@/app/types';
|
||||
|
||||
/**
|
||||
* Maps workflow nodes and connections into the vue-flow canvas shape.
|
||||
*
|
||||
* All per-node-id projections (subtitle, validation errors, execution status,
|
||||
* render type, sticky-note z-index, etc.) live on `renderData`, produced by
|
||||
* `useWorkflowDocumentRenderData`. This composable is the final glue layer —
|
||||
* it reads from `renderData` and `connections` to assemble `CanvasNode` and
|
||||
* `CanvasConnection` objects.
|
||||
*/
|
||||
export function useCanvasMapping({
|
||||
nodes,
|
||||
connections,
|
||||
workflowObject,
|
||||
renderData,
|
||||
isExperimentalNdvActive = ref(false),
|
||||
}: {
|
||||
nodes: Ref<INodeUi[]>;
|
||||
connections: Ref<IConnections>;
|
||||
workflowObject: Ref<WorkflowObjectAccessors>;
|
||||
renderData: Ref<CanvasRenderData>;
|
||||
isExperimentalNdvActive?: Ref<boolean>;
|
||||
}) {
|
||||
const i18n = useI18n();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const workflowDocumentStore = injectWorkflowDocumentStore();
|
||||
const workflowExecutionStateStore = computed(() =>
|
||||
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId),
|
||||
);
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
const { dirtinessByName } = useNodeDirtiness();
|
||||
|
||||
function createStickyNoteRenderType(node: INodeUi): CanvasNodeStickyNoteRender {
|
||||
return {
|
||||
type: CanvasNodeRenderType.StickyNote,
|
||||
options: {
|
||||
width: node.parameters.width as number,
|
||||
height: node.parameters.height as number,
|
||||
color: node.parameters.color as number,
|
||||
content: node.parameters.content as string,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAddNodesRenderType(): CanvasNodeAddNodesRender {
|
||||
return {
|
||||
type: CanvasNodeRenderType.AddNodes,
|
||||
options: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createChoicePromptRenderType(): CanvasNodeChoicePromptRender {
|
||||
return {
|
||||
type: CanvasNodeRenderType.ChoicePrompt,
|
||||
options: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultNodeRenderType(node: INodeUi): CanvasNodeDefaultRender {
|
||||
const nodeType = nodeTypeDescriptionByNodeId.value[node.id];
|
||||
const source = simulatedNodeTypeDescriptionByNodeId.value[node.id] ?? nodeType ?? node.type;
|
||||
const icon = getNodeIconSource(
|
||||
source,
|
||||
node,
|
||||
workflowDocumentStore.value.getExpressionHandler(),
|
||||
);
|
||||
|
||||
return {
|
||||
type: CanvasNodeRenderType.Default,
|
||||
options: {
|
||||
trigger: isTriggerNodeById.value[node.id],
|
||||
configuration: nodeTypesStore.isConfigNode(workflowObject.value, node, node.type),
|
||||
configurable: nodeTypesStore.isConfigurableNode(
|
||||
workflowObject.value,
|
||||
node,
|
||||
node.type,
|
||||
node.typeVersion,
|
||||
),
|
||||
tooltip: nodeTooltipById.value[node.id],
|
||||
dirtiness: dirtinessByName.value[node.name],
|
||||
icon,
|
||||
placeholder: node.placeholder,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const renderTypeByNodeId = computed(
|
||||
() =>
|
||||
nodes.value.reduce<Record<string, CanvasNodeData['render']>>((acc, node) => {
|
||||
switch (node.type) {
|
||||
case `${CanvasNodeRenderType.StickyNote}`:
|
||||
acc[node.id] = createStickyNoteRenderType(node);
|
||||
break;
|
||||
case `${CanvasNodeRenderType.AddNodes}`:
|
||||
acc[node.id] = createAddNodesRenderType();
|
||||
break;
|
||||
case `${CanvasNodeRenderType.ChoicePrompt}`:
|
||||
acc[node.id] = createChoicePromptRenderType();
|
||||
break;
|
||||
default:
|
||||
acc[node.id] = createDefaultNodeRenderType(node);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {}) ?? {},
|
||||
);
|
||||
|
||||
const nodeTypeDescriptionByNodeId = computed(() =>
|
||||
nodes.value.reduce<Record<string, INodeTypeDescription | null>>((acc, node) => {
|
||||
acc[node.id] =
|
||||
nodeTypesStore.getNodeType(node.type, node.typeVersion) ??
|
||||
nodeTypesStore.communityNodeType(node.type)?.nodeDescription ??
|
||||
null;
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const isTriggerNodeById = computed(() =>
|
||||
nodes.value.reduce<Record<string, boolean>>((acc, node) => {
|
||||
acc[node.id] = nodeTypesStore.isTriggerNode(node.type);
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const nodeSubtitleById = computed(() => {
|
||||
return nodes.value.reduce<Record<string, string>>((acc, node) => {
|
||||
try {
|
||||
const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id];
|
||||
if (!nodeTypeDescription) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const nodeSubtitle =
|
||||
nodeHelpers.getNodeSubtitle(node, nodeTypeDescription, workflowObject.value) ?? '';
|
||||
if (nodeSubtitle.includes(CUSTOM_API_CALL_KEY)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[node.id] = nodeSubtitle;
|
||||
} catch (e) {}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
|
||||
const nodePinnedDataById = computed(() =>
|
||||
nodes.value.reduce<Record<string, INodeExecutionData[] | undefined>>((acc, node) => {
|
||||
acc[node.id] = workflowDocumentStore.value.getNodePinData(node.name);
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const nodeTooltipById = computed(() => {
|
||||
if (!workflowExecutionStateStore.value.isWorkflowRunning) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const activeTriggerNodeCount = nodes.value.filter(
|
||||
(node) => isTriggerNodeById.value[node.id] && !node.disabled,
|
||||
).length;
|
||||
const triggerNodeName = workflowsStore.getWorkflowExecution?.triggerNode;
|
||||
|
||||
// For workflows with multiple active trigger nodes, we show a tooltip only when
|
||||
// trigger node name is known
|
||||
if (triggerNodeName === undefined && activeTriggerNodeCount !== 1) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return nodes.value.reduce<Record<string, string | undefined>>((acc, node) => {
|
||||
const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id];
|
||||
if (nodeTypeDescription && isTriggerNodeById.value[node.id]) {
|
||||
if (
|
||||
!!node.disabled ||
|
||||
(triggerNodeName !== undefined && triggerNodeName !== node.name) ||
|
||||
!['new', 'unknown', 'waiting'].includes(nodeExecutionStatusById.value[node.id]) ||
|
||||
nodePinnedDataById.value[node.id]
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (typeof nodeTypeDescription.eventTriggerDescription === 'string') {
|
||||
const nodeName = i18n.shortNodeType(nodeTypeDescription.name);
|
||||
const { eventTriggerDescription } = nodeTypeDescription;
|
||||
acc[node.id] = i18n
|
||||
.nodeText(nodeTypeDescription.name)
|
||||
.eventTriggerDescription(nodeName, eventTriggerDescription ?? '');
|
||||
} else {
|
||||
acc[node.id] = i18n.baseText('node.waitingForYouToCreateAnEventIn', {
|
||||
interpolate: {
|
||||
nodeType: nodeTypeDescription ? getTriggerNodeServiceName(nodeTypeDescription) : '',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
|
||||
const nodeExecutionRunningById = computed(() =>
|
||||
nodes.value.reduce<Record<string, boolean>>((acc, node) => {
|
||||
acc[node.id] = workflowExecutionStateStore.value.executingNode.isNodeExecuting(node.name);
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const nodeExecutionWaitingForNextById = computed(() =>
|
||||
nodes.value.reduce<Record<string, boolean>>((acc, node) => {
|
||||
acc[node.id] =
|
||||
node.name === workflowExecutionStateStore.value.executingNode.lastAddedExecutingNode &&
|
||||
workflowExecutionStateStore.value.executingNode.executingNode.length === 0 &&
|
||||
workflowExecutionStateStore.value.isWorkflowRunning;
|
||||
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const nodeExecutionStatusById = computed(() =>
|
||||
nodes.value.reduce<Record<string, ExecutionStatus>>((acc, node) => {
|
||||
const tasks = workflowsStore.getWorkflowRunData?.[node.name] ?? [];
|
||||
|
||||
let lastExecutionStatus = tasks.at(-1)?.executionStatus;
|
||||
if (tasks.length > 1 && lastExecutionStatus === 'canceled') {
|
||||
lastExecutionStatus = tasks.at(-2)?.executionStatus;
|
||||
}
|
||||
acc[node.id] = lastExecutionStatus ?? 'new';
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const nodeExecutionRunDataById = computed(() =>
|
||||
nodes.value.reduce<Record<string, ITaskData[] | null>>((acc, node) => {
|
||||
acc[node.id] = workflowsStore.getWorkflowResultDataByNodeName(node.name);
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
// Create a map for O(1) node lookups by name
|
||||
const nodesByName = computed(() => new Map(nodes.value.map((n) => [n.name, n])));
|
||||
|
||||
const nodeExecutionRunDataOutputMapById = ref<Record<string, ExecutionOutputMap>>({});
|
||||
|
||||
throttledWatch(
|
||||
() => workflowsStore.workflowExecutionResultDataLastUpdate,
|
||||
() => {
|
||||
nodeExecutionRunDataOutputMapById.value = Object.keys(nodeExecutionRunDataById.value).reduce<
|
||||
Record<string, ExecutionOutputMap>
|
||||
>((acc, nodeId) => {
|
||||
acc[nodeId] = {};
|
||||
|
||||
const outputData = { iterations: 0, total: 0 };
|
||||
for (const runIteration of nodeExecutionRunDataById.value[nodeId] ?? []) {
|
||||
const data = runIteration.data ?? {};
|
||||
|
||||
for (const connectionType of Object.keys(data)) {
|
||||
const connectionTypeData = data[connectionType] ?? {};
|
||||
acc[nodeId][connectionType] = acc[nodeId][connectionType] ?? {};
|
||||
|
||||
for (const outputIndex of Object.keys(connectionTypeData)) {
|
||||
const parsedOutputIndex = parseInt(outputIndex, 10);
|
||||
const connectionTypeOutputIndexData = connectionTypeData[parsedOutputIndex] ?? [];
|
||||
|
||||
acc[nodeId][connectionType][outputIndex] = acc[nodeId][connectionType][
|
||||
outputIndex
|
||||
] ?? {
|
||||
...outputData,
|
||||
...(connectionType !== NodeConnectionTypes.Main ? { byTarget: {} } : {}),
|
||||
};
|
||||
// For non-main connections, check if items are wrapped in a response field
|
||||
// (common for AI nodes like embeddings, tools, etc.)
|
||||
// Note: We check only the first item assuming uniform structure across all items
|
||||
let itemCount = connectionTypeOutputIndexData.length;
|
||||
if (
|
||||
connectionType !== NodeConnectionTypes.Main &&
|
||||
connectionTypeOutputIndexData.length > 0
|
||||
) {
|
||||
const firstItem = connectionTypeOutputIndexData[0];
|
||||
// AI nodes typically wrap all items uniformly in response field
|
||||
if (
|
||||
firstItem?.json &&
|
||||
typeof firstItem.json === 'object' &&
|
||||
'response' in firstItem.json &&
|
||||
Array.isArray(firstItem.json.response)
|
||||
) {
|
||||
// Use response array length for all items (assuming uniform structure)
|
||||
itemCount = firstItem.json.response.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (runIteration.executionStatus !== 'canceled') {
|
||||
acc[nodeId][connectionType][outputIndex].iterations += 1;
|
||||
}
|
||||
acc[nodeId][connectionType][outputIndex].total += itemCount;
|
||||
|
||||
// For non-main connections, track per-target execution counts
|
||||
if (connectionType !== NodeConnectionTypes.Main) {
|
||||
const callingNodeName = runIteration.source?.[0]?.previousNode;
|
||||
if (callingNodeName) {
|
||||
const callingNode = nodesByName.value.get(callingNodeName);
|
||||
if (callingNode) {
|
||||
const targetId = callingNode.id;
|
||||
const outputEntry = acc[nodeId][connectionType][outputIndex];
|
||||
|
||||
if (outputEntry.byTarget) {
|
||||
if (!outputEntry.byTarget[targetId]) {
|
||||
outputEntry.byTarget[targetId] = {
|
||||
total: 0,
|
||||
iterations: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (runIteration.executionStatus !== 'canceled') {
|
||||
outputEntry.byTarget[targetId].iterations += 1;
|
||||
}
|
||||
outputEntry.byTarget[targetId].total += itemCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
},
|
||||
{ throttle: CANVAS_EXECUTION_DATA_THROTTLE_DURATION, immediate: true },
|
||||
);
|
||||
|
||||
const nodeValidationErrorsById = computed(() =>
|
||||
nodes.value.reduce<Record<string, string[]>>((acc, node) => {
|
||||
const validationErrors: string[] = [];
|
||||
|
||||
if (node?.issues !== undefined) {
|
||||
validationErrors.push(...nodeHelpers.nodeIssuesToString(node.issues, node));
|
||||
}
|
||||
|
||||
acc[node.id] = validationErrors;
|
||||
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const nodeHasIssuesById = computed(() =>
|
||||
nodes.value.reduce<Record<string, boolean>>((acc, node) => {
|
||||
const hasExecutionErrors =
|
||||
(renderData.value.executionIssuesByNodeName.get(node.name)?.value?.length ?? 0) > 0;
|
||||
const hasValidationErrors = nodeValidationErrorsById.value[node.id]?.length > 0;
|
||||
|
||||
if (['crashed', 'error'].includes(nodeExecutionStatusById.value[node.id])) {
|
||||
acc[node.id] = true;
|
||||
} else if (nodePinnedDataById.value[node.id]) {
|
||||
acc[node.id] = false;
|
||||
} else if (hasValidationErrors) {
|
||||
acc[node.id] = true;
|
||||
} else if (hasExecutionErrors) {
|
||||
acc[node.id] = true;
|
||||
} else {
|
||||
const tasks = workflowsStore.getWorkflowRunData?.[node.name] ?? [];
|
||||
acc[node.id] = Boolean(tasks.at(-1)?.error);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const nodeExecutionWaitingById = computed(() =>
|
||||
nodes.value.reduce<Record<string, string | undefined>>((acc, node) => {
|
||||
const isExecutionSummary = (execution: object): execution is ExecutionSummary =>
|
||||
'waitTill' in execution;
|
||||
|
||||
const workflowExecution = workflowsStore.getWorkflowExecution;
|
||||
const lastNodeExecuted = workflowExecution?.data?.resultData?.lastNodeExecuted;
|
||||
|
||||
if (workflowExecution && lastNodeExecuted && isExecutionSummary(workflowExecution)) {
|
||||
if (
|
||||
node.name === workflowExecution.data?.resultData?.lastNodeExecuted &&
|
||||
workflowExecution?.waitTill &&
|
||||
!workflowExecution?.finished
|
||||
) {
|
||||
if (
|
||||
node &&
|
||||
node.type === WAIT_NODE_TYPE &&
|
||||
['webhook', 'form'].includes(node.parameters.resume as string)
|
||||
) {
|
||||
acc[node.id] =
|
||||
node.parameters.resume === 'webhook'
|
||||
? i18n.baseText('node.theNodeIsWaitingWebhookCall')
|
||||
: i18n.baseText('node.theNodeIsWaitingFormCall');
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (node?.parameters.operation === SEND_AND_WAIT_OPERATION) {
|
||||
acc[node.id] = i18n.baseText('node.theNodeIsWaitingUserInput');
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (node?.type === FORM_NODE_TYPE) {
|
||||
acc[node.id] = i18n.baseText('node.theNodeIsWaitingFormCall');
|
||||
return acc;
|
||||
}
|
||||
|
||||
const waitDate = new Date(workflowExecution.waitTill);
|
||||
|
||||
if (waitDate.getTime() === WAIT_INDEFINITELY.getTime()) {
|
||||
acc[node.id] = i18n.baseText(
|
||||
'node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall',
|
||||
);
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[node.id] = i18n.baseText('node.nodeIsWaitingTill', {
|
||||
interpolate: {
|
||||
date: waitDate.toLocaleDateString(),
|
||||
time: waitDate.toLocaleTimeString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const additionalNodePropertiesById = computed(() => {
|
||||
type StickyNoteBoundingBox = BoundingBox & {
|
||||
id: string;
|
||||
area: number;
|
||||
zIndex: number;
|
||||
};
|
||||
|
||||
const stickyNodeBaseZIndex = -100;
|
||||
|
||||
const stickyNodeBoundingBoxes = nodes.value.reduce<StickyNoteBoundingBox[]>((acc, node) => {
|
||||
if (node.type === STICKY_NODE_TYPE) {
|
||||
const x = node.position[0];
|
||||
const y = node.position[1];
|
||||
const width = node.parameters.width as number;
|
||||
const height = node.parameters.height as number;
|
||||
|
||||
acc.push({
|
||||
id: node.id,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
area: width * height,
|
||||
zIndex: stickyNodeBaseZIndex,
|
||||
});
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const sortedStickyNodeBoundingBoxes = stickyNodeBoundingBoxes.sort((a, b) => b.area - a.area);
|
||||
sortedStickyNodeBoundingBoxes.forEach((node, index) => {
|
||||
node.zIndex = stickyNodeBaseZIndex + index;
|
||||
});
|
||||
|
||||
for (let i = 0; i < sortedStickyNodeBoundingBoxes.length; i++) {
|
||||
const node1 = sortedStickyNodeBoundingBoxes[i];
|
||||
for (let j = i + 1; j < sortedStickyNodeBoundingBoxes.length; j++) {
|
||||
const node2 = sortedStickyNodeBoundingBoxes[j];
|
||||
if (checkOverlap(node1, node2)) {
|
||||
if (node1.area < node2.area && node1.zIndex <= node2.zIndex) {
|
||||
// Ensure node1 (smaller area) has a higher zIndex than node2 (larger area)
|
||||
node1.zIndex = node2.zIndex + 1;
|
||||
} else if (node2.area < node1.area && node2.zIndex <= node1.zIndex) {
|
||||
// Ensure node2 (smaller area) has a higher zIndex than node1 (larger area)
|
||||
node2.zIndex = node1.zIndex + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sortedStickyNodeBoundingBoxes.reduce<Record<string, Partial<CanvasNode>>>(
|
||||
(acc, node) => {
|
||||
acc[node.id] = {
|
||||
style: {
|
||||
zIndex: node.zIndex,
|
||||
},
|
||||
};
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
const simulatedNodeTypeDescriptionByNodeId = computed(() => {
|
||||
return nodes.value.reduce<Record<string, INodeTypeDescription | null>>((acc, node) => {
|
||||
if ([SIMULATE_NODE_TYPE, SIMULATE_TRIGGER_NODE_TYPE].includes(node.type)) {
|
||||
const icon = node.parameters?.icon as string;
|
||||
const iconValue = workflowObject.value.expression.getSimpleParameterValue(
|
||||
node,
|
||||
icon,
|
||||
'internal',
|
||||
{},
|
||||
);
|
||||
|
||||
if (iconValue && typeof iconValue === 'string') {
|
||||
acc[node.id] = nodeTypesStore.getNodeType(iconValue);
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
|
||||
function filterOutCanceled(tasks: ITaskData[] | null): ITaskData[] | null {
|
||||
if (!tasks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!tasks) return null;
|
||||
return tasks.filter((task) => task.executionStatus !== 'canceled');
|
||||
}
|
||||
|
||||
@@ -571,17 +56,18 @@ export function useCanvasMapping({
|
||||
// they wrap each node's actual rendered size. Sticky notes are omitted —
|
||||
// their own width/height parameters are read by the group mapper directly.
|
||||
const nodeDisplaySizeById = computed(() => {
|
||||
const rd = renderData.value;
|
||||
const dimensionsById: Record<string, { width: number; height: number }> = {};
|
||||
|
||||
for (const node of nodes.value) {
|
||||
const render = renderTypeByNodeId.value[node.id];
|
||||
const render = rd.renderTypeByNodeId.get(node.id)?.value;
|
||||
|
||||
if (render?.type !== CanvasNodeRenderType.Default) continue;
|
||||
|
||||
dimensionsById[node.id] = computeNodeDisplaySize(
|
||||
node.id,
|
||||
render.options,
|
||||
renderData.value,
|
||||
rd,
|
||||
isExperimentalNdvActive.value,
|
||||
);
|
||||
}
|
||||
@@ -592,15 +78,19 @@ export function useCanvasMapping({
|
||||
const connectionsBySourceNode = connections.value;
|
||||
const connectionsByDestinationNode =
|
||||
workflowUtils.mapConnectionsByDestination(connectionsBySourceNode);
|
||||
const rd = renderData.value;
|
||||
const additionalProperties = rd.additionalPropertiesByNodeId.value;
|
||||
|
||||
return nodes.value.map<CanvasNode>((node) => {
|
||||
const outputConnections = connectionsBySourceNode[node.name] ?? {};
|
||||
const inputConnections = connectionsByDestinationNode[node.name] ?? {};
|
||||
|
||||
const runData = rd.executionRunDataByNodeId.get(node.id)?.value ?? null;
|
||||
|
||||
const data: CanvasNodeData = {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
subtitle: nodeSubtitleById.value[node.id] ?? '',
|
||||
subtitle: rd.subtitleByNodeId.get(node.id)?.value ?? '',
|
||||
type: node.type,
|
||||
typeVersion: node.typeVersion,
|
||||
disabled: node.disabled,
|
||||
@@ -609,21 +99,23 @@ export function useCanvasMapping({
|
||||
[CanvasConnectionMode.Output]: outputConnections,
|
||||
},
|
||||
issues: {
|
||||
validation: nodeValidationErrorsById.value[node.id],
|
||||
visible: nodeHasIssuesById.value[node.id],
|
||||
validation: rd.validationErrorsByNodeId.get(node.id)?.value ?? [],
|
||||
visible: rd.hasIssuesByNodeId.get(node.id)?.value ?? false,
|
||||
},
|
||||
execution: {
|
||||
status: nodeExecutionStatusById.value[node.id],
|
||||
waiting: nodeExecutionWaitingById.value[node.id],
|
||||
waitingForNext: nodeExecutionWaitingForNextById.value[node.id],
|
||||
running: nodeExecutionRunningById.value[node.id],
|
||||
status: rd.executionStatusByNodeId.get(node.id)?.value,
|
||||
waiting: rd.executionWaitingByNodeId.get(node.id)?.value,
|
||||
waitingForNext: rd.executionWaitingForNextByNodeId.get(node.id)?.value ?? false,
|
||||
running: rd.executionRunningByNodeId.get(node.id)?.value ?? false,
|
||||
},
|
||||
runData: {
|
||||
outputMap: nodeExecutionRunDataOutputMapById.value[node.id],
|
||||
iterations: filterOutCanceled(nodeExecutionRunDataById.value[node.id])?.length ?? 0,
|
||||
visible: !!nodeExecutionRunDataById.value[node.id],
|
||||
outputMap: rd.executionRunDataOutputMapByNodeId.get(node.id),
|
||||
iterations: filterOutCanceled(runData)?.length ?? 0,
|
||||
visible: !!runData,
|
||||
},
|
||||
render: renderTypeByNodeId.value[node.id] ?? { type: 'default', options: {} },
|
||||
render:
|
||||
rd.renderTypeByNodeId.get(node.id)?.value ??
|
||||
({ type: node.type, options: {} } as CanvasNodeData['render']),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -632,7 +124,7 @@ export function useCanvasMapping({
|
||||
type: 'canvas-node',
|
||||
position: { x: node.position[0], y: node.position[1] },
|
||||
data,
|
||||
...additionalNodePropertiesById.value[node.id],
|
||||
...additionalProperties[node.id],
|
||||
draggable: node.draggable,
|
||||
};
|
||||
});
|
||||
@@ -640,63 +132,57 @@ export function useCanvasMapping({
|
||||
|
||||
const mappedConnections = computed<CanvasConnection[]>(() => {
|
||||
return mapLegacyConnectionsToCanvasConnections(connections.value ?? [], nodes.value ?? []).map(
|
||||
(connection) => {
|
||||
const type = getConnectionType(connection);
|
||||
const label = getConnectionLabel(connection);
|
||||
const data = getConnectionData(connection);
|
||||
|
||||
return {
|
||||
...connection,
|
||||
data,
|
||||
type,
|
||||
label,
|
||||
markerEnd: MarkerType.ArrowClosed,
|
||||
};
|
||||
},
|
||||
(connection) => ({
|
||||
...connection,
|
||||
data: getConnectionData(connection),
|
||||
type: 'canvas-edge',
|
||||
label: getConnectionLabel(connection),
|
||||
markerEnd: MarkerType.ArrowClosed,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
function getConnectionData(connection: CanvasConnection): CanvasConnectionData {
|
||||
const rd = renderData.value;
|
||||
const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle);
|
||||
const runData = nodeExecutionRunDataOutputMapById.value[connection.source]?.[type]?.[index];
|
||||
const outputMap = rd.executionRunDataOutputMapByNodeId.get(connection.source);
|
||||
const runData = outputMap?.[type]?.[index];
|
||||
const runDataTotal = runData?.total ?? 0;
|
||||
|
||||
const sourceTasks = nodeExecutionRunDataById.value[connection.source] ?? [];
|
||||
const sourceTasks = rd.executionRunDataByNodeId.get(connection.source)?.value ?? [];
|
||||
let lastSourceTask: ITaskData | undefined = sourceTasks[sourceTasks.length - 1];
|
||||
if (lastSourceTask?.executionStatus === 'canceled' && sourceTasks.length > 1) {
|
||||
lastSourceTask = sourceTasks[sourceTasks.length - 2];
|
||||
}
|
||||
|
||||
const sourcePinned = rd.pinnedDataByNodeId.get(connection.source)?.value;
|
||||
const sourceRunData = rd.executionRunDataByNodeId.get(connection.source)?.value;
|
||||
const targetRunData = rd.executionRunDataByNodeId.get(connection.target)?.value;
|
||||
const sourceRunning = rd.executionRunningByNodeId.get(connection.source)?.value ?? false;
|
||||
const sourceHasIssues = rd.hasIssuesByNodeId.get(connection.source)?.value ?? false;
|
||||
|
||||
let status: CanvasConnectionData['status'];
|
||||
if (nodeExecutionRunningById.value[connection.source] && runDataTotal === 0) {
|
||||
if (sourceRunning && runDataTotal === 0) {
|
||||
status = 'running';
|
||||
} else if (
|
||||
nodePinnedDataById.value[connection.source] &&
|
||||
nodeExecutionRunDataById.value[connection.source]
|
||||
) {
|
||||
} else if (sourcePinned && sourceRunData) {
|
||||
status = 'pinned';
|
||||
} else if (nodeHasIssuesById.value[connection.source]) {
|
||||
} else if (sourceHasIssues) {
|
||||
status = 'error';
|
||||
} else if (runDataTotal > 0 && lastSourceTask?.executionStatus !== 'canceled') {
|
||||
// For non-main connections (model, memory, tool, etc.), only mark as executed
|
||||
// if the target node also executed, since these are passive connections
|
||||
// Non-main connections (model/memory/tool) are passive — only mark
|
||||
// success when the target node also produced run data.
|
||||
const isMainConnection = type === NodeConnectionTypes.Main;
|
||||
const targetNodeHasAnyExecution = nodeExecutionRunDataById.value[connection.target];
|
||||
|
||||
if (isMainConnection || targetNodeHasAnyExecution) {
|
||||
if (isMainConnection || targetRunData) {
|
||||
status = 'success';
|
||||
}
|
||||
}
|
||||
|
||||
const sourceInputs = renderData.value.nodeInputsByNodeId.get(connection.source)?.value ?? [];
|
||||
const targetInputs = renderData.value.nodeInputsByNodeId.get(connection.target)?.value ?? [];
|
||||
const sourceInputs = rd.nodeInputsByNodeId.get(connection.source)?.value ?? [];
|
||||
const targetInputs = rd.nodeInputsByNodeId.get(connection.target)?.value ?? [];
|
||||
const maxConnections = [...sourceInputs, ...targetInputs]
|
||||
.filter((port) => port.type === type)
|
||||
.reduce<number | undefined>((acc, port) => {
|
||||
if (port.maxConnections === undefined) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (port.maxConnections === undefined) return acc;
|
||||
return Math.min(acc ?? Infinity, port.maxConnections);
|
||||
}, undefined);
|
||||
|
||||
@@ -707,76 +193,61 @@ export function useCanvasMapping({
|
||||
};
|
||||
}
|
||||
|
||||
function getConnectionType(_: CanvasConnection): string {
|
||||
return 'canvas-edge';
|
||||
}
|
||||
|
||||
function getConnectionLabel(connection: CanvasConnection): string {
|
||||
const fromNode = nodesByName.value.get(connection.data?.source.node ?? '');
|
||||
if (!fromNode) {
|
||||
return '';
|
||||
}
|
||||
const rd = renderData.value;
|
||||
const sourceId = connection.source;
|
||||
|
||||
if (nodePinnedDataById.value[fromNode.id]) {
|
||||
const pinnedDataCount = nodePinnedDataById.value[fromNode.id]?.length ?? 0;
|
||||
const pinned = rd.pinnedDataByNodeId.get(sourceId)?.value;
|
||||
if (pinned) {
|
||||
const pinnedDataCount = pinned.length;
|
||||
return pinnedDataCount > 0
|
||||
? i18n.baseText('ndv.output.items', {
|
||||
adjustToNumber: pinnedDataCount,
|
||||
interpolate: { count: String(pinnedDataCount) },
|
||||
})
|
||||
: '';
|
||||
} else if (nodeExecutionRunDataById.value[fromNode.id]) {
|
||||
const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle);
|
||||
const outputData = nodeExecutionRunDataOutputMapById.value[fromNode.id]?.[type]?.[index];
|
||||
|
||||
// For non-main connections, use per-target data if available
|
||||
const isMainConnection = type === NodeConnectionTypes.Main;
|
||||
const targetHasExecutionData = nodeExecutionRunDataById.value[connection.target];
|
||||
|
||||
if (!isMainConnection && outputData?.byTarget) {
|
||||
// Look up the target node to get per-connection counts
|
||||
const targetNodeId = connection.target;
|
||||
const targetData = outputData.byTarget[targetNodeId];
|
||||
|
||||
if (targetData && targetData.total > 0 && targetHasExecutionData) {
|
||||
return i18n.baseText(
|
||||
targetData.iterations > 1 ? 'ndv.output.itemsTotal' : 'ndv.output.items',
|
||||
{
|
||||
adjustToNumber: targetData.total,
|
||||
interpolate: { count: String(targetData.total) },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Target hasn't executed, show no label
|
||||
return '';
|
||||
}
|
||||
|
||||
// For main connections, use aggregate counts
|
||||
const runDataTotal = outputData?.total ?? 0;
|
||||
const hasMultipleRunDataIterations = (outputData?.iterations ?? 1) > 1;
|
||||
|
||||
return runDataTotal > 0 && (isMainConnection || targetHasExecutionData)
|
||||
? i18n.baseText(
|
||||
hasMultipleRunDataIterations ? 'ndv.output.itemsTotal' : 'ndv.output.items',
|
||||
{
|
||||
adjustToNumber: runDataTotal,
|
||||
interpolate: { count: String(runDataTotal) },
|
||||
},
|
||||
)
|
||||
: '';
|
||||
}
|
||||
|
||||
return '';
|
||||
const sourceRunData = rd.executionRunDataByNodeId.get(sourceId)?.value;
|
||||
if (!sourceRunData) return '';
|
||||
|
||||
const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle);
|
||||
const outputMap = rd.executionRunDataOutputMapByNodeId.get(sourceId);
|
||||
const outputData = outputMap?.[type]?.[index];
|
||||
|
||||
const isMainConnection = type === NodeConnectionTypes.Main;
|
||||
const targetRunData = rd.executionRunDataByNodeId.get(connection.target)?.value;
|
||||
|
||||
// Non-main connections (AI tool/memory/embedding) track per-target counts
|
||||
// when the target has run data; otherwise stay quiet.
|
||||
if (!isMainConnection && outputData?.byTarget) {
|
||||
const targetData = outputData.byTarget[connection.target];
|
||||
if (targetData && targetData.total > 0 && targetRunData) {
|
||||
return i18n.baseText(
|
||||
targetData.iterations > 1 ? 'ndv.output.itemsTotal' : 'ndv.output.items',
|
||||
{
|
||||
adjustToNumber: targetData.total,
|
||||
interpolate: { count: String(targetData.total) },
|
||||
},
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const runDataTotal = outputData?.total ?? 0;
|
||||
const hasMultipleRunDataIterations = (outputData?.iterations ?? 1) > 1;
|
||||
|
||||
return runDataTotal > 0 && (isMainConnection || targetRunData)
|
||||
? i18n.baseText(hasMultipleRunDataIterations ? 'ndv.output.itemsTotal' : 'ndv.output.items', {
|
||||
adjustToNumber: runDataTotal,
|
||||
interpolate: { count: String(runDataTotal) },
|
||||
})
|
||||
: '';
|
||||
}
|
||||
|
||||
return {
|
||||
additionalNodePropertiesById,
|
||||
nodeExecutionRunDataOutputMapById,
|
||||
nodeExecutionWaitingForNextById,
|
||||
nodeHasIssuesById,
|
||||
nodeDisplaySizeById,
|
||||
connections: mappedConnections,
|
||||
nodes: mappedNodes,
|
||||
connections: mappedConnections,
|
||||
nodeDisplaySizeById,
|
||||
};
|
||||
}
|
||||
|
||||
+6
-30
@@ -4,6 +4,7 @@ import { createTestingPinia } from '@pinia/testing';
|
||||
import { render } from '@testing-library/vue';
|
||||
import type { EventBus } from '@n8n/utils/event-bus';
|
||||
import type { CanvasEventBusEvents } from '@/features/workflows/canvas/canvas.types';
|
||||
import { createEmptyCanvasRenderData } from '@/features/workflows/canvas/canvas.utils';
|
||||
|
||||
// Mock useVueFlow - capture onNodesInitialized callback and updateNode spy
|
||||
let nodesInitializedCallback: (() => void) | null = null;
|
||||
@@ -92,12 +93,7 @@ describe('SyncedWorkflowCanvas', () => {
|
||||
id: 'test-canvas',
|
||||
nodes: [],
|
||||
connections: [],
|
||||
renderData: {
|
||||
nodeInputsByNodeId: new Map(),
|
||||
nodeOutputsByNodeId: new Map(),
|
||||
pinnedDataByNodeName: {},
|
||||
executionIssuesByNodeName: new Map(),
|
||||
},
|
||||
renderData: createEmptyCanvasRenderData(),
|
||||
},
|
||||
});
|
||||
expect(container).toBeTruthy();
|
||||
@@ -111,12 +107,7 @@ describe('SyncedWorkflowCanvas', () => {
|
||||
nodes: [],
|
||||
connections: [],
|
||||
applyLayout: true,
|
||||
renderData: {
|
||||
nodeInputsByNodeId: new Map(),
|
||||
nodeOutputsByNodeId: new Map(),
|
||||
pinnedDataByNodeName: {},
|
||||
executionIssuesByNodeName: new Map(),
|
||||
},
|
||||
renderData: createEmptyCanvasRenderData(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -139,12 +130,7 @@ describe('SyncedWorkflowCanvas', () => {
|
||||
nodes: [],
|
||||
connections: [],
|
||||
applyLayout: false,
|
||||
renderData: {
|
||||
nodeInputsByNodeId: new Map(),
|
||||
nodeOutputsByNodeId: new Map(),
|
||||
pinnedDataByNodeName: {},
|
||||
executionIssuesByNodeName: new Map(),
|
||||
},
|
||||
renderData: createEmptyCanvasRenderData(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -163,12 +149,7 @@ describe('SyncedWorkflowCanvas', () => {
|
||||
id: 'test-canvas',
|
||||
nodes: [],
|
||||
connections: [],
|
||||
renderData: {
|
||||
nodeInputsByNodeId: new Map(),
|
||||
nodeOutputsByNodeId: new Map(),
|
||||
pinnedDataByNodeName: {},
|
||||
executionIssuesByNodeName: new Map(),
|
||||
},
|
||||
renderData: createEmptyCanvasRenderData(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -188,12 +169,7 @@ describe('SyncedWorkflowCanvas', () => {
|
||||
nodes: [],
|
||||
connections: [],
|
||||
applyLayout: true,
|
||||
renderData: {
|
||||
nodeInputsByNodeId: new Map(),
|
||||
nodeOutputsByNodeId: new Map(),
|
||||
pinnedDataByNodeName: {},
|
||||
executionIssuesByNodeName: new Map(),
|
||||
},
|
||||
renderData: createEmptyCanvasRenderData(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+3
-12
@@ -3,6 +3,7 @@ import { defineComponent, h, ref } from 'vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { render, screen } from '@testing-library/vue';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import { createEmptyCanvasRenderData } from '@/features/workflows/canvas/canvas.utils';
|
||||
|
||||
interface CapturedCanvasProps {
|
||||
id: string;
|
||||
@@ -102,18 +103,8 @@ describe('WorkflowDiffContent', () => {
|
||||
isSourceWorkflowNew: false,
|
||||
nodesDiff: new Map(),
|
||||
connectionsDiff: new Map(),
|
||||
sourceRenderData: {
|
||||
nodeInputsByNodeId: new Map(),
|
||||
nodeOutputsByNodeId: new Map(),
|
||||
pinnedDataByNodeName: {},
|
||||
executionIssuesByNodeName: new Map(),
|
||||
},
|
||||
targetRenderData: {
|
||||
nodeInputsByNodeId: new Map(),
|
||||
nodeOutputsByNodeId: new Map(),
|
||||
pinnedDataByNodeName: {},
|
||||
executionIssuesByNodeName: new Map(),
|
||||
},
|
||||
sourceRenderData: createEmptyCanvasRenderData(),
|
||||
targetRenderData: createEmptyCanvasRenderData(),
|
||||
};
|
||||
|
||||
describe('panels', () => {
|
||||
|
||||
+34
-11
@@ -1,11 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ref, computed } from 'vue';
|
||||
import { mapConnections, useWorkflowDiff } from './useWorkflowDiff';
|
||||
import type {
|
||||
CanvasConnection,
|
||||
CanvasNode,
|
||||
ExecutionOutputMap,
|
||||
} from '@/features/workflows/canvas/canvas.types';
|
||||
import type { CanvasConnection, CanvasNode } from '@/features/workflows/canvas/canvas.types';
|
||||
import type { ExecutionOutputMap } from '@/app/types/executionData';
|
||||
import type { INodeUi, IWorkflowDb } from '@/Interface';
|
||||
import { NodeDiffStatus, type IConnections } from 'n8n-workflow';
|
||||
import { useCanvasMapping } from '@/features/workflows/canvas/composables/useCanvasMapping';
|
||||
@@ -18,11 +15,6 @@ vi.mock('@/app/stores/workflows.store', () => ({
|
||||
}));
|
||||
|
||||
const mockDocumentStore = vi.hoisted(() => ({
|
||||
createWorkflowObject: vi.fn().mockReturnValue({
|
||||
id: 'test-workflow',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
}),
|
||||
hydrate: vi.fn(),
|
||||
render: {
|
||||
nodeInputsByNodeId: new Map(),
|
||||
@@ -36,10 +28,18 @@ const mockDocumentStore = vi.hoisted(() => ({
|
||||
vi.mock('@/app/stores/workflowDocument.store', () => ({
|
||||
useWorkflowDocumentStore: () => mockDocumentStore,
|
||||
createWorkflowDocumentId: vi.fn().mockReturnValue('test-id'),
|
||||
injectWorkflowDocumentStore: () => ({ value: mockDocumentStore }),
|
||||
disposeWorkflowDocumentStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/stores/workflowDocument/useWorkflowDocumentRenderData', async () => {
|
||||
const { createEmptyCanvasRenderData } = await vi.importActual<
|
||||
typeof import('@/features/workflows/canvas/canvas.utils')
|
||||
>('@/features/workflows/canvas/canvas.utils');
|
||||
return {
|
||||
useWorkflowDocumentRenderData: vi.fn(() => createEmptyCanvasRenderData()),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/app/stores/workflowExecutionState.store', () => ({
|
||||
useWorkflowExecutionStateStore: () => ({
|
||||
activeExecutionIssuesByNodeName: new Map(),
|
||||
@@ -428,5 +428,28 @@ describe('useWorkflowDiff', () => {
|
||||
// Node with existing ID should keep its ID
|
||||
expect(passedNodes[1].id).toBe('existing-id');
|
||||
});
|
||||
|
||||
it('hydrates the render-data store with the same node IDs used for canvas mapping', () => {
|
||||
const nodeWithoutId = {
|
||||
name: 'Node Without ID',
|
||||
type: 'test-node',
|
||||
typeVersion: 1,
|
||||
position: [100, 100] as [number, number],
|
||||
parameters: {},
|
||||
} as INodeUi;
|
||||
|
||||
const sourceWorkflow = createMockWorkflow('source', [nodeWithoutId]);
|
||||
|
||||
useWorkflowDiff(sourceWorkflow, undefined);
|
||||
|
||||
// Canvas mapping receives nodes with generated IDs; the render-data store
|
||||
// must be hydrated with those same IDs, otherwise canvas lookups
|
||||
// (handles, render type, subtitle, status) miss.
|
||||
const canvasNodes = mockUseCanvasMapping.mock.calls[0][0].nodes.value;
|
||||
const hydratedNodes = mockDocumentStore.hydrate.mock.calls[0][0].nodes;
|
||||
|
||||
expect(canvasNodes[0].id).toBeDefined();
|
||||
expect(hydratedNodes[0].id).toBe(canvasNodes[0].id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+48
-36
@@ -1,19 +1,32 @@
|
||||
import type { CanvasConnection, CanvasNode } from '@/features/workflows/canvas/canvas.types';
|
||||
import type { INodeUi, IWorkflowDb } from '@/Interface';
|
||||
import type { MaybeRefOrGetter, Ref, ComputedRef } from 'vue';
|
||||
import { toValue, computed, ref, watchEffect, shallowRef, onScopeDispose } from 'vue';
|
||||
import {
|
||||
toValue,
|
||||
computed,
|
||||
ref,
|
||||
watchEffect,
|
||||
shallowRef,
|
||||
onScopeDispose,
|
||||
effectScope,
|
||||
type MaybeRefOrGetter,
|
||||
type Ref,
|
||||
type ComputedRef,
|
||||
type EffectScope,
|
||||
} from 'vue';
|
||||
import { useCanvasMapping } from '@/features/workflows/canvas/composables/useCanvasMapping';
|
||||
import type { Workflow, IConnections, INodeTypeDescription, NodeDiff } from 'n8n-workflow';
|
||||
import type { IConnections, INodeTypeDescription, NodeDiff } from 'n8n-workflow';
|
||||
import { compareWorkflowsNodes, NodeDiffStatus } from 'n8n-workflow';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import {
|
||||
injectWorkflowDocumentStore,
|
||||
useWorkflowDocumentStore,
|
||||
createWorkflowDocumentId,
|
||||
disposeWorkflowDocumentStore,
|
||||
} from '@/app/stores/workflowDocument.store';
|
||||
import { useWorkflowDocumentRenderData } from '@/app/stores/workflowDocument/useWorkflowDocumentRenderData';
|
||||
import type { CanvasRenderData } from '@/features/workflows/canvas/canvas.utils';
|
||||
import {
|
||||
createEmptyCanvasRenderData,
|
||||
type CanvasRenderData,
|
||||
} from '@/features/workflows/canvas/canvas.utils';
|
||||
|
||||
export function mapConnections(connections: CanvasConnection[]) {
|
||||
return connections.reduce(
|
||||
@@ -26,14 +39,10 @@ export function mapConnections(connections: CanvasConnection[]) {
|
||||
);
|
||||
}
|
||||
|
||||
function createWorkflowRefs(
|
||||
workflow: MaybeRefOrGetter<IWorkflowDb | undefined>,
|
||||
createWorkflowObject: (nodes: INodeUi[], connections: IConnections) => Workflow,
|
||||
) {
|
||||
function createWorkflowRefs(workflow: MaybeRefOrGetter<IWorkflowDb | undefined>) {
|
||||
const workflowRef = computed(() => toValue(workflow));
|
||||
const workflowNodes = ref<INodeUi[]>([]);
|
||||
const workflowConnections = ref<IConnections>({});
|
||||
const workflowObjectRef = shallowRef<Workflow>(createWorkflowObject([], {}));
|
||||
|
||||
watchEffect(() => {
|
||||
const workflowValue = workflowRef.value;
|
||||
@@ -47,7 +56,6 @@ function createWorkflowRefs(
|
||||
return node;
|
||||
});
|
||||
|
||||
workflowObjectRef.value = createWorkflowObject(nodesWithIds, workflowValue.connections);
|
||||
workflowNodes.value = nodesWithIds;
|
||||
workflowConnections.value = workflowValue.connections;
|
||||
}
|
||||
@@ -57,7 +65,6 @@ function createWorkflowRefs(
|
||||
workflowRef,
|
||||
workflowNodes,
|
||||
workflowConnections,
|
||||
workflowObjectRef,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,15 +72,11 @@ function createWorkflowDiff(
|
||||
workflowRef: ComputedRef<IWorkflowDb | undefined>,
|
||||
workflowNodes: Ref<INodeUi[]>,
|
||||
workflowConnections: Ref<IConnections>,
|
||||
workflowObjectRef: Ref<Workflow>,
|
||||
renderData: Ref<CanvasRenderData>,
|
||||
) {
|
||||
// Call useCanvasMapping at setup time, not inside computed
|
||||
// This is required because useCanvasMapping uses inject() internally
|
||||
const { nodes, connections } = useCanvasMapping({
|
||||
nodes: workflowNodes,
|
||||
connections: workflowConnections,
|
||||
workflowObject: workflowObjectRef,
|
||||
renderData,
|
||||
});
|
||||
|
||||
@@ -111,14 +114,16 @@ function createWorkflowDiff(
|
||||
};
|
||||
}
|
||||
|
||||
function createDiffRenderData(workflowRef: ComputedRef<IWorkflowDb | undefined>, side: string) {
|
||||
const renderData = shallowRef<CanvasRenderData>({
|
||||
nodeInputsByNodeId: new Map(),
|
||||
nodeOutputsByNodeId: new Map(),
|
||||
pinnedDataByNodeName: {},
|
||||
executionIssuesByNodeName: new Map(),
|
||||
});
|
||||
function createDiffRenderData(
|
||||
workflowRef: ComputedRef<IWorkflowDb | undefined>,
|
||||
workflowNodes: Ref<INodeUi[]>,
|
||||
side: string,
|
||||
) {
|
||||
const renderData = shallowRef<CanvasRenderData>(createEmptyCanvasRenderData());
|
||||
let workflowDocumentStore: ReturnType<typeof useWorkflowDocumentStore> | null = null;
|
||||
// `useWorkflowDocumentRenderData` is side-effectful; own its scope so it can
|
||||
// be torn down when the diffed workflow changes or this side disposes.
|
||||
let renderDataScope: EffectScope | undefined;
|
||||
|
||||
watchEffect(() => {
|
||||
const wf = workflowRef.value;
|
||||
@@ -127,16 +132,30 @@ function createDiffRenderData(workflowRef: ComputedRef<IWorkflowDb | undefined>,
|
||||
if (workflowDocumentStore) {
|
||||
disposeWorkflowDocumentStore(workflowDocumentStore);
|
||||
}
|
||||
renderDataScope?.stop();
|
||||
|
||||
const versionId = wf.versionId ?? `diff-${side}`;
|
||||
const docId = createWorkflowDocumentId(wf.id, versionId);
|
||||
|
||||
workflowDocumentStore = useWorkflowDocumentStore(docId);
|
||||
workflowDocumentStore.hydrate({ ...wf, versionId } as IWorkflowDb);
|
||||
renderData.value = useWorkflowDocumentRenderData(docId);
|
||||
// Hydrate from the same normalized nodes that feed the canvas so the
|
||||
// render-data maps are keyed by the same node IDs the canvas looks up.
|
||||
// Shallow-copy the nodes so the document store owns/mutates its own node
|
||||
// objects (e.g. position snapping) without leaking into `workflowNodes`.
|
||||
workflowDocumentStore.hydrate({
|
||||
...wf,
|
||||
nodes: workflowNodes.value.map((node) => ({ ...node })),
|
||||
versionId,
|
||||
} as IWorkflowDb);
|
||||
renderDataScope = effectScope(true);
|
||||
renderDataScope.run(() => {
|
||||
renderData.value = useWorkflowDocumentRenderData(docId);
|
||||
});
|
||||
});
|
||||
|
||||
function dispose() {
|
||||
renderDataScope?.stop();
|
||||
renderDataScope = undefined;
|
||||
if (workflowDocumentStore) {
|
||||
disposeWorkflowDocumentStore(workflowDocumentStore);
|
||||
workflowDocumentStore = null;
|
||||
@@ -150,24 +169,19 @@ export const useWorkflowDiff = (
|
||||
sourceWorkflow: MaybeRefOrGetter<IWorkflowDb | undefined>,
|
||||
targetWorkflow: MaybeRefOrGetter<IWorkflowDb | undefined>,
|
||||
) => {
|
||||
const workflowDocumentStore = injectWorkflowDocumentStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
|
||||
const sourceRefs = createWorkflowRefs(
|
||||
sourceWorkflow,
|
||||
workflowDocumentStore.value.createWorkflowObject,
|
||||
);
|
||||
const targetRefs = createWorkflowRefs(
|
||||
targetWorkflow,
|
||||
workflowDocumentStore.value.createWorkflowObject,
|
||||
);
|
||||
const sourceRefs = createWorkflowRefs(sourceWorkflow);
|
||||
const targetRefs = createWorkflowRefs(targetWorkflow);
|
||||
|
||||
const { renderData: sourceRenderData, dispose: disposeSource } = createDiffRenderData(
|
||||
sourceRefs.workflowRef,
|
||||
sourceRefs.workflowNodes,
|
||||
'source',
|
||||
);
|
||||
const { renderData: targetRenderData, dispose: disposeTarget } = createDiffRenderData(
|
||||
targetRefs.workflowRef,
|
||||
targetRefs.workflowNodes,
|
||||
'target',
|
||||
);
|
||||
|
||||
@@ -175,7 +189,6 @@ export const useWorkflowDiff = (
|
||||
sourceRefs.workflowRef,
|
||||
sourceRefs.workflowNodes,
|
||||
sourceRefs.workflowConnections,
|
||||
sourceRefs.workflowObjectRef,
|
||||
sourceRenderData,
|
||||
);
|
||||
|
||||
@@ -183,7 +196,6 @@ export const useWorkflowDiff = (
|
||||
targetRefs.workflowRef,
|
||||
targetRefs.workflowNodes,
|
||||
targetRefs.workflowConnections,
|
||||
targetRefs.workflowObjectRef,
|
||||
targetRenderData,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user