feat: Community Nodes in the Nodes Panel (#13923)

Co-authored-by: Dana Lee <dana@n8n.io>
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
Michael Kret
2025-05-09 13:14:41 +03:00
committed by GitHub
parent 345b60e8d3
commit 24638420bd
55 changed files with 2548 additions and 152 deletions

View File

@@ -18,6 +18,18 @@ import { getVisibleSelect } from '../utils';
const credentialsModal = new CredentialsModal();
const nodeCreatorFeature = new NodeCreator();
const workflowPage = new WorkflowPage();
const ADD_TO_WORKFLOW_BUTTON = 'Add to workflow';
const addCommunityNodeToCanvas = (name: string) => {
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.searchBar().find('input').clear().type(name);
nodeCreatorFeature.getters.getCreatorItem(name).find('.el-tooltip__trigger').should('exist');
nodeCreatorFeature.actions.selectNode(name);
cy.contains('span', name).should('be.visible');
cy.contains(ADD_TO_WORKFLOW_BUTTON).should('be.visible').click();
};
// We separate-out the custom nodes because they require injecting nodes and credentials
// so the /nodes and /credentials endpoints are intercepted and non-cached.
@@ -48,23 +60,36 @@ describe('Community and custom nodes in canvas', () => {
});
});
// next intercepts are not strictly needed, but they make the tests faster
// - intercept request to vetted community types, returning empty list
// - intercept request to vetted community type details, return null
// - intercept request npm registry, return 404
// --------------------------------------------------------------------------
cy.intercept('/community-node-types', (req) => {
req.reply({
statusCode: 200,
body: {
data: [],
},
});
});
cy.intercept('GET', '/community-node-types/*', {
statusCode: 200,
body: null,
});
cy.intercept('GET', 'https://registry.npmjs.org/*', {
statusCode: 404,
body: {},
});
// --------------------------------------------------------------------------
workflowPage.actions.visit();
});
it('should render and select community node', () => {
const customNode = 'E2E Node';
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.searchBar().find('input').clear().type(customNode);
nodeCreatorFeature.getters
.getCreatorItem(customNode)
.find('.el-tooltip__trigger')
.should('exist');
nodeCreatorFeature.actions.selectNode(customNode);
// TODO: Replace once we have canvas feature utils
cy.get('.data-display .node-name').contains(customNode).should('exist');
addCommunityNodeToCanvas('E2E Node');
const nodeParameters = () => cy.getByTestId('node-parameters');
const firstParameter = () => nodeParameters().find('.parameter-item').eq(0);
@@ -87,7 +112,7 @@ describe('Community and custom nodes in canvas', () => {
it('should render custom node with n8n credential', () => {
workflowPage.actions.addNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('E2E Node with native n8n credential', true, true);
addCommunityNodeToCanvas('E2E Node with native n8n credential');
workflowPage.getters.nodeCredentialsLabel().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.editCredentialModal().should('be.visible');
@@ -96,7 +121,7 @@ describe('Community and custom nodes in canvas', () => {
it('should render custom node with custom credential', () => {
workflowPage.actions.addNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('E2E Node with custom credential', true, true);
addCommunityNodeToCanvas('E2E Node with custom credential');
workflowPage.getters.nodeCredentialsLabel().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.editCredentialModal().should('be.visible');

View File

@@ -0,0 +1,23 @@
import type { INodeTypeDescription } from 'n8n-workflow';
export interface CommunityNodeAttributes {
authorGithubUrl: string;
authorName: string;
checksum: string;
description: string;
displayName: string;
name: string;
numberOfStars: number;
numberOfDownloads: number;
packageName: string;
createdAt: string;
updatedAt: string;
npmVersion: string;
}
export interface CommunityNodeData {
id: number;
attributes: CommunityNodeAttributes & {
nodeDescription: INodeTypeDescription;
};
}

View File

@@ -111,6 +111,7 @@ export interface FrontendSettings {
isMultiMain: boolean;
pushBackend: 'sse' | 'websocket';
communityNodesEnabled: boolean;
unverifiedCommunityNodesEnabled: boolean;
aiAssistant: {
enabled: boolean;
};

View File

@@ -5,6 +5,7 @@ export type * from './scaling';
export type * from './frontend-settings';
export type * from './user';
export type * from './api-keys';
export type * from './community-node-types';
export type { Collaborator } from './push/collaboration';
export type { HeartbeatMessage } from './push/heartbeat';

View File

@@ -33,6 +33,18 @@ class CommunityPackagesConfig {
/** Whether to reinstall any missing community packages */
@Env('N8N_REINSTALL_MISSING_PACKAGES')
reinstallMissing: boolean = false;
/** Whether to block installation of not verified packages */
@Env('N8N_UNVERIFIED_PACKAGES_ENABLED')
unverifiedEnabled: boolean = true;
/** Whether to enable and show search suggestion of packages verified by n8n */
@Env('N8N_VERIFIED_PACKAGES_ENABLED')
verifiedEnabled: boolean = true;
/** Whether to load community packages */
@Env('N8N_COMMUNITY_PACKAGES_PREVENT_LOADING')
preventLoading: boolean = false;
}
@Config

View File

@@ -120,6 +120,9 @@ describe('GlobalConfig', () => {
enabled: true,
registry: 'https://registry.npmjs.org',
reinstallMissing: false,
unverifiedEnabled: true,
verifiedEnabled: true,
preventLoading: false,
},
errorTriggerType: 'n8n-nodes-base.errorTrigger',
include: [],

View File

@@ -0,0 +1,84 @@
import type { CommunityNodeAttributes } from '@n8n/api-types';
import type { InstalledPackages } from '@n8n/db';
import { mock } from 'jest-mock-extended';
import type { INodeTypeDescription } from 'n8n-workflow';
import { CommunityPackagesController } from '@/controllers/community-packages.controller';
import type { NodeRequest } from '@/requests';
import type { EventService } from '../../events/event.service';
import type { Push } from '../../push';
import type { CommunityNodeTypesService } from '../../services/community-node-types.service';
import type { CommunityPackagesService } from '../../services/community-packages.service';
describe('CommunityPackagesController', () => {
const push = mock<Push>();
const communityPackagesService = mock<CommunityPackagesService>();
const eventService = mock<EventService>();
const communityNodeTypesService = mock<CommunityNodeTypesService>();
const controller = new CommunityPackagesController(
push,
communityPackagesService,
eventService,
communityNodeTypesService,
);
beforeEach(() => {
jest.clearAllMocks();
});
describe('installPackage', () => {
it('should throw error if verify in options but no checksum', async () => {
const request = mock<NodeRequest.Post>({
user: { id: 'user123' },
body: { name: 'n8n-nodes-test', verify: true },
});
communityNodeTypesService.findVetted.mockReturnValue(undefined);
await expect(controller.installPackage(request)).rejects.toThrow(
'Package n8n-nodes-test is not vetted for installation',
);
});
it('should have correct version', async () => {
const request = mock<NodeRequest.Post>({
user: { id: 'user123' },
body: { name: 'n8n-nodes-test', verify: true, version: '1.0.0' },
});
communityNodeTypesService.findVetted.mockReturnValue(
mock<CommunityNodeAttributes & { nodeDescription: INodeTypeDescription }>({
checksum: 'checksum',
}),
);
communityPackagesService.parseNpmPackageName.mockReturnValue({
rawString: 'n8n-nodes-test',
packageName: 'n8n-nodes-test',
version: '1.1.1',
});
communityPackagesService.isPackageInstalled.mockResolvedValue(false);
communityPackagesService.hasPackageLoaded.mockReturnValue(false);
communityPackagesService.checkNpmPackageStatus.mockResolvedValue({
status: 'OK',
});
communityPackagesService.installPackage.mockResolvedValue(
mock<InstalledPackages>({
installedNodes: [],
}),
);
await controller.installPackage(request);
expect(communityPackagesService.installPackage).toHaveBeenCalledWith(
'n8n-nodes-test',
'1.0.0',
'checksum',
);
expect(eventService.emit).toHaveBeenCalledWith(
'community-package-installed',
expect.objectContaining({
packageVersion: '1.0.0',
}),
);
});
});
});

View File

@@ -0,0 +1,20 @@
import type { CommunityNodeAttributes } from '@n8n/api-types';
import { Get, RestController } from '@n8n/decorators';
import { Request } from 'express';
import { CommunityNodeTypesService } from '@/services/community-node-types.service';
@RestController('/community-node-types')
export class CommunityNodeTypesController {
constructor(private readonly communityNodeTypesService: CommunityNodeTypesService) {}
@Get('/:name')
async getCommunityNodeAttributes(req: Request): Promise<CommunityNodeAttributes | null> {
return this.communityNodeTypesService.getCommunityNodeAttributes(req.params.name);
}
@Get('/')
async getCommunityNodeTypes() {
return await this.communityNodeTypesService.getDescriptions();
}
}

View File

@@ -14,6 +14,8 @@ import { Push } from '@/push';
import { NodeRequest } from '@/requests';
import { CommunityPackagesService } from '@/services/community-packages.service';
import { CommunityNodeTypesService } from '../services/community-node-types.service';
const {
PACKAGE_NOT_INSTALLED,
PACKAGE_NAME_NOT_PROVIDED,
@@ -37,17 +39,28 @@ export class CommunityPackagesController {
private readonly push: Push,
private readonly communityPackagesService: CommunityPackagesService,
private readonly eventService: EventService,
private readonly communityNodeTypesService: CommunityNodeTypesService,
) {}
@Post('/')
@GlobalScope('communityPackage:install')
async installPackage(req: NodeRequest.Post) {
const { name } = req.body;
const { name, verify, version } = req.body;
if (!name) {
throw new BadRequestError(PACKAGE_NAME_NOT_PROVIDED);
}
let checksum: string | undefined = undefined;
// Get the checksum for the package if flagged to verify
if (verify) {
checksum = this.communityNodeTypesService.findVetted(name)?.checksum;
if (!checksum) {
throw new BadRequestError(`Package ${name} is not vetted for installation`);
}
}
let parsed: CommunityPackages.ParsedPackageName;
try {
@@ -85,11 +98,13 @@ export class CommunityPackagesController {
throw new BadRequestError(`Package "${name}" is banned so it cannot be installed`);
}
const packageVersion = version ?? parsed.version;
let installedPackage: InstalledPackages;
try {
installedPackage = await this.communityPackagesService.installPackage(
parsed.packageName,
parsed.version,
packageVersion,
checksum,
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON;
@@ -99,7 +114,7 @@ export class CommunityPackagesController {
inputString: name,
packageName: parsed.packageName,
success: false,
packageVersion: parsed.version,
packageVersion,
failureReason: errorMessage,
});
@@ -130,7 +145,7 @@ export class CommunityPackagesController {
inputString: name,
packageName: parsed.packageName,
success: true,
packageVersion: parsed.version,
packageVersion,
packageNodeNames: installedPackage.installedNodes.map((node) => node.name),
packageAuthor: installedPackage.authorName,
packageAuthorEmail: installedPackage.authorEmail,
@@ -266,7 +281,7 @@ export class CommunityPackagesController {
this.push.broadcast({
type: 'reloadNodeType',
data: {
name: node.name,
name: node.type,
version: node.latestVersion,
},
});

View File

@@ -94,11 +94,13 @@ export class LoadNodesAndCredentials {
await this.loadNodesFromNodeModules(nodeModulesDir, '@n8n/n8n-nodes-langchain');
}
// Load nodes from any other `n8n-nodes-*` packages in the download directory
// This includes the community nodes
await this.loadNodesFromNodeModules(
path.join(this.instanceSettings.nodesDownloadDir, 'node_modules'),
);
if (!this.globalConfig.nodes.communityPackages.preventLoading) {
// Load nodes from any other `n8n-nodes-*` packages in the download directory
// This includes the community nodes
await this.loadNodesFromNodeModules(
path.join(this.instanceSettings.nodesDownloadDir, 'node_modules'),
);
}
await this.loadNodesFromCustomDirectories();
await this.postProcessLoaders();

View File

@@ -217,7 +217,7 @@ export declare namespace AnnotationTagsRequest {
export declare namespace NodeRequest {
type GetAll = AuthenticatedRequest;
type Post = AuthenticatedRequest<{}, {}, { name?: string }>;
type Post = AuthenticatedRequest<{}, {}, { name?: string; verify?: boolean; version?: string }>;
type Delete = AuthenticatedRequest<{}, {}, {}, { name: string }>;

View File

@@ -124,6 +124,7 @@ export class Server extends AbstractServer {
if (this.globalConfig.nodes.communityPackages.enabled) {
await import('@/controllers/community-packages.controller');
await import('@/controllers/community-node-types.controller');
}
if (inE2ETests) {

View File

@@ -1,14 +1,15 @@
import type { GlobalConfig } from '@n8n/config';
import { LICENSE_FEATURES } from '@n8n/constants';
import { InstalledNodes } from '@n8n/db';
import { InstalledPackages } from '@n8n/db';
import { InstalledNodesRepository } from '@n8n/db';
import { InstalledPackagesRepository } from '@n8n/db';
import axios from 'axios';
import { exec } from 'child_process';
import { mkdir as fsMkdir } from 'fs/promises';
import { mkdir as fsMkdir, readFile, writeFile } from 'fs/promises';
import { mocked } from 'jest-mock';
import { mock } from 'jest-mock-extended';
import type { PackageDirectoryLoader } from 'n8n-core';
import type { Logger, InstanceSettings, PackageDirectoryLoader } from 'n8n-core';
import type { PublicInstalledPackage } from 'n8n-workflow';
import {
@@ -17,9 +18,11 @@ import {
NPM_PACKAGE_STATUS_GOOD,
RESPONSE_ERROR_MESSAGES,
} from '@/constants';
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import type { CommunityPackages } from '@/interfaces';
import type { License } from '@/license';
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import { CommunityPackagesService } from '@/services/community-packages.service';
import { mockInstance } from '@test/mocking';
import { COMMUNITY_NODE_VERSION, COMMUNITY_PACKAGE_VERSION } from '@test-integration/constants';
@@ -45,6 +48,7 @@ describe('CommunityPackagesService', () => {
communityPackages: {
reinstallMissing: false,
registry: 'some.random.host',
unverifiedEnabled: true,
},
},
});
@@ -69,12 +73,19 @@ describe('CommunityPackagesService', () => {
});
});
const instanceSettings = mock<InstanceSettings>({
nodesDownloadDir: '/tmp/n8n-jest-global-downloads',
});
const logger = mock<Logger>();
const publisher = mock<Publisher>();
const communityPackagesService = new CommunityPackagesService(
mock(),
mock(),
mock(),
instanceSettings,
logger,
installedPackageRepository,
loadNodesAndCredentials,
mock(),
publisher,
license,
globalConfig,
);
@@ -129,7 +140,9 @@ describe('CommunityPackagesService', () => {
describe('executeCommand()', () => {
beforeEach(() => {
mocked(fsMkdir).mockReset();
mocked(fsMkdir).mockResolvedValue(undefined);
mocked(exec).mockReset();
mocked(exec).mockImplementation(execMock);
});
test('should call command with valid options', async () => {
@@ -359,68 +372,169 @@ describe('CommunityPackagesService', () => {
Object.assign(communityPackagesService, { missingPackages });
};
describe('updateNpmModule', () => {
const installedPackage = mock<InstalledPackages>({ packageName: mockPackageName() });
const packageDirectoryLoader = mock<PackageDirectoryLoader>({
loadedNodes: [{ name: nodeName, version: 1 }],
describe('updatePackage', () => {
const PACKAGE_NAME = 'n8n-nodes-test';
const installedPackageForUpdateTest = mock<InstalledPackages>({
packageName: PACKAGE_NAME,
});
beforeEach(async () => {
const packageDirectoryLoader = mock<PackageDirectoryLoader>({
loadedNodes: [{ name: 'a-node-from-the-loader', version: 1 }],
});
const testBlockDownloadDir = instanceSettings.nodesDownloadDir;
const testBlockPackageDir = `${testBlockDownloadDir}/node_modules/${PACKAGE_NAME}`;
const testBlockTarballName = `${PACKAGE_NAME}-latest.tgz`;
const testBlockRegistry = globalConfig.nodes.communityPackages.registry;
const testBlockNpmInstallArgs = [
'--audit=false',
'--fund=false',
'--bin-links=false',
'--install-strategy=shallow',
'--ignore-scripts=true',
'--package-lock=false',
`--registry=${testBlockRegistry}`,
].join(' ');
const execMockForThisBlock = (command: string, optionsOrCallback: any, callback?: any) => {
const actualCallback = typeof optionsOrCallback === 'function' ? optionsOrCallback : callback;
if (command.startsWith('npm pack') && command.includes(PACKAGE_NAME)) {
actualCallback(null, { stdout: testBlockTarballName, stderr: '' });
} else {
actualCallback(null, 'Done', '');
}
};
beforeEach(() => {
jest.clearAllMocks();
mocked(exec).mockImplementation(execMockForThisBlock as typeof exec);
mocked(fsMkdir).mockResolvedValue(undefined);
mocked(readFile).mockResolvedValue(
JSON.stringify({
name: PACKAGE_NAME,
version: '1.0.0', // Mocked version from package.json inside tarball
dependencies: { 'some-actual-dep': '1.2.3' },
devDependencies: { 'a-dev-dep': '1.0.0' },
peerDependencies: { 'a-peer-dep': '2.0.0' },
optionalDependencies: { 'an-optional-dep': '3.0.0' },
}),
);
mocked(writeFile).mockResolvedValue(undefined);
loadNodesAndCredentials.loadPackage.mockResolvedValue(packageDirectoryLoader);
mocked(exec).mockImplementation(execMock);
loadNodesAndCredentials.unloadPackage.mockResolvedValue(undefined);
loadNodesAndCredentials.postProcessLoaders.mockResolvedValue(undefined);
installedPackageRepository.remove.mockResolvedValue(undefined as any);
installedPackageRepository.saveInstalledPackageWithNodes.mockResolvedValue(
installedPackageForUpdateTest,
);
publisher.publishCommand.mockResolvedValue(undefined);
});
test('should call `exec` with the correct command and registry', async () => {
//
test('should call `exec` with the correct sequence of commands, handle file ops, and interact with services', async () => {
// ARRANGE
//
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
//
// ACT
//
await communityPackagesService.updatePackage(installedPackage.packageName, installedPackage);
await communityPackagesService.updatePackage(
installedPackageForUpdateTest.packageName,
installedPackageForUpdateTest,
);
//
// ASSERT
//
// ASSERT:
expect(exec).toHaveBeenCalledTimes(5);
expect(exec).toHaveBeenCalledTimes(1);
expect(exec).toHaveBeenNthCalledWith(
1,
`npm install ${installedPackage.packageName}@latest --audit=false --fund=false --bin-links=false --install-strategy=shallow --omit=dev --omit=optional --omit=peer --registry=some.random.host`,
expect.any(Object),
`rm -rf ${testBlockPackageDir}`,
expect.any(Function),
);
expect(loadNodesAndCredentials.unloadPackage).toHaveBeenCalledWith(
installedPackage.packageName,
expect(exec).toHaveBeenNthCalledWith(
2,
`npm pack ${PACKAGE_NAME}@latest --registry=${testBlockRegistry} --quiet`,
{ cwd: testBlockDownloadDir },
expect.any(Function),
);
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith(
installedPackage.packageName,
expect(exec).toHaveBeenNthCalledWith(
3,
`tar -xzf ${testBlockTarballName} -C ${testBlockPackageDir} --strip-components=1`,
{ cwd: testBlockDownloadDir },
expect.any(Function),
);
expect(exec).toHaveBeenNthCalledWith(
4,
`npm install ${testBlockNpmInstallArgs}`,
{ cwd: testBlockPackageDir },
expect.any(Function),
);
expect(exec).toHaveBeenNthCalledWith(
5,
`rm ${testBlockTarballName}`,
{ cwd: testBlockDownloadDir },
expect.any(Function),
);
expect(fsMkdir).toHaveBeenCalledWith(testBlockPackageDir, { recursive: true });
expect(readFile).toHaveBeenCalledWith(`${testBlockPackageDir}/package.json`, 'utf-8');
expect(writeFile).toHaveBeenCalledWith(
`${testBlockPackageDir}/package.json`,
JSON.stringify(
{
name: PACKAGE_NAME,
version: '1.0.0',
dependencies: { 'some-actual-dep': '1.2.3' },
},
null,
2,
),
'utf-8',
);
expect(loadNodesAndCredentials.unloadPackage).toHaveBeenCalledWith(PACKAGE_NAME);
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith(PACKAGE_NAME);
expect(loadNodesAndCredentials.postProcessLoaders).toHaveBeenCalledTimes(1);
expect(installedPackageRepository.remove).toHaveBeenCalledWith(installedPackageForUpdateTest);
expect(installedPackageRepository.saveInstalledPackageWithNodes).toHaveBeenCalledWith(
packageDirectoryLoader,
);
expect(publisher.publishCommand).toHaveBeenCalledWith({
command: 'community-package-update',
payload: { packageName: PACKAGE_NAME, packageVersion: 'latest' },
});
});
test('should throw when not licensed', async () => {
//
test('should throw when not licensed for custom registry if custom registry is different from default', async () => {
// ARRANGE
//
license.isCustomNpmRegistryEnabled.mockReturnValue(false);
//
// ACT
//
const promise = communityPackagesService.updatePackage(
installedPackage.packageName,
installedPackage,
// ACT & ASSERT
await expect(
communityPackagesService.updatePackage(
installedPackageForUpdateTest.packageName,
installedPackageForUpdateTest,
),
).rejects.toThrow(
new FeatureNotLicensedError(LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY),
);
});
});
//
// ASSERT
//
await expect(promise).rejects.toThrow(
'Your license does not allow for feat:communityNodes:customRegistry.',
describe('installPackage', () => {
test('should throw when installation of not vetted packages is forbidden', async () => {
globalConfig.nodes.communityPackages.unverifiedEnabled = false;
globalConfig.nodes.communityPackages.registry = 'https://registry.npmjs.org';
await expect(communityPackagesService.installPackage('package', '0.1.0')).rejects.toThrow(
'Installation of non-vetted community packages is forbidden!',
);
});
});

View File

@@ -0,0 +1,106 @@
import type { CommunityNodeAttributes, CommunityNodeData } from '@n8n/api-types';
import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { Logger } from 'n8n-core';
import type { INodeTypeDescription } from 'n8n-workflow';
import { ensureError } from 'n8n-workflow';
import { CommunityPackagesService } from './community-packages.service';
import { paginatedRequest } from '../utils/community-nodes-request-utils';
const UPDATE_INTERVAL = 8 * 60 * 60 * 1000;
const N8N_VETTED_NODE_TYPES_STAGING_URL = 'https://api-staging.n8n.io/api/community-nodes';
const N8N_VETTED_NODE_TYPES_PRODUCTION_URL = 'https://api.n8n.io/api/community-nodes';
@Service()
export class CommunityNodeTypesService {
private communityNodes: {
[key: string]: CommunityNodeAttributes & {
nodeDescription: INodeTypeDescription;
};
} = {};
private lastUpdateTimestamp = 0;
constructor(
private readonly logger: Logger,
private globalConfig: GlobalConfig,
private communityPackagesService: CommunityPackagesService,
) {}
private async fetchNodeTypes() {
try {
let data: CommunityNodeData[] = [];
if (
this.globalConfig.nodes.communityPackages.enabled &&
this.globalConfig.nodes.communityPackages.verifiedEnabled
) {
const environment = this.globalConfig.license.tenantId === 1 ? 'production' : 'staging';
const url =
environment === 'production'
? N8N_VETTED_NODE_TYPES_PRODUCTION_URL
: N8N_VETTED_NODE_TYPES_STAGING_URL;
data = await paginatedRequest(url);
}
this.updateData(data);
} catch (error) {
this.logger.error('Failed to fetch community node types', { error: ensureError(error) });
}
}
private updateData(data: CommunityNodeData[]) {
if (!data?.length) return;
this.resetData();
for (const entry of data) {
this.communityNodes[entry.attributes.name] = entry.attributes;
}
this.lastUpdateTimestamp = Date.now();
}
private resetData() {
this.communityNodes = {};
}
private updateRequired() {
if (!this.lastUpdateTimestamp) return true;
return Date.now() - this.lastUpdateTimestamp > UPDATE_INTERVAL;
}
async getDescriptions(): Promise<INodeTypeDescription[]> {
const nodesDescriptions: INodeTypeDescription[] = [];
if (this.updateRequired() || !Object.keys(this.communityNodes).length) {
await this.fetchNodeTypes();
}
const installedPackages = (
(await this.communityPackagesService.getAllInstalledPackages()) ?? []
).map((p) => p.packageName);
for (const node of Object.values(this.communityNodes)) {
if (installedPackages.includes(node.name.split('.')[0])) continue;
nodesDescriptions.push(node.nodeDescription);
}
return nodesDescriptions;
}
getCommunityNodeAttributes(type: string): CommunityNodeAttributes | null {
const node = this.communityNodes[type];
if (!node) return null;
const { nodeDescription, ...attributes } = node;
return attributes;
}
findVetted(packageName: string) {
const vettedTypes = Object.keys(this.communityNodes);
const nodeName = vettedTypes.find((t) => t.includes(packageName));
if (!nodeName) return;
return this.communityNodes[nodeName];
}
}

View File

@@ -5,7 +5,7 @@ import { InstalledPackagesRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import axios from 'axios';
import { exec } from 'child_process';
import { mkdir as fsMkdir } from 'fs/promises';
import { mkdir, readFile, writeFile } from 'fs/promises';
import type { PackageDirectoryLoader } from 'n8n-core';
import { InstanceSettings, Logger } from 'n8n-core';
import { UnexpectedError, UserError, type PublicInstalledPackage } from 'n8n-workflow';
@@ -25,14 +25,15 @@ import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { toError } from '@/utils';
import { verifyIntegrity } from '../utils/npm-utils';
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
const NPM_COMMON_ARGS = ['--audit=false', '--fund=false'];
const NPM_INSTALL_ARGS = [
'--bin-links=false',
'--install-strategy=shallow',
'--omit=dev',
'--omit=optional',
'--omit=peer',
'--ignore-scripts=true',
'--package-lock=false',
];
const {
@@ -133,6 +134,7 @@ export class CommunityPackagesService {
return { packageName, scope, version, rawString };
}
/** @deprecated */
async executeNpmCommand(command: string, options?: { doNotHandleError?: boolean }) {
const downloadFolder = this.instanceSettings.nodesDownloadDir;
@@ -146,7 +148,7 @@ export class CommunityPackagesService {
},
};
await fsMkdir(downloadFolder, { recursive: true });
await mkdir(downloadFolder, { recursive: true });
try {
const commandResult = await asyncExec(command, execOptions);
@@ -308,8 +310,12 @@ export class CommunityPackagesService {
);
}
async installPackage(packageName: string, version?: string): Promise<InstalledPackages> {
return await this.installOrUpdatePackage(packageName, { version });
async installPackage(
packageName: string,
version?: string,
checksum?: string,
): Promise<InstalledPackages> {
return await this.installOrUpdatePackage(packageName, { version, checksum });
}
async updatePackage(
@@ -328,24 +334,44 @@ export class CommunityPackagesService {
});
}
private getNpmInstallArgs() {
private getNpmRegistry() {
const { registry } = this.globalConfig.nodes.communityPackages;
if (registry !== DEFAULT_REGISTRY && !this.license.isCustomNpmRegistryEnabled()) {
throw new FeatureNotLicensedError(LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY);
}
return [...NPM_COMMON_ARGS, ...NPM_INSTALL_ARGS, `--registry=${registry}`].join(' ');
return registry;
}
private getNpmInstallArgs() {
return [...NPM_COMMON_ARGS, ...NPM_INSTALL_ARGS, `--registry=${this.getNpmRegistry()}`].join(
' ',
);
}
private checkInstallPermissions(isUpdate: boolean, checksumProvided: boolean) {
if (isUpdate) return;
if (!this.globalConfig.nodes.communityPackages.unverifiedEnabled && !checksumProvided) {
throw new UnexpectedError('Installation of non-vetted community packages is forbidden!');
}
}
private async installOrUpdatePackage(
packageName: string,
options: { version?: string } | { installedPackage: InstalledPackages },
options: { version?: string; checksum?: string } | { installedPackage: InstalledPackages },
) {
const isUpdate = 'installedPackage' in options;
const packageVersion = isUpdate || !options.version ? 'latest' : options.version;
const command = `npm install ${packageName}@${packageVersion} ${this.getNpmInstallArgs()}`;
const shouldValidateChecksum = 'checksum' in options && Boolean(options.checksum);
this.checkInstallPermissions(isUpdate, shouldValidateChecksum);
if (!isUpdate && options.checksum) {
await verifyIntegrity(packageName, packageVersion, this.getNpmRegistry(), options.checksum);
}
try {
await this.executeNpmCommand(command);
await this.downloadPackage(packageName, packageVersion);
} catch (error) {
if (error instanceof Error && error.message === RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND) {
throw new UserError('npm package not found', { extra: { packageName } });
@@ -360,7 +386,7 @@ export class CommunityPackagesService {
} catch (error) {
// Remove this package since loading it failed
try {
await this.executeNpmCommand(`npm remove ${packageName}`);
await this.deletePackageDirectory(packageName);
} catch {}
throw new UnexpectedError(RESPONSE_ERROR_MESSAGES.PACKAGE_LOADING_FAILED, { cause: error });
}
@@ -388,25 +414,73 @@ export class CommunityPackagesService {
} else {
// Remove this package since it contains no loadable nodes
try {
await this.executeNpmCommand(`npm remove ${packageName}`);
await this.deletePackageDirectory(packageName);
} catch {}
throw new UnexpectedError(RESPONSE_ERROR_MESSAGES.PACKAGE_DOES_NOT_CONTAIN_NODES);
}
}
async installOrUpdateNpmPackage(packageName: string, packageVersion: string) {
await this.executeNpmCommand(
`npm install ${packageName}@${packageVersion} ${this.getNpmInstallArgs()}`,
);
await this.downloadPackage(packageName, packageVersion);
await this.loadNodesAndCredentials.loadPackage(packageName);
await this.loadNodesAndCredentials.postProcessLoaders();
this.logger.info(`Community package installed: ${packageName}`);
}
async removeNpmPackage(packageName: string) {
await this.executeNpmCommand(`npm remove ${packageName}`);
await this.deletePackageDirectory(packageName);
await this.loadNodesAndCredentials.unloadPackage(packageName);
await this.loadNodesAndCredentials.postProcessLoaders();
this.logger.info(`Community package uninstalled: ${packageName}`);
}
private resolvePackageDirectory(packageName: string) {
const downloadFolder = this.instanceSettings.nodesDownloadDir;
return `${downloadFolder}/node_modules/${packageName}`;
}
private async downloadPackage(packageName: string, packageVersion: string): Promise<string> {
const registry = this.getNpmRegistry();
const downloadFolder = this.instanceSettings.nodesDownloadDir;
const packageDirectory = this.resolvePackageDirectory(packageName);
// (Re)create the packageDir
await this.deletePackageDirectory(packageName);
await mkdir(packageDirectory, { recursive: true });
// TODO: make sure that this works for scoped packages as well
// if (packageName.startsWith('@') && packageName.includes('/')) {}
const { stdout: tarOutput } = await asyncExec(
`npm pack ${packageName}@${packageVersion} --registry=${registry} --quiet`,
{ cwd: downloadFolder },
);
const tarballName = tarOutput?.trim();
try {
await asyncExec(`tar -xzf ${tarballName} -C ${packageDirectory} --strip-components=1`, {
cwd: downloadFolder,
});
// Strip dev, optional, and peer dependencies before running `npm install`
const packageJsonPath = `${packageDirectory}/package.json`;
const packageJsonContent = await readFile(packageJsonPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { devDependencies, peerDependencies, optionalDependencies, ...packageJson } =
JSON.parse(packageJsonContent);
await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2), 'utf-8');
await asyncExec(`npm install ${this.getNpmInstallArgs()}`, { cwd: packageDirectory });
} finally {
await asyncExec(`rm ${tarballName}`, { cwd: downloadFolder });
}
return packageDirectory;
}
private async deletePackageDirectory(packageName: string) {
const packageDirectory = this.resolvePackageDirectory(packageName);
await asyncExec(`rm -rf ${packageDirectory}`);
}
}

View File

@@ -178,6 +178,7 @@ export class FrontendService {
isMultiMain: this.instanceSettings.isMultiMain,
pushBackend: this.pushConfig.backend,
communityNodesEnabled: this.globalConfig.nodes.communityPackages.enabled,
unverifiedCommunityNodesEnabled: this.globalConfig.nodes.communityPackages.unverifiedEnabled,
deployment: {
type: config.getEnv('deployment.type'),
},

View File

@@ -0,0 +1,125 @@
import type { CommunityNodeData } from '@n8n/api-types';
import nock from 'nock';
import { paginatedRequest } from '../community-nodes-request-utils';
describe('strapiPaginatedRequest', () => {
const baseUrl = 'https://strapi.test/api/nodes';
afterEach(() => {
nock.cleanAll();
});
it('should fetch and combine multiple pages of data', async () => {
const page1 = [
{
id: 1,
attributes: { name: 'Node1', nodeDescription: { name: 'n1', version: 1 } } as any,
},
];
const page2 = [
{
id: 2,
attributes: { name: 'Node2', nodeDescription: { name: 'n2', version: 2 } } as any,
},
];
nock('https://strapi.test')
.get('/api/nodes')
.query(true)
.reply(200, {
data: page1,
meta: {
pagination: {
page: 1,
pageSize: 25,
pageCount: 2,
total: 2,
},
},
});
nock('https://strapi.test')
.get('/api/nodes')
.query(true)
.reply(200, {
data: page2,
meta: {
pagination: {
page: 2,
pageSize: 25,
pageCount: 2,
total: 2,
},
},
});
const result = await paginatedRequest('https://strapi.test/api/nodes');
expect(result).toHaveLength(2);
expect(result[0].id).toBe(1);
expect(result[1].id).toBe(2);
});
it('should return empty array if no data', async () => {
nock('https://strapi.test')
.get('/api/nodes')
.query(true)
.reply(200, {
data: [],
meta: {
pagination: {
page: 1,
pageSize: 25,
pageCount: 0,
total: 0,
},
},
});
const result = await paginatedRequest(baseUrl);
expect(result).toEqual([]);
});
it('should return single page data', async () => {
const singlePage: CommunityNodeData[] = [
{
id: 1,
attributes: {
name: 'NodeSingle',
nodeDescription: { name: 'n1', version: 1 },
} as any,
},
];
nock('https://strapi.test')
.get('/api/nodes')
.query(true)
.reply(200, {
data: singlePage,
meta: {
pagination: {
page: 1,
pageSize: 25,
pageCount: 1,
total: 1,
},
},
});
const result = await paginatedRequest(baseUrl);
expect(result).toHaveLength(1);
expect(result[0].attributes.name).toBe('NodeSingle');
});
it('should return an empty array if the request fails', async () => {
const endpoint = '/nodes';
nock(baseUrl).get(endpoint).query(true).replyWithError('Request failed');
const result = await paginatedRequest(`${baseUrl}${endpoint}`);
expect(result).toEqual([]);
});
});

View File

@@ -0,0 +1,67 @@
import { UnexpectedError } from 'n8n-workflow';
import nock from 'nock';
import { verifyIntegrity } from '../npm-utils';
describe('verifyIntegrity', () => {
const registryUrl = 'https://registry.npmjs.org';
const packageName = 'test-package';
const version = '1.0.0';
const integrity = 'sha512-hash==';
afterEach(() => {
nock.cleanAll();
});
it('should verify integrity successfully', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.reply(200, {
dist: { integrity },
});
await expect(
verifyIntegrity(packageName, version, registryUrl, integrity),
).resolves.not.toThrow();
});
it('should throw error if checksum does not match', async () => {
const wrongHash = 'sha512-nottherighthash==';
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.reply(200, {
dist: { integrity },
});
await expect(verifyIntegrity(packageName, version, registryUrl, wrongHash)).rejects.toThrow(
UnexpectedError,
);
});
it('should throw error if metadata request fails', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.reply(500);
await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow();
});
it('should throw UnexpectedError and preserve original error as cause', async () => {
const integrity = 'sha512-somerandomhash==';
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('Network failure');
try {
await verifyIntegrity(packageName, version, registryUrl, integrity);
throw new Error('Expected error was not thrown');
} catch (error: any) {
expect(error).toBeInstanceOf(UnexpectedError);
expect(error.message).toBe('Checksum verification failed');
expect(error.cause).toBeDefined();
expect(error.cause.message).toContain('Network failure');
}
});
});

View File

@@ -0,0 +1,68 @@
import type { CommunityNodeData } from '@n8n/api-types';
import { Container } from '@n8n/di';
import axios from 'axios';
import { ErrorReporter, Logger } from 'n8n-core';
interface ResponseData {
data: CommunityNodeData[];
meta: Meta;
}
interface Meta {
pagination: Pagination;
}
interface Pagination {
page: number;
pageSize: number;
pageCount: number;
total: number;
}
export async function paginatedRequest(url: string): Promise<CommunityNodeData[]> {
let returnData: CommunityNodeData[] = [];
let responseData: CommunityNodeData[] | undefined = [];
const params = {
pagination: {
page: 1,
pageSize: 25,
},
};
do {
let response;
try {
response = await axios.get<ResponseData>(url, {
headers: { 'Content-Type': 'application/json' },
params,
});
} catch (error) {
Container.get(ErrorReporter).error(error, {
tags: { source: 'communityNodesPaginatedRequest' },
});
Container.get(Logger).error(
`Error while fetching community nodes: ${(error as Error).message}`,
);
break;
}
responseData = response?.data?.data;
if (!responseData?.length) break;
returnData = returnData.concat(responseData);
if (response?.data?.meta?.pagination) {
const { page, pageCount } = response?.data.meta.pagination;
if (page === pageCount) {
break;
}
}
params.pagination.page++;
} while (responseData?.length);
return returnData;
}

View File

@@ -0,0 +1,27 @@
import axios from 'axios';
import { UnexpectedError } from 'n8n-workflow';
const REQUEST_TIMEOUT = 30000;
export async function verifyIntegrity(
packageName: string,
version: string,
registryUrl: string,
expectedIntegrity: string,
) {
const timeoutOption = { timeout: REQUEST_TIMEOUT };
try {
const url = `${registryUrl.replace(/\/+$/, '')}/${encodeURIComponent(packageName)}`;
const metadata = await axios.get<{ dist: { integrity: string } }>(
`${url}/${version}`,
timeoutOption,
);
if (metadata?.data?.dist?.integrity !== expectedIntegrity) {
throw new UnexpectedError('Checksum verification failed. Package integrity does not match.');
}
} catch (error) {
throw new UnexpectedError('Checksum verification failed', { cause: error });
}
}

View File

@@ -113,6 +113,8 @@ const { t } = useI18n();
}
.tooltipIcon {
margin-left: var(--spacing-3xs);
color: var(--color-text-base);
font-size: var(--font-size-2xs);
}
.panelArrow {
font-size: var(--font-size-2xs);

View File

@@ -11,6 +11,7 @@ export const defaultSettings: FrontendSettings = {
},
allowedModules: {},
communityNodesEnabled: false,
unverifiedCommunityNodesEnabled: true,
defaultLocale: '',
endpointForm: '',
endpointFormTest: '',

View File

@@ -12,8 +12,10 @@ export async function getInstalledCommunityNodes(
export async function installNewPackage(
context: IRestApiContext,
name: string,
verify?: boolean,
version?: string,
): Promise<PublicInstalledPackage> {
return await post(context.baseUrl, '/community-packages', { name });
return await post(context.baseUrl, '/community-packages', { name, verify, version });
}
export async function uninstallPackage(context: IRestApiContext, name: string): Promise<void> {

View File

@@ -6,19 +6,51 @@ import type {
} from '@n8n/api-types';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { INodeTranslationHeaders, IRestApiContext } from '@/Interface';
import type {
INodeListSearchResult,
INodePropertyOptions,
INodeTypeDescription,
INodeTypeNameVersion,
NodeParameterValueType,
ResourceMapperFields,
import type { CommunityNodeAttributes } from '@n8n/api-types';
import {
type INodeListSearchResult,
type INodePropertyOptions,
type INodeTypeDescription,
type INodeTypeNameVersion,
type NodeParameterValueType,
type ResourceMapperFields,
sleep,
} from 'n8n-workflow';
import axios from 'axios';
async function fetchNodeTypesJsonWithRetry(url: string, retries = 5, delay = 500) {
for (let attempt = 0; attempt < retries; attempt++) {
const response = await axios.get(url, { withCredentials: true });
if (typeof response.data === 'object' && response.data !== null) {
return response.data;
}
await sleep(delay * attempt);
}
throw new Error('Could not fetch node types');
}
export async function getNodeTypes(baseUrl: string) {
const { data } = await axios.get(baseUrl + 'types/nodes.json', { withCredentials: true });
return data;
return await fetchNodeTypesJsonWithRetry(baseUrl + 'types/nodes.json');
}
export async function fetchCommunityNodeTypes(
context: IRestApiContext,
): Promise<INodeTypeDescription[]> {
return await makeRestApiRequest(context, 'GET', '/community-node-types');
}
export async function fetchCommunityNodeAttributes(
context: IRestApiContext,
type: string,
): Promise<CommunityNodeAttributes | null> {
return await makeRestApiRequest(
context,
'GET',
`/community-node-types/${encodeURIComponent(type)}`,
);
}
export async function getNodeTranslationHeaders(

View File

@@ -109,7 +109,7 @@ async function onSubmit() {
);
if (!updateInformation) return;
//updade code parameter
//update code parameter
emit('valueChanged', updateInformation);
//update code generated for prompt parameter

View File

@@ -4,6 +4,7 @@ import type { PublicInstalledPackage } from 'n8n-workflow';
import { NPM_PACKAGE_DOCS_BASE_URL, COMMUNITY_PACKAGE_MANAGE_ACTIONS } from '@/constants';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useSettingsStore } from '@/stores/settings.store';
interface Props {
communityPackage?: PublicInstalledPackage | null;
@@ -19,6 +20,7 @@ const { openCommunityPackageUpdateConfirmModal, openCommunityPackageUninstallCon
useUIStore();
const i18n = useI18n();
const telemetry = useTelemetry();
const settingsStore = useSettingsStore();
const packageActions = [
{
@@ -95,7 +97,10 @@ function onUpdateClick() {
</template>
<n8n-icon icon="exclamation-triangle" color="danger" size="large" />
</n8n-tooltip>
<n8n-tooltip v-else-if="communityPackage.updateAvailable" placement="top">
<n8n-tooltip
v-else-if="settingsStore.isUnverifiedPackagesEnabled && communityPackage.updateAvailable"
placement="top"
>
<template #content>
<div>
{{ i18n.baseText('settings.communityNodes.updateAvailable.tooltip') }}

View File

@@ -12,6 +12,7 @@ import { useCommunityNodesStore } from '@/stores/communityNodes.store';
import { ref } from 'vue';
import { useTelemetry } from '@/composables/useTelemetry';
import { useI18n } from '@/composables/useI18n';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
const communityNodesStore = useCommunityNodesStore();
@@ -44,6 +45,7 @@ const onInstallClick = async () => {
infoTextErrorMessage.value = '';
loading.value = true;
await communityNodesStore.installPackage(packageName.value);
await useNodeTypesStore().getNodeTypes();
loading.value = false;
modalBus.emit('close');
toast.showMessage({

View File

@@ -7,6 +7,7 @@ import { createEventBus } from '@n8n/utils/event-bus';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { computed, ref } from 'vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
export type CommunityPackageManageMode = 'uninstall' | 'update' | 'view-documentation';
@@ -90,6 +91,7 @@ const onUninstall = async () => {
});
loading.value = true;
await communityNodesStore.uninstallPackage(props.activePackageName);
await useNodeTypesStore().getNodeTypes();
toast.showMessage({
title: i18n.baseText('settings.communityNodes.messages.uninstall.success.title'),
type: 'success',
@@ -115,6 +117,7 @@ const onUpdate = async () => {
loading.value = true;
const updatedVersion = activePackage.value.updateAvailable;
await communityNodesStore.updatePackage(props.activePackageName);
await useNodeTypesStore().getNodeTypes();
toast.showMessage({
title: i18n.baseText('settings.communityNodes.messages.update.success.title'),
message: i18n.baseText('settings.communityNodes.messages.update.success.message', {

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import CommunityNodeInstallHint from '../Panel/CommunityNodeInstallHint.vue';
import { N8nButton } from '@n8n/design-system';
export interface Props {
isPreview: boolean;
}
defineProps<Props>();
const i18n = useI18n();
</script>
<template>
<div>
<CommunityNodeInstallHint
v-if="isPreview"
:hint="i18n.baseText('communityNodeItem.node.hint')"
/>
<div v-else :class="$style.marginLeft">
<N8nButton
size="medium"
type="secondary"
icon="plus"
:label="i18n.baseText('communityNodeItem.label')"
outline
/>
</div>
</div>
</template>
<style lang="scss" module>
.marginLeft {
margin-left: var(--spacing-xs);
}
</style>

View File

@@ -18,6 +18,7 @@ import { useViewStacks } from '../composables/useViewStacks';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useNodeType } from '@/composables/useNodeType';
import { isNodePreviewKey } from '../utils';
export interface Props {
nodeType: SimplifiedNodeType;
@@ -45,6 +46,9 @@ const draggablePosition = ref({ x: -100, y: -100 });
const draggableDataTransfer = ref(null as Element | null);
const description = computed<string>(() => {
if (isCommunityNodePreview.value) {
return props.nodeType.description;
}
if (isSendAndWaitCategory.value) {
return '';
}
@@ -60,7 +64,14 @@ const description = computed<string>(() => {
fallback: props.nodeType.description,
});
});
const showActionArrow = computed(() => hasActions.value && !isSendAndWaitCategory.value);
const showActionArrow = computed(() => {
// show action arrow if it's a community node and the community node details are not opened
if (isCommunityNode.value && !activeViewStack.communityNodeDetails) {
return true;
}
return hasActions.value && !isSendAndWaitCategory.value;
});
const isSendAndWaitCategory = computed(() => activeViewStack.subcategory === HITL_SUBCATEGORY);
const dataTestId = computed(() =>
hasActions.value ? 'node-creator-action-item' : 'node-creator-node-item',
@@ -82,6 +93,7 @@ const draggableStyle = computed<{ top: string; left: string }>(() => ({
}));
const isCommunityNode = computed<boolean>(() => isCommunityPackageName(props.nodeType.name));
const isCommunityNodePreview = computed<boolean>(() => isNodePreviewKey(props.nodeType.name));
const displayName = computed<string>(() => {
const trimmedDisplayName = props.nodeType.displayName.trimEnd();
@@ -143,7 +155,10 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
<NodeIcon :class="$style.nodeIcon" :node-type="nodeType" />
</template>
<template v-if="isCommunityNode" #tooltip>
<template
v-if="isCommunityNode && !isCommunityNodePreview && !activeViewStack?.communityNodeDetails"
#tooltip
>
<p
v-n8n-html="
i18n.baseText('generic.communityNode.tooltip', {
@@ -192,6 +207,7 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
width: 40px;
z-index: 1;
}
.communityNodeIcon {
vertical-align: top;
}

View File

@@ -23,11 +23,15 @@ import { useViewStacks } from '../composables/useViewStacks';
import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue';
import type { IDataObject } from 'n8n-workflow';
import { type IDataObject } from 'n8n-workflow';
import { useTelemetry } from '@/composables/useTelemetry';
import { useI18n } from '@/composables/useI18n';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import OrderSwitcher from './../OrderSwitcher.vue';
import { isNodePreviewKey } from '../utils';
import CommunityNodeInfo from '../Panel/CommunityNodeInfo.vue';
import CommunityNodeFooter from '../Panel/CommunityNodeFooter.vue';
const emit = defineEmits<{
nodeTypeSelected: [value: [actionKey: string, nodeName: string] | [nodeName: string]];
@@ -90,6 +94,8 @@ const subcategory = computed(() => useViewStacks().activeViewStack.subcategory);
const rootView = computed(() => useViewStacks().activeViewStack.rootView);
const communityNodeDetails = computed(() => useViewStacks().activeViewStack?.communityNodeDetails);
const placeholderTriggerActions = getPlaceholderTriggerActions(subcategory.value || '');
const hasNoTriggerActions = computed(
@@ -113,6 +119,16 @@ const containsAPIAction = computed(() => {
const isTriggerRootView = computed(() => rootView.value === TRIGGER_NODE_CREATOR_VIEW);
const shouldShowTriggers = computed(() => {
if (communityNodeDetails.value && !parsedTriggerActions.value.length) {
// do not show baseline trigger actions for community nodes if it is not installed
return (
!isNodePreviewKey(useViewStacks().activeViewStack?.items?.[0].key) && isTriggerRootView.value
);
}
return isTriggerRootView.value || parsedTriggerActionsBaseline.value.length !== 0;
});
registerKeyHook('ActionsKeyRight', {
keyboardKeys: ['ArrowRight', 'Enter'],
condition: (type) => type === 'action',
@@ -157,6 +173,8 @@ function onSelected(actionCreateElement: INodeCreateElement) {
(actionData?.value as IDataObject)?.operation === 'message'
) {
emit('nodeTypeSelected', [OPEN_AI_NODE_MESSAGE_ASSISTANT_TYPE]);
} else if (isNodePreviewKey(actionData?.key)) {
return;
} else {
emit('nodeTypeSelected', [actionData.key as string]);
}
@@ -216,10 +234,17 @@ onMounted(() => {
</script>
<template>
<div :class="$style.container">
<div
:class="{
[$style.container]: true,
[$style.containerPaddingBottom]: !communityNodeDetails,
}"
>
<CommunityNodeInfo v-if="communityNodeDetails" />
<OrderSwitcher v-if="rootView" :root-view="rootView">
<template v-if="isTriggerRootView || parsedTriggerActionsBaseline.length !== 0" #triggers>
<template v-if="shouldShowTriggers" #triggers>
<!-- Triggers Category -->
<CategorizedItemsRenderer
v-memo="[search]"
:elements="parsedTriggerActions"
@@ -298,7 +323,7 @@ onMounted(() => {
</CategorizedItemsRenderer>
</template>
</OrderSwitcher>
<div v-if="containsAPIAction" :class="$style.apiHint">
<div v-if="containsAPIAction && !communityNodeDetails" :class="$style.apiHint">
<span
v-n8n-html="
i18n.baseText('nodeCreator.actionsList.apiCall', {
@@ -308,6 +333,11 @@ onMounted(() => {
@click.prevent="addHttpNode"
/>
</div>
<CommunityNodeFooter
:class="$style.communityNodeFooter"
v-if="communityNodeDetails"
:package-name="communityNodeDetails.packageName"
/>
</div>
</template>
@@ -315,9 +345,17 @@ onMounted(() => {
.container {
display: flex;
flex-direction: column;
min-height: 100%;
}
.containerPaddingBottom {
padding-bottom: var(--spacing-3xl);
}
.communityNodeFooter {
margin-top: auto;
}
.resetSearch {
cursor: pointer;
line-height: var(--font-line-height-regular);

View File

@@ -21,17 +21,27 @@ import type { BaseTextKey } from '@/plugins/i18n';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { TriggerView, RegularView, AIView, AINodesView } from '../viewsData';
import { flattenCreateElements, transformNodeType } from '../utils';
import {
flattenCreateElements,
filterAndSearchNodes,
prepareCommunityNodeDetailsViewStack,
transformNodeType,
} from '../utils';
import { useViewStacks } from '../composables/useViewStacks';
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue';
import NoResults from '../Panel/NoResults.vue';
import { useI18n } from '@/composables/useI18n';
import { getNodeIconSource } from '@/utils/nodeIcon';
import { useActions } from '../composables/useActions';
import { SEND_AND_WAIT_OPERATION, type INodeParameters } from 'n8n-workflow';
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
export interface Props {
rootView: 'trigger' | 'action';
}
@@ -43,15 +53,36 @@ const emit = defineEmits<{
const i18n = useI18n();
const { mergedNodes, actions, onSubcategorySelected } = useNodeCreatorStore();
const { pushViewStack, popViewStack } = useViewStacks();
const { pushViewStack, popViewStack, isAiSubcategoryView } = useViewStacks();
const { setAddedNodeActionParameters } = useActions();
const { registerKeyHook } = useKeyboardNavigation();
const activeViewStack = computed(() => useViewStacks().activeViewStack);
const globalSearchItemsDiff = computed(() => useViewStacks().globalSearchItemsDiff);
function getFilteredActions(node: NodeCreateElement) {
const communityNodesAndActions = computed(() => useNodeTypesStore().communityNodesAndActions);
const moreFromCommunity = computed(() => {
return filterAndSearchNodes(
communityNodesAndActions.value.mergedNodes,
activeViewStack.value.search ?? '',
isAiSubcategoryView(activeViewStack.value),
);
});
const isSearchResultEmpty = computed(() => {
return (
(activeViewStack.value.items || []).length === 0 &&
globalSearchItemsDiff.value.length + moreFromCommunity.value.length === 0
);
});
function getFilteredActions(
node: NodeCreateElement,
actions: Record<string, ActionTypeDescription[]>,
) {
const nodeActions = actions?.[node.key] || [];
if (activeViewStack.value.subcategory === HITL_SUBCATEGORY) {
return getHumanInTheLoopActions(nodeActions);
@@ -103,7 +134,23 @@ function onSelected(item: INodeCreateElement) {
}
if (item.type === 'node') {
const nodeActions = getFilteredActions(item);
let nodeActions = getFilteredActions(item, actions);
if (isCommunityPackageName(item.key) && !activeViewStack.value.communityNodeDetails) {
if (!nodeActions.length) {
nodeActions = getFilteredActions(item, communityNodesAndActions.value.actions);
}
const viewStack = prepareCommunityNodeDetailsViewStack(
item,
getNodeIconSource(item.properties),
activeViewStack.value.rootView,
nodeActions,
);
pushViewStack(viewStack);
return;
}
// If there is only one action, use it
if (nodeActions.length === 1) {
@@ -176,7 +223,7 @@ function subcategoriesMapper(item: INodeCreateElement) {
if (item.type !== 'node') return item;
const hasTriggerGroup = item.properties.group.includes('trigger');
const nodeActions = getFilteredActions(item);
const nodeActions = getFilteredActions(item, actions);
const hasActions = nodeActions.length > 0;
if (hasTriggerGroup && hasActions) {
@@ -197,7 +244,7 @@ function baseSubcategoriesFilter(item: INodeCreateElement): boolean {
if (item.type !== 'node') return false;
const hasTriggerGroup = item.properties.group.includes('trigger');
const nodeActions = getFilteredActions(item);
const nodeActions = getFilteredActions(item, actions);
const hasActions = nodeActions.length > 0;
const isTriggerRootView = activeViewStack.value.rootView === TRIGGER_NODE_CREATOR_VIEW;
@@ -216,6 +263,7 @@ function onKeySelect(activeItemId: string) {
const mergedItems = flattenCreateElements([
...(activeViewStack.value.items ?? []),
...(globalSearchItemsDiff.value ?? []),
...(moreFromCommunity.value ?? []),
]);
const item = mergedItems.find((i) => i.uuid === activeItemId);
@@ -246,10 +294,7 @@ registerKeyHook('MainViewArrowLeft', {
:class="$style.items"
@selected="onSelected"
>
<template
v-if="(activeViewStack.items || []).length === 0 && globalSearchItemsDiff.length === 0"
#empty
>
<template v-if="isSearchResultEmpty" #empty>
<NoResults
:root-view="activeViewStack.rootView"
show-icon
@@ -259,12 +304,24 @@ registerKeyHook('MainViewArrowLeft', {
/>
</template>
</ItemsRenderer>
<!-- Results in other categories -->
<CategorizedItemsRenderer
v-if="globalSearchItemsDiff.length > 0"
:elements="globalSearchItemsDiff"
:category="i18n.baseText('nodeCreator.categoryNames.otherCategories')"
@selected="onSelected"
:expanded="true"
>
</CategorizedItemsRenderer>
<!-- Results in community nodes -->
<CategorizedItemsRenderer
v-if="moreFromCommunity.length > 0"
:elements="moreFromCommunity"
:category="i18n.baseText('nodeCreator.categoryNames.moreFromCommunity')"
@selected="onSelected"
:expanded="true"
>
</CategorizedItemsRenderer>
</span>

View File

@@ -0,0 +1,213 @@
import { createComponentRenderer } from '@/__tests__/render';
import { type TestingPinia, createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import CommunityNodeDetails from './CommunityNodeDetails.vue';
import { fireEvent, waitFor } from '@testing-library/vue';
const fetchCredentialTypes = vi.fn();
const getCommunityNodeAttributes = vi.fn(() => ({ npmVersion: '1.0.0' }));
const getNodeTypes = vi.fn();
const installPackage = vi.fn();
const getAllNodeCreateElements = vi.fn(() => [
{
key: 'n8n-nodes-test.OtherNode',
properties: {
defaults: {
name: 'OtherNode',
},
description: 'Other node description',
displayName: 'Other Node',
group: ['transform'],
name: 'n8n-nodes-test.OtherNode',
outputs: ['main'],
},
subcategory: '*',
type: 'node',
uuid: 'n8n-nodes-test.OtherNode-32f238f0-2b05-47ce-b43d-7fab6d7ba3cb',
},
]);
const popViewStack = vi.fn();
const pushViewStack = vi.fn();
const showError = vi.fn();
const removeNodeFromMergedNodes = vi.fn();
const usersStore = {
isInstanceOwner: true,
};
vi.mock('@/stores/credentials.store', () => ({
useCredentialsStore: vi.fn(() => ({
fetchCredentialTypes,
})),
}));
vi.mock('@/stores/nodeCreator.store', () => ({
useNodeCreatorStore: vi.fn(() => ({
actions: [],
removeNodeFromMergedNodes,
})),
}));
vi.mock('@/stores/nodeTypes.store', () => ({
useNodeTypesStore: vi.fn(() => ({
getCommunityNodeAttributes,
getNodeTypes,
})),
}));
vi.mock('@/stores/communityNodes.store', () => ({
useCommunityNodesStore: vi.fn(() => ({
installPackage,
})),
}));
vi.mock('@/stores/users.store', () => ({
useUsersStore: vi.fn(() => usersStore),
}));
vi.mock('@/composables/useToast', () => ({
useToast: vi.fn(() => ({
showMessage: vi.fn(),
showError,
})),
}));
vi.mock('../composables/useViewStacks', () => ({
useViewStacks: vi.fn(() => ({
activeViewStack: {
communityNodeDetails: {
description: 'Other node description',
installed: false,
key: 'n8n-nodes-preview-test.OtherNode',
nodeIcon: undefined,
packageName: 'n8n-nodes-test',
title: 'Other Node',
},
hasSearch: false,
items: [
{
key: 'n8n-nodes-preview-test.OtherNode',
properties: {
defaults: {
name: 'OtherNode',
},
description: 'Other node description',
displayName: 'Other Node',
group: ['transform'],
name: 'n8n-nodes-preview-test.OtherNode',
outputs: ['main'],
},
subcategory: '*',
type: 'node',
uuid: 'n8n-nodes-preview-test.OtherNode-32f238f0-2b05-47ce-b43d-7fab6d7ba3cb',
},
],
mode: 'community-node',
rootView: undefined,
subcategory: 'Other Node',
title: 'Community node details',
},
pushViewStack,
popViewStack,
getAllNodeCreateElements,
})),
}));
describe('CommunityNodeDetails', () => {
const renderComponent = createComponentRenderer(CommunityNodeDetails);
let pinia: TestingPinia;
beforeEach(() => {
pinia = createTestingPinia();
setActivePinia(pinia);
});
afterEach(() => {
vi.resetAllMocks();
});
it('should render correctly and install node', async () => {
const wrapper = renderComponent({ pinia });
const installButton = wrapper.getByTestId('install-community-node-button');
expect(wrapper.container.querySelector('.title span')?.textContent).toEqual('Other Node');
expect(installButton.querySelector('span')?.textContent).toEqual('Install Node');
await fireEvent.click(installButton);
await waitFor(() => expect(removeNodeFromMergedNodes).toHaveBeenCalled());
expect(getCommunityNodeAttributes).toHaveBeenCalledWith('n8n-nodes-preview-test.OtherNode');
expect(installPackage).toHaveBeenCalledWith('n8n-nodes-test', true, '1.0.0');
expect(fetchCredentialTypes).toHaveBeenCalledWith(true);
expect(getAllNodeCreateElements).toHaveBeenCalled();
expect(popViewStack).toHaveBeenCalled();
expect(pushViewStack).toHaveBeenCalledWith(
{
communityNodeDetails: {
description: 'Other node description',
installed: true,
key: 'n8n-nodes-test.OtherNode',
nodeIcon: undefined,
packageName: 'n8n-nodes-test',
title: 'Other Node',
},
hasSearch: false,
items: [
{
key: 'n8n-nodes-test.OtherNode',
properties: {
defaults: {
name: 'OtherNode',
},
description: 'Other node description',
displayName: 'Other Node',
group: ['transform'],
name: 'n8n-nodes-test.OtherNode',
outputs: ['main'],
},
subcategory: '*',
type: 'node',
uuid: 'n8n-nodes-test.OtherNode-32f238f0-2b05-47ce-b43d-7fab6d7ba3cb',
},
],
mode: 'community-node',
rootView: undefined,
subcategory: 'Other Node',
title: 'Community node details',
},
{
transitionDirection: 'none',
},
);
expect(removeNodeFromMergedNodes).toHaveBeenCalled();
});
it('should handle errors during node installation', async () => {
installPackage.mockImplementation(() => {
throw new Error('Installation failed');
});
const wrapper = renderComponent({ pinia });
const installButton = wrapper.getByTestId('install-community-node-button');
await fireEvent.click(installButton);
expect(showError).toHaveBeenCalledWith(expect.any(Error), 'Error installing new package');
expect(pushViewStack).not.toHaveBeenCalled();
expect(popViewStack).not.toHaveBeenCalled();
});
it('should not render install button if not instance owner', async () => {
usersStore.isInstanceOwner = false;
const wrapper = renderComponent({ pinia });
expect(wrapper.queryByTestId('install-community-node-button')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,167 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useViewStacks } from '../composables/useViewStacks';
import { useUsersStore } from '@/stores/users.store';
import { useCommunityNodesStore } from '@/stores/communityNodes.store';
import { useToast } from '@/composables/useToast';
import { i18n } from '@/plugins/i18n';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import { getNodeIconSource } from '@/utils/nodeIcon';
import { prepareCommunityNodeDetailsViewStack, removePreviewToken } from '../utils';
import { N8nText } from '@n8n/design-system';
const { activeViewStack, pushViewStack, popViewStack, getAllNodeCreateElements } = useViewStacks();
const { communityNodeDetails } = activeViewStack;
const loading = ref(false);
const communityNodesStore = useCommunityNodesStore();
const nodeCreatorStore = useNodeCreatorStore();
const toast = useToast();
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();
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 = async (key: string) => {
await useNodeTypesStore().getNodeTypes();
await useCredentialsStore().fetchCredentialTypes(true);
updateViewStack(key);
nodeCreatorStore.removeNodeFromMergedNodes(key);
};
const getNpmVersion = async (key: string) => {
const communityNodeAttributes = await useNodeTypesStore().getCommunityNodeAttributes(key);
if (communityNodeAttributes) {
return communityNodeAttributes.npmVersion;
}
return undefined;
};
const onInstall = async () => {
if (isOwner.value && activeViewStack.communityNodeDetails && !communityNodeDetails?.installed) {
const { key, packageName } = activeViewStack.communityNodeDetails;
try {
loading.value = true;
await communityNodesStore.installPackage(packageName, true, await getNpmVersion(key));
await updateStoresAndViewStack(key);
toast.showMessage({
title: i18n.baseText('settings.communityNodes.messages.install.success'),
type: 'success',
});
} catch (error) {
toast.showError(error, i18n.baseText('settings.communityNodes.messages.install.error'));
} finally {
loading.value = false;
}
}
};
</script>
<template>
<div :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>
</div>
<div>
<div v-if="communityNodeDetails?.installed" :class="$style.installed">
<FontAwesomeIcon :class="$style.installedIcon" icon="cube" />
<N8nText color="text-light" size="small" bold>
{{ i18n.baseText('communityNodeDetails.installed') }}
</N8nText>
</div>
<N8nButton
v-else-if="isOwner"
:loading="loading"
:disabled="loading"
label="Install Node"
size="small"
@click="onInstall"
data-test-id="install-community-node-button"
/>
</div>
</div>
</div>
</template>
<style lang="scss" module>
.container {
width: 100%;
padding: var(--spacing-s);
display: flex;
flex-direction: column;
padding-bottom: var(--spacing-xs);
}
.header {
display: flex;
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-s);
}
.installedIcon {
margin-right: var(--spacing-3xs);
color: var(--color-text-base);
font-size: var(--font-size-2xs);
}
.installed {
display: flex;
align-items: center;
margin-right: var(--spacing-xs);
}
</style>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { i18n } from '@/plugins/i18n';
import { N8nText, N8nLink } from '@n8n/design-system';
export interface Props {
packageName: string;
}
const props = defineProps<Props>();
const openCommunityNodeDocsPage = () => {
const newTab = window.open(`https://www.npmjs.com/package/${props.packageName}`, '_blank');
if (newTab) newTab.opener = null;
};
</script>
<template>
<N8nLink
theme="text"
@click="openCommunityNodeDocsPage"
:class="$style.container"
:title="i18n.baseText('communityNodesDocsLink.link.title')"
>
<N8nText size="small" bold style="margin-right: 5px">
{{ i18n.baseText('communityNodesDocsLink.title') }}
</N8nText>
<FontAwesomeIcon icon="external-link-alt" />
</N8nLink>
</template>
<style lang="scss" module>
.container {
display: flex;
align-items: center;
margin-left: auto;
padding-bottom: var(--spacing-5xs);
}
</style>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { VIEWS } from '@/constants';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { captureException } from '@sentry/vue';
import { N8nText, N8nLink } from '@n8n/design-system';
export interface Props {
packageName: string;
}
const props = defineProps<Props>();
const router = useRouter();
const bugsUrl = ref<string>(`https://registry.npmjs.org/${props.packageName}`);
async function openSettingsPage() {
await router.push({ name: VIEWS.COMMUNITY_NODES });
}
async function openIssuesPage() {
if (bugsUrl.value) {
window.open(bugsUrl.value, '_blank');
}
}
async function getBugsUrl(packageName: string) {
const url = `https://registry.npmjs.org/${packageName}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error('Could not get metadata for package');
}
const data = await response.json();
if (data.bugs?.url) {
bugsUrl.value = data.bugs.url;
}
} catch (error) {
captureException(error);
}
}
onMounted(async () => {
if (props.packageName) {
await getBugsUrl(props.packageName);
}
});
</script>
<template>
<div :class="$style.container">
<N8nLink theme="text" @click="openSettingsPage">
<N8nText size="small" color="primary" bold> Manage </N8nText>
</N8nLink>
<N8nText size="small" color="primary" bold>|</N8nText>
<N8nLink theme="text" @click="openIssuesPage">
<N8nText size="small" color="primary" bold> Report issue </N8nText>
</N8nLink>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-s);
padding-bottom: var(--spacing-s);
}
</style>

View File

@@ -0,0 +1,160 @@
import { createComponentRenderer } from '@/__tests__/render';
import { type TestingPinia, createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import CommunityNodeInfo from './CommunityNodeInfo.vue';
import type { PublicInstalledPackage } from 'n8n-workflow';
import { waitFor } from '@testing-library/vue';
const getCommunityNodeAttributes = vi.fn();
const communityNodesStore: { getInstalledPackages: PublicInstalledPackage[] } = {
getInstalledPackages: [],
};
vi.mock('@/stores/nodeTypes.store', () => ({
useNodeTypesStore: vi.fn(() => ({
getCommunityNodeAttributes,
})),
}));
vi.mock('@/stores/users.store', () => ({
useUsersStore: vi.fn(() => ({
isInstanceOwner: true,
})),
}));
vi.mock('@/stores/communityNodes.store', () => ({
useCommunityNodesStore: vi.fn(() => communityNodesStore),
}));
vi.mock('../composables/useViewStacks', () => ({
useViewStacks: vi.fn(() => ({
activeViewStack: {
communityNodeDetails: {
description: 'Other node description',
installed: false,
key: 'n8n-nodes-preview-test.OtherNode',
nodeIcon: undefined,
packageName: 'n8n-nodes-test',
title: 'Other Node',
},
hasSearch: false,
items: [
{
key: 'n8n-nodes-preview-test.OtherNode',
properties: {
defaults: {
name: 'OtherNode',
},
description: 'Other node description',
displayName: 'Other Node',
group: ['transform'],
name: 'n8n-nodes-preview-test.OtherNode',
outputs: ['main'],
},
subcategory: '*',
type: 'node',
uuid: 'n8n-nodes-preview-test.OtherNode-32f238f0-2b05-47ce-b43d-7fab6d7ba3cb',
},
],
mode: 'community-node',
rootView: undefined,
subcategory: 'Other Node',
title: 'Community node details',
},
})),
}));
describe('CommunityNodeInfo', () => {
const renderComponent = createComponentRenderer(CommunityNodeInfo);
let pinia: TestingPinia;
let originalFetch: typeof global.fetch;
beforeEach(() => {
pinia = createTestingPinia();
setActivePinia(pinia);
originalFetch = global.fetch;
global.fetch = vi.fn();
});
afterEach(() => {
global.fetch = originalFetch;
vi.resetAllMocks();
});
it('should render correctly with communityNodeAttributes', async () => {
getCommunityNodeAttributes.mockResolvedValue({
npmVersion: '1.0.0',
authorName: 'contributor',
numberOfDownloads: 9999,
});
communityNodesStore.getInstalledPackages = [
{
installedVersion: '1.0.0',
packageName: 'n8n-nodes-test',
} as PublicInstalledPackage,
];
const wrapper = renderComponent({ pinia });
await waitFor(() => expect(wrapper.queryByTestId('number-of-downloads')).toBeInTheDocument());
expect(wrapper.container.querySelector('.description')?.textContent).toEqual(
'Other node description',
);
expect(wrapper.getByTestId('verified-tag').textContent).toEqual('Verified');
expect(wrapper.getByTestId('number-of-downloads').textContent).toEqual('9,999 Downloads');
expect(wrapper.getByTestId('publisher-name').textContent).toEqual('Published by contributor');
});
it('should render correctly with fetched info', async () => {
const packageData = {
maintainers: [{ name: 'testAuthor' }],
};
const downloadsData = {
downloads: [
{ downloads: 10, day: '2023-01-01' },
{ downloads: 20, day: '2023-01-02' },
{ downloads: 30, day: '2023-01-03' },
],
};
// Set up the fetch mock to return different responses based on URL
vi.stubGlobal(
'fetch',
vi.fn(async (url: RequestInfo | URL) => {
if (typeof url === 'string' && url.includes('registry.npmjs.org')) {
return {
ok: true,
json: async () => packageData,
};
}
if (typeof url === 'string' && url.includes('api.npmjs.org/downloads')) {
return {
ok: true,
json: async () => downloadsData,
};
}
return {
ok: false,
json: async () => ({}),
};
}),
);
getCommunityNodeAttributes.mockResolvedValue(null);
const wrapper = renderComponent({ pinia });
await waitFor(() => expect(wrapper.queryByTestId('number-of-downloads')).toBeInTheDocument());
expect(wrapper.container.querySelector('.description')?.textContent).toEqual(
'Other node description',
);
expect(wrapper.getByTestId('number-of-downloads').textContent).toEqual('60 Downloads');
expect(wrapper.getByTestId('publisher-name').textContent).toEqual('Published by testAuthor');
});
});

View File

@@ -0,0 +1,207 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useViewStacks } from '../composables/useViewStacks';
import { useUsersStore } from '@/stores/users.store';
import { i18n } from '@/plugins/i18n';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCommunityNodesStore } from '@/stores/communityNodes.store';
import { captureException } from '@sentry/vue';
import { N8nText, N8nTooltip, N8nIcon } from '@n8n/design-system';
const { activeViewStack } = useViewStacks();
const { communityNodeDetails } = activeViewStack;
interface DownloadData {
downloads: Array<{ downloads: number }>;
}
const publisherName = ref<string | undefined>(undefined);
const downloads = ref<string | null>(null);
const verified = ref(false);
const communityNodesStore = useCommunityNodesStore();
const nodeTypesStore = useNodeTypesStore();
const isOwner = computed(() => useUsersStore().isInstanceOwner);
const ownerEmailList = computed(() =>
useUsersStore()
.allUsers.filter((user) => user.role?.includes('owner'))
.map((user) => user.email),
);
const formatNumber = (number: number) => {
if (!number) return null;
return new Intl.NumberFormat('en-US').format(number);
};
async function fetchPackageInfo(packageName: string) {
const communityNodeAttributes = await nodeTypesStore.getCommunityNodeAttributes(
activeViewStack.communityNodeDetails?.key || '',
);
if (communityNodeAttributes) {
publisherName.value = communityNodeAttributes.authorName;
downloads.value = formatNumber(communityNodeAttributes.numberOfDownloads);
const packageInfo = communityNodesStore.getInstalledPackages.find(
(p) => p.packageName === communityNodeAttributes.packageName,
);
if (!packageInfo) {
verified.value = true;
} else {
verified.value = packageInfo.installedVersion === communityNodeAttributes.npmVersion;
}
return;
}
const url = `https://registry.npmjs.org/${packageName}`;
try {
const response = await fetch(url);
if (!response.ok) {
captureException(new Error('Could not get metadata for package'), { extra: { packageName } });
return;
}
const data = await response.json();
const publisher = data.maintainers?.[0]?.name as string | undefined;
publisherName.value = publisher;
const today = new Date().toISOString().split('T')[0];
const downloadsUrl = `https://api.npmjs.org/downloads/range/2022-01-01:${today}/${packageName}`;
const downloadsResponse = await fetch(downloadsUrl);
if (!downloadsResponse.ok) {
captureException(new Error('Could not get downloads for package'), {
extra: { packageName },
});
return;
}
const downloadsData: DownloadData = await downloadsResponse.json();
if (!downloadsData.downloads || !downloadsData.downloads.length) return;
const total = downloadsData.downloads.reduce((sum, day) => sum + day.downloads, 0);
downloads.value = formatNumber(total);
} catch (error) {
captureException(error, { extra: { packageName } });
}
}
onMounted(async () => {
if (communityNodeDetails?.packageName) {
await fetchPackageInfo(communityNodeDetails.packageName);
}
});
</script>
<template>
<div :class="$style.container">
<N8nText :class="$style.description" color="text-base" size="medium">
{{ communityNodeDetails?.description }}
</N8nText>
<div :class="$style.separator"></div>
<div :class="$style.info">
<N8nTooltip placement="top" v-if="verified">
<template #content>{{ i18n.baseText('communityNodeInfo.approved') }}</template>
<div>
<FontAwesomeIcon :class="$style.tooltipIcon" icon="check-circle" />
<N8nText color="text-light" size="xsmall" bold data-test-id="verified-tag">
{{ i18n.baseText('communityNodeInfo.approved.label') }}
</N8nText>
</div>
</N8nTooltip>
<N8nTooltip placement="top" v-else>
<template #content>{{ i18n.baseText('communityNodeInfo.unverified') }}</template>
<div>
<FontAwesomeIcon :class="$style.tooltipIcon" icon="cube" />
<N8nText color="text-light" size="xsmall" bold>
{{ i18n.baseText('communityNodeInfo.unverified.label') }}
</N8nText>
</div>
</N8nTooltip>
<div v-if="downloads">
<FontAwesomeIcon :class="$style.tooltipIcon" icon="download" />
<N8nText color="text-light" size="xsmall" bold data-test-id="number-of-downloads">
{{ i18n.baseText('communityNodeInfo.downloads', { interpolate: { downloads } }) }}
</N8nText>
</div>
<div v-if="publisherName">
<FontAwesomeIcon :class="$style.tooltipIcon" icon="user" />
<N8nText color="text-light" size="xsmall" bold data-test-id="publisher-name">
{{ i18n.baseText('communityNodeInfo.publishedBy', { interpolate: { publisherName } }) }}
</N8nText>
</div>
</div>
<div v-if="!isOwner && !communityNodeDetails?.installed" :class="$style.contactOwnerHint">
<N8nIcon color="text-light" icon="info-circle" size="large" />
<nN8nText color="text-base" size="medium">
<div style="padding-bottom: 8px">
{{ i18n.baseText('communityNodeInfo.contact.admin') }}
</div>
<N8nText bold v-if="ownerEmailList.length">
{{ ownerEmailList.join(', ') }}
</N8nText>
</nN8nText>
</div>
</div>
</template>
<style lang="scss" module>
.container {
width: 100%;
padding: var(--spacing-s);
padding-top: 0;
margin-top: 0;
display: flex;
flex-direction: column;
}
.nodeIcon {
--node-icon-size: 36px;
margin-right: var(--spacing-s);
}
.description {
margin: var(--spacing-m) 0;
}
.separator {
height: var(--border-width-base);
background: var(--color-foreground-base);
margin-bottom: var(--spacing-m);
}
.info {
display: flex;
align-items: center;
justify-content: left;
gap: var(--spacing-m);
margin-bottom: var(--spacing-m);
flex-wrap: wrap;
}
.info div {
display: flex;
align-items: center;
gap: var(--spacing-3xs);
}
.tooltipIcon {
color: var(--color-text-light);
font-size: var(--font-size-2xs);
}
.contactOwnerHint {
display: flex;
align-items: center;
gap: var(--spacing-s);
padding: var(--spacing-xs);
border: var(--border-width-base) solid var(--color-foreground-base);
border-radius: 0.25em;
}
</style>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { useUsersStore } from '@/stores/users.store';
import { computed } from 'vue';
import { N8nText, N8nIcon } from '@n8n/design-system';
export interface Props {
hint: string;
}
const isOwner = computed(() => useUsersStore().isInstanceOwner);
defineProps<Props>();
</script>
<template>
<div v-if="isOwner" :class="$style.container">
<N8nIcon color="text-light" icon="info-circle" size="large" />
<N8nText color="text-base" size="medium"> {{ hint }} </N8nText>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
align-items: center;
gap: var(--spacing-s);
margin: var(--spacing-xs);
margin-top: 0;
padding: var(--spacing-xs);
border: var(--border-width-base) solid var(--color-foreground-base);
border-radius: 0.25em;
pointer-events: none;
cursor: default;
}
</style>

View File

@@ -21,6 +21,11 @@ import { useI18n } from '@/composables/useI18n';
import { useDebounce } from '@/composables/useDebounce';
import NodeIcon from '@/components/NodeIcon.vue';
import CommunityNodeDetails from './CommunityNodeDetails.vue';
import CommunityNodeInfo from './CommunityNodeInfo.vue';
import CommunityNodeDocsLink from './CommunityNodeDocsLink.vue';
import CommunityNodeFooter from './CommunityNodeFooter.vue';
const i18n = useI18n();
const { callDebounced } = useDebounce();
@@ -31,19 +36,39 @@ const nodeCreatorStore = useNodeCreatorStore();
const activeViewStack = computed(() => useViewStacks().activeViewStack);
const communityNodeDetails = computed(() => activeViewStack.value.communityNodeDetails);
const viewStacks = computed(() => useViewStacks().viewStacks);
const isActionsMode = computed(() => useViewStacks().activeViewStackMode === 'actions');
const searchPlaceholder = computed(() =>
isActionsMode.value
? i18n.baseText('nodeCreator.actionsCategory.searchActions', {
interpolate: { node: activeViewStack.value.title as string },
})
: i18n.baseText('nodeCreator.searchBar.searchNodes'),
);
const searchPlaceholder = computed(() => {
let node = activeViewStack.value?.title as string;
if (communityNodeDetails.value) {
node = communityNodeDetails.value.title;
}
if (isActionsMode.value) {
return i18n.baseText('nodeCreator.actionsCategory.searchActions', {
interpolate: { node },
});
}
return i18n.baseText('nodeCreator.searchBar.searchNodes');
});
const showSearchBar = computed(() => {
if (activeViewStack.value.communityNodeDetails) return false;
return activeViewStack.value.hasSearch;
});
const nodeCreatorView = computed(() => useNodeCreatorStore().selectedView);
const isCommunityNodeActionsMode = computed(() => {
return communityNodeDetails.value && isActionsMode.value && activeViewStack.value.subcategory;
});
function getDefaultActiveIndex(search: string = ''): number {
if (activeViewStack.value.mode === 'actions') {
// For actions, set the active focus to the first action, not category
@@ -165,6 +190,11 @@ function onBackButton() {
:size="20"
/>
<p v-if="activeViewStack.title" :class="$style.title" v-text="activeViewStack.title" />
<CommunityNodeDocsLink
v-if="communityNodeDetails"
:package-name="communityNodeDetails.packageName"
/>
</div>
<p
v-if="activeViewStack.subtitle"
@@ -172,8 +202,9 @@ function onBackButton() {
v-text="activeViewStack.subtitle"
/>
</header>
<SearchBar
v-if="activeViewStack.hasSearch"
v-if="showSearchBar"
:class="$style.searchBar"
:placeholder="
searchPlaceholder ? searchPlaceholder : i18n.baseText('nodeCreator.searchBar.searchNodes')
@@ -181,6 +212,10 @@ function onBackButton() {
:model-value="activeViewStack.search"
@update:model-value="onSearch"
/>
<CommunityNodeDetails v-if="communityNodeDetails" />
<CommunityNodeInfo v-if="communityNodeDetails && !isActionsMode" />
<div :class="$style.renderedItems">
<n8n-notice
v-if="activeViewStack.info && !activeViewStack.search"
@@ -194,6 +229,11 @@ function onBackButton() {
<!-- Nodes Mode -->
<NodesRenderer v-else :root-view="nodeCreatorView" v-bind="$attrs" />
</div>
<CommunityNodeFooter
v-if="communityNodeDetails && !isCommunityNodeActionsMode"
:package-name="communityNodeDetails.packageName"
/>
</aside>
</transition>
</template>

View File

@@ -10,6 +10,8 @@ import ItemsRenderer from './ItemsRenderer.vue';
import CategoryItem from '../ItemTypes/CategoryItem.vue';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import CommunityNodeInstallHint from '../Panel/CommunityNodeInstallHint.vue';
export interface Props {
elements: INodeCreateElement[];
category: string;
@@ -20,18 +22,24 @@ export interface Props {
expanded?: boolean;
}
import { useI18n } from '@/composables/useI18n';
const props = withDefaults(defineProps<Props>(), {
elements: () => [],
});
const { popViewStack } = useViewStacks();
const { popViewStack, activeViewStack } = useViewStacks();
const { registerKeyHook } = useKeyboardNavigation();
const { workflowId } = useWorkflowsStore();
const nodeCreatorStore = useNodeCreatorStore();
const i18n = useI18n();
const activeItemId = computed(() => useKeyboardNavigation()?.activeItemId);
const actionCount = computed(() => props.elements.filter(({ type }) => type === 'action').length);
const expanded = ref(props.expanded ?? false);
const isPreview = computed(
() => activeViewStack.communityNodeDetails && !activeViewStack.communityNodeDetails.installed,
);
function toggleExpanded() {
setExpanded(!expanded.value);
@@ -115,12 +123,18 @@ registerKeyHook(`CategoryLeft_${props.category}`, {
<div v-if="expanded && actionCount > 0 && $slots.default" :class="$style.contentSlot">
<slot />
</div>
<CommunityNodeInstallHint
v-if="isPreview"
:hint="i18n.baseText('communityNodeItem.actions.hint')"
/>
<!-- Pass through listeners & empty slot to ItemsRenderer -->
<ItemsRenderer
v-if="expanded"
v-bind="$attrs"
:elements="elements"
:is-trigger="isTriggerCategory"
:class="[{ [$style.preview]: isPreview }]"
>
<template #default> </template>
<template #empty>
@@ -153,4 +167,9 @@ registerKeyHook(`CategoryLeft_${props.category}`, {
.categorizedItemsRenderer {
padding-bottom: var(--spacing-s);
}
.preview {
opacity: 0.7;
pointer-events: none;
cursor: default;
}
</style>

View File

@@ -9,8 +9,11 @@ import LabelItem from '../ItemTypes/LabelItem.vue';
import ActionItem from '../ItemTypes/ActionItem.vue';
import ViewItem from '../ItemTypes/ViewItem.vue';
import LinkItem from '../ItemTypes/LinkItem.vue';
import CommunityNodeItem from '../ItemTypes/CommunityNodeItem.vue';
import CategorizedItemsRenderer from './CategorizedItemsRenderer.vue';
import { useViewStacks } from '../composables/useViewStacks';
export interface Props {
elements?: INodeCreateElement[];
activeIndex?: number;
@@ -33,9 +36,24 @@ const emit = defineEmits<{
const renderedItems = ref<INodeCreateElement[]>([]);
const renderAnimationRequest = ref<number>(0);
const { activeViewStack } = useViewStacks();
const activeItemId = computed(() => useKeyboardNavigation()?.activeItemId);
const communityNode = computed(() => activeViewStack.mode === 'community-node');
const isPreview = computed(() => {
return communityNode.value && !activeViewStack.communityNodeDetails?.installed;
});
const highlightActiveItem = computed(() => {
if (activeViewStack.communityNodeDetails && !activeViewStack.communityNodeDetails.installed) {
return false;
}
return true;
});
// Lazy render large items lists to prevent the browser from freezing
// when loading many items.
function renderItems() {
@@ -145,9 +163,10 @@ watch(
ref="iteratorItems"
:class="{
clickable: !disabled,
[$style.active]: activeItemId === item.uuid,
[$style.iteratorItem]: true,
[$style.active]: activeItemId === item.uuid && highlightActiveItem,
[$style.iteratorItem]: !communityNode,
[$style[item.type]]: true,
[$style.preview]: isPreview,
// Borderless is only applied to views
[$style.borderless]: item.type === 'view' && item.properties.borderless === true,
}"
@@ -157,10 +176,13 @@ watch(
@click="wrappedEmit('selected', item)"
>
<LabelItem v-if="item.type === 'label'" :item="item" />
<SubcategoryItem v-if="item.type === 'subcategory'" :item="item.properties" />
<CommunityNodeItem v-if="communityNode" :is-preview="isPreview" />
<NodeItem
v-if="item.type === 'node'"
v-if="item.type === 'node' && !communityNode"
:node-type="item.properties"
:active="true"
:subcategory="item.subcategory"
@@ -282,4 +304,9 @@ watch(
}
}
}
.preview {
pointer-events: none;
cursor: default;
}
</style>

View File

@@ -193,7 +193,7 @@ function triggersCategory(nodeTypeDescription: INodeTypeDescription): ActionType
function resourceCategories(nodeTypeDescription: INodeTypeDescription): ActionTypeDescription[] {
const transformedNodes: ActionTypeDescription[] = [];
const matchedProperties = nodeTypeDescription.properties.filter(
(property) => property.displayName?.toLowerCase() === 'resource',
(property) => property.name === 'resource',
);
matchedProperties.forEach((property) => {

View File

@@ -24,6 +24,7 @@ import difference from 'lodash-es/difference';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import {
extendItemsWithUUID,
flattenCreateElements,
groupItemsInSections,
isAINode,
@@ -43,11 +44,21 @@ import { AI_TRANSFORM_NODE_TYPE } from 'n8n-workflow';
import type { NodeConnectionType, INodeInputFilter } from 'n8n-workflow';
import { useCanvasStore } from '@/stores/canvas.store';
import { useSettingsStore } from '@/stores/settings.store';
export type CommunityNodeDetails = {
key: string;
title: string;
description: string;
packageName: string;
installed: boolean;
nodeIcon?: NodeIconSource;
};
import { useUIStore } from '@/stores/ui.store';
import { type NodeIconSource } from '@/utils/nodeIcon';
import { getThemedValue } from '@/utils/nodeTypesUtils';
interface ViewStack {
export interface ViewStack {
uuid?: string;
title?: string;
subtitle?: string;
@@ -57,20 +68,21 @@ interface ViewStack {
nodeIcon?: NodeIconSource;
rootView?: NodeFilterType;
activeIndex?: number;
transitionDirection?: 'in' | 'out';
transitionDirection?: 'in' | 'out' | 'none';
hasSearch?: boolean;
preventBack?: boolean;
items?: INodeCreateElement[];
baselineItems?: INodeCreateElement[];
searchItems?: SimplifiedNodeType[];
forceIncludeNodes?: string[];
mode?: 'actions' | 'nodes';
mode?: 'actions' | 'nodes' | 'community-node';
hideActions?: boolean;
baseFilter?: (item: INodeCreateElement) => boolean;
itemsMapper?: (item: INodeCreateElement) => INodeCreateElement;
actionsFilter?: (items: ActionTypeDescription[]) => ActionTypeDescription[];
panelClass?: string;
sections?: string[] | NodeViewItemSection[];
communityNodeDetails?: CommunityNodeDetails;
}
export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
@@ -150,12 +162,18 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
return viewStacks.value[viewStacks.value.length - 1];
}
function getAllNodeCreateElements() {
return nodeCreatorStore.mergedNodes.map((item) =>
transformNodeType(item),
) as NodeCreateElement[];
}
// Generate a delta between the global search results(all nodes) and the stack search results
const globalSearchItemsDiff = computed<INodeCreateElement[]>(() => {
const stack = getLastActiveStack();
if (!stack?.search || isAiSubcategoryView(stack)) return [];
const allNodes = nodeCreatorStore.mergedNodes.map((item) => transformNodeType(item));
const allNodes = getAllNodeCreateElements();
// Apply filtering for AI nodes if the current view is not the AI root view
const filteredNodes = isAiRootView(stack) ? allNodes : filterOutAiNodes(allNodes);
@@ -417,14 +435,10 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
updateCurrentViewStack({ baselineItems: stackItems });
}
function extendItemsWithUUID(items: INodeCreateElement[]) {
return items.map((item) => ({
...item,
uuid: `${item.key}-${uuid()}`,
}));
}
function pushViewStack(stack: ViewStack, options: { resetStacks?: boolean } = {}) {
function pushViewStack(
stack: ViewStack,
options: { resetStacks?: boolean; transitionDirection?: 'in' | 'out' | 'none' } = {},
) {
if (options.resetStacks) {
resetViewStacks();
}
@@ -437,7 +451,7 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
viewStacks.value.push({
...stack,
uuid: newStackUuid,
transitionDirection: 'in',
transitionDirection: options.transitionDirection ?? 'in',
activeIndex: 0,
});
setStackBaselineItems();
@@ -480,5 +494,6 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
updateCurrentViewStack,
pushViewStack,
popViewStack,
getAllNodeCreateElements,
};
});

View File

@@ -1,7 +1,14 @@
import type { SectionCreateElement } from '@/Interface';
import type {
ActionTypeDescription,
NodeCreateElement,
SectionCreateElement,
SimplifiedNodeType,
} from '@/Interface';
import {
formatTriggerActionName,
filterAndSearchNodes,
groupItemsInSections,
prepareCommunityNodeDetailsViewStack,
removeTrailingTrigger,
sortNodeCreateElements,
} from './utils';
@@ -11,6 +18,10 @@ import {
mockSectionCreateElement,
} from './__tests__/utils';
vi.mock('@/stores/settings.store', () => ({
useSettingsStore: vi.fn(() => ({ settings: {}, isAskAiEnabled: true })),
}));
describe('NodeCreator - utils', () => {
describe('groupItemsInSections', () => {
it('should handle multiple sections (with "other" section)', () => {
@@ -82,6 +93,239 @@ describe('NodeCreator - utils', () => {
expect(formatTriggerActionName(actionName)).toEqual(expected);
});
});
describe('filterAndSearchNodes', () => {
const mergedNodes: SimplifiedNodeType[] = [
{
displayName: 'Sample Node',
defaults: {
name: 'SampleNode',
},
description: 'Sample description',
name: 'n8n-nodes-preview-test.SampleNode',
group: ['transform'],
outputs: ['main'],
},
{
displayName: 'Other Node',
defaults: {
name: 'OtherNode',
},
description: 'Other node description',
name: 'n8n-nodes-preview-test.OtherNode',
group: ['transform'],
outputs: ['main'],
},
];
test('should return only one node', () => {
const result = filterAndSearchNodes(mergedNodes, 'sample', false);
expect(result.length).toEqual(1);
expect(result[0].key).toEqual('n8n-nodes-preview-test.SampleNode');
});
test('should return two nodes', () => {
const result = filterAndSearchNodes(mergedNodes, 'node', false);
expect(result.length).toEqual(2);
expect(result[1].key).toEqual('n8n-nodes-preview-test.SampleNode');
expect(result[0].key).toEqual('n8n-nodes-preview-test.OtherNode');
});
});
describe('prepareCommunityNodeDetailsViewStack', () => {
const nodeCreateElement: NodeCreateElement = {
key: 'n8n-nodes-preview-test.OtherNode',
properties: {
defaults: {
name: 'OtherNode',
},
description: 'Other node description',
displayName: 'Other Node',
group: ['transform'],
name: 'n8n-nodes-preview-test.OtherNode',
outputs: ['main'],
},
subcategory: '*',
type: 'node',
uuid: 'n8n-nodes-preview-test.OtherNode-32f238f0-2b05-47ce-b43d-7fab6d7ba3cb',
};
test('should return "community-node" view stack', () => {
const result = prepareCommunityNodeDetailsViewStack(nodeCreateElement, undefined, undefined);
expect(result).toEqual({
communityNodeDetails: {
description: 'Other node description',
installed: false,
key: 'n8n-nodes-preview-test.OtherNode',
nodeIcon: undefined,
packageName: 'n8n-nodes-test',
title: 'Other Node',
},
hasSearch: false,
items: [
{
key: 'n8n-nodes-preview-test.OtherNode',
properties: {
defaults: {
name: 'OtherNode',
},
description: 'Other node description',
displayName: 'Other Node',
group: ['transform'],
name: 'n8n-nodes-preview-test.OtherNode',
outputs: ['main'],
},
subcategory: '*',
type: 'node',
uuid: 'n8n-nodes-preview-test.OtherNode-32f238f0-2b05-47ce-b43d-7fab6d7ba3cb',
},
],
mode: 'community-node',
rootView: undefined,
subcategory: 'Other Node',
title: 'Community node details',
});
});
test('should return "actions" view stack', () => {
const nodeActions: ActionTypeDescription[] = [
{
name: 'n8n-nodes-preview-test.OtherNode',
group: ['trigger'],
codex: {
label: 'Log Actions',
categories: ['Actions'],
},
iconUrl: 'icons/n8n-nodes-preview-test/dist/nodes/Test/test.svg',
outputs: ['main'],
defaults: {
name: 'LogSnag',
},
actionKey: 'publish',
description: 'Publish an event',
displayOptions: {
show: {
resource: ['log'],
},
},
values: {
operation: 'publish',
},
displayName: 'Publish an event',
},
{
name: 'n8n-nodes-preview-test.OtherNode',
group: ['trigger'],
codex: {
label: 'Insight Actions',
categories: ['Actions'],
},
iconUrl: 'icons/n8n-nodes-preview-test/dist/nodes/Test/test.svg',
outputs: ['main'],
defaults: {
name: 'LogSnag',
},
actionKey: 'publish',
description: 'Publish an insight',
displayOptions: {
show: {
resource: ['insight'],
},
},
values: {
operation: 'publish',
},
displayName: 'Publish an insight',
},
];
const result = prepareCommunityNodeDetailsViewStack(
nodeCreateElement,
undefined,
undefined,
nodeActions,
);
expect(result).toEqual({
communityNodeDetails: {
description: 'Other node description',
installed: false,
key: 'n8n-nodes-preview-test.OtherNode',
nodeIcon: undefined,
packageName: 'n8n-nodes-test',
title: 'Other Node',
},
hasSearch: false,
items: [
{
key: 'n8n-nodes-preview-test.OtherNode',
properties: {
actionKey: 'publish',
codex: {
categories: ['Actions'],
label: 'Log Actions',
},
defaults: {
name: 'LogSnag',
},
description: 'Publish an event',
displayName: 'Publish an event',
displayOptions: {
show: {
resource: ['log'],
},
},
group: ['trigger'],
iconUrl: 'icons/n8n-nodes-preview-test/dist/nodes/Test/test.svg',
name: 'n8n-nodes-preview-test.OtherNode',
outputs: ['main'],
values: {
operation: 'publish',
},
},
subcategory: 'Other Node',
type: 'action',
uuid: expect.any(String),
},
{
key: 'n8n-nodes-preview-test.OtherNode',
properties: {
actionKey: 'publish',
codex: {
categories: ['Actions'],
label: 'Insight Actions',
},
defaults: {
name: 'LogSnag',
},
description: 'Publish an insight',
displayName: 'Publish an insight',
displayOptions: {
show: {
resource: ['insight'],
},
},
group: ['trigger'],
iconUrl: 'icons/n8n-nodes-preview-test/dist/nodes/Test/test.svg',
name: 'n8n-nodes-preview-test.OtherNode',
outputs: ['main'],
values: {
operation: 'publish',
},
},
subcategory: 'Other Node',
type: 'action',
uuid: expect.any(String),
},
],
mode: 'actions',
rootView: undefined,
subcategory: 'Other Node',
title: 'Community node details',
});
});
});
describe('removeTrailingTrigger', () => {
test.each([
['Telegram Trigger', 'Telegram'],

View File

@@ -5,6 +5,8 @@ import type {
SimplifiedNodeType,
INodeCreateElement,
SectionCreateElement,
ActionTypeDescription,
NodeFilterType,
} from '@/Interface';
import {
AI_CATEGORY_AGENTS,
@@ -25,6 +27,10 @@ import * as changeCase from 'change-case';
import { useSettingsStore } from '@/stores/settings.store';
import { SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import type { NodeIconSource } from '../../../utils/nodeIcon';
import type { CommunityNodeDetails, ViewStack } from './composables/useViewStacks';
const COMMUNITY_NODE_TYPE_PREVIEW_TOKEN = '-preview';
export function transformNodeType(
node: SimplifiedNodeType,
@@ -221,3 +227,76 @@ export const formatTriggerActionName = (actionPropertyName: string) => {
}
return changeCase.noCase(name);
};
export const removePreviewToken = (key: string) =>
key.replace(COMMUNITY_NODE_TYPE_PREVIEW_TOKEN, '');
export const isNodePreviewKey = (key = '') => key.includes(COMMUNITY_NODE_TYPE_PREVIEW_TOKEN);
export function extendItemsWithUUID(items: INodeCreateElement[]) {
return items.map((item) => ({
...item,
uuid: `${item.key}-${uuidv4()}`,
}));
}
export const filterAndSearchNodes = (
mergedNodes: SimplifiedNodeType[],
search: string,
isAgentSubcategory: boolean,
) => {
if (!search || isAgentSubcategory) return [];
const vettedNodes = mergedNodes.map((item) => transformNodeType(item)) as NodeCreateElement[];
const searchResult: INodeCreateElement[] = extendItemsWithUUID(
searchNodes(search || '', vettedNodes),
);
return searchResult;
};
export function prepareCommunityNodeDetailsViewStack(
item: NodeCreateElement,
nodeIcon: NodeIconSource | undefined,
rootView: NodeFilterType | undefined,
nodeActions: ActionTypeDescription[] = [],
): ViewStack {
const installed = !isNodePreviewKey(item.key);
const packageName = removePreviewToken(item.key.split('.')[0]);
const communityNodeDetails: CommunityNodeDetails = {
title: item.properties.displayName,
description: item.properties.description,
key: item.key,
nodeIcon,
installed,
packageName,
};
if (nodeActions.length) {
const transformedActions = nodeActions?.map((a) =>
transformNodeType(a, item.properties.displayName, 'action'),
);
return {
subcategory: item.properties.displayName,
title: i18n.baseText('nodeSettings.communityNodeDetails.title'),
rootView,
hasSearch: false,
mode: 'actions',
items: transformedActions,
communityNodeDetails,
};
}
return {
subcategory: item.properties.displayName,
title: i18n.baseText('nodeSettings.communityNodeDetails.title'),
rootView,
hasSearch: false,
items: [item],
mode: 'community-node',
communityNodeDetails,
};
}

View File

@@ -1,5 +1,6 @@
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { ReloadNodeType } from '@n8n/api-types/push/hot-reload';
import { isCommunityPackageName } from '../../../utils/nodeTypesUtils';
/**
* Handles the 'reloadNodeType' event from the push connection, which indicates
@@ -9,5 +10,6 @@ export async function reloadNodeType({ data }: ReloadNodeType) {
const nodeTypesStore = useNodeTypesStore();
await nodeTypesStore.getNodeTypes();
await nodeTypesStore.getFullNodesProperties([data]);
const isCommunityNode = isCommunityPackageName(data.name);
await nodeTypesStore.getFullNodesProperties([data], !isCommunityNode);
}

View File

@@ -1220,6 +1220,7 @@
"nodeCreator.actionsTooltip.actionsPerformStep": "Actions perform a step once your workflow has already started. <a target=\"_blank\" href=\"https://docs.n8n.io/integrations/builtin/\"> Learn more</a>",
"nodeCreator.actionsCallout.noTriggerItems": "No <strong>{nodeName}</strong> Triggers available. Users often combine the following Triggers with <strong>{nodeName}</strong> Actions.",
"nodeCreator.categoryNames.otherCategories": "Results in other categories",
"nodeCreator.categoryNames.moreFromCommunity": "More from the community",
"nodeCreator.subnodes": "sub-nodes",
"nodeCreator.noResults.dontWorryYouCanProbablyDoItWithThe": "Dont worry, you can probably do it with the",
"nodeCreator.noResults.httpRequest": "HTTP Request",
@@ -1408,6 +1409,7 @@
"nodeSettings.scopes.expandedNoticeWithScopes": "<a data-key=\"toggle-expand\">{count} scope</a> available for {activeCredential} credentials<br>{scopes}<br><a data-key=\"show-less\">Show less</a> | <a data-key=\"toggle-expand\">{count} scopes</a> available for {activeCredential} credentials<br>{scopes}<br><a data-key=\"show-less\">Show less</a>",
"nodeSettings.scopes.notice": "<a data-key=\"toggle-expand\">{count} scope</a> available for {activeCredential} credentials | <a data-key=\"toggle-expand\">{count} scopes</a> available for {activeCredential} credentials",
"nodeSettings.theNodeIsNotValidAsItsTypeIsUnknown": "The node is not valid as its type ({nodeType}) is unknown",
"nodeSettings.communityNodeDetails.title": "Community node details",
"nodeSettings.communityNodeUnknown.title": "Install this node to use it",
"nodeSettings.communityNodeUnknown.description": "This node is not currently installed. It's part of the {action} community package.",
"nodeSettings.communityNodeUnknown.installLink.text": "How to install community nodes",
@@ -3155,6 +3157,19 @@
"insights.chart.failed": "Failed",
"insights.chart.succeeded": "Successful",
"insights.chart.loading": "Loading data...",
"communityNodesDocsLink.link.title": "Open community node docs",
"communityNodesDocsLink.title": "Docs",
"communityNodeItem.node.hint": "Install this node to start using it",
"communityNodeItem.actions.hint": "Install this node to start using actions",
"communityNodeItem.label": "Add to workflow",
"communityNodeDetails.installed": "Installed",
"communityNodeInfo.approved": "This community node has been reviewed and approved by n8n",
"communityNodeInfo.approved.label": "Verified",
"communityNodeInfo.unverified": "This community node was added via npm and has not been verified by n8n",
"communityNodeInfo.unverified.label": "Via npm",
"communityNodeInfo.downloads": "{downloads} Downloads",
"communityNodeInfo.publishedBy": "Published by {publisherName}",
"communityNodeInfo.contact.admin": "Please contact an administrator to install this community node:",
"insights.upgradeModal.button.dismiss": "Dismiss",
"insights.upgradeModal.button.upgrade": "Upgrade",
"insights.upgradeModal.content": "Viewing this time period requires an enterprise plan. Upgrade to Enterprise to unlock advanced features.",

View File

@@ -54,8 +54,17 @@ export const useCommunityNodesStore = defineStore(STORES.COMMUNITY_NODES, () =>
}, timeout);
};
const installPackage = async (packageName: string): Promise<void> => {
await communityNodesApi.installNewPackage(rootStore.restApiContext, packageName);
const installPackage = async (
packageName: string,
verify?: boolean,
version?: string,
): Promise<void> => {
await communityNodesApi.installNewPackage(
rootStore.restApiContext,
packageName,
verify,
version,
);
await fetchInstalledPackages();
};

View File

@@ -70,6 +70,10 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
mergedNodes.value = nodes;
}
function removeNodeFromMergedNodes(nodeName: string) {
mergedNodes.value = mergedNodes.value.filter((n) => n.name !== nodeName);
}
function setActions(nodes: ActionsRecord<typeof mergedNodes.value>) {
actions.value = nodes;
}
@@ -390,16 +394,17 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
showScrim,
mergedNodes,
actions,
allNodeCreatorNodes,
setShowScrim,
setSelectedView,
setOpenSource,
setActions,
setMergeNodes,
removeNodeFromMergedNodes,
setNodeCreatorState,
openSelectiveNodeCreator,
openNodeCreatorForConnectingNode,
openNodeCreatorForTriggerNodes,
allNodeCreatorNodes,
onCreatorOpened,
onNodeFilterChanged,
onCategoryExpanded,

View File

@@ -25,18 +25,31 @@ import { useRootStore } from './root.store';
import * as utils from '@/utils/credentialOnlyNodes';
import { groupNodeTypesByNameAndType } from '@/utils/nodeTypes/nodeTypeTransforms';
import { computed, ref } from 'vue';
import { useActionsGenerator } from '../components/Node/NodeCreator/composables/useActionsGeneration';
import { removePreviewToken } from '../components/Node/NodeCreator/utils';
import { useSettingsStore } from '@/stores/settings.store';
export type NodeTypesStore = ReturnType<typeof useNodeTypesStore>;
export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
const nodeTypes = ref<NodeTypesByTypeNameAndVersion>({});
const communityPreviews = ref<INodeTypeDescription[]>([]);
const rootStore = useRootStore();
const actionsGenerator = useActionsGenerator();
const settingsStore = useSettingsStore();
// ---------------------------------------------------------------------------
// #region Computed
// ---------------------------------------------------------------------------
const communityNodesAndActions = computed(() => {
return actionsGenerator.generateMergedNodesAndActions(communityPreviews.value, []);
});
const allNodeTypes = computed(() => {
return Object.values(nodeTypes.value).reduce<INodeTypeDescription[]>(
(allNodeTypes, nodeType) => {
@@ -273,14 +286,22 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
return nodesInformation;
};
const getFullNodesProperties = async (nodesToBeFetched: INodeTypeNameVersion[]) => {
const getFullNodesProperties = async (
nodesToBeFetched: INodeTypeNameVersion[],
replaceNodeTypes = true,
) => {
const credentialsStore = useCredentialsStore();
await credentialsStore.fetchCredentialTypes(true);
await getNodesInformation(nodesToBeFetched);
if (replaceNodeTypes) {
await getNodesInformation(nodesToBeFetched);
}
};
const getNodeTypes = async () => {
const nodeTypes = await nodeTypesApi.getNodeTypes(rootStore.baseUrl);
await fetchCommunityNodePreviews();
if (nodeTypes.length) {
setNodeTypes(nodeTypes);
}
@@ -328,6 +349,34 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
return await nodeTypesApi.getNodeParameterActionResult(rootStore.restApiContext, sendData);
};
const fetchCommunityNodePreviews = async () => {
if (!settingsStore.isCommunityNodesFeatureEnabled) {
return;
}
try {
communityPreviews.value = await nodeTypesApi.fetchCommunityNodeTypes(
rootStore.restApiContext,
);
} catch (error) {
communityPreviews.value = [];
}
};
const getCommunityNodeAttributes = async (nodeName: string) => {
if (!settingsStore.isCommunityNodesFeatureEnabled) {
return null;
}
try {
return await nodeTypesApi.fetchCommunityNodeAttributes(
rootStore.restApiContext,
removePreviewToken(nodeName),
);
} catch (error) {
return null;
}
};
// #endregion
return {
@@ -346,6 +395,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
visibleNodeTypesByOutputConnectionTypeNames,
visibleNodeTypesByInputConnectionTypeNames,
isConfigurableNode,
communityNodesAndActions,
getResourceMapperFields,
getLocalResourceMapperFields,
getNodeParameterActionResult,
@@ -358,5 +408,6 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
getNodeTranslationHeaders,
setNodeTypes,
removeNodeTypes,
getCommunityNodeAttributes,
};
});

View File

@@ -157,6 +157,10 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const isCommunityNodesFeatureEnabled = computed(() => settings.value.communityNodesEnabled);
const isUnverifiedPackagesEnabled = computed(
() => settings.value.unverifiedCommunityNodesEnabled,
);
const allowedModules = computed(() => settings.value.allowedModules);
const isQueueModeEnabled = computed(() => settings.value.executionMode === 'queue');
@@ -256,6 +260,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const fetchedSettings = await settingsApi.getSettings(rootStore.restApiContext);
setSettings(fetchedSettings);
settings.value.communityNodesEnabled = fetchedSettings.communityNodesEnabled;
settings.value.unverifiedCommunityNodesEnabled =
fetchedSettings.unverifiedCommunityNodesEnabled;
setAllowedModules(fetchedSettings.allowedModules);
setSaveDataErrorExecution(fetchedSettings.saveDataErrorExecution);
setSaveDataSuccessExecution(fetchedSettings.saveDataSuccessExecution);
@@ -422,6 +428,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
templatesHost,
pushBackend,
isCommunityNodesFeatureEnabled,
isUnverifiedPackagesEnabled,
allowedModules,
isQueueModeEnabled,
isMultiMain,

View File

@@ -105,7 +105,9 @@ describe('util: Node Icon', () => {
});
it('should create a file source from iconUrl if available', () => {
const result = getNodeIconSource(mock<IconNodeType>({ iconUrl: 'images/node-icon.svg' }));
const result = getNodeIconSource(
mock<IconNodeType>({ iconUrl: 'images/node-icon.svg', name: undefined }),
);
expect(result).toEqual({
type: 'file',
src: 'https://example.com/images/node-icon.svg',
@@ -120,6 +122,7 @@ describe('util: Node Icon', () => {
iconColor: 'blue',
iconData: undefined,
iconUrl: undefined,
name: undefined,
}),
);
expect(result).toEqual({
@@ -130,7 +133,9 @@ describe('util: Node Icon', () => {
});
it('should include badge if available', () => {
const result = getNodeIconSource(mock<IconNodeType>({ badgeIconUrl: 'images/badge.svg' }));
const result = getNodeIconSource(
mock<IconNodeType>({ badgeIconUrl: 'images/badge.svg', name: undefined }),
);
expect(result?.badge).toEqual({
type: 'file',
src: 'https://example.com/images/badge.svg',

View File

@@ -3,6 +3,7 @@ import type { IVersionNode } from '../Interface';
import { useRootStore } from '../stores/root.store';
import { useUIStore } from '../stores/ui.store';
import { getThemedValue } from './nodeTypesUtils';
import { isNodePreviewKey } from '../components/Node/NodeCreator/utils';
type NodeIconSourceIcon = { type: 'icon'; name: string; color?: string };
type NodeIconSourceFile = {
@@ -17,9 +18,9 @@ export type NodeIconType = 'file' | 'icon' | 'unknown';
type IconNodeTypeDescription = Pick<
INodeTypeDescription,
'icon' | 'iconUrl' | 'iconColor' | 'defaults' | 'badgeIconUrl'
'icon' | 'iconUrl' | 'iconColor' | 'defaults' | 'badgeIconUrl' | 'name'
>;
type IconVersionNode = Pick<IVersionNode, 'icon' | 'iconUrl' | 'iconData' | 'defaults'>;
type IconVersionNode = Pick<IVersionNode, 'icon' | 'iconUrl' | 'iconData' | 'defaults' | 'name'>;
export type IconNodeType = IconNodeTypeDescription | IconVersionNode;
export const getNodeIcon = (nodeType: IconNodeType): string | null => {
@@ -72,6 +73,15 @@ export function getNodeIconSource(nodeType?: IconNodeType | null): NodeIconSourc
}
}
if (nodeType.name && isNodePreviewKey(nodeType.name) && typeof nodeType.iconUrl === 'string') {
// If node type is a node preview it would have full icon url
return {
type: 'file',
src: nodeType.iconUrl,
badge: undefined,
};
}
const iconUrl = getNodeIconUrl(nodeType);
if (iconUrl) {
return createFileIconSource(prefixBaseUrl(iconUrl));

View File

@@ -17,6 +17,7 @@ import { usePushConnection } from '@/composables/usePushConnection';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useSettingsStore } from '@/stores/settings.store';
const PACKAGE_COUNT_THRESHOLD = 31;
@@ -33,6 +34,7 @@ const documentTitle = useDocumentTitle();
const communityNodesStore = useCommunityNodesStore();
const uiStore = useUIStore();
const settingsStore = useSettingsStore();
const getEmptyStateDescription = computed(() => {
const packageCount = communityNodesStore.availablePackageCount;
@@ -139,7 +141,11 @@ onBeforeUnmount(() => {
<div :class="$style.headingContainer">
<n8n-heading size="2xlarge">{{ i18n.baseText('settings.communityNodes') }}</n8n-heading>
<n8n-button
v-if="communityNodesStore.getInstalledPackages.length > 0 && !loading"
v-if="
settingsStore.isUnverifiedPackagesEnabled &&
communityNodesStore.getInstalledPackages.length > 0 &&
!loading
"
:label="i18n.baseText('settings.communityNodes.installModal.installButton.label')"
size="large"
@click="openInstallModal"