feat(editor): Distinguish official verified nodes from community built nodes (#15630)

This commit is contained in:
Elias Meire
2025-05-23 17:04:22 +02:00
committed by GitHub
parent dc0802bbd1
commit 7f0c6d62e6
21 changed files with 437 additions and 283 deletions

View File

@@ -1,6 +1,6 @@
import type { INodeTypeDescription } from 'n8n-workflow'; import type { INodeTypeDescription } from 'n8n-workflow';
export interface CommunityNodeAttributes { export type CommunityNodeType = {
authorGithubUrl: string; authorGithubUrl: string;
authorName: string; authorName: string;
checksum: string; checksum: string;
@@ -13,11 +13,8 @@ export interface CommunityNodeAttributes {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
npmVersion: string; npmVersion: string;
} isOfficialNode: boolean;
companyName?: string;
export interface CommunityNodeData { nodeDescription: INodeTypeDescription;
id: number; isInstalled: boolean;
attributes: CommunityNodeAttributes & { };
nodeDescription: INodeTypeDescription;
};
}

View File

@@ -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 type { InstalledPackages } from '@n8n/db';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { INodeTypeDescription } from 'n8n-workflow';
import { CommunityPackagesController } from '@/controllers/community-packages.controller'; import { CommunityPackagesController } from '@/controllers/community-packages.controller';
import type { NodeRequest } from '@/requests'; import type { NodeRequest } from '@/requests';
@@ -46,7 +45,7 @@ describe('CommunityPackagesController', () => {
body: { name: 'n8n-nodes-test', verify: true, version: '1.0.0' }, body: { name: 'n8n-nodes-test', verify: true, version: '1.0.0' },
}); });
communityNodeTypesService.findVetted.mockReturnValue( communityNodeTypesService.findVetted.mockReturnValue(
mock<CommunityNodeAttributes & { nodeDescription: INodeTypeDescription }>({ mock<CommunityNodeType>({
checksum: 'checksum', checksum: 'checksum',
}), }),
); );

View File

@@ -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 { Get, RestController } from '@n8n/decorators';
import { Request } from 'express'; import { Request } from 'express';
@@ -9,12 +9,12 @@ export class CommunityNodeTypesController {
constructor(private readonly communityNodeTypesService: CommunityNodeTypesService) {} constructor(private readonly communityNodeTypesService: CommunityNodeTypesService) {}
@Get('/:name') @Get('/:name')
async getCommunityNodeAttributes(req: Request): Promise<CommunityNodeAttributes | null> { async getCommunityNodeType(req: Request): Promise<CommunityNodeType | null> {
return this.communityNodeTypesService.getCommunityNodeAttributes(req.params.name); return await this.communityNodeTypesService.getCommunityNodeType(req.params.name);
} }
@Get('/') @Get('/')
async getCommunityNodeTypes() { async getCommunityNodeTypes() {
return await this.communityNodeTypesService.getDescriptions(); return await this.communityNodeTypesService.getCommunityNodeTypes();
} }
} }

View File

@@ -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 { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { Logger } from 'n8n-core'; import { Logger } from 'n8n-core';
import type { INodeTypeDescription } from 'n8n-workflow'; import { ensureError, type INodeTypeDescription } from 'n8n-workflow';
import { ensureError } from 'n8n-workflow';
import { CommunityPackagesService } from './community-packages.service'; 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 UPDATE_INTERVAL = 8 * 60 * 60 * 1000;
const N8N_VETTED_NODE_TYPES_STAGING_URL = 'https://api-staging.n8n.io/api/community-nodes'; export type StrapiCommunityNodeType = {
const N8N_VETTED_NODE_TYPES_PRODUCTION_URL = 'https://api.n8n.io/api/community-nodes'; 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() @Service()
export class CommunityNodeTypesService { export class CommunityNodeTypesService {
private communityNodes: { private communityNodeTypes: Map<string, StrapiCommunityNodeType> = new Map();
[key: string]: CommunityNodeAttributes & {
nodeDescription: INodeTypeDescription;
};
} = {};
private lastUpdateTimestamp = 0; private lastUpdateTimestamp = 0;
@@ -31,39 +41,33 @@ export class CommunityNodeTypesService {
private async fetchNodeTypes() { private async fetchNodeTypes() {
try { try {
let data: CommunityNodeData[] = []; let data: StrapiCommunityNodeType[] = [];
if ( if (
this.globalConfig.nodes.communityPackages.enabled && this.globalConfig.nodes.communityPackages.enabled &&
this.globalConfig.nodes.communityPackages.verifiedEnabled this.globalConfig.nodes.communityPackages.verifiedEnabled
) { ) {
const environment = this.globalConfig.license.tenantId === 1 ? 'production' : 'staging'; const environment = this.globalConfig.license.tenantId === 1 ? 'production' : 'staging';
const url = data = await getCommunityNodeTypes(environment);
environment === 'production'
? N8N_VETTED_NODE_TYPES_PRODUCTION_URL
: N8N_VETTED_NODE_TYPES_STAGING_URL;
data = await paginatedRequest(url);
} }
this.updateData(data); this.updateCommunityNodeTypes(data);
} catch (error) { } catch (error) {
this.logger.error('Failed to fetch community node types', { error: ensureError(error) }); this.logger.error('Failed to fetch community node types', { error: ensureError(error) });
} }
} }
private updateData(data: CommunityNodeData[]) { private updateCommunityNodeTypes(nodeTypes: StrapiCommunityNodeType[]) {
if (!data?.length) return; if (!nodeTypes?.length) return;
this.resetData(); this.resetCommunityNodeTypes();
for (const entry of data) { this.communityNodeTypes = new Map(nodeTypes.map((nodeType) => [nodeType.name, nodeType]));
this.communityNodes[entry.attributes.name] = entry.attributes;
}
this.lastUpdateTimestamp = Date.now(); this.lastUpdateTimestamp = Date.now();
} }
private resetData() { private resetCommunityNodeTypes() {
this.communityNodes = {}; this.communityNodeTypes = new Map();
} }
private updateRequired() { private updateRequired() {
@@ -71,36 +75,37 @@ export class CommunityNodeTypesService {
return Date.now() - this.lastUpdateTimestamp > UPDATE_INTERVAL; return Date.now() - this.lastUpdateTimestamp > UPDATE_INTERVAL;
} }
async getDescriptions(): Promise<INodeTypeDescription[]> { private async createIsInstalled() {
const nodesDescriptions: INodeTypeDescription[] = []; 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(); await this.fetchNodeTypes();
} }
const installedPackages = ( const isInstalled = await this.createIsInstalled();
(await this.communityPackagesService.getAllInstalledPackages()) ?? []
).map((p) => p.packageName);
for (const node of Object.values(this.communityNodes)) { return Array.from(this.communityNodeTypes.values()).map((nodeType) => ({
if (installedPackages.includes(node.name.split('.')[0])) continue; ...nodeType,
nodesDescriptions.push(node.nodeDescription); isInstalled: isInstalled(nodeType.name),
} }));
return nodesDescriptions;
} }
getCommunityNodeAttributes(type: string): CommunityNodeAttributes | null { async getCommunityNodeType(type: string): Promise<CommunityNodeType | null> {
const node = this.communityNodes[type]; const nodeType = this.communityNodeTypes.get(type);
if (!node) return null; const isInstalled = await this.createIsInstalled();
const { nodeDescription, ...attributes } = node; if (!nodeType) return null;
return attributes; return { ...nodeType, isInstalled: isInstalled(nodeType.name) };
} }
findVetted(packageName: string) { findVetted(packageName: string) {
const vettedTypes = Object.keys(this.communityNodes); const vettedTypes = Array.from(this.communityNodeTypes.keys());
const nodeName = vettedTypes.find((t) => t.includes(packageName)); const nodeName = vettedTypes.find((t) => t.includes(packageName));
if (!nodeName) return; if (!nodeName) return;
return this.communityNodes[nodeName]; return this.communityNodeTypes.get(nodeName);
} }
} }

View File

@@ -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([]);
});
});

View 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([]);
});
});
});

View 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);
}

View File

@@ -1,10 +1,9 @@
import type { CommunityNodeData } from '@n8n/api-types';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import axios from 'axios'; import axios from 'axios';
import { ErrorReporter, Logger } from 'n8n-core'; import { ErrorReporter, Logger } from 'n8n-core';
interface ResponseData { interface ResponseData<T> {
data: CommunityNodeData[]; data: Array<Entity<T>>;
meta: Meta; meta: Meta;
} }
@@ -12,6 +11,11 @@ interface Meta {
pagination: Pagination; pagination: Pagination;
} }
export interface Entity<T> {
id: number;
attributes: T;
}
interface Pagination { interface Pagination {
page: number; page: number;
pageSize: number; pageSize: number;
@@ -19,9 +23,9 @@ interface Pagination {
total: number; total: number;
} }
export async function paginatedRequest(url: string): Promise<CommunityNodeData[]> { export async function paginatedRequest<T>(url: string): Promise<T[]> {
let returnData: CommunityNodeData[] = []; let returnData: T[] = [];
let responseData: CommunityNodeData[] | undefined = []; let responseData: T[] | undefined = [];
const params = { const params = {
pagination: { pagination: {
@@ -33,7 +37,7 @@ export async function paginatedRequest(url: string): Promise<CommunityNodeData[]
do { do {
let response; let response;
try { try {
response = await axios.get<ResponseData>(url, { response = await axios.get<ResponseData<T>>(url, {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
params, params,
}); });
@@ -47,7 +51,7 @@ export async function paginatedRequest(url: string): Promise<CommunityNodeData[]
break; break;
} }
responseData = response?.data?.data; responseData = response?.data?.data?.map((item) => item.attributes);
if (!responseData?.length) break; if (!responseData?.length) break;

View File

@@ -4,7 +4,6 @@ import { ElTag } from 'element-plus';
import { useI18n } from '../../composables/useI18n'; import { useI18n } from '../../composables/useI18n';
import type { NodeCreatorTag } from '../../types/node-creator-node'; import type { NodeCreatorTag } from '../../types/node-creator-node';
import N8nTooltip from '../N8nTooltip';
export interface Props { export interface Props {
active?: boolean; active?: boolean;
@@ -14,6 +13,7 @@ export interface Props {
tag?: NodeCreatorTag; tag?: NodeCreatorTag;
title: string; title: string;
showActionArrow?: boolean; showActionArrow?: boolean;
isOfficial?: boolean;
} }
defineProps<Props>(); defineProps<Props>();
@@ -22,6 +22,8 @@ defineEmits<{
tooltipClick: [e: MouseEvent]; tooltipClick: [e: MouseEvent];
}>(); }>();
defineSlots<{ icon: {}; extraDetails: {}; dragContent: {} }>();
const { t } = useI18n(); const { t } = useI18n();
</script> </script>
@@ -49,16 +51,8 @@ const { t } = useI18n();
:title="t('nodeCreator.nodeItem.triggerIconTitle')" :title="t('nodeCreator.nodeItem.triggerIconTitle')"
:class="$style.triggerIcon" :class="$style.triggerIcon"
/> />
<N8nTooltip
v-if="!!$slots.tooltip" <slot name="extraDetails" />
placement="top"
data-test-id="node-creator-item-tooltip"
>
<template #content>
<slot name="tooltip" />
</template>
<n8n-icon :class="$style.tooltipIcon" icon="cube" />
</N8nTooltip>
</div> </div>
<p <p
v-if="description" v-if="description"
@@ -121,7 +115,9 @@ const { t } = useI18n();
width: 12px; width: 12px;
} }
.details { .details {
display: flex;
align-items: center; align-items: center;
gap: var(--spacing-3xs);
} }
.nodeIcon { .nodeIcon {
display: flex; display: flex;
@@ -141,12 +137,10 @@ const { t } = useI18n();
} }
.aiIcon { .aiIcon {
margin-left: var(--spacing-3xs);
color: var(--color-secondary); color: var(--color-secondary);
} }
.triggerIcon { .triggerIcon {
margin-left: var(--spacing-3xs);
color: var(--color-primary); color: var(--color-primary);
} }
</style> </style>

View File

@@ -1,12 +1,13 @@
import type { INodeTranslationHeaders, IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { import type {
ActionResultRequestDto, ActionResultRequestDto,
CommunityNodeType,
OptionsRequestDto, OptionsRequestDto,
ResourceLocatorRequestDto, ResourceLocatorRequestDto,
ResourceMapperFieldsRequestDto, ResourceMapperFieldsRequestDto,
} from '@n8n/api-types'; } from '@n8n/api-types';
import { makeRestApiRequest } from '@/utils/apiUtils'; import axios from 'axios';
import type { INodeTranslationHeaders, IRestApiContext } from '@/Interface';
import type { CommunityNodeAttributes } from '@n8n/api-types';
import { import {
type INodeListSearchResult, type INodeListSearchResult,
type INodePropertyOptions, type INodePropertyOptions,
@@ -16,7 +17,6 @@ import {
type ResourceMapperFields, type ResourceMapperFields,
sleep, sleep,
} from 'n8n-workflow'; } from 'n8n-workflow';
import axios from 'axios';
async function fetchNodeTypesJsonWithRetry(url: string, retries = 5, delay = 500) { async function fetchNodeTypesJsonWithRetry(url: string, retries = 5, delay = 500) {
for (let attempt = 0; attempt < retries; attempt++) { for (let attempt = 0; attempt < retries; attempt++) {
@@ -38,14 +38,14 @@ export async function getNodeTypes(baseUrl: string) {
export async function fetchCommunityNodeTypes( export async function fetchCommunityNodeTypes(
context: IRestApiContext, context: IRestApiContext,
): Promise<INodeTypeDescription[]> { ): Promise<CommunityNodeType[]> {
return await makeRestApiRequest(context, 'GET', '/community-node-types'); return await makeRestApiRequest(context, 'GET', '/community-node-types');
} }
export async function fetchCommunityNodeAttributes( export async function fetchCommunityNodeAttributes(
context: IRestApiContext, context: IRestApiContext,
type: string, type: string,
): Promise<CommunityNodeAttributes | null> { ): Promise<CommunityNodeType | null> {
return await makeRestApiRequest( return await makeRestApiRequest(
context, context,
'GET', 'GET',

View File

@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import type { SimplifiedNodeType } from '@/Interface'; import type { SimplifiedNodeType } from '@/Interface';
import { import {
COMMUNITY_NODES_INSTALLATION_DOCS_URL, COMMUNITY_NODES_INSTALLATION_DOCS_URL,
@@ -8,17 +7,21 @@ import {
DRAG_EVENT_DATA_KEY, DRAG_EVENT_DATA_KEY,
HITL_SUBCATEGORY, HITL_SUBCATEGORY,
} from '@/constants'; } 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 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 { useActions } from '../composables/useActions';
import { useViewStacks } from '../composables/useViewStacks'; import { useViewStacks } from '../composables/useViewStacks';
import { useI18n } from '@/composables/useI18n'; import { isNodePreviewKey, removePreviewToken } from '../utils';
import { useTelemetry } from '@/composables/useTelemetry';
import { useNodeType } from '@/composables/useNodeType';
import { isNodePreviewKey } from '../utils';
export interface Props { export interface Props {
nodeType: SimplifiedNodeType; nodeType: SimplifiedNodeType;
@@ -40,6 +43,7 @@ const { activeViewStack } = useViewStacks();
const { isSubNodeType } = useNodeType({ const { isSubNodeType } = useNodeType({
nodeType: props.nodeType, nodeType: props.nodeType,
}); });
const nodeTypesStore = useNodeTypesStore();
const dragging = ref(false); const dragging = ref(false);
const draggablePosition = ref({ x: -100, y: -100 }); const draggablePosition = ref({ x: -100, y: -100 });
@@ -108,6 +112,18 @@ const isTrigger = computed<boolean>(() => {
return props.nodeType.group.includes('trigger') && !hasActions.value; 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 { function onDragStart(event: DragEvent): void {
if (event.dataTransfer) { if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copy'; event.dataTransfer.effectAllowed = 'copy';
@@ -145,6 +161,7 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
:title="displayName" :title="displayName"
:show-action-arrow="showActionArrow" :show-action-arrow="showActionArrow"
:is-trigger="isTrigger" :is-trigger="isTrigger"
:is-official="isOfficial"
:data-test-id="dataTestId" :data-test-id="dataTestId"
:tag="nodeType.tag" :tag="nodeType.tag"
@dragstart="onDragStart" @dragstart="onDragStart"
@@ -155,22 +172,38 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
<NodeIcon :class="$style.nodeIcon" :node-type="nodeType" /> <NodeIcon :class="$style.nodeIcon" :node-type="nodeType" />
</template> </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 <template
v-if="isCommunityNode && !isCommunityNodePreview && !activeViewStack?.communityNodeDetails" v-else-if="
#tooltip isCommunityNode && !isCommunityNodePreview && !activeViewStack?.communityNodeDetails
"
#extraDetails
> >
<p <N8nTooltip placement="top" :show-after="500">
v-n8n-html=" <template #content>
i18n.baseText('generic.communityNode.tooltip', { <p
interpolate: { v-n8n-html="
packageName: nodeType.name.split('.')[0], i18n.baseText('generic.communityNode.tooltip', {
docURL: COMMUNITY_NODES_INSTALLATION_DOCS_URL, interpolate: {
}, packageName: nodeType.name.split('.')[0],
}) docURL: COMMUNITY_NODES_INSTALLATION_DOCS_URL,
" },
:class="$style.communityNodeIcon" })
@click="onCommunityNodeTooltipClick" "
/> :class="$style.communityNodeIcon"
@click="onCommunityNodeTooltipClick"
/>
</template>
<n8n-icon size="small" :class="$style.icon" icon="cube" />
</N8nTooltip>
</template> </template>
<template #dragContent> <template #dragContent>
<div <div
@@ -230,4 +263,14 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
width: 1px; width: 1px;
height: 1px; height: 1px;
} }
.icon {
display: inline-flex;
color: var(--color-text-base);
width: 12px;
&.official {
width: 14px;
}
}
</style> </style>

View File

@@ -2,7 +2,8 @@ import { createComponentRenderer } from '@/__tests__/render';
import { type TestingPinia, createTestingPinia } from '@pinia/testing'; import { type TestingPinia, createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import CommunityNodeDetails from './CommunityNodeDetails.vue'; 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 fetchCredentialTypes = vi.fn();
const getCommunityNodeAttributes = vi.fn(() => ({ npmVersion: '1.0.0' })); const getCommunityNodeAttributes = vi.fn(() => ({ npmVersion: '1.0.0' }));
@@ -55,6 +56,7 @@ vi.mock('@/stores/nodeTypes.store', () => ({
useNodeTypesStore: vi.fn(() => ({ useNodeTypesStore: vi.fn(() => ({
getCommunityNodeAttributes, getCommunityNodeAttributes,
getNodeTypes, getNodeTypes,
communityNodeType: vi.fn(() => ({ isOfficialNode: true })),
})), })),
})); }));
@@ -108,7 +110,7 @@ vi.mock('../composables/useViewStacks', () => ({
mode: 'community-node', mode: 'community-node',
rootView: undefined, rootView: undefined,
subcategory: 'Other Node', subcategory: 'Other Node',
title: 'Community node details', title: 'Node details',
}, },
pushViewStack, pushViewStack,
popViewStack, popViewStack,
@@ -135,9 +137,9 @@ describe('CommunityNodeDetails', () => {
const installButton = wrapper.getByTestId('install-community-node-button'); const installButton = wrapper.getByTestId('install-community-node-button');
expect(wrapper.container.querySelector('.title span')?.textContent).toEqual('Other Node'); 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()); await waitFor(() => expect(removeNodeFromMergedNodes).toHaveBeenCalled());
@@ -155,6 +157,7 @@ describe('CommunityNodeDetails', () => {
nodeIcon: undefined, nodeIcon: undefined,
packageName: 'n8n-nodes-test', packageName: 'n8n-nodes-test',
title: 'Other Node', title: 'Other Node',
official: true,
}, },
hasSearch: false, hasSearch: false,
items: [ items: [
@@ -178,9 +181,10 @@ describe('CommunityNodeDetails', () => {
mode: 'community-node', mode: 'community-node',
rootView: undefined, rootView: undefined,
subcategory: 'Other Node', subcategory: 'Other Node',
title: 'Community node details', title: 'Node details',
}, },
{ {
resetStacks: true,
transitionDirection: 'none', transitionDirection: 'none',
}, },
); );
@@ -196,7 +200,7 @@ describe('CommunityNodeDetails', () => {
const installButton = wrapper.getByTestId('install-community-node-button'); 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(showError).toHaveBeenCalledWith(expect.any(Error), 'Error installing new package');
expect(pushViewStack).not.toHaveBeenCalled(); expect(pushViewStack).not.toHaveBeenCalled();

View File

@@ -8,6 +8,7 @@ import { i18n } from '@/plugins/i18n';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store'; import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useCredentialsStore } from '@/stores/credentials.store'; import { useCredentialsStore } from '@/stores/credentials.store';
import OfficialIcon from 'virtual:icons/mdi/verified';
import { getNodeIconSource } from '@/utils/nodeIcon'; import { getNodeIconSource } from '@/utils/nodeIcon';
@@ -44,6 +45,7 @@ const updateViewStack = (key: string) => {
); );
pushViewStack(viewStack, { pushViewStack(viewStack, {
resetStacks: true,
transitionDirection: 'none', transitionDirection: 'none',
}); });
} else { } else {
@@ -95,33 +97,50 @@ const onInstall = async () => {
</script> </script>
<template> <template>
<div :class="$style.container"> <div v-if="communityNodeDetails" :class="$style.container">
<div :class="$style.header"> <div :class="$style.header">
<div :class="$style.title"> <div :class="$style.title">
<NodeIcon <NodeIcon
v-if="communityNodeDetails?.nodeIcon" v-if="communityNodeDetails.nodeIcon"
:class="$style.nodeIcon" :class="$style.nodeIcon"
:icon-source="communityNodeDetails.nodeIcon" :icon-source="communityNodeDetails.nodeIcon"
:circle="false" :circle="false"
:show-tooltip="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> <div>
<div v-if="communityNodeDetails?.installed" :class="$style.installed"> <div v-if="communityNodeDetails.installed" :class="$style.installed">
<FontAwesomeIcon :class="$style.installedIcon" icon="cube" /> <FontAwesomeIcon
v-if="!communityNodeDetails.official"
:class="$style.installedIcon"
icon="cube"
/>
<N8nText color="text-light" size="small" bold> <N8nText color="text-light" size="small" bold>
{{ i18n.baseText('communityNodeDetails.installed') }} {{ i18n.baseText('communityNodeDetails.installed') }}
</N8nText> </N8nText>
</div> </div>
<N8nButton <N8nButton
v-else-if="isOwner" v-if="isOwner && !communityNodeDetails.installed"
:loading="loading" :loading="loading"
:disabled="loading" :disabled="loading"
label="Install Node" :label="i18n.baseText('communityNodeDetails.install')"
size="small" size="small"
@click="onInstall"
data-test-id="install-community-node-button" data-test-id="install-community-node-button"
@click="onInstall"
/> />
</div> </div>
</div> </div>
@@ -138,6 +157,7 @@ const onInstall = async () => {
} }
.header { .header {
display: flex; display: flex;
gap: var(--spacing-2xs);
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
@@ -159,6 +179,14 @@ const onInstall = async () => {
font-size: var(--font-size-2xs); 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 { .installed {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -59,7 +59,7 @@ vi.mock('../composables/useViewStacks', () => ({
mode: 'community-node', mode: 'community-node',
rootView: undefined, rootView: undefined,
subcategory: 'Other Node', subcategory: 'Other Node',
title: 'Community node details', title: 'Node details',
}, },
})), })),
})); }));

View File

@@ -7,6 +7,7 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCommunityNodesStore } from '@/stores/communityNodes.store'; import { useCommunityNodesStore } from '@/stores/communityNodes.store';
import { captureException } from '@sentry/vue'; import { captureException } from '@sentry/vue';
import { N8nText, N8nTooltip, N8nIcon } from '@n8n/design-system'; import { N8nText, N8nTooltip, N8nIcon } from '@n8n/design-system';
import ShieldIcon from 'virtual:icons/fa-solid/shield-alt';
const { activeViewStack } = useViewStacks(); const { activeViewStack } = useViewStacks();
@@ -19,6 +20,7 @@ interface DownloadData {
const publisherName = ref<string | undefined>(undefined); const publisherName = ref<string | undefined>(undefined);
const downloads = ref<string | null>(null); const downloads = ref<string | null>(null);
const verified = ref(false); const verified = ref(false);
const official = ref(false);
const communityNodesStore = useCommunityNodesStore(); const communityNodesStore = useCommunityNodesStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
@@ -41,8 +43,9 @@ async function fetchPackageInfo(packageName: string) {
); );
if (communityNodeAttributes) { if (communityNodeAttributes) {
publisherName.value = communityNodeAttributes.authorName; publisherName.value = communityNodeAttributes.companyName ?? communityNodeAttributes.authorName;
downloads.value = formatNumber(communityNodeAttributes.numberOfDownloads); downloads.value = formatNumber(communityNodeAttributes.numberOfDownloads);
official.value = communityNodeAttributes.isOfficialNode;
const packageInfo = communityNodesStore.getInstalledPackages.find( const packageInfo = communityNodesStore.getInstalledPackages.find(
(p) => p.packageName === communityNodeAttributes.packageName, (p) => p.packageName === communityNodeAttributes.packageName,
); );
@@ -106,17 +109,21 @@ onMounted(async () => {
</N8nText> </N8nText>
<div :class="$style.separator"></div> <div :class="$style.separator"></div>
<div :class="$style.info"> <div :class="$style.info">
<N8nTooltip placement="top" v-if="verified"> <N8nTooltip v-if="verified" placement="top">
<template #content>{{ i18n.baseText('communityNodeInfo.approved') }}</template> <template #content>{{
official
? i18n.baseText('communityNodeInfo.officialApproved')
: i18n.baseText('communityNodeInfo.approved')
}}</template>
<div> <div>
<FontAwesomeIcon :class="$style.tooltipIcon" icon="check-circle" /> <ShieldIcon :class="$style.tooltipIcon" />
<N8nText color="text-light" size="xsmall" bold data-test-id="verified-tag"> <N8nText color="text-light" size="xsmall" bold data-test-id="verified-tag">
{{ i18n.baseText('communityNodeInfo.approved.label') }} {{ i18n.baseText('communityNodeInfo.approved.label') }}
</N8nText> </N8nText>
</div> </div>
</N8nTooltip> </N8nTooltip>
<N8nTooltip placement="top" v-else> <N8nTooltip v-else placement="top">
<template #content>{{ i18n.baseText('communityNodeInfo.unverified') }}</template> <template #content>{{ i18n.baseText('communityNodeInfo.unverified') }}</template>
<div> <div>
<FontAwesomeIcon :class="$style.tooltipIcon" icon="cube" /> <FontAwesomeIcon :class="$style.tooltipIcon" icon="cube" />
@@ -146,7 +153,7 @@ onMounted(async () => {
<div style="padding-bottom: 8px"> <div style="padding-bottom: 8px">
{{ i18n.baseText('communityNodeInfo.contact.admin') }} {{ i18n.baseText('communityNodeInfo.contact.admin') }}
</div> </div>
<N8nText bold v-if="ownerEmailList.length"> <N8nText v-if="ownerEmailList.length" bold>
{{ ownerEmailList.join(', ') }} {{ ownerEmailList.join(', ') }}
</N8nText> </N8nText>
</N8nText> </N8nText>
@@ -188,12 +195,13 @@ onMounted(async () => {
.info div { .info div {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-3xs); gap: var(--spacing-4xs);
} }
.tooltipIcon { .tooltipIcon {
color: var(--color-text-light); color: var(--color-text-light);
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
width: 12px;
} }
.contactOwnerHint { .contactOwnerHint {

View File

@@ -120,11 +120,13 @@ registerKeyHook(`CategoryLeft_${props.category}`, {
</n8n-tooltip> </n8n-tooltip>
</span> </span>
</CategoryItem> </CategoryItem>
<div v-if="expanded && actionCount > 0 && $slots.default" :class="$style.contentSlot"> <div v-if="expanded && actionCount > 0 && $slots.default" :class="$style.contentSlot">
<slot /> <slot />
</div> </div>
<CommunityNodeInstallHint <CommunityNodeInstallHint
v-if="isPreview" v-if="isPreview && expanded"
:hint="i18n.baseText('communityNodeItem.actions.hint')" :hint="i18n.baseText('communityNodeItem.actions.hint')"
/> />

View File

@@ -51,6 +51,8 @@ export type CommunityNodeDetails = {
description: string; description: string;
packageName: string; packageName: string;
installed: boolean; installed: boolean;
official: boolean;
companyName?: string;
nodeIcon?: NodeIconSource; nodeIcon?: NodeIconSource;
}; };

View File

@@ -17,6 +17,8 @@ import {
mockNodeCreateElement, mockNodeCreateElement,
mockSectionCreateElement, mockSectionCreateElement,
} from './__tests__/utils'; } from './__tests__/utils';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
vi.mock('@/stores/settings.store', () => ({ vi.mock('@/stores/settings.store', () => ({
useSettingsStore: vi.fn(() => ({ settings: {}, isAskAiEnabled: true })), useSettingsStore: vi.fn(() => ({ settings: {}, isAskAiEnabled: true })),
@@ -134,6 +136,9 @@ describe('NodeCreator - utils', () => {
}); });
}); });
describe('prepareCommunityNodeDetailsViewStack', () => { describe('prepareCommunityNodeDetailsViewStack', () => {
beforeEach(() => {
setActivePinia(createTestingPinia());
});
const nodeCreateElement: NodeCreateElement = { const nodeCreateElement: NodeCreateElement = {
key: 'n8n-nodes-preview-test.OtherNode', key: 'n8n-nodes-preview-test.OtherNode',
properties: { properties: {
@@ -162,6 +167,7 @@ describe('NodeCreator - utils', () => {
nodeIcon: undefined, nodeIcon: undefined,
packageName: 'n8n-nodes-test', packageName: 'n8n-nodes-test',
title: 'Other Node', title: 'Other Node',
official: false,
}, },
hasSearch: false, hasSearch: false,
items: [ items: [
@@ -185,7 +191,7 @@ describe('NodeCreator - utils', () => {
mode: 'community-node', mode: 'community-node',
rootView: undefined, rootView: undefined,
subcategory: 'Other Node', subcategory: 'Other Node',
title: 'Community node details', title: 'Node details',
}); });
}); });
@@ -255,6 +261,7 @@ describe('NodeCreator - utils', () => {
nodeIcon: undefined, nodeIcon: undefined,
packageName: 'n8n-nodes-test', packageName: 'n8n-nodes-test',
title: 'Other Node', title: 'Other Node',
official: false,
}, },
hasSearch: false, hasSearch: false,
items: [ items: [
@@ -322,7 +329,7 @@ describe('NodeCreator - utils', () => {
mode: 'actions', mode: 'actions',
rootView: undefined, rootView: undefined,
subcategory: 'Other Node', subcategory: 'Other Node',
title: 'Community node details', title: 'Node details',
}); });
}); });
}); });

View File

@@ -29,6 +29,7 @@ import { useSettingsStore } from '@/stores/settings.store';
import { SEND_AND_WAIT_OPERATION } from 'n8n-workflow'; import { SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import type { NodeIconSource } from '../../../utils/nodeIcon'; import type { NodeIconSource } from '../../../utils/nodeIcon';
import type { CommunityNodeDetails, ViewStack } from './composables/useViewStacks'; import type { CommunityNodeDetails, ViewStack } from './composables/useViewStacks';
import { useNodeTypesStore } from '../../../stores/nodeTypes.store';
const COMMUNITY_NODE_TYPE_PREVIEW_TOKEN = '-preview'; const COMMUNITY_NODE_TYPE_PREVIEW_TOKEN = '-preview';
@@ -264,6 +265,8 @@ export function prepareCommunityNodeDetailsViewStack(
): ViewStack { ): ViewStack {
const installed = !isNodePreviewKey(item.key); const installed = !isNodePreviewKey(item.key);
const packageName = removePreviewToken(item.key.split('.')[0]); const packageName = removePreviewToken(item.key.split('.')[0]);
const nodeTypesStore = useNodeTypesStore();
const nodeType = nodeTypesStore.communityNodeType(removePreviewToken(item.key));
const communityNodeDetails: CommunityNodeDetails = { const communityNodeDetails: CommunityNodeDetails = {
title: item.properties.displayName, title: item.properties.displayName,
@@ -271,7 +274,9 @@ export function prepareCommunityNodeDetailsViewStack(
key: item.key, key: item.key,
nodeIcon, nodeIcon,
installed, installed,
official: nodeType?.isOfficialNode ?? false,
packageName, packageName,
companyName: nodeType?.companyName,
}; };
if (nodeActions.length) { if (nodeActions.length) {

View File

@@ -50,6 +50,7 @@
"generic.resetAllFilters": "Reset all filters", "generic.resetAllFilters": "Reset all filters",
"generic.communityNode": "Community Node", "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.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.copy": "Copy",
"generic.delete": "Delete", "generic.delete": "Delete",
"generic.dontShowAgain": "Don't show again", "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.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.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.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.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.description": "This node is not currently installed. It's part of the {action} community package.",
"nodeSettings.communityNodeUnknown.installLink.text": "How to install community nodes", "nodeSettings.communityNodeUnknown.installLink.text": "How to install community nodes",
@@ -3170,7 +3171,9 @@
"communityNodeItem.actions.hint": "Install this node to start using actions", "communityNodeItem.actions.hint": "Install this node to start using actions",
"communityNodeItem.label": "Add to workflow", "communityNodeItem.label": "Add to workflow",
"communityNodeDetails.installed": "Installed", "communityNodeDetails.installed": "Installed",
"communityNodeDetails.install": "Install node",
"communityNodeInfo.approved": "This community node has been reviewed and approved by n8n", "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.approved.label": "Verified",
"communityNodeInfo.unverified": "This community node was added via npm and has not been verified by n8n", "communityNodeInfo.unverified": "This community node was added via npm and has not been verified by n8n",
"communityNodeInfo.unverified.label": "Via npm", "communityNodeInfo.unverified.label": "Via npm",

View File

@@ -1,5 +1,6 @@
import type { import type {
ActionResultRequestDto, ActionResultRequestDto,
CommunityNodeType,
OptionsRequestDto, OptionsRequestDto,
ResourceLocatorRequestDto, ResourceLocatorRequestDto,
ResourceMapperFieldsRequestDto, ResourceMapperFieldsRequestDto,
@@ -35,7 +36,7 @@ export type NodeTypesStore = ReturnType<typeof useNodeTypesStore>;
export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => { export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
const nodeTypes = ref<NodeTypesByTypeNameAndVersion>({}); const nodeTypes = ref<NodeTypesByTypeNameAndVersion>({});
const communityPreviews = ref<INodeTypeDescription[]>([]); const vettedCommunityNodeTypes = ref<Map<string, CommunityNodeType>>(new Map());
const rootStore = useRootStore(); const rootStore = useRootStore();
@@ -47,34 +48,41 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
// #region Computed // #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(() => { const communityNodesAndActions = computed(() => {
return actionsGenerator.generateMergedNodesAndActions(communityPreviews.value, []); return actionsGenerator.generateMergedNodesAndActions(unofficialCommunityNodeTypes.value, []);
}); });
const allNodeTypes = computed(() => { const allNodeTypes = computed(() => {
return Object.values(nodeTypes.value).reduce<INodeTypeDescription[]>( return Object.values(nodeTypes.value).flatMap((nodeType) =>
(allNodeTypes, nodeType) => { Object.keys(nodeType).map((version) => nodeType[Number(version)]),
const versionNumbers = Object.keys(nodeType).map(Number);
const allNodeVersions = versionNumbers.map((version) => nodeType[version]);
return [...allNodeTypes, ...allNodeVersions];
},
[],
); );
}); });
const allLatestNodeTypes = computed(() => { const allLatestNodeTypes = computed(() => {
return Object.values(nodeTypes.value).reduce<INodeTypeDescription[]>( return Object.values(nodeTypes.value)
(allLatestNodeTypes, nodeVersions) => { .map((nodeVersions) => {
const versionNumbers = Object.keys(nodeVersions).map(Number); const versionNumbers = Object.keys(nodeVersions).map(Number);
const latestNodeVersion = nodeVersions[Math.max(...versionNumbers)]; return nodeVersions[Math.max(...versionNumbers)];
})
if (!latestNodeVersion) return allLatestNodeTypes; .filter(Boolean);
return [...allLatestNodeTypes, latestNodeVersion];
},
[],
);
}); });
const getNodeType = computed(() => { const getNodeType = computed(() => {
@@ -159,7 +167,9 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
}); });
const visibleNodeTypes = computed(() => { const visibleNodeTypes = computed(() => {
return allLatestNodeTypes.value.filter((nodeType: INodeTypeDescription) => !nodeType.hidden); return allLatestNodeTypes.value
.concat(officialCommunityNodeTypes.value)
.filter((nodeType) => !nodeType.hidden);
}); });
const nativelyNumberSuffixedDefaults = computed(() => { const nativelyNumberSuffixedDefaults = computed(() => {
@@ -360,11 +370,15 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
return; return;
} }
try { try {
communityPreviews.value = await nodeTypesApi.fetchCommunityNodeTypes( const communityNodeTypes = await nodeTypesApi.fetchCommunityNodeTypes(
rootStore.restApiContext, rootStore.restApiContext,
); );
vettedCommunityNodeTypes.value = new Map(
communityNodeTypes.map((nodeType) => [nodeType.name, nodeType]),
);
} catch (error) { } catch (error) {
communityPreviews.value = []; vettedCommunityNodeTypes.value = new Map();
} }
}; };
@@ -402,6 +416,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
visibleNodeTypesByInputConnectionTypeNames, visibleNodeTypesByInputConnectionTypeNames,
isConfigurableNode, isConfigurableNode,
communityNodesAndActions, communityNodesAndActions,
communityNodeType,
getResourceMapperFields, getResourceMapperFields,
getLocalResourceMapperFields, getLocalResourceMapperFields,
getNodeParameterActionResult, getNodeParameterActionResult,