mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat(editor): Distinguish official verified nodes from community built nodes (#15630)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export interface CommunityNodeAttributes {
|
||||
export type CommunityNodeType = {
|
||||
authorGithubUrl: string;
|
||||
authorName: string;
|
||||
checksum: string;
|
||||
@@ -13,11 +13,8 @@ export interface CommunityNodeAttributes {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
npmVersion: string;
|
||||
}
|
||||
|
||||
export interface CommunityNodeData {
|
||||
id: number;
|
||||
attributes: CommunityNodeAttributes & {
|
||||
isOfficialNode: boolean;
|
||||
companyName?: string;
|
||||
nodeDescription: INodeTypeDescription;
|
||||
};
|
||||
}
|
||||
isInstalled: boolean;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { CommunityNodeAttributes } from '@n8n/api-types';
|
||||
import type { CommunityNodeType } 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';
|
||||
@@ -46,7 +45,7 @@ describe('CommunityPackagesController', () => {
|
||||
body: { name: 'n8n-nodes-test', verify: true, version: '1.0.0' },
|
||||
});
|
||||
communityNodeTypesService.findVetted.mockReturnValue(
|
||||
mock<CommunityNodeAttributes & { nodeDescription: INodeTypeDescription }>({
|
||||
mock<CommunityNodeType>({
|
||||
checksum: 'checksum',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CommunityNodeAttributes } from '@n8n/api-types';
|
||||
import type { CommunityNodeType } from '@n8n/api-types';
|
||||
import { Get, RestController } from '@n8n/decorators';
|
||||
import { Request } from 'express';
|
||||
|
||||
@@ -9,12 +9,12 @@ export class CommunityNodeTypesController {
|
||||
constructor(private readonly communityNodeTypesService: CommunityNodeTypesService) {}
|
||||
|
||||
@Get('/:name')
|
||||
async getCommunityNodeAttributes(req: Request): Promise<CommunityNodeAttributes | null> {
|
||||
return this.communityNodeTypesService.getCommunityNodeAttributes(req.params.name);
|
||||
async getCommunityNodeType(req: Request): Promise<CommunityNodeType | null> {
|
||||
return await this.communityNodeTypesService.getCommunityNodeType(req.params.name);
|
||||
}
|
||||
|
||||
@Get('/')
|
||||
async getCommunityNodeTypes() {
|
||||
return await this.communityNodeTypesService.getDescriptions();
|
||||
return await this.communityNodeTypesService.getCommunityNodeTypes();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
import type { CommunityNodeAttributes, CommunityNodeData } from '@n8n/api-types';
|
||||
import type { CommunityNodeType } 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 { ensureError, type INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { CommunityPackagesService } from './community-packages.service';
|
||||
import { paginatedRequest } from '../utils/community-nodes-request-utils';
|
||||
import { getCommunityNodeTypes } from '../utils/community-node-types-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';
|
||||
export type StrapiCommunityNodeType = {
|
||||
authorGithubUrl: string;
|
||||
authorName: string;
|
||||
checksum: string;
|
||||
description: string;
|
||||
displayName: string;
|
||||
name: string;
|
||||
numberOfStars: number;
|
||||
numberOfDownloads: number;
|
||||
packageName: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
npmVersion: string;
|
||||
isOfficialNode: boolean;
|
||||
companyName?: string;
|
||||
nodeDescription: INodeTypeDescription;
|
||||
};
|
||||
|
||||
@Service()
|
||||
export class CommunityNodeTypesService {
|
||||
private communityNodes: {
|
||||
[key: string]: CommunityNodeAttributes & {
|
||||
nodeDescription: INodeTypeDescription;
|
||||
};
|
||||
} = {};
|
||||
private communityNodeTypes: Map<string, StrapiCommunityNodeType> = new Map();
|
||||
|
||||
private lastUpdateTimestamp = 0;
|
||||
|
||||
@@ -31,39 +41,33 @@ export class CommunityNodeTypesService {
|
||||
|
||||
private async fetchNodeTypes() {
|
||||
try {
|
||||
let data: CommunityNodeData[] = [];
|
||||
let data: StrapiCommunityNodeType[] = [];
|
||||
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);
|
||||
data = await getCommunityNodeTypes(environment);
|
||||
}
|
||||
|
||||
this.updateData(data);
|
||||
this.updateCommunityNodeTypes(data);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to fetch community node types', { error: ensureError(error) });
|
||||
}
|
||||
}
|
||||
|
||||
private updateData(data: CommunityNodeData[]) {
|
||||
if (!data?.length) return;
|
||||
private updateCommunityNodeTypes(nodeTypes: StrapiCommunityNodeType[]) {
|
||||
if (!nodeTypes?.length) return;
|
||||
|
||||
this.resetData();
|
||||
this.resetCommunityNodeTypes();
|
||||
|
||||
for (const entry of data) {
|
||||
this.communityNodes[entry.attributes.name] = entry.attributes;
|
||||
}
|
||||
this.communityNodeTypes = new Map(nodeTypes.map((nodeType) => [nodeType.name, nodeType]));
|
||||
|
||||
this.lastUpdateTimestamp = Date.now();
|
||||
}
|
||||
|
||||
private resetData() {
|
||||
this.communityNodes = {};
|
||||
private resetCommunityNodeTypes() {
|
||||
this.communityNodeTypes = new Map();
|
||||
}
|
||||
|
||||
private updateRequired() {
|
||||
@@ -71,36 +75,37 @@ export class CommunityNodeTypesService {
|
||||
return Date.now() - this.lastUpdateTimestamp > UPDATE_INTERVAL;
|
||||
}
|
||||
|
||||
async getDescriptions(): Promise<INodeTypeDescription[]> {
|
||||
const nodesDescriptions: INodeTypeDescription[] = [];
|
||||
private async createIsInstalled() {
|
||||
const installedPackages = (await this.communityPackagesService.getAllInstalledPackages()) ?? [];
|
||||
const installedPackageNames = new Set(installedPackages.map((p) => p.packageName));
|
||||
|
||||
if (this.updateRequired() || !Object.keys(this.communityNodes).length) {
|
||||
return (nodeTypeName: string) => installedPackageNames.has(nodeTypeName.split('.')[0]);
|
||||
}
|
||||
|
||||
async getCommunityNodeTypes(): Promise<CommunityNodeType[]> {
|
||||
if (this.updateRequired() || !this.communityNodeTypes.size) {
|
||||
await this.fetchNodeTypes();
|
||||
}
|
||||
|
||||
const installedPackages = (
|
||||
(await this.communityPackagesService.getAllInstalledPackages()) ?? []
|
||||
).map((p) => p.packageName);
|
||||
const isInstalled = await this.createIsInstalled();
|
||||
|
||||
for (const node of Object.values(this.communityNodes)) {
|
||||
if (installedPackages.includes(node.name.split('.')[0])) continue;
|
||||
nodesDescriptions.push(node.nodeDescription);
|
||||
return Array.from(this.communityNodeTypes.values()).map((nodeType) => ({
|
||||
...nodeType,
|
||||
isInstalled: isInstalled(nodeType.name),
|
||||
}));
|
||||
}
|
||||
|
||||
return nodesDescriptions;
|
||||
}
|
||||
|
||||
getCommunityNodeAttributes(type: string): CommunityNodeAttributes | null {
|
||||
const node = this.communityNodes[type];
|
||||
if (!node) return null;
|
||||
const { nodeDescription, ...attributes } = node;
|
||||
return attributes;
|
||||
async getCommunityNodeType(type: string): Promise<CommunityNodeType | null> {
|
||||
const nodeType = this.communityNodeTypes.get(type);
|
||||
const isInstalled = await this.createIsInstalled();
|
||||
if (!nodeType) return null;
|
||||
return { ...nodeType, isInstalled: isInstalled(nodeType.name) };
|
||||
}
|
||||
|
||||
findVetted(packageName: string) {
|
||||
const vettedTypes = Object.keys(this.communityNodes);
|
||||
const vettedTypes = Array.from(this.communityNodeTypes.keys());
|
||||
const nodeName = vettedTypes.find((t) => t.includes(packageName));
|
||||
if (!nodeName) return;
|
||||
return this.communityNodes[nodeName];
|
||||
return this.communityNodeTypes.get(nodeName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
128
packages/cli/src/utils/__tests__/strapi-utils.test.ts
Normal file
128
packages/cli/src/utils/__tests__/strapi-utils.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import { paginatedRequest } from '../strapi-utils';
|
||||
|
||||
describe('Strapi utils', () => {
|
||||
describe('paginatedRequest', () => {
|
||||
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 } },
|
||||
},
|
||||
];
|
||||
|
||||
const page2 = [
|
||||
{
|
||||
id: 2,
|
||||
attributes: { name: 'Node2', nodeDescription: { name: 'n2', version: 2 } },
|
||||
},
|
||||
];
|
||||
|
||||
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<(typeof page1)[number]['attributes']>(
|
||||
'https://strapi.test/api/nodes',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('Node1');
|
||||
expect(result[1].name).toBe('Node2');
|
||||
});
|
||||
|
||||
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 = [
|
||||
{
|
||||
id: 1,
|
||||
attributes: {
|
||||
name: 'NodeSingle',
|
||||
nodeDescription: { name: 'n1', version: 1 },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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<(typeof singlePage)[number]['attributes']>(baseUrl);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
35
packages/cli/src/utils/community-node-types-utils.ts
Normal file
35
packages/cli/src/utils/community-node-types-utils.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { paginatedRequest } from './strapi-utils';
|
||||
|
||||
export type StrapiCommunityNodeType = {
|
||||
authorGithubUrl: string;
|
||||
authorName: string;
|
||||
checksum: string;
|
||||
description: string;
|
||||
displayName: string;
|
||||
name: string;
|
||||
numberOfStars: number;
|
||||
numberOfDownloads: number;
|
||||
packageName: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
npmVersion: string;
|
||||
isOfficialNode: boolean;
|
||||
companyName?: string;
|
||||
nodeDescription: INodeTypeDescription;
|
||||
};
|
||||
|
||||
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';
|
||||
|
||||
export async function getCommunityNodeTypes(
|
||||
environment: 'staging' | 'production',
|
||||
): Promise<StrapiCommunityNodeType[]> {
|
||||
const url =
|
||||
environment === 'production'
|
||||
? N8N_VETTED_NODE_TYPES_PRODUCTION_URL
|
||||
: N8N_VETTED_NODE_TYPES_STAGING_URL;
|
||||
|
||||
return await paginatedRequest<StrapiCommunityNodeType>(url);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
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[];
|
||||
interface ResponseData<T> {
|
||||
data: Array<Entity<T>>;
|
||||
meta: Meta;
|
||||
}
|
||||
|
||||
@@ -12,6 +11,11 @@ interface Meta {
|
||||
pagination: Pagination;
|
||||
}
|
||||
|
||||
export interface Entity<T> {
|
||||
id: number;
|
||||
attributes: T;
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
@@ -19,9 +23,9 @@ interface Pagination {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function paginatedRequest(url: string): Promise<CommunityNodeData[]> {
|
||||
let returnData: CommunityNodeData[] = [];
|
||||
let responseData: CommunityNodeData[] | undefined = [];
|
||||
export async function paginatedRequest<T>(url: string): Promise<T[]> {
|
||||
let returnData: T[] = [];
|
||||
let responseData: T[] | undefined = [];
|
||||
|
||||
const params = {
|
||||
pagination: {
|
||||
@@ -33,7 +37,7 @@ export async function paginatedRequest(url: string): Promise<CommunityNodeData[]
|
||||
do {
|
||||
let response;
|
||||
try {
|
||||
response = await axios.get<ResponseData>(url, {
|
||||
response = await axios.get<ResponseData<T>>(url, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
params,
|
||||
});
|
||||
@@ -47,7 +51,7 @@ export async function paginatedRequest(url: string): Promise<CommunityNodeData[]
|
||||
break;
|
||||
}
|
||||
|
||||
responseData = response?.data?.data;
|
||||
responseData = response?.data?.data?.map((item) => item.attributes);
|
||||
|
||||
if (!responseData?.length) break;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ElTag } from 'element-plus';
|
||||
|
||||
import { useI18n } from '../../composables/useI18n';
|
||||
import type { NodeCreatorTag } from '../../types/node-creator-node';
|
||||
import N8nTooltip from '../N8nTooltip';
|
||||
|
||||
export interface Props {
|
||||
active?: boolean;
|
||||
@@ -14,6 +13,7 @@ export interface Props {
|
||||
tag?: NodeCreatorTag;
|
||||
title: string;
|
||||
showActionArrow?: boolean;
|
||||
isOfficial?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
@@ -22,6 +22,8 @@ defineEmits<{
|
||||
tooltipClick: [e: MouseEvent];
|
||||
}>();
|
||||
|
||||
defineSlots<{ icon: {}; extraDetails: {}; dragContent: {} }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
@@ -49,16 +51,8 @@ const { t } = useI18n();
|
||||
:title="t('nodeCreator.nodeItem.triggerIconTitle')"
|
||||
:class="$style.triggerIcon"
|
||||
/>
|
||||
<N8nTooltip
|
||||
v-if="!!$slots.tooltip"
|
||||
placement="top"
|
||||
data-test-id="node-creator-item-tooltip"
|
||||
>
|
||||
<template #content>
|
||||
<slot name="tooltip" />
|
||||
</template>
|
||||
<n8n-icon :class="$style.tooltipIcon" icon="cube" />
|
||||
</N8nTooltip>
|
||||
|
||||
<slot name="extraDetails" />
|
||||
</div>
|
||||
<p
|
||||
v-if="description"
|
||||
@@ -121,7 +115,9 @@ const { t } = useI18n();
|
||||
width: 12px;
|
||||
}
|
||||
.details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3xs);
|
||||
}
|
||||
.nodeIcon {
|
||||
display: flex;
|
||||
@@ -141,12 +137,10 @@ const { t } = useI18n();
|
||||
}
|
||||
|
||||
.aiIcon {
|
||||
margin-left: var(--spacing-3xs);
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.triggerIcon {
|
||||
margin-left: var(--spacing-3xs);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { INodeTranslationHeaders, IRestApiContext } from '@/Interface';
|
||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||
import type {
|
||||
ActionResultRequestDto,
|
||||
CommunityNodeType,
|
||||
OptionsRequestDto,
|
||||
ResourceLocatorRequestDto,
|
||||
ResourceMapperFieldsRequestDto,
|
||||
} from '@n8n/api-types';
|
||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||
import type { INodeTranslationHeaders, IRestApiContext } from '@/Interface';
|
||||
import type { CommunityNodeAttributes } from '@n8n/api-types';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
type INodeListSearchResult,
|
||||
type INodePropertyOptions,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
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++) {
|
||||
@@ -38,14 +38,14 @@ export async function getNodeTypes(baseUrl: string) {
|
||||
|
||||
export async function fetchCommunityNodeTypes(
|
||||
context: IRestApiContext,
|
||||
): Promise<INodeTypeDescription[]> {
|
||||
): Promise<CommunityNodeType[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/community-node-types');
|
||||
}
|
||||
|
||||
export async function fetchCommunityNodeAttributes(
|
||||
context: IRestApiContext,
|
||||
type: string,
|
||||
): Promise<CommunityNodeAttributes | null> {
|
||||
): Promise<CommunityNodeType | null> {
|
||||
return await makeRestApiRequest(
|
||||
context,
|
||||
'GET',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { SimplifiedNodeType } from '@/Interface';
|
||||
import {
|
||||
COMMUNITY_NODES_INSTALLATION_DOCS_URL,
|
||||
@@ -8,17 +7,21 @@ import {
|
||||
DRAG_EVENT_DATA_KEY,
|
||||
HITL_SUBCATEGORY,
|
||||
} from '@/constants';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||
import NodeIcon from '@/components/NodeIcon.vue';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
|
||||
import OfficialIcon from 'virtual:icons/mdi/verified';
|
||||
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useNodeType } from '@/composables/useNodeType';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { N8nTooltip } from '@n8n/design-system';
|
||||
import { useActions } from '../composables/useActions';
|
||||
import { useViewStacks } from '../composables/useViewStacks';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useNodeType } from '@/composables/useNodeType';
|
||||
import { isNodePreviewKey } from '../utils';
|
||||
import { isNodePreviewKey, removePreviewToken } from '../utils';
|
||||
|
||||
export interface Props {
|
||||
nodeType: SimplifiedNodeType;
|
||||
@@ -40,6 +43,7 @@ const { activeViewStack } = useViewStacks();
|
||||
const { isSubNodeType } = useNodeType({
|
||||
nodeType: props.nodeType,
|
||||
});
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
|
||||
const dragging = ref(false);
|
||||
const draggablePosition = ref({ x: -100, y: -100 });
|
||||
@@ -108,6 +112,18 @@ const isTrigger = computed<boolean>(() => {
|
||||
return props.nodeType.group.includes('trigger') && !hasActions.value;
|
||||
});
|
||||
|
||||
const communityNodeType = computed(() => {
|
||||
return nodeTypesStore.communityNodeType(removePreviewToken(props.nodeType.name));
|
||||
});
|
||||
|
||||
const isOfficial = computed(() => {
|
||||
return communityNodeType.value?.isOfficialNode ?? false;
|
||||
});
|
||||
|
||||
const author = computed(() => {
|
||||
return communityNodeType.value?.displayName ?? displayName.value;
|
||||
});
|
||||
|
||||
function onDragStart(event: DragEvent): void {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
@@ -145,6 +161,7 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
|
||||
:title="displayName"
|
||||
:show-action-arrow="showActionArrow"
|
||||
:is-trigger="isTrigger"
|
||||
:is-official="isOfficial"
|
||||
:data-test-id="dataTestId"
|
||||
:tag="nodeType.tag"
|
||||
@dragstart="onDragStart"
|
||||
@@ -155,10 +172,23 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
|
||||
<NodeIcon :class="$style.nodeIcon" :node-type="nodeType" />
|
||||
</template>
|
||||
|
||||
<template v-if="isOfficial" #extraDetails>
|
||||
<N8nTooltip placement="top" :show-after="500">
|
||||
<template #content>
|
||||
{{ i18n.baseText('generic.officialNode.tooltip', { interpolate: { author: author } }) }}
|
||||
</template>
|
||||
<OfficialIcon :class="[$style.icon, $style.official]" />
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="isCommunityNode && !isCommunityNodePreview && !activeViewStack?.communityNodeDetails"
|
||||
#tooltip
|
||||
v-else-if="
|
||||
isCommunityNode && !isCommunityNodePreview && !activeViewStack?.communityNodeDetails
|
||||
"
|
||||
#extraDetails
|
||||
>
|
||||
<N8nTooltip placement="top" :show-after="500">
|
||||
<template #content>
|
||||
<p
|
||||
v-n8n-html="
|
||||
i18n.baseText('generic.communityNode.tooltip', {
|
||||
@@ -172,6 +202,9 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
|
||||
@click="onCommunityNodeTooltipClick"
|
||||
/>
|
||||
</template>
|
||||
<n8n-icon size="small" :class="$style.icon" icon="cube" />
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
<template #dragContent>
|
||||
<div
|
||||
v-show="dragging"
|
||||
@@ -230,4 +263,14 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
color: var(--color-text-base);
|
||||
width: 12px;
|
||||
|
||||
&.official {
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,8 @@ 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';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
|
||||
const fetchCredentialTypes = vi.fn();
|
||||
const getCommunityNodeAttributes = vi.fn(() => ({ npmVersion: '1.0.0' }));
|
||||
@@ -55,6 +56,7 @@ vi.mock('@/stores/nodeTypes.store', () => ({
|
||||
useNodeTypesStore: vi.fn(() => ({
|
||||
getCommunityNodeAttributes,
|
||||
getNodeTypes,
|
||||
communityNodeType: vi.fn(() => ({ isOfficialNode: true })),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -108,7 +110,7 @@ vi.mock('../composables/useViewStacks', () => ({
|
||||
mode: 'community-node',
|
||||
rootView: undefined,
|
||||
subcategory: 'Other Node',
|
||||
title: 'Community node details',
|
||||
title: 'Node details',
|
||||
},
|
||||
pushViewStack,
|
||||
popViewStack,
|
||||
@@ -135,9 +137,9 @@ describe('CommunityNodeDetails', () => {
|
||||
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');
|
||||
expect(installButton.querySelector('span')?.textContent).toEqual('Install node');
|
||||
|
||||
await fireEvent.click(installButton);
|
||||
await userEvent.click(installButton);
|
||||
|
||||
await waitFor(() => expect(removeNodeFromMergedNodes).toHaveBeenCalled());
|
||||
|
||||
@@ -155,6 +157,7 @@ describe('CommunityNodeDetails', () => {
|
||||
nodeIcon: undefined,
|
||||
packageName: 'n8n-nodes-test',
|
||||
title: 'Other Node',
|
||||
official: true,
|
||||
},
|
||||
hasSearch: false,
|
||||
items: [
|
||||
@@ -178,9 +181,10 @@ describe('CommunityNodeDetails', () => {
|
||||
mode: 'community-node',
|
||||
rootView: undefined,
|
||||
subcategory: 'Other Node',
|
||||
title: 'Community node details',
|
||||
title: 'Node details',
|
||||
},
|
||||
{
|
||||
resetStacks: true,
|
||||
transitionDirection: 'none',
|
||||
},
|
||||
);
|
||||
@@ -196,7 +200,7 @@ describe('CommunityNodeDetails', () => {
|
||||
|
||||
const installButton = wrapper.getByTestId('install-community-node-button');
|
||||
|
||||
await fireEvent.click(installButton);
|
||||
await userEvent.click(installButton);
|
||||
|
||||
expect(showError).toHaveBeenCalledWith(expect.any(Error), 'Error installing new package');
|
||||
expect(pushViewStack).not.toHaveBeenCalled();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { i18n } from '@/plugins/i18n';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import OfficialIcon from 'virtual:icons/mdi/verified';
|
||||
|
||||
import { getNodeIconSource } from '@/utils/nodeIcon';
|
||||
|
||||
@@ -44,6 +45,7 @@ const updateViewStack = (key: string) => {
|
||||
);
|
||||
|
||||
pushViewStack(viewStack, {
|
||||
resetStacks: true,
|
||||
transitionDirection: 'none',
|
||||
});
|
||||
} else {
|
||||
@@ -95,33 +97,50 @@ const onInstall = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<div v-if="communityNodeDetails" :class="$style.container">
|
||||
<div :class="$style.header">
|
||||
<div :class="$style.title">
|
||||
<NodeIcon
|
||||
v-if="communityNodeDetails?.nodeIcon"
|
||||
v-if="communityNodeDetails.nodeIcon"
|
||||
:class="$style.nodeIcon"
|
||||
:icon-source="communityNodeDetails.nodeIcon"
|
||||
:circle="false"
|
||||
:show-tooltip="false"
|
||||
/>
|
||||
<span>{{ communityNodeDetails?.title }}</span>
|
||||
<span>{{ communityNodeDetails.title }}</span>
|
||||
<N8nTooltip v-if="communityNodeDetails.official" placement="bottom" :show-after="500">
|
||||
<template #content>
|
||||
{{
|
||||
i18n.baseText('generic.officialNode.tooltip', {
|
||||
interpolate: {
|
||||
author: communityNodeDetails.companyName ?? communityNodeDetails.title,
|
||||
},
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<OfficialIcon :class="$style.officialIcon" />
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="communityNodeDetails?.installed" :class="$style.installed">
|
||||
<FontAwesomeIcon :class="$style.installedIcon" icon="cube" />
|
||||
<div v-if="communityNodeDetails.installed" :class="$style.installed">
|
||||
<FontAwesomeIcon
|
||||
v-if="!communityNodeDetails.official"
|
||||
:class="$style.installedIcon"
|
||||
icon="cube"
|
||||
/>
|
||||
<N8nText color="text-light" size="small" bold>
|
||||
{{ i18n.baseText('communityNodeDetails.installed') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<N8nButton
|
||||
v-else-if="isOwner"
|
||||
v-if="isOwner && !communityNodeDetails.installed"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
label="Install Node"
|
||||
:label="i18n.baseText('communityNodeDetails.install')"
|
||||
size="small"
|
||||
@click="onInstall"
|
||||
data-test-id="install-community-node-button"
|
||||
@click="onInstall"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,6 +157,7 @@ const onInstall = async () => {
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
gap: var(--spacing-2xs);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -159,6 +179,14 @@ const onInstall = async () => {
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
|
||||
.officialIcon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--spacing-4xs);
|
||||
color: var(--color-text-base);
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.installed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -59,7 +59,7 @@ vi.mock('../composables/useViewStacks', () => ({
|
||||
mode: 'community-node',
|
||||
rootView: undefined,
|
||||
subcategory: 'Other Node',
|
||||
title: 'Community node details',
|
||||
title: 'Node details',
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
import ShieldIcon from 'virtual:icons/fa-solid/shield-alt';
|
||||
|
||||
const { activeViewStack } = useViewStacks();
|
||||
|
||||
@@ -19,6 +20,7 @@ interface DownloadData {
|
||||
const publisherName = ref<string | undefined>(undefined);
|
||||
const downloads = ref<string | null>(null);
|
||||
const verified = ref(false);
|
||||
const official = ref(false);
|
||||
const communityNodesStore = useCommunityNodesStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
|
||||
@@ -41,8 +43,9 @@ async function fetchPackageInfo(packageName: string) {
|
||||
);
|
||||
|
||||
if (communityNodeAttributes) {
|
||||
publisherName.value = communityNodeAttributes.authorName;
|
||||
publisherName.value = communityNodeAttributes.companyName ?? communityNodeAttributes.authorName;
|
||||
downloads.value = formatNumber(communityNodeAttributes.numberOfDownloads);
|
||||
official.value = communityNodeAttributes.isOfficialNode;
|
||||
const packageInfo = communityNodesStore.getInstalledPackages.find(
|
||||
(p) => p.packageName === communityNodeAttributes.packageName,
|
||||
);
|
||||
@@ -106,17 +109,21 @@ onMounted(async () => {
|
||||
</N8nText>
|
||||
<div :class="$style.separator"></div>
|
||||
<div :class="$style.info">
|
||||
<N8nTooltip placement="top" v-if="verified">
|
||||
<template #content>{{ i18n.baseText('communityNodeInfo.approved') }}</template>
|
||||
<N8nTooltip v-if="verified" placement="top">
|
||||
<template #content>{{
|
||||
official
|
||||
? i18n.baseText('communityNodeInfo.officialApproved')
|
||||
: i18n.baseText('communityNodeInfo.approved')
|
||||
}}</template>
|
||||
<div>
|
||||
<FontAwesomeIcon :class="$style.tooltipIcon" icon="check-circle" />
|
||||
<ShieldIcon :class="$style.tooltipIcon" />
|
||||
<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>
|
||||
<N8nTooltip v-else placement="top">
|
||||
<template #content>{{ i18n.baseText('communityNodeInfo.unverified') }}</template>
|
||||
<div>
|
||||
<FontAwesomeIcon :class="$style.tooltipIcon" icon="cube" />
|
||||
@@ -146,7 +153,7 @@ onMounted(async () => {
|
||||
<div style="padding-bottom: 8px">
|
||||
{{ i18n.baseText('communityNodeInfo.contact.admin') }}
|
||||
</div>
|
||||
<N8nText bold v-if="ownerEmailList.length">
|
||||
<N8nText v-if="ownerEmailList.length" bold>
|
||||
{{ ownerEmailList.join(', ') }}
|
||||
</N8nText>
|
||||
</N8nText>
|
||||
@@ -188,12 +195,13 @@ onMounted(async () => {
|
||||
.info div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3xs);
|
||||
gap: var(--spacing-4xs);
|
||||
}
|
||||
|
||||
.tooltipIcon {
|
||||
color: var(--color-text-light);
|
||||
font-size: var(--font-size-2xs);
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.contactOwnerHint {
|
||||
|
||||
@@ -120,11 +120,13 @@ registerKeyHook(`CategoryLeft_${props.category}`, {
|
||||
</n8n-tooltip>
|
||||
</span>
|
||||
</CategoryItem>
|
||||
|
||||
<div v-if="expanded && actionCount > 0 && $slots.default" :class="$style.contentSlot">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<CommunityNodeInstallHint
|
||||
v-if="isPreview"
|
||||
v-if="isPreview && expanded"
|
||||
:hint="i18n.baseText('communityNodeItem.actions.hint')"
|
||||
/>
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ export type CommunityNodeDetails = {
|
||||
description: string;
|
||||
packageName: string;
|
||||
installed: boolean;
|
||||
official: boolean;
|
||||
companyName?: string;
|
||||
nodeIcon?: NodeIconSource;
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
mockNodeCreateElement,
|
||||
mockSectionCreateElement,
|
||||
} from './__tests__/utils';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
vi.mock('@/stores/settings.store', () => ({
|
||||
useSettingsStore: vi.fn(() => ({ settings: {}, isAskAiEnabled: true })),
|
||||
@@ -134,6 +136,9 @@ describe('NodeCreator - utils', () => {
|
||||
});
|
||||
});
|
||||
describe('prepareCommunityNodeDetailsViewStack', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia());
|
||||
});
|
||||
const nodeCreateElement: NodeCreateElement = {
|
||||
key: 'n8n-nodes-preview-test.OtherNode',
|
||||
properties: {
|
||||
@@ -162,6 +167,7 @@ describe('NodeCreator - utils', () => {
|
||||
nodeIcon: undefined,
|
||||
packageName: 'n8n-nodes-test',
|
||||
title: 'Other Node',
|
||||
official: false,
|
||||
},
|
||||
hasSearch: false,
|
||||
items: [
|
||||
@@ -185,7 +191,7 @@ describe('NodeCreator - utils', () => {
|
||||
mode: 'community-node',
|
||||
rootView: undefined,
|
||||
subcategory: 'Other Node',
|
||||
title: 'Community node details',
|
||||
title: 'Node details',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -255,6 +261,7 @@ describe('NodeCreator - utils', () => {
|
||||
nodeIcon: undefined,
|
||||
packageName: 'n8n-nodes-test',
|
||||
title: 'Other Node',
|
||||
official: false,
|
||||
},
|
||||
hasSearch: false,
|
||||
items: [
|
||||
@@ -322,7 +329,7 @@ describe('NodeCreator - utils', () => {
|
||||
mode: 'actions',
|
||||
rootView: undefined,
|
||||
subcategory: 'Other Node',
|
||||
title: 'Community node details',
|
||||
title: 'Node details',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ 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';
|
||||
import { useNodeTypesStore } from '../../../stores/nodeTypes.store';
|
||||
|
||||
const COMMUNITY_NODE_TYPE_PREVIEW_TOKEN = '-preview';
|
||||
|
||||
@@ -264,6 +265,8 @@ export function prepareCommunityNodeDetailsViewStack(
|
||||
): ViewStack {
|
||||
const installed = !isNodePreviewKey(item.key);
|
||||
const packageName = removePreviewToken(item.key.split('.')[0]);
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const nodeType = nodeTypesStore.communityNodeType(removePreviewToken(item.key));
|
||||
|
||||
const communityNodeDetails: CommunityNodeDetails = {
|
||||
title: item.properties.displayName,
|
||||
@@ -271,7 +274,9 @@ export function prepareCommunityNodeDetailsViewStack(
|
||||
key: item.key,
|
||||
nodeIcon,
|
||||
installed,
|
||||
official: nodeType?.isOfficialNode ?? false,
|
||||
packageName,
|
||||
companyName: nodeType?.companyName,
|
||||
};
|
||||
|
||||
if (nodeActions.length) {
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"generic.resetAllFilters": "Reset all filters",
|
||||
"generic.communityNode": "Community Node",
|
||||
"generic.communityNode.tooltip": "This is a node from our community. It's part of the {packageName} package. <a href=\"{docURL}\" target=\"_blank\" title=\"Read the n8n docs\">Learn more</a>",
|
||||
"generic.officialNode.tooltip": "This is an official node maintained by {author}",
|
||||
"generic.copy": "Copy",
|
||||
"generic.delete": "Delete",
|
||||
"generic.dontShowAgain": "Don't show again",
|
||||
@@ -1409,7 +1410,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.communityNodeDetails.title": "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",
|
||||
@@ -3170,7 +3171,9 @@
|
||||
"communityNodeItem.actions.hint": "Install this node to start using actions",
|
||||
"communityNodeItem.label": "Add to workflow",
|
||||
"communityNodeDetails.installed": "Installed",
|
||||
"communityNodeDetails.install": "Install node",
|
||||
"communityNodeInfo.approved": "This community node has been reviewed and approved by n8n",
|
||||
"communityNodeInfo.officialApproved": "This 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",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ActionResultRequestDto,
|
||||
CommunityNodeType,
|
||||
OptionsRequestDto,
|
||||
ResourceLocatorRequestDto,
|
||||
ResourceMapperFieldsRequestDto,
|
||||
@@ -35,7 +36,7 @@ export type NodeTypesStore = ReturnType<typeof useNodeTypesStore>;
|
||||
export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
|
||||
const nodeTypes = ref<NodeTypesByTypeNameAndVersion>({});
|
||||
|
||||
const communityPreviews = ref<INodeTypeDescription[]>([]);
|
||||
const vettedCommunityNodeTypes = ref<Map<string, CommunityNodeType>>(new Map());
|
||||
|
||||
const rootStore = useRootStore();
|
||||
|
||||
@@ -47,34 +48,41 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
|
||||
// #region Computed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const communityNodeType = computed(() => {
|
||||
return (nodeTypeName: string) => {
|
||||
return vettedCommunityNodeTypes.value.get(nodeTypeName);
|
||||
};
|
||||
});
|
||||
|
||||
const officialCommunityNodeTypes = computed(() =>
|
||||
Array.from(vettedCommunityNodeTypes.value.values())
|
||||
.filter(({ isOfficialNode, isInstalled }) => isOfficialNode && !isInstalled)
|
||||
.map(({ nodeDescription }) => nodeDescription),
|
||||
);
|
||||
|
||||
const unofficialCommunityNodeTypes = computed(() =>
|
||||
Array.from(vettedCommunityNodeTypes.value.values())
|
||||
.filter(({ isOfficialNode, isInstalled }) => !isOfficialNode && !isInstalled)
|
||||
.map(({ nodeDescription }) => nodeDescription),
|
||||
);
|
||||
|
||||
const communityNodesAndActions = computed(() => {
|
||||
return actionsGenerator.generateMergedNodesAndActions(communityPreviews.value, []);
|
||||
return actionsGenerator.generateMergedNodesAndActions(unofficialCommunityNodeTypes.value, []);
|
||||
});
|
||||
|
||||
const allNodeTypes = computed(() => {
|
||||
return Object.values(nodeTypes.value).reduce<INodeTypeDescription[]>(
|
||||
(allNodeTypes, nodeType) => {
|
||||
const versionNumbers = Object.keys(nodeType).map(Number);
|
||||
const allNodeVersions = versionNumbers.map((version) => nodeType[version]);
|
||||
|
||||
return [...allNodeTypes, ...allNodeVersions];
|
||||
},
|
||||
[],
|
||||
return Object.values(nodeTypes.value).flatMap((nodeType) =>
|
||||
Object.keys(nodeType).map((version) => nodeType[Number(version)]),
|
||||
);
|
||||
});
|
||||
|
||||
const allLatestNodeTypes = computed(() => {
|
||||
return Object.values(nodeTypes.value).reduce<INodeTypeDescription[]>(
|
||||
(allLatestNodeTypes, nodeVersions) => {
|
||||
return Object.values(nodeTypes.value)
|
||||
.map((nodeVersions) => {
|
||||
const versionNumbers = Object.keys(nodeVersions).map(Number);
|
||||
const latestNodeVersion = nodeVersions[Math.max(...versionNumbers)];
|
||||
|
||||
if (!latestNodeVersion) return allLatestNodeTypes;
|
||||
|
||||
return [...allLatestNodeTypes, latestNodeVersion];
|
||||
},
|
||||
[],
|
||||
);
|
||||
return nodeVersions[Math.max(...versionNumbers)];
|
||||
})
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
const getNodeType = computed(() => {
|
||||
@@ -159,7 +167,9 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
|
||||
});
|
||||
|
||||
const visibleNodeTypes = computed(() => {
|
||||
return allLatestNodeTypes.value.filter((nodeType: INodeTypeDescription) => !nodeType.hidden);
|
||||
return allLatestNodeTypes.value
|
||||
.concat(officialCommunityNodeTypes.value)
|
||||
.filter((nodeType) => !nodeType.hidden);
|
||||
});
|
||||
|
||||
const nativelyNumberSuffixedDefaults = computed(() => {
|
||||
@@ -360,11 +370,15 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
communityPreviews.value = await nodeTypesApi.fetchCommunityNodeTypes(
|
||||
const communityNodeTypes = await nodeTypesApi.fetchCommunityNodeTypes(
|
||||
rootStore.restApiContext,
|
||||
);
|
||||
|
||||
vettedCommunityNodeTypes.value = new Map(
|
||||
communityNodeTypes.map((nodeType) => [nodeType.name, nodeType]),
|
||||
);
|
||||
} catch (error) {
|
||||
communityPreviews.value = [];
|
||||
vettedCommunityNodeTypes.value = new Map();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -402,6 +416,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
|
||||
visibleNodeTypesByInputConnectionTypeNames,
|
||||
isConfigurableNode,
|
||||
communityNodesAndActions,
|
||||
communityNodeType,
|
||||
getResourceMapperFields,
|
||||
getLocalResourceMapperFields,
|
||||
getNodeParameterActionResult,
|
||||
|
||||
Reference in New Issue
Block a user