mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
feat(editor): Preview not installed community tools (#24859)
This commit is contained in:
+288
-2
@@ -1,5 +1,6 @@
|
||||
import { inProduction } from '@n8n/backend-common';
|
||||
|
||||
import { getCommunityNodeTypes } from '../community-node-types-utils';
|
||||
import { CommunityNodeTypesService } from '../community-node-types.service';
|
||||
|
||||
jest.mock('@n8n/backend-common', () => ({
|
||||
@@ -164,8 +165,16 @@ describe('CommunityNodeTypesService', () => {
|
||||
|
||||
it('should process nodeTypes normally when array has content', () => {
|
||||
const mockNodeTypes = [
|
||||
{ name: 'test-node-1', version: '1.0.0' },
|
||||
{ name: 'test-node-2', version: '1.1.0' },
|
||||
{
|
||||
name: 'test-node-1',
|
||||
version: '1.0.0',
|
||||
nodeDescription: { name: 'test-node-1', usableAsTool: false },
|
||||
},
|
||||
{
|
||||
name: 'test-node-2',
|
||||
version: '1.1.0',
|
||||
nodeDescription: { name: 'test-node-2', usableAsTool: false },
|
||||
},
|
||||
];
|
||||
const setCommunityNodeTypesSpy = jest.spyOn(service as any, 'setCommunityNodeTypes');
|
||||
|
||||
@@ -249,18 +258,21 @@ describe('CommunityNodeTypesService', () => {
|
||||
packageName: 'n8n-nodes-air',
|
||||
checksum: 'checksum-air',
|
||||
npmVersion: '1.0.0',
|
||||
nodeDescription: { name: 'n8n-nodes-air.air', usableAsTool: false },
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-airparser.airparser',
|
||||
packageName: 'n8n-nodes-airparser',
|
||||
checksum: 'checksum-airparser',
|
||||
npmVersion: '2.0.0',
|
||||
nodeDescription: { name: 'n8n-nodes-airparser.airparser', usableAsTool: false },
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-example.example',
|
||||
packageName: 'n8n-nodes-example',
|
||||
checksum: 'checksum-example',
|
||||
npmVersion: '3.0.0',
|
||||
nodeDescription: { name: 'n8n-nodes-example.example', usableAsTool: false },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -316,16 +328,19 @@ describe('CommunityNodeTypesService', () => {
|
||||
name: 'n8n-nodes-test.test',
|
||||
packageName: 'n8n-nodes-test',
|
||||
checksum: 'checksum-test',
|
||||
nodeDescription: { name: 'n8n-nodes-test.test', usableAsTool: false },
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-testing.testing',
|
||||
packageName: 'n8n-nodes-testing',
|
||||
checksum: 'checksum-testing',
|
||||
nodeDescription: { name: 'n8n-nodes-testing.testing', usableAsTool: false },
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-tester.tester',
|
||||
packageName: 'n8n-nodes-tester',
|
||||
checksum: 'checksum-tester',
|
||||
nodeDescription: { name: 'n8n-nodes-tester.tester', usableAsTool: false },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -341,6 +356,277 @@ describe('CommunityNodeTypesService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCommunityNodeTypes', () => {
|
||||
beforeEach(() => {
|
||||
communityPackagesServiceMock.getAllInstalledPackages = jest.fn().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('should create AI tool versions for nodes with usableAsTool flag', async () => {
|
||||
const mockNodeTypes = [
|
||||
{
|
||||
name: 'n8n-nodes-test.test',
|
||||
packageName: 'n8n-nodes-test',
|
||||
nodeDescription: {
|
||||
name: 'test-node-preview',
|
||||
displayName: 'Test Node',
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
usableAsTool: true,
|
||||
codex: {
|
||||
subcategories: {
|
||||
Tools: ['Custom Tools'],
|
||||
},
|
||||
resources: { primaryDocumentation: [{ url: 'https://example.com' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
(getCommunityNodeTypes as jest.Mock).mockResolvedValueOnce(mockNodeTypes);
|
||||
|
||||
const result = await service.getCommunityNodeTypes();
|
||||
|
||||
expect(result.length).toBe(2); // original + tool version
|
||||
|
||||
const originalNode = result.find((n) => n.name === 'n8n-nodes-test.test');
|
||||
const toolNode = result.find((n) => n.name === 'n8n-nodes-test.testTool');
|
||||
|
||||
expect(originalNode).toBeDefined();
|
||||
expect(toolNode).toBeDefined();
|
||||
expect(toolNode?.name).toBe('n8n-nodes-test.testTool');
|
||||
expect(toolNode?.nodeDescription.name).toBe('test-node-previewTool');
|
||||
expect(toolNode?.nodeDescription.displayName).toBe('Test Node Tool');
|
||||
expect(toolNode?.nodeDescription.inputs).toEqual([]);
|
||||
expect(toolNode?.nodeDescription.outputs).toEqual(['ai_tool']);
|
||||
expect(toolNode?.nodeDescription.codex?.categories).toEqual(['AI']);
|
||||
expect(toolNode?.nodeDescription.codex?.subcategories).toEqual({
|
||||
AI: ['Tools'],
|
||||
Tools: ['Custom Tools'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not create AI tool versions for nodes without usableAsTool flag', async () => {
|
||||
const mockNodeTypes = [
|
||||
{
|
||||
name: 'n8n-nodes-test.test',
|
||||
packageName: 'n8n-nodes-test',
|
||||
nodeDescription: {
|
||||
name: 'test-node-preview',
|
||||
displayName: 'Test Node',
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
usableAsTool: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
(getCommunityNodeTypes as jest.Mock).mockResolvedValueOnce(mockNodeTypes);
|
||||
|
||||
const result = await service.getCommunityNodeTypes();
|
||||
|
||||
expect(result.length).toBe(1); // only original
|
||||
|
||||
const toolNode = result.find((n) => n.name === 'n8n-nodes-test.testTool');
|
||||
expect(toolNode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not create AI tool version for node with tool in type', async () => {
|
||||
const mockNodeTypes = [
|
||||
{
|
||||
name: 'n8n-nodes-test.testTool',
|
||||
packageName: 'n8n-nodes-test',
|
||||
nodeDescription: {
|
||||
name: 'test-node-previewTool',
|
||||
displayName: 'Test Node',
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
usableAsTool: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
(getCommunityNodeTypes as jest.Mock).mockResolvedValueOnce(mockNodeTypes);
|
||||
|
||||
const result = await service.getCommunityNodeTypes();
|
||||
|
||||
expect(result.length).toBe(1); // only original
|
||||
|
||||
const toolNode = result.find((n) => n.name === 'n8n-nodes-test.testToolTool');
|
||||
expect(toolNode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use default "Other Tools" when codex subcategories are not defined', async () => {
|
||||
const mockNodeTypes = [
|
||||
{
|
||||
name: 'n8n-nodes-test.test',
|
||||
packageName: 'n8n-nodes-test',
|
||||
nodeDescription: {
|
||||
name: 'test-node-preview',
|
||||
displayName: 'Test Node',
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
usableAsTool: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
(getCommunityNodeTypes as jest.Mock).mockResolvedValueOnce(mockNodeTypes);
|
||||
|
||||
const result = await service.getCommunityNodeTypes();
|
||||
const toolNode = result.find((n) => n.name === 'n8n-nodes-test.testTool');
|
||||
|
||||
expect(toolNode).toBeDefined();
|
||||
expect(toolNode?.nodeDescription.codex?.subcategories?.Tools).toEqual(['Other Tools']);
|
||||
});
|
||||
|
||||
it('should preserve original codex resources when creating tool version', async () => {
|
||||
const mockResources = {
|
||||
primaryDocumentation: [{ url: 'https://example.com/docs' }],
|
||||
};
|
||||
|
||||
const mockNodeTypes = [
|
||||
{
|
||||
name: 'n8n-nodes-test.test',
|
||||
packageName: 'n8n-nodes-test',
|
||||
nodeDescription: {
|
||||
name: 'test-node-preview',
|
||||
displayName: 'Test Node',
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
usableAsTool: true,
|
||||
codex: {
|
||||
resources: mockResources,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
(getCommunityNodeTypes as jest.Mock).mockResolvedValueOnce(mockNodeTypes);
|
||||
|
||||
const result = await service.getCommunityNodeTypes();
|
||||
const toolNode = result.find((n) => n.name === 'n8n-nodes-test.testTool');
|
||||
|
||||
expect(toolNode?.nodeDescription.codex?.resources).toEqual(mockResources);
|
||||
});
|
||||
|
||||
it('should not include Recommended Tools subcategory in tool version', async () => {
|
||||
const mockNodeTypes = [
|
||||
{
|
||||
name: 'n8n-nodes-test.test',
|
||||
packageName: 'n8n-nodes-test',
|
||||
nodeDescription: {
|
||||
name: 'test-node-preview',
|
||||
displayName: 'Test Node',
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
usableAsTool: true,
|
||||
codex: {
|
||||
resources: {
|
||||
primaryDocumentation: [{ url: 'https://example.com/docs' }],
|
||||
},
|
||||
subcategories: {
|
||||
AI: ['Tools'],
|
||||
Tools: ['Recommended Tools', 'Other Tools'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
(getCommunityNodeTypes as jest.Mock).mockResolvedValueOnce(mockNodeTypes);
|
||||
|
||||
const result = await service.getCommunityNodeTypes();
|
||||
const toolNode = result.find((n) => n.name === 'n8n-nodes-test.testTool');
|
||||
|
||||
expect(toolNode?.nodeDescription.codex?.subcategories?.Tools).not.toContain(
|
||||
'Recommended Tools',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple nodes with usableAsTool flag', async () => {
|
||||
const mockNodeTypes = [
|
||||
{
|
||||
name: 'n8n-nodes-test1.test1',
|
||||
packageName: 'n8n-nodes-test1',
|
||||
nodeDescription: {
|
||||
name: 'test-node-1-preview',
|
||||
displayName: 'Test Node 1',
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
usableAsTool: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-test2.test2',
|
||||
packageName: 'n8n-nodes-test2',
|
||||
nodeDescription: {
|
||||
name: 'test-node-2-preview',
|
||||
displayName: 'Test Node 2',
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
usableAsTool: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-test3.test3',
|
||||
packageName: 'n8n-nodes-test3',
|
||||
nodeDescription: {
|
||||
name: 'test-node-3-preview',
|
||||
displayName: 'Test Node 3',
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
usableAsTool: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
(getCommunityNodeTypes as jest.Mock).mockResolvedValueOnce(mockNodeTypes);
|
||||
|
||||
const result = await service.getCommunityNodeTypes();
|
||||
|
||||
expect(result.length).toBe(5); // 3 original + 2 tool versions
|
||||
|
||||
expect(result.find((n) => n.name === 'n8n-nodes-test1.test1Tool')).toBeDefined();
|
||||
expect(result.find((n) => n.name === 'n8n-nodes-test2.test2Tool')).toBeDefined();
|
||||
expect(result.find((n) => n.name === 'n8n-nodes-test3.test3Tool')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not mutate original node type when creating tool version', async () => {
|
||||
const mockNodeTypes = [
|
||||
{
|
||||
name: 'n8n-nodes-test.test',
|
||||
packageName: 'n8n-nodes-test',
|
||||
nodeDescription: {
|
||||
name: 'test-node-preview',
|
||||
displayName: 'Test Node',
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
usableAsTool: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
(getCommunityNodeTypes as jest.Mock).mockResolvedValueOnce(mockNodeTypes);
|
||||
|
||||
const result = await service.getCommunityNodeTypes();
|
||||
|
||||
const originalNode = result.find((n) => n.name === 'n8n-nodes-test.test');
|
||||
const toolNode = result.find((n) => n.name === 'n8n-nodes-test.testTool');
|
||||
|
||||
// Ensure original node is not modified
|
||||
expect(originalNode?.name).toBe('n8n-nodes-test.test');
|
||||
expect(originalNode?.nodeDescription.name).toBe('test-node-preview');
|
||||
expect(originalNode?.nodeDescription.displayName).toBe('Test Node');
|
||||
expect(originalNode?.nodeDescription.inputs).toEqual(['main']);
|
||||
expect(originalNode?.nodeDescription.outputs).toEqual(['main']);
|
||||
|
||||
// Ensure tool node has correct modifications
|
||||
expect(toolNode?.name).toBe('n8n-nodes-test.testTool');
|
||||
expect(toolNode?.nodeDescription.name).toBe('test-node-previewTool');
|
||||
expect(toolNode?.nodeDescription.displayName).toBe('Test Node Tool');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectUpdates', () => {
|
||||
const { getCommunityNodesMetadata } = require('../community-node-types-utils');
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { CommunityNodeType } from '@n8n/api-types';
|
||||
import { Logger, inProduction } from '@n8n/backend-common';
|
||||
import { inProduction, Logger } from '@n8n/backend-common';
|
||||
import { Service } from '@n8n/di';
|
||||
import { ensureError } from 'n8n-workflow';
|
||||
import { ensureError, isToolType, NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import {
|
||||
getCommunityNodeTypes,
|
||||
getCommunityNodesMetadata,
|
||||
@@ -126,9 +127,45 @@ export class CommunityNodeTypesService {
|
||||
|
||||
this.setCommunityNodeTypes(nodeTypes);
|
||||
|
||||
this.createAiTools();
|
||||
|
||||
this.lastUpdateTimestamp = Date.now();
|
||||
}
|
||||
|
||||
private createAiTools() {
|
||||
const usableAsTools = Array.from(this.communityNodeTypes.values()).filter(
|
||||
(nodeType) => nodeType.nodeDescription.usableAsTool && !isToolType(nodeType.name),
|
||||
);
|
||||
const forbiddenCategories = ['Recommended Tools'];
|
||||
for (const nodeType of usableAsTools) {
|
||||
const clonedNodeType = cloneDeep(nodeType);
|
||||
const toolSubcategories = clonedNodeType.nodeDescription.codex?.subcategories?.Tools ?? [
|
||||
'Other Tools',
|
||||
];
|
||||
// don't allow community nodes to appear in Recommended Tools category
|
||||
const filteredToolSubcategories = toolSubcategories.filter(
|
||||
(subcategory) => !forbiddenCategories.includes(subcategory),
|
||||
);
|
||||
// this parameter is valid npm package name
|
||||
clonedNodeType.name += 'Tool';
|
||||
// this parameter has -preview suffix
|
||||
clonedNodeType.nodeDescription.name += 'Tool';
|
||||
clonedNodeType.nodeDescription.inputs = [];
|
||||
clonedNodeType.nodeDescription.outputs = [NodeConnectionTypes.AiTool];
|
||||
clonedNodeType.nodeDescription.displayName += ' Tool';
|
||||
clonedNodeType.nodeDescription.codex = {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Tools'],
|
||||
Tools: filteredToolSubcategories,
|
||||
},
|
||||
resources: clonedNodeType.nodeDescription.codex?.resources ?? {},
|
||||
};
|
||||
|
||||
this.communityNodeTypes.set(clonedNodeType.name, clonedNodeType);
|
||||
}
|
||||
}
|
||||
|
||||
private setCommunityNodeTypes(nodeTypes: StrapiCommunityNodeType[]) {
|
||||
for (const nodeType of nodeTypes) {
|
||||
this.communityNodeTypes.set(nodeType.name, nodeType);
|
||||
|
||||
@@ -232,6 +232,7 @@ describe('FrontendService', () => {
|
||||
},
|
||||
},
|
||||
authCookie: { secure: false },
|
||||
communityNodesEnabled: false,
|
||||
previewMode: false,
|
||||
enterprise: { saml: false, ldap: false, oidc: false },
|
||||
};
|
||||
@@ -260,6 +261,7 @@ describe('FrontendService', () => {
|
||||
},
|
||||
},
|
||||
authCookie: { secure: false },
|
||||
communityNodesEnabled: false,
|
||||
previewMode: false,
|
||||
enterprise: { saml: false, ldap: false, oidc: false },
|
||||
mfa: {
|
||||
|
||||
@@ -91,6 +91,8 @@ export type PublicFrontendSettings = {
|
||||
loginUrl: FrontendSettings['sso']['oidc']['loginUrl'];
|
||||
};
|
||||
};
|
||||
/** Used to fetch community nodes on preview instance */
|
||||
communityNodesEnabled: FrontendSettings['communityNodesEnabled'];
|
||||
|
||||
mfa?: {
|
||||
enabled: boolean;
|
||||
@@ -544,6 +546,7 @@ export class FrontendService {
|
||||
previewMode,
|
||||
enterprise: { saml, ldap, oidc },
|
||||
mfa,
|
||||
communityNodesEnabled,
|
||||
} = await this.getSettings();
|
||||
|
||||
const publicSettings: PublicFrontendSettings = {
|
||||
@@ -568,6 +571,7 @@ export class FrontendService {
|
||||
authCookie,
|
||||
previewMode,
|
||||
enterprise: { saml, ldap, oidc },
|
||||
communityNodesEnabled,
|
||||
};
|
||||
if (includeMfaSettings) {
|
||||
publicSettings.mfa = mfa;
|
||||
|
||||
@@ -3814,6 +3814,83 @@ describe('useCanvasOperations', () => {
|
||||
expect(updateNodeAtIndexSpy).toHaveBeenNthCalledWith(1, 0, workflow.nodes[0]);
|
||||
expect(updateNodeAtIndexSpy).toHaveBeenNthCalledWith(2, 1, workflow.nodes[1]);
|
||||
});
|
||||
|
||||
it('should remove preview token from node type when initializing', () => {
|
||||
const updateNodeAtIndexSpy = vi.spyOn(workflowState, 'updateNodeAtIndex');
|
||||
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||
const nodeWithPreview = createTestNode({
|
||||
type: 'n8n-nodes-community.testNode-preview',
|
||||
name: 'testNode',
|
||||
});
|
||||
const workflow = createTestWorkflow({
|
||||
nodes: [nodeWithPreview],
|
||||
connections: {},
|
||||
});
|
||||
workflowsStore.workflow.nodes = [nodeWithPreview];
|
||||
const { initializeUnknownNodes } = useCanvasOperations();
|
||||
initializeUnknownNodes(workflow.nodes);
|
||||
|
||||
expect(updateNodeAtIndexSpy).toHaveBeenCalledTimes(1);
|
||||
const updatedNode = updateNodeAtIndexSpy.mock.calls[0][1];
|
||||
expect(updatedNode.type).toBe('n8n-nodes-community.testNode');
|
||||
expect(updatedNode.type).not.toContain('-preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveNodeData', () => {
|
||||
it('should resolve node parameters and webhooks for installed nodes', () => {
|
||||
const nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||
nodeTypesStore.getIsNodeInstalled = vi.fn().mockReturnValue(true);
|
||||
|
||||
const nodeTypeDescription = mockNodeTypeDescription({
|
||||
name: 'n8n-nodes-base.httpRequest',
|
||||
webhooks: [
|
||||
{
|
||||
name: 'default',
|
||||
httpMethod: 'GET',
|
||||
path: 'test',
|
||||
responseMode: 'onReceived',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { addNode } = useCanvasOperations();
|
||||
const node = addNode(
|
||||
{
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
},
|
||||
nodeTypeDescription,
|
||||
);
|
||||
|
||||
expect(nodeTypesStore.getIsNodeInstalled).toHaveBeenCalledWith('n8n-nodes-base.httpRequest');
|
||||
expect(node).toBeDefined();
|
||||
});
|
||||
|
||||
it('should skip resolving parameters and webhooks for non-installed nodes', () => {
|
||||
const nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||
nodeTypesStore.getIsNodeInstalled = vi.fn().mockReturnValue(false);
|
||||
|
||||
const nodeTypeDescription = mockNodeTypeDescription({
|
||||
name: 'n8n-nodes-community.notInstalled',
|
||||
});
|
||||
|
||||
const { addNode } = useCanvasOperations();
|
||||
const node = addNode(
|
||||
{
|
||||
type: 'n8n-nodes-community.notInstalled',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
},
|
||||
nodeTypeDescription,
|
||||
);
|
||||
|
||||
expect(nodeTypesStore.getIsNodeInstalled).toHaveBeenCalledWith(
|
||||
'n8n-nodes-community.notInstalled',
|
||||
);
|
||||
expect(node).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetWorkspace', () => {
|
||||
|
||||
@@ -132,6 +132,7 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import { useTemplatesStore } from '@/features/workflows/templates/templates.store';
|
||||
import { isValidNodeConnectionType } from '@/app/utils/typeGuards';
|
||||
import { useParentFolder } from '@/features/core/folders/composables/useParentFolder';
|
||||
import { removePreviewToken } from '@/features/shared/nodeCreator/nodeCreator.utils';
|
||||
import { useWorkflowState } from '@/app/composables/useWorkflowState';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import {
|
||||
@@ -1092,7 +1093,7 @@ export function useCanvasOperations() {
|
||||
node.name ??
|
||||
nodeHelpers.getDefaultNodeName(node) ??
|
||||
(nodeTypeDescription.defaults.name as string);
|
||||
const type = nodeTypeDescription.name;
|
||||
const type = node.type ?? nodeTypeDescription.name;
|
||||
const typeVersion = node.typeVersion;
|
||||
const position =
|
||||
options.forcePosition && node.position
|
||||
@@ -1115,8 +1116,11 @@ export function useCanvasOperations() {
|
||||
};
|
||||
|
||||
resolveNodeName(nodeData);
|
||||
resolveNodeParameters(nodeData, nodeTypeDescription);
|
||||
resolveNodeWebhook(nodeData, nodeTypeDescription);
|
||||
|
||||
if (nodeTypesStore.getIsNodeInstalled(nodeData.type)) {
|
||||
resolveNodeParameters(nodeData, nodeTypeDescription);
|
||||
resolveNodeWebhook(nodeData, nodeTypeDescription);
|
||||
}
|
||||
|
||||
return nodeData;
|
||||
}
|
||||
@@ -2289,12 +2293,10 @@ export function useCanvasOperations() {
|
||||
const { workflowDocumentStore } = await workflowHelpers.initState(data, useWorkflowState());
|
||||
data.nodes.forEach((node) => {
|
||||
const nodeTypeDescription = requireNodeTypeDescription(node.type, node.typeVersion);
|
||||
const isUnknownNode =
|
||||
!nodeTypesStore.getNodeType(node.type, node.typeVersion) &&
|
||||
!nodeTypesStore.communityNodeType(node.type)?.nodeDescription;
|
||||
const isInstalledNode = nodeTypesStore.getIsNodeInstalled(node.type);
|
||||
nodeHelpers.matchCredentials(node);
|
||||
// skip this step because nodeTypeDescription is missing for unknown nodes
|
||||
if (!isUnknownNode) {
|
||||
if (isInstalledNode) {
|
||||
resolveNodeParameters(node, nodeTypeDescription);
|
||||
resolveNodeWebhook(node, nodeTypeDescription);
|
||||
}
|
||||
@@ -2309,14 +2311,19 @@ export function useCanvasOperations() {
|
||||
|
||||
const initializeUnknownNodes = (nodes: INode[]) => {
|
||||
nodes.forEach((node) => {
|
||||
const nodeTypeDescription = requireNodeTypeDescription(node.type, node.typeVersion);
|
||||
// we need to fetch installed node, so remove preview token
|
||||
const nodeTypeDescription = requireNodeTypeDescription(
|
||||
removePreviewToken(node.type),
|
||||
node.typeVersion,
|
||||
);
|
||||
nodeHelpers.matchCredentials(node);
|
||||
resolveNodeParameters(node, nodeTypeDescription);
|
||||
resolveNodeWebhook(node, nodeTypeDescription);
|
||||
const nodeIndex = workflowsStore.workflow.nodes.findIndex((n) => {
|
||||
return n.name === node.name;
|
||||
});
|
||||
workflowState.updateNodeAtIndex(nodeIndex, node);
|
||||
// make sure that preview node type is always removed
|
||||
workflowState.updateNodeAtIndex(nodeIndex, { ...node, type: removePreviewToken(node.type) });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2454,8 +2461,21 @@ export function useCanvasOperations() {
|
||||
|
||||
// Create a workflow with the new nodes and connections that we can use
|
||||
// the rename method
|
||||
const tempWorkflow: Workflow = workflowsStore.createWorkflowObject(createNodes, newConnections);
|
||||
const tempWorkflow: Workflow = workflowsStore.createWorkflowObject(
|
||||
createNodes,
|
||||
newConnections,
|
||||
true,
|
||||
);
|
||||
|
||||
// createWorkflowObject strips out unknown parameters, bring them back for not installed nodes
|
||||
for (const nodeName of Object.keys(tempWorkflow.nodes)) {
|
||||
const node = tempWorkflow.nodes[nodeName];
|
||||
const isInstalledNode = nodeTypesStore.getIsNodeInstalled(node.type);
|
||||
if (!isInstalledNode) {
|
||||
const originalParameters = createNodes.find((n) => n.name === nodeName)?.parameters;
|
||||
node.parameters = originalParameters ?? node.parameters;
|
||||
}
|
||||
}
|
||||
// Rename all the nodes of which the name changed
|
||||
for (oldName in nodeNameTable) {
|
||||
const nameToChangeTo = nodeNameTable[oldName];
|
||||
|
||||
@@ -177,8 +177,9 @@ export function useWorkflowInitialization(workflowState: WorkflowState) {
|
||||
}
|
||||
|
||||
async function initializeData() {
|
||||
const isPreviewPage = settingsStore.isPreviewMode && isDemoRoute.value;
|
||||
const loadPromises = (() => {
|
||||
if (settingsStore.isPreviewMode && isDemoRoute.value) return [];
|
||||
if (isPreviewPage) return [];
|
||||
|
||||
const promises: Array<Promise<unknown>> = [
|
||||
workflowsListStore.fetchActiveWorkflows(),
|
||||
@@ -207,8 +208,14 @@ export function useWorkflowInitialization(workflowState: WorkflowState) {
|
||||
}
|
||||
|
||||
try {
|
||||
// important to load community nodes to render them correctly
|
||||
if (isPreviewPage) {
|
||||
loadPromises.push(nodeTypesStore.fetchCommunityNodePreviews());
|
||||
} else {
|
||||
//We don't need to await this as community node previews are not critical and needed only in nodes search panel
|
||||
void nodeTypesStore.fetchCommunityNodePreviews();
|
||||
}
|
||||
await Promise.all(loadPromises);
|
||||
void nodeTypesStore.fetchCommunityNodePreviews();
|
||||
} catch (error) {
|
||||
toast.showError(
|
||||
error,
|
||||
|
||||
@@ -54,7 +54,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
|
||||
|
||||
const communityNodeType = computed(() => {
|
||||
return (nodeTypeName: string) => {
|
||||
return vettedCommunityNodeTypes.value.get(nodeTypeName);
|
||||
return vettedCommunityNodeTypes.value.get(removePreviewToken(nodeTypeName));
|
||||
};
|
||||
});
|
||||
|
||||
@@ -146,13 +146,13 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
|
||||
if (!workflow.nodes[node.name]) {
|
||||
return false;
|
||||
}
|
||||
const nodeType = getNodeType.value(nodeTypeName);
|
||||
const nodeType =
|
||||
getNodeType.value(nodeTypeName) ?? communityNodeType.value(nodeTypeName)?.nodeDescription;
|
||||
if (!nodeType) {
|
||||
return false;
|
||||
}
|
||||
const outputs = NodeHelpers.getNodeOutputs(workflow, node, nodeType);
|
||||
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
|
||||
|
||||
return outputTypes
|
||||
? outputTypes.filter((output) => output !== NodeConnectionTypes.Main).length > 0
|
||||
: false;
|
||||
@@ -343,7 +343,6 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
|
||||
|
||||
const getNodeTypes = async () => {
|
||||
const nodeTypes = await nodeTypesApi.getNodeTypes(rootStore.baseUrl);
|
||||
|
||||
if (nodeTypes.length) {
|
||||
setNodeTypes(nodeTypes);
|
||||
}
|
||||
@@ -425,8 +424,10 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
|
||||
|
||||
const getIsNodeInstalled = computed(() => {
|
||||
return (nodeTypeName: string) => {
|
||||
const cleanedNodeTypeName = removePreviewToken(nodeTypeName);
|
||||
return (
|
||||
!!getNodeType.value(nodeTypeName) || !!communityNodeType.value(nodeTypeName)?.isInstalled
|
||||
!!getNodeType.value(cleanedNodeTypeName) ||
|
||||
!!communityNodeType.value(cleanedNodeTypeName)?.isInstalled
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -65,9 +65,11 @@ const getNodeType = vi.fn((_nodeTypeName: string): Partial<INodeTypeDescription>
|
||||
webhooks: [],
|
||||
properties: [],
|
||||
}));
|
||||
const communityNodeType = vi.fn();
|
||||
vi.mock('@/app/stores/nodeTypes.store', () => ({
|
||||
useNodeTypesStore: vi.fn(() => ({
|
||||
getNodeType,
|
||||
communityNodeType,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -2421,6 +2423,108 @@ describe('useWorkflowsStore', () => {
|
||||
expect(workflowsApi.getLastSuccessfulExecution).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeTypes() - getByNameAndVersion', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
workflowsStore = useWorkflowsStore();
|
||||
});
|
||||
|
||||
it('should return node type for core nodes', () => {
|
||||
const mockNodeType = mockNodeTypeDescription({
|
||||
name: 'n8n-nodes-base.httpRequest',
|
||||
displayName: 'HTTP Request',
|
||||
});
|
||||
|
||||
getNodeType.mockReturnValue(mockNodeType);
|
||||
|
||||
const nodeTypes = workflowsStore.getNodeTypes();
|
||||
const result = nodeTypes.getByNameAndVersion('n8n-nodes-base.httpRequest', 1);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.description.name).toBe('n8n-nodes-base.httpRequest');
|
||||
});
|
||||
|
||||
it('should fallback to community node type when core node not found', () => {
|
||||
const mockCommunityNodeDescription = mockNodeTypeDescription({
|
||||
name: 'n8n-nodes-test.test',
|
||||
displayName: 'Test Node',
|
||||
});
|
||||
|
||||
getNodeType.mockReturnValue(null);
|
||||
|
||||
communityNodeType.mockReturnValue({
|
||||
name: 'n8n-nodes-test.test',
|
||||
packageName: 'n8n-nodes-test',
|
||||
checksum: 'test-checksum',
|
||||
npmVersion: '1.0.0',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
numberOfStars: 0,
|
||||
numberOfDownloads: 0,
|
||||
authorGithubUrl: '',
|
||||
authorName: '',
|
||||
description: '',
|
||||
displayName: 'Test Node',
|
||||
isOfficialNode: false,
|
||||
nodeDescription: mockCommunityNodeDescription,
|
||||
isInstalled: false,
|
||||
});
|
||||
|
||||
const nodeTypes = workflowsStore.getNodeTypes();
|
||||
const result = nodeTypes.getByNameAndVersion('n8n-nodes-test.test');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.description.name).toBe('n8n-nodes-test.test');
|
||||
});
|
||||
|
||||
it('should return undefined when node type is not found', () => {
|
||||
getNodeType.mockReturnValue(null);
|
||||
|
||||
communityNodeType.mockReturnValue(undefined);
|
||||
|
||||
const nodeTypes = workflowsStore.getNodeTypes();
|
||||
const result = nodeTypes.getByNameAndVersion('non-existent-node');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use community node description when available and core node is null', () => {
|
||||
const mockCommunityNodeDescription = mockNodeTypeDescription({
|
||||
name: 'n8n-nodes-community.customNode',
|
||||
displayName: 'Custom Community Node',
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
});
|
||||
|
||||
getNodeType.mockReturnValue(null);
|
||||
|
||||
communityNodeType.mockReturnValue({
|
||||
name: 'n8n-nodes-community.customNode',
|
||||
packageName: 'n8n-nodes-community',
|
||||
checksum: 'test-checksum',
|
||||
npmVersion: '1.0.0',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
numberOfStars: 10,
|
||||
numberOfDownloads: 100,
|
||||
authorGithubUrl: '',
|
||||
authorName: '',
|
||||
description: '',
|
||||
displayName: 'Custom Community Node',
|
||||
isOfficialNode: false,
|
||||
nodeDescription: mockCommunityNodeDescription,
|
||||
isInstalled: true,
|
||||
});
|
||||
|
||||
const nodeTypes = workflowsStore.getNodeTypes();
|
||||
const result = nodeTypes.getByNameAndVersion('n8n-nodes-community.customNode');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.description.name).toBe('n8n-nodes-community.customNode');
|
||||
expect(result?.description.displayName).toBe('Custom Community Node');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function getMockEditFieldsNode(): Partial<INodeTypeDescription> {
|
||||
|
||||
@@ -513,7 +513,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||
nodeTypes: {},
|
||||
init: async (): Promise<void> => {},
|
||||
getByNameAndVersion: (nodeType: string, version?: number): INodeType | undefined => {
|
||||
const nodeTypeDescription = nodeTypesStore.getNodeType(nodeType, version);
|
||||
const nodeTypeDescription =
|
||||
nodeTypesStore.getNodeType(nodeType, version) ??
|
||||
nodeTypesStore.communityNodeType(nodeType)?.nodeDescription ??
|
||||
null;
|
||||
if (nodeTypeDescription === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+26
-1
@@ -165,7 +165,7 @@ describe('CommunityPackageManageConfirmModal', () => {
|
||||
expect(screen.getByText('Package includes: TestNode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should notinclude table with affected workflows', async () => {
|
||||
it('should not include table with affected workflows', async () => {
|
||||
useSettingsStore().setSettings({ ...defaultSettings, communityNodesEnabled: true });
|
||||
|
||||
nodeTypesStore.loadNodeTypesIfNotLoaded = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -194,4 +194,29 @@ describe('CommunityPackageManageConfirmModal', () => {
|
||||
screen.getByText('Nodes from this package are not used in any workflows'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show warning if it is not defined', async () => {
|
||||
useSettingsStore().setSettings({ ...defaultSettings, communityNodesEnabled: true });
|
||||
|
||||
nodeTypesStore.loadNodeTypesIfNotLoaded = vi.fn().mockResolvedValue(undefined);
|
||||
nodeTypesStore.getCommunityNodeAttributes = vi.fn().mockResolvedValue({ npmVersion: '1.5.0' });
|
||||
|
||||
fetchWorkflowsWithNodesIncluded.mockResolvedValue({
|
||||
data: [],
|
||||
});
|
||||
|
||||
// uninstall mode does not have a warning
|
||||
const screen = renderComponent({
|
||||
props: {
|
||||
modalName: 'test-modal',
|
||||
activePackageName: 'n8n-nodes-test',
|
||||
mode: 'uninstall',
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
const testId = screen.queryByTestId('communityPackageManageConfirmModal-warning');
|
||||
expect(testId).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+5
-2
@@ -128,7 +128,10 @@ const onUninstall = async () => {
|
||||
});
|
||||
loading.value = true;
|
||||
await communityNodesStore.uninstallPackage(props.activePackageName);
|
||||
await useNodeTypesStore().getNodeTypes();
|
||||
await Promise.all([
|
||||
useNodeTypesStore().getNodeTypes(),
|
||||
useNodeTypesStore().fetchCommunityNodePreviews(),
|
||||
]);
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.communityNodes.messages.uninstall.success.title'),
|
||||
type: 'success',
|
||||
@@ -249,7 +252,7 @@ onMounted(async () => {
|
||||
<template #content>
|
||||
<N8nText color="text-dark" :bold="true">{{ getModalContent.message }}</N8nText>
|
||||
<N8nNotice
|
||||
v-if="!isLatestPackageVerified"
|
||||
v-if="!isLatestPackageVerified && getModalContent.warning"
|
||||
data-test-id="communityPackageManageConfirmModal-warning"
|
||||
:content="getModalContent.warning"
|
||||
/>
|
||||
|
||||
+1
@@ -58,6 +58,7 @@ vi.mock('@/app/stores/nodeTypes.store', () => ({
|
||||
getCommunityNodeAttributes,
|
||||
getNodeTypes,
|
||||
communityNodeType: vi.fn(() => ({ isOfficialNode: true })),
|
||||
fetchCommunityNodePreviews: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
+3
@@ -81,6 +81,7 @@ beforeEach(() => {
|
||||
|
||||
vi.mocked(communityNodesStore.installPackage).mockResolvedValue(undefined);
|
||||
vi.mocked(nodeTypesStore.getNodeTypes).mockResolvedValue(undefined);
|
||||
vi.mocked(nodeTypesStore.fetchCommunityNodePreviews).mockResolvedValue(undefined);
|
||||
vi.mocked(credentialsStore.fetchCredentialTypes).mockResolvedValue(undefined);
|
||||
vi.mocked(nodeTypesStore.getCommunityNodeAttributes).mockResolvedValue({
|
||||
npmVersion: '1.0.0',
|
||||
@@ -157,6 +158,7 @@ describe('useInstallNode', () => {
|
||||
'1.0.0',
|
||||
);
|
||||
expect(nodeTypesStore.getNodeTypes).toHaveBeenCalled();
|
||||
expect(nodeTypesStore.fetchCommunityNodePreviews).toHaveBeenCalled();
|
||||
expect(credentialsStore.fetchCredentialTypes).toHaveBeenCalledWith(true);
|
||||
expect(showMessage).toHaveBeenCalledWith({
|
||||
title: 'settings.communityNodes.messages.install.success',
|
||||
@@ -175,6 +177,7 @@ describe('useInstallNode', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(communityNodesStore.installPackage).toHaveBeenCalledWith('test-package');
|
||||
expect(nodeTypesStore.getNodeTypes).toHaveBeenCalled();
|
||||
expect(nodeTypesStore.fetchCommunityNodePreviews).toHaveBeenCalled();
|
||||
expect(credentialsStore.fetchCredentialTypes).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
|
||||
+6
-2
@@ -68,11 +68,15 @@ export function useInstallNode() {
|
||||
}
|
||||
|
||||
// refresh store information about installed nodes
|
||||
await nodeTypesStore.getNodeTypes();
|
||||
await credentialsStore.fetchCredentialTypes(true);
|
||||
await Promise.all([
|
||||
nodeTypesStore.getNodeTypes(),
|
||||
nodeTypesStore.fetchCommunityNodePreviews(),
|
||||
credentialsStore.fetchCredentialTypes(true),
|
||||
]);
|
||||
await nextTick();
|
||||
|
||||
// update parameters and webhooks for freshly installed nodes
|
||||
// rename types from preview version to the actual version
|
||||
const nodeType = props.nodeType;
|
||||
if (nodeType && workflowsStore.workflow.nodes?.length) {
|
||||
const nodesToUpdate = workflowsStore.workflow.nodes.filter(
|
||||
|
||||
+7
-3
@@ -138,9 +138,15 @@ function onSelected(item: INodeCreateElement) {
|
||||
}
|
||||
|
||||
if (item.type === 'node') {
|
||||
const payload = nodeCreateElementToNodeTypeSelectedPayload(item);
|
||||
let nodeActions = getFilteredActions(item, actions);
|
||||
const notInstalledCommunityNode =
|
||||
isCommunityPackageName(item.key) && !useNodeTypesStore().getIsNodeInstalled(item.key);
|
||||
|
||||
if (shouldShowCommunityNodeDetails(isCommunityPackageName(item.key), activeViewStack.value)) {
|
||||
if (
|
||||
shouldShowCommunityNodeDetails(isCommunityPackageName(item.key), activeViewStack.value) ||
|
||||
notInstalledCommunityNode
|
||||
) {
|
||||
if (!nodeActions.length) {
|
||||
nodeActions = getFilteredActions(item, communityNodesAndActions.value.actions);
|
||||
}
|
||||
@@ -156,8 +162,6 @@ function onSelected(item: INodeCreateElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = nodeCreateElementToNodeTypeSelectedPayload(item);
|
||||
|
||||
// If there is only one action, use it
|
||||
if (nodeActions.length === 1) {
|
||||
emit('nodeTypeSelected', [payload]);
|
||||
|
||||
-166
@@ -1,166 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useInstallNode } from '@/features/settings/communityNodes/composables/useInstallNode';
|
||||
import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.store';
|
||||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import { getNodeIconSource } from '@/app/utils/nodeIcon';
|
||||
import { N8nButton, N8nIcon, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||
import { i18n } from '@n8n/i18n';
|
||||
import OfficialIcon from 'virtual:icons/mdi/verified';
|
||||
import { computed } from 'vue';
|
||||
import { useViewStacks } from '../../composables/useViewStacks';
|
||||
import { prepareCommunityNodeDetailsViewStack, removePreviewToken } from '../../nodeCreator.utils';
|
||||
import NodeIcon from '@/app/components/NodeIcon.vue';
|
||||
|
||||
const {
|
||||
activeViewStack,
|
||||
pushViewStack,
|
||||
popViewStack,
|
||||
getAllNodeCreateElements,
|
||||
updateCurrentViewStack,
|
||||
} = useViewStacks();
|
||||
|
||||
const { communityNodeDetails } = activeViewStack;
|
||||
|
||||
const nodeCreatorStore = useNodeCreatorStore();
|
||||
const { installNode, loading } = useInstallNode();
|
||||
|
||||
const isOwner = computed(() => useUsersStore().isInstanceOwner);
|
||||
|
||||
const updateViewStack = (key: string) => {
|
||||
const installedNodeKey = removePreviewToken(key);
|
||||
const installedNode = getAllNodeCreateElements().find((node) => node.key === installedNodeKey);
|
||||
|
||||
if (installedNode) {
|
||||
const nodeActions = nodeCreatorStore.actions?.[installedNode.key] || [];
|
||||
|
||||
popViewStack();
|
||||
|
||||
updateCurrentViewStack({ searchItems: nodeCreatorStore.mergedNodes });
|
||||
|
||||
const viewStack = prepareCommunityNodeDetailsViewStack(
|
||||
installedNode,
|
||||
getNodeIconSource(installedNode.properties),
|
||||
activeViewStack.rootView,
|
||||
nodeActions,
|
||||
);
|
||||
|
||||
pushViewStack(viewStack, {
|
||||
transitionDirection: 'none',
|
||||
});
|
||||
} else {
|
||||
const viewStack = { ...activeViewStack };
|
||||
viewStack.communityNodeDetails!.installed = true;
|
||||
|
||||
pushViewStack(activeViewStack, { resetStacks: true });
|
||||
}
|
||||
};
|
||||
|
||||
const updateStoresAndViewStack = (key: string) => {
|
||||
updateViewStack(key);
|
||||
nodeCreatorStore.removeNodeFromMergedNodes(key);
|
||||
};
|
||||
|
||||
const onInstall = async () => {
|
||||
if (isOwner.value && activeViewStack.communityNodeDetails && !communityNodeDetails?.installed) {
|
||||
const { key, packageName } = activeViewStack.communityNodeDetails;
|
||||
const result = await installNode({ type: 'verified', packageName, nodeType: key });
|
||||
if (result.success) {
|
||||
updateStoresAndViewStack(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="communityNodeDetails" :class="$style.container">
|
||||
<div :class="$style.header">
|
||||
<div :class="$style.title">
|
||||
<NodeIcon
|
||||
v-if="communityNodeDetails.nodeIcon"
|
||||
:class="$style.nodeIcon"
|
||||
:icon-source="communityNodeDetails.nodeIcon"
|
||||
:circle="false"
|
||||
:show-tooltip="false"
|
||||
/>
|
||||
<span>{{ communityNodeDetails.title }}</span>
|
||||
<N8nTooltip v-if="communityNodeDetails.official" placement="bottom" :show-after="500">
|
||||
<template #content>
|
||||
{{
|
||||
i18n.baseText('generic.officialNode.tooltip', {
|
||||
interpolate: {
|
||||
author: communityNodeDetails.companyName ?? communityNodeDetails.title,
|
||||
},
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<OfficialIcon :class="$style.officialIcon" />
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="communityNodeDetails.installed" :class="$style.installed">
|
||||
<N8nIcon v-if="!communityNodeDetails.official" :class="$style.installedIcon" icon="box" />
|
||||
<N8nText color="text-light" size="small" bold>
|
||||
{{ i18n.baseText('communityNodeDetails.installed') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<N8nButton
|
||||
v-if="isOwner && !communityNodeDetails.installed"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
:label="i18n.baseText('communityNodeDetails.install')"
|
||||
size="small"
|
||||
data-test-id="install-community-node-button"
|
||||
@click="onInstall"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
width: 100%;
|
||||
padding: var(--spacing--sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--spacing--xs);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
gap: var(--spacing--2xs);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color--text);
|
||||
font-size: var(--font-size--xl);
|
||||
font-weight: var(--font-weight--bold);
|
||||
}
|
||||
.nodeIcon {
|
||||
--node--icon--size: 36px;
|
||||
margin-right: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.installedIcon {
|
||||
margin-right: var(--spacing--3xs);
|
||||
color: var(--color--text);
|
||||
font-size: var(--font-size--2xs);
|
||||
}
|
||||
|
||||
.officialIcon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--spacing--4xs);
|
||||
color: var(--color--text);
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.installed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: var(--spacing--xs);
|
||||
}
|
||||
</style>
|
||||
+6
-2
@@ -42,7 +42,11 @@ import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useExternalHooks } from '@/app/composables/useExternalHooks';
|
||||
|
||||
import { sortNodeCreateElements, transformNodeType } from '../nodeCreator.utils';
|
||||
import {
|
||||
removePreviewToken,
|
||||
sortNodeCreateElements,
|
||||
transformNodeType,
|
||||
} from '../nodeCreator.utils';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { PUSH_NODES_OFFSET } from '@/app/utils/nodeViewUtils';
|
||||
import { useCanvasStore } from '@/app/stores/canvas.store';
|
||||
@@ -215,7 +219,7 @@ export const useActions = () => {
|
||||
actionData: NodeCreateElement,
|
||||
): NodeTypeSelectedPayload {
|
||||
const result: NodeTypeSelectedPayload = {
|
||||
type: actionData.key,
|
||||
type: removePreviewToken(actionData.key),
|
||||
};
|
||||
|
||||
if (typeof actionData.resource === 'string' || typeof actionData.operation === 'string') {
|
||||
|
||||
+4
-1
@@ -20,6 +20,7 @@ import { useNDVStore } from '@/features/ndv/shared/ndv.store';
|
||||
import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.store';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import type * as nodeCreatorUtils from './nodeCreator.utils';
|
||||
|
||||
const workflow_id = 'workflow-id';
|
||||
const category_name = 'category-name';
|
||||
@@ -50,8 +51,10 @@ vi.mock('@/features/workflows/canvas/canvas.utils', () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./nodeCreator.utils', async () => {
|
||||
vi.mock('./nodeCreator.utils', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof nodeCreatorUtils>();
|
||||
return {
|
||||
...original,
|
||||
prepareCommunityNodeDetailsViewStack: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
+3
-1
@@ -190,7 +190,9 @@ function onActivate(event: MouseEvent) {
|
||||
<div v-if="isDisabled" :class="$style.disabledLabel">
|
||||
({{ i18n.baseText('node.disabled') }})
|
||||
</div>
|
||||
<div v-if="subtitle" :class="$style.subtitle">{{ subtitle }}</div>
|
||||
<div v-if="subtitle && !isNotInstalledCommunityNode" :class="$style.subtitle">
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
<CanvasNodeStatusIcons v-if="!isDisabled" :class="$style.statusIcons" />
|
||||
</div>
|
||||
|
||||
@@ -1134,6 +1134,10 @@ export function getNodeOutputs(
|
||||
): Array<NodeConnectionType | INodeOutputConfiguration> {
|
||||
let outputs: Array<NodeConnectionType | INodeOutputConfiguration> = [];
|
||||
|
||||
if (!nodeTypeData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(nodeTypeData.outputs)) {
|
||||
outputs = nodeTypeData.outputs;
|
||||
} else {
|
||||
@@ -1659,6 +1663,9 @@ export function isTriggerNode(nodeTypeData: INodeTypeDescription) {
|
||||
}
|
||||
|
||||
export function isExecutable(workflow: Workflow, node: INode, nodeTypeData: INodeTypeDescription) {
|
||||
if (!nodeTypeData) {
|
||||
return false;
|
||||
}
|
||||
const outputs = getNodeOutputs(workflow, node, nodeTypeData);
|
||||
const outputNames = getConnectionTypes(outputs);
|
||||
return (
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
WorkflowExecuteMode,
|
||||
ProxyInput,
|
||||
INode,
|
||||
INodeType,
|
||||
} from './interfaces';
|
||||
import * as NodeHelpers from './node-helpers';
|
||||
import { createResultError, createResultOk } from './result';
|
||||
@@ -185,7 +186,16 @@ export class WorkflowDataProxy {
|
||||
}
|
||||
|
||||
private buildAgentToolInfo(node: INode) {
|
||||
const nodeType = this.workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
const nodeType: INodeType | undefined = this.workflow.nodeTypes.getByNameAndVersion(
|
||||
node.type,
|
||||
node.typeVersion,
|
||||
);
|
||||
if (!nodeType) {
|
||||
return {
|
||||
name: node.name,
|
||||
type: node.type,
|
||||
};
|
||||
}
|
||||
const type = nodeType.description.displayName;
|
||||
const params = NodeHelpers.getNodeParameters(
|
||||
nodeType.description.properties,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
getNodeWebhookPath,
|
||||
isToolType,
|
||||
isHitlToolType,
|
||||
getNodeOutputs,
|
||||
} from '../src/node-helpers';
|
||||
import type { Workflow } from '../src/workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
@@ -4422,6 +4423,72 @@ describe('NodeHelpers', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('getNodeOutputs', () => {
|
||||
const workflowMock = {
|
||||
expression: {
|
||||
getSimpleParameterValue: vi.fn().mockReturnValue([NodeConnectionTypes.Main]),
|
||||
},
|
||||
} as unknown as Workflow;
|
||||
|
||||
test('Should return empty array when nodeTypeData is null', () => {
|
||||
const node: INode = {
|
||||
id: 'testNodeId',
|
||||
name: 'TestNode',
|
||||
position: [0, 0],
|
||||
type: 'n8n-nodes-base.TestNode',
|
||||
typeVersion: 1,
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
const result = getNodeOutputs(workflowMock, node, null as unknown as INodeTypeDescription);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('Should return empty array when nodeTypeData is undefined', () => {
|
||||
const node: INode = {
|
||||
id: 'testNodeId',
|
||||
name: 'TestNode',
|
||||
position: [0, 0],
|
||||
type: 'n8n-nodes-base.TestNode',
|
||||
typeVersion: 1,
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
const result = getNodeOutputs(
|
||||
workflowMock,
|
||||
node,
|
||||
undefined as unknown as INodeTypeDescription,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('Should return outputs array when nodeTypeData is valid', () => {
|
||||
const node: INode = {
|
||||
id: 'testNodeId',
|
||||
name: 'TestNode',
|
||||
position: [0, 0],
|
||||
type: 'n8n-nodes-base.TestNode',
|
||||
typeVersion: 1,
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
const nodeTypeData: INodeTypeDescription = {
|
||||
name: 'TestNode',
|
||||
displayName: 'Test Node',
|
||||
group: ['transform'],
|
||||
description: 'Test node description',
|
||||
version: 1,
|
||||
defaults: {},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main, NodeConnectionTypes.AiAgent],
|
||||
properties: [],
|
||||
};
|
||||
|
||||
const result = getNodeOutputs(workflowMock, node, nodeTypeData);
|
||||
expect(result).toEqual([NodeConnectionTypes.Main, NodeConnectionTypes.AiAgent]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExecutable', () => {
|
||||
const workflowMock = {
|
||||
expression: {
|
||||
@@ -4591,6 +4658,34 @@ describe('NodeHelpers', () => {
|
||||
expect(result).toEqual(testData.expected);
|
||||
});
|
||||
}
|
||||
|
||||
test('Should return false when nodeTypeData is null', () => {
|
||||
const node: INode = {
|
||||
id: 'testNodeId',
|
||||
name: 'TestNode',
|
||||
position: [0, 0],
|
||||
type: 'n8n-nodes-base.TestNode',
|
||||
typeVersion: 1,
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
const result = isExecutable(workflowMock, node, null as unknown as INodeTypeDescription);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('Should return false when nodeTypeData is undefined', () => {
|
||||
const node: INode = {
|
||||
id: 'testNodeId',
|
||||
name: 'TestNode',
|
||||
position: [0, 0],
|
||||
type: 'n8n-nodes-base.TestNode',
|
||||
typeVersion: 1,
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
const result = isExecutable(workflowMock, node, undefined as unknown as INodeTypeDescription);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
describe('displayParameter', () => {
|
||||
const testNode: INode = {
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
type IWorkflowDataProxyAdditionalKeys,
|
||||
type WorkflowExecuteMode,
|
||||
type WorkflowSettingsBinaryMode,
|
||||
type INodeType,
|
||||
type INodeTypes,
|
||||
} from '../src/interfaces';
|
||||
import { BINARY_MODE_COMBINED } from '../src/constants';
|
||||
import {
|
||||
@@ -1751,4 +1753,158 @@ describe('WorkflowDataProxy', () => {
|
||||
expect(error).toBeInstanceOf(ExpressionError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAgentToolInfo', () => {
|
||||
it('should return basic info when nodeType is null', () => {
|
||||
const mockWorkflowWithNullNodeType = new Workflow({
|
||||
id: '123',
|
||||
name: 'test workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Node1',
|
||||
type: 'n8n-nodes-base.unknownNode',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
active: false,
|
||||
nodeTypes: {
|
||||
getByNameAndVersion: () => undefined as unknown as INodeType,
|
||||
} as unknown as INodeTypes,
|
||||
settings: {},
|
||||
});
|
||||
|
||||
const dataProxy = new WorkflowDataProxy(
|
||||
mockWorkflowWithNullNodeType,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
'Node1',
|
||||
[],
|
||||
{},
|
||||
'integrated',
|
||||
{},
|
||||
);
|
||||
|
||||
const node: INode = {
|
||||
id: 'node1',
|
||||
name: 'Node1',
|
||||
type: 'n8n-nodes-base.unknownNode',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
const result = (dataProxy as any).buildAgentToolInfo(node);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'Node1',
|
||||
type: 'n8n-nodes-base.unknownNode',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return full info when nodeType is valid', () => {
|
||||
const mockWorkflow = new Workflow({
|
||||
id: '123',
|
||||
name: 'test workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Node1',
|
||||
type: 'n8n-nodes-base.testNode',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
active: false,
|
||||
nodeTypes: Helpers.NodeTypes(),
|
||||
settings: {},
|
||||
});
|
||||
|
||||
const dataProxy = new WorkflowDataProxy(
|
||||
mockWorkflow,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
'Node1',
|
||||
[],
|
||||
{},
|
||||
'integrated',
|
||||
{},
|
||||
);
|
||||
|
||||
const node: INode = {
|
||||
id: 'node1',
|
||||
name: 'Node1',
|
||||
type: 'n8n-nodes-base.testNode',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
const result = (dataProxy as any).buildAgentToolInfo(node);
|
||||
|
||||
expect(result).toHaveProperty('name');
|
||||
expect(result).toHaveProperty('type');
|
||||
expect(result.name).toBe('Node1');
|
||||
});
|
||||
|
||||
it('should handle undefined nodeType gracefully', () => {
|
||||
const mockWorkflowWithUndefinedNodeType = new Workflow({
|
||||
id: '123',
|
||||
name: 'test workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Node1',
|
||||
type: 'n8n-nodes-base.missingNode',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
active: false,
|
||||
nodeTypes: {
|
||||
getByNameAndVersion: () => undefined as unknown as INodeType,
|
||||
} as unknown as INodeTypes,
|
||||
settings: {},
|
||||
});
|
||||
|
||||
const dataProxy = new WorkflowDataProxy(
|
||||
mockWorkflowWithUndefinedNodeType,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
'Node1',
|
||||
[],
|
||||
{},
|
||||
'integrated',
|
||||
{},
|
||||
);
|
||||
|
||||
const node: INode = {
|
||||
id: 'node1',
|
||||
name: 'Node1',
|
||||
type: 'n8n-nodes-base.missingNode',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
const result = (dataProxy as any).buildAgentToolInfo(node);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'Node1',
|
||||
type: 'n8n-nodes-base.missingNode',
|
||||
});
|
||||
expect(result).not.toHaveProperty('displayName');
|
||||
expect(result).not.toHaveProperty('params');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user