feat(editor): Preview not installed community tools (#24859)

This commit is contained in:
yehorkardash
2026-02-06 13:27:15 +01:00
committed by GitHub
parent fb3fe5fb35
commit b262d95b83
24 changed files with 893 additions and 201 deletions
@@ -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;
}
@@ -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();
});
});
@@ -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"
/>
@@ -58,6 +58,7 @@ vi.mock('@/app/stores/nodeTypes.store', () => ({
getCommunityNodeAttributes,
getNodeTypes,
communityNodeType: vi.fn(() => ({ isOfficialNode: true })),
fetchCommunityNodePreviews: vi.fn(),
})),
}));
@@ -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);
});
@@ -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(
@@ -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]);
@@ -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>
@@ -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') {
@@ -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(),
};
});
@@ -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>
+7
View File
@@ -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 (
+11 -1
View File
@@ -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');
});
});
});