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:
@@ -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,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>
|
||||
|
||||
@@ -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