Skip to content

Commit

Permalink
fix(editor): Allow to re-open sub-connection node creator if already …
Browse files Browse the repository at this point in the history
…active (#13041)
  • Loading branch information
OlegIvaniv authored Feb 5, 2025
1 parent 2a33d07 commit 16d59e9
Show file tree
Hide file tree
Showing 5 changed files with 157 additions and 55 deletions.
4 changes: 3 additions & 1 deletion packages/editor-ui/src/Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import type {
REGULAR_NODE_CREATOR_VIEW,
AI_OTHERS_NODE_CREATOR_VIEW,
ROLE,
AI_UNCATEGORIZED_CATEGORY,
} from '@/constants';
import type { BulkCommand, Undoable } from '@/models/history';

Expand Down Expand Up @@ -1012,7 +1013,8 @@ export type NodeFilterType =
| typeof REGULAR_NODE_CREATOR_VIEW
| typeof TRIGGER_NODE_CREATOR_VIEW
| typeof AI_NODE_CREATOR_VIEW
| typeof AI_OTHERS_NODE_CREATOR_VIEW;
| typeof AI_OTHERS_NODE_CREATOR_VIEW
| typeof AI_UNCATEGORIZED_CATEGORY;

export type NodeCreatorOpenSource =
| ''
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
AI_NODE_CREATOR_VIEW,
REGULAR_NODE_CREATOR_VIEW,
TRIGGER_NODE_CREATOR_VIEW,
AI_UNCATEGORIZED_CATEGORY,
} from '@/constants';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
Expand Down Expand Up @@ -95,6 +96,7 @@ watch(
[REGULAR_NODE_CREATOR_VIEW]: RegularView,
[AI_NODE_CREATOR_VIEW]: AIView,
[AI_OTHERS_NODE_CREATOR_VIEW]: AINodesView,
[AI_UNCATEGORIZED_CATEGORY]: AINodesView,
};
const itemKey = selectedView;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,50 +313,54 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
}

await nextTick();
pushViewStack({
title: relatedAIView?.properties.title,
...extendedInfo,
rootView: AI_OTHERS_NODE_CREATOR_VIEW,
mode: 'nodes',
items: nodeCreatorStore.allNodeCreatorNodes,
nodeIcon: {
iconType: 'icon',
icon: relatedAIView?.properties.icon,
color: relatedAIView?.properties.iconProps?.color,
},
panelClass: relatedAIView?.properties.panelClass,
baseFilter: (i: INodeCreateElement) => {
// AI Code node could have any connection type so we don't want to display it
// in the compatible connection view as it would be displayed in all of them
if (i.key === AI_CODE_NODE_TYPE) return false;
const displayNode = nodesByConnectionType[connectionType].includes(i.key);

// TODO: Filtering works currently fine for displaying compatible node when dropping
// input connections. However, it does not work for output connections.
// For that reason does it currently display nodes that are maybe not compatible
// but then errors once it got selected by the user.
if (displayNode && filter?.nodes?.length) {
return filter.nodes.includes(i.key);
}

return displayNode;
},
itemsMapper(item) {
return {
...item,
subcategory: connectionType,
};
},
actionsFilter: (items: ActionTypeDescription[]) => {
// Filter out actions that are not compatible with the connection type
if (items.some((item) => item.outputConnectionType)) {
return items.filter((item) => item.outputConnectionType === connectionType);
}
return items;
pushViewStack(
{
title: relatedAIView?.properties.title,
...extendedInfo,
rootView: AI_OTHERS_NODE_CREATOR_VIEW,
mode: 'nodes',
items: nodeCreatorStore.allNodeCreatorNodes,
nodeIcon: {
iconType: 'icon',
icon: relatedAIView?.properties.icon,
color: relatedAIView?.properties.iconProps?.color,
},
panelClass: relatedAIView?.properties.panelClass,
baseFilter: (i: INodeCreateElement) => {
// AI Code node could have any connection type so we don't want to display it
// in the compatible connection view as it would be displayed in all of them
if (i.key === AI_CODE_NODE_TYPE) return false;
const displayNode = nodesByConnectionType[connectionType].includes(i.key);

// TODO: Filtering works currently fine for displaying compatible node when dropping
// input connections. However, it does not work for output connections.
// For that reason does it currently display nodes that are maybe not compatible
// but then errors once it got selected by the user.
if (displayNode && filter?.nodes?.length) {
return filter.nodes.includes(i.key);
}

return displayNode;
},
itemsMapper(item) {
return {
...item,
subcategory: connectionType,
};
},
actionsFilter: (items: ActionTypeDescription[]) => {
// Filter out actions that are not compatible with the connection type
if (items.some((item) => item.outputConnectionType)) {
return items.filter((item) => item.outputConnectionType === connectionType);
}
return items;
},
hideActions: true,
preventBack: true,
},
hideActions: true,
preventBack: true,
});
{ resetStacks: true },
);
}

function setStackBaselineItems() {
Expand Down Expand Up @@ -417,7 +421,11 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
}));
}

function pushViewStack(stack: ViewStack) {
function pushViewStack(stack: ViewStack, options: { resetStacks?: boolean } = {}) {
if (options.resetStacks) {
resetViewStacks();
}

if (activeViewStack.value.uuid) {
updateCurrentViewStack({ activeIndex: getActiveItemIndex() });
}
Expand Down
97 changes: 96 additions & 1 deletion packages/editor-ui/src/stores/nodeCreator.store.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { createPinia, setActivePinia } from 'pinia';
import { useNodeCreatorStore } from './nodeCreator.store';
import { useTelemetry } from '@/composables/useTelemetry';
import { CUSTOM_API_CALL_KEY, REGULAR_NODE_CREATOR_VIEW } from '@/constants';
import {
AI_UNCATEGORIZED_CATEGORY,
CUSTOM_API_CALL_KEY,
REGULAR_NODE_CREATOR_VIEW,
} from '@/constants';
import type { INodeCreateElement } from '@/Interface';
import { parseCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
import { NodeConnectionType } from 'n8n-workflow';
import { CanvasConnectionMode } from '@/types';

const workflow_id = 'workflow-id';
const category_name = 'category-name';
Expand All @@ -29,6 +36,31 @@ vi.mock('@/composables/useTelemetry', () => {
};
});

// Mock the workflows store so that getNodeById returns a dummy node.
vi.mock('@/stores/workflows.store', () => {
return {
useWorkflowsStore: () => ({
getNodeById: vi.fn((id?: string) => {
return id ? { id, name: 'Test Node' } : null;
}),
workflowTriggerNodes: [],
workflowId: 'dummy-workflow-id',
getCurrentWorkflow: vi.fn(() => ({
getNode: vi.fn(() => ({
type: 'n8n-node.example',
typeVersion: 1,
})),
})),
}),
};
});

vi.mock('@/utils/canvasUtilsV2', () => {
return {
parseCanvasConnectionHandleString: vi.fn(),
};
});

describe('useNodeCreatorStore', () => {
let nodeCreatorStore: ReturnType<typeof useNodeCreatorStore>;

Expand Down Expand Up @@ -290,6 +322,69 @@ describe('useNodeCreatorStore', () => {
},
);
});
describe('selective connection view', () => {
const mockedParseCanvasConnectionHandleString = vi.mocked(
parseCanvasConnectionHandleString,
true,
);

it('sets nodeCreatorView to AI_UNCATEGORIZED_CATEGORY when connection type is not Main', async () => {
mockedParseCanvasConnectionHandleString.mockReturnValue({
type: NodeConnectionType.AiLanguageModel, // any value that is not NodeConnectionType.Main
index: 0,
mode: CanvasConnectionMode.Input,
});

const connection = {
source: 'node-1',
sourceHandle: 'fake-handle',
};

nodeCreatorStore.openNodeCreatorForConnectingNode({
connection,
eventSource: 'plus_endpoint',
nodeCreatorView: REGULAR_NODE_CREATOR_VIEW,
});

expect(nodeCreatorStore.selectedView).toEqual(AI_UNCATEGORIZED_CATEGORY);
});

it('uses the provided nodeCreatorView when connection type is Main', async () => {
mockedParseCanvasConnectionHandleString.mockReturnValue({
type: NodeConnectionType.Main,
index: 0,
mode: CanvasConnectionMode.Input,
});

const connection = {
source: 'node-2',
sourceHandle: 'fake-handle-main',
};

nodeCreatorStore.openNodeCreatorForConnectingNode({
connection,
eventSource: 'plus_endpoint',
nodeCreatorView: REGULAR_NODE_CREATOR_VIEW,
});

expect(nodeCreatorStore.selectedView).toEqual(REGULAR_NODE_CREATOR_VIEW);
});

it('does not update state if no source node is found', async () => {
const connection = {
source: '',
sourceHandle: 'any-handle',
};

nodeCreatorStore.openNodeCreatorForConnectingNode({
connection,
eventSource: 'plus_endpoint',
nodeCreatorView: REGULAR_NODE_CREATOR_VIEW,
});

expect(nodeCreatorStore.selectedView).not.toEqual(REGULAR_NODE_CREATOR_VIEW);
});
});
});

function getSessionId(time: number) {
Expand Down
15 changes: 5 additions & 10 deletions packages/editor-ui/src/stores/nodeCreator.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { defineStore } from 'pinia';
import {
AI_NODE_CREATOR_VIEW,
AI_OTHERS_NODE_CREATOR_VIEW,
AI_UNCATEGORIZED_CATEGORY,
CUSTOM_API_CALL_KEY,
NODE_CREATOR_OPEN_SOURCES,
REGULAR_NODE_CREATOR_VIEW,
Expand All @@ -20,7 +21,7 @@ import type {
import { computed, ref } from 'vue';
import { transformNodeType } from '@/components/Node/NodeCreator/utils';
import type { IDataObject, INodeInputConfiguration, NodeParameterValueType } from 'n8n-workflow';
import { NodeConnectionType, nodeConnectionTypes, NodeHelpers } from 'n8n-workflow';
import { NodeConnectionType, NodeHelpers } from 'n8n-workflow';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useUIStore } from '@/stores/ui.store';
import { useNDVStore } from '@/stores/ndv.store';
Expand Down Expand Up @@ -121,10 +122,6 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
createNodeActive,
nodeCreatorView,
}: ToggleNodeCreatorOptions) {
if (createNodeActive === isCreateNodeActive.value) {
return;
}

if (!nodeCreatorView) {
nodeCreatorView =
workflowsStore.workflowTriggerNodes.length > 0
Expand Down Expand Up @@ -183,18 +180,16 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
uiStore.lastInteractedWithNodeHandle = connection.sourceHandle ?? null;
uiStore.lastInteractedWithNodeId = sourceNode.id;

const isOutput = mode === CanvasConnectionMode.Output;
const isScopedConnection = type !== NodeConnectionType.Main;
setNodeCreatorState({
source: eventSource,
createNodeActive: true,
nodeCreatorView,
nodeCreatorView: isScopedConnection ? AI_UNCATEGORIZED_CATEGORY : nodeCreatorView,
});

// TODO: The animation is a bit glitchy because we're updating view stack immediately
// after the node creator is opened
const isOutput = mode === CanvasConnectionMode.Output;
const isScopedConnection =
type !== NodeConnectionType.Main && nodeConnectionTypes.includes(type);

if (isScopedConnection) {
useViewStacks()
.gotoCompatibleConnectionView(type, isOutput, getNodeCreatorFilter(sourceNode.name, type))
Expand Down

0 comments on commit 16d59e9

Please sign in to comment.