mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
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:
@@ -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');
|
||||
|
||||
23
packages/@n8n/api-types/src/community-node-types.ts
Normal file
23
packages/@n8n/api-types/src/community-node-types.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -111,6 +111,7 @@ export interface FrontendSettings {
|
||||
isMultiMain: boolean;
|
||||
pushBackend: 'sse' | 'websocket';
|
||||
communityNodesEnabled: boolean;
|
||||
unverifiedCommunityNodesEnabled: boolean;
|
||||
aiAssistant: {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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!',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
106
packages/cli/src/services/community-node-types.service.ts
Normal file
106
packages/cli/src/services/community-node-types.service.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
125
packages/cli/src/utils/__tests__/community-nodes-request.test.ts
Normal file
125
packages/cli/src/utils/__tests__/community-nodes-request.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
67
packages/cli/src/utils/__tests__/npm-utils.test.ts
Normal file
67
packages/cli/src/utils/__tests__/npm-utils.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
68
packages/cli/src/utils/community-nodes-request-utils.ts
Normal file
68
packages/cli/src/utils/community-nodes-request-utils.ts
Normal 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;
|
||||
}
|
||||
27
packages/cli/src/utils/npm-utils.ts
Normal file
27
packages/cli/src/utils/npm-utils.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -11,6 +11,7 @@ export const defaultSettings: FrontendSettings = {
|
||||
},
|
||||
allowedModules: {},
|
||||
communityNodesEnabled: false,
|
||||
unverifiedCommunityNodesEnabled: true,
|
||||
defaultLocale: '',
|
||||
endpointForm: '',
|
||||
endpointFormTest: '',
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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": "Don’t 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.",
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user