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,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,22 +172,38 @@ 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
>
<p
v-n8n-html="
i18n.baseText('generic.communityNode.tooltip', {
interpolate: {
packageName: nodeType.name.split('.')[0],
docURL: COMMUNITY_NODES_INSTALLATION_DOCS_URL,
},
})
"
:class="$style.communityNodeIcon"
@click="onCommunityNodeTooltipClick"
/>
<N8nTooltip placement="top" :show-after="500">
<template #content>
<p
v-n8n-html="
i18n.baseText('generic.communityNode.tooltip', {
interpolate: {
packageName: nodeType.name.split('.')[0],
docURL: COMMUNITY_NODES_INSTALLATION_DOCS_URL,
},
})
"
:class="$style.communityNodeIcon"
@click="onCommunityNodeTooltipClick"
/>
</template>
<n8n-icon size="small" :class="$style.icon" icon="cube" />
</N8nTooltip>
</template>
<template #dragContent>
<div
@@ -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>

View File

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

View File

@@ -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;

View File

@@ -59,7 +59,7 @@ vi.mock('../composables/useViewStacks', () => ({
mode: 'community-node',
rootView: undefined,
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 { 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 {

View File

@@ -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')"
/>

View File

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

View File

@@ -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',
});
});
});

View File

@@ -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) {