mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat: Community Nodes in the Nodes Panel (#13923)
Co-authored-by: Dana Lee <dana@n8n.io> Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
@@ -109,7 +109,7 @@ async function onSubmit() {
|
||||
);
|
||||
if (!updateInformation) return;
|
||||
|
||||
//updade code parameter
|
||||
//update code parameter
|
||||
emit('valueChanged', updateInformation);
|
||||
|
||||
//update code generated for prompt parameter
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { PublicInstalledPackage } from 'n8n-workflow';
|
||||
import { NPM_PACKAGE_DOCS_BASE_URL, COMMUNITY_PACKAGE_MANAGE_ACTIONS } from '@/constants';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
||||
interface Props {
|
||||
communityPackage?: PublicInstalledPackage | null;
|
||||
@@ -19,6 +20,7 @@ const { openCommunityPackageUpdateConfirmModal, openCommunityPackageUninstallCon
|
||||
useUIStore();
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const packageActions = [
|
||||
{
|
||||
@@ -95,7 +97,10 @@ function onUpdateClick() {
|
||||
</template>
|
||||
<n8n-icon icon="exclamation-triangle" color="danger" size="large" />
|
||||
</n8n-tooltip>
|
||||
<n8n-tooltip v-else-if="communityPackage.updateAvailable" placement="top">
|
||||
<n8n-tooltip
|
||||
v-else-if="settingsStore.isUnverifiedPackagesEnabled && communityPackage.updateAvailable"
|
||||
placement="top"
|
||||
>
|
||||
<template #content>
|
||||
<div>
|
||||
{{ i18n.baseText('settings.communityNodes.updateAvailable.tooltip') }}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useCommunityNodesStore } from '@/stores/communityNodes.store';
|
||||
import { ref } from 'vue';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
|
||||
const communityNodesStore = useCommunityNodesStore();
|
||||
|
||||
@@ -44,6 +45,7 @@ const onInstallClick = async () => {
|
||||
infoTextErrorMessage.value = '';
|
||||
loading.value = true;
|
||||
await communityNodesStore.installPackage(packageName.value);
|
||||
await useNodeTypesStore().getNodeTypes();
|
||||
loading.value = false;
|
||||
modalBus.emit('close');
|
||||
toast.showMessage({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
|
||||
export type CommunityPackageManageMode = 'uninstall' | 'update' | 'view-documentation';
|
||||
|
||||
@@ -90,6 +91,7 @@ const onUninstall = async () => {
|
||||
});
|
||||
loading.value = true;
|
||||
await communityNodesStore.uninstallPackage(props.activePackageName);
|
||||
await useNodeTypesStore().getNodeTypes();
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.communityNodes.messages.uninstall.success.title'),
|
||||
type: 'success',
|
||||
@@ -115,6 +117,7 @@ const onUpdate = async () => {
|
||||
loading.value = true;
|
||||
const updatedVersion = activePackage.value.updateAvailable;
|
||||
await communityNodesStore.updatePackage(props.activePackageName);
|
||||
await useNodeTypesStore().getNodeTypes();
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.communityNodes.messages.update.success.title'),
|
||||
message: i18n.baseText('settings.communityNodes.messages.update.success.message', {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import CommunityNodeInstallHint from '../Panel/CommunityNodeInstallHint.vue';
|
||||
import { N8nButton } from '@n8n/design-system';
|
||||
|
||||
export interface Props {
|
||||
isPreview: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const i18n = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<CommunityNodeInstallHint
|
||||
v-if="isPreview"
|
||||
:hint="i18n.baseText('communityNodeItem.node.hint')"
|
||||
/>
|
||||
|
||||
<div v-else :class="$style.marginLeft">
|
||||
<N8nButton
|
||||
size="medium"
|
||||
type="secondary"
|
||||
icon="plus"
|
||||
:label="i18n.baseText('communityNodeItem.label')"
|
||||
outline
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.marginLeft {
|
||||
margin-left: var(--spacing-xs);
|
||||
}
|
||||
</style>
|
||||
@@ -18,6 +18,7 @@ import { useViewStacks } from '../composables/useViewStacks';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useNodeType } from '@/composables/useNodeType';
|
||||
import { isNodePreviewKey } from '../utils';
|
||||
|
||||
export interface Props {
|
||||
nodeType: SimplifiedNodeType;
|
||||
@@ -45,6 +46,9 @@ const draggablePosition = ref({ x: -100, y: -100 });
|
||||
const draggableDataTransfer = ref(null as Element | null);
|
||||
|
||||
const description = computed<string>(() => {
|
||||
if (isCommunityNodePreview.value) {
|
||||
return props.nodeType.description;
|
||||
}
|
||||
if (isSendAndWaitCategory.value) {
|
||||
return '';
|
||||
}
|
||||
@@ -60,7 +64,14 @@ const description = computed<string>(() => {
|
||||
fallback: props.nodeType.description,
|
||||
});
|
||||
});
|
||||
const showActionArrow = computed(() => hasActions.value && !isSendAndWaitCategory.value);
|
||||
const showActionArrow = computed(() => {
|
||||
// show action arrow if it's a community node and the community node details are not opened
|
||||
if (isCommunityNode.value && !activeViewStack.communityNodeDetails) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return hasActions.value && !isSendAndWaitCategory.value;
|
||||
});
|
||||
const isSendAndWaitCategory = computed(() => activeViewStack.subcategory === HITL_SUBCATEGORY);
|
||||
const dataTestId = computed(() =>
|
||||
hasActions.value ? 'node-creator-action-item' : 'node-creator-node-item',
|
||||
@@ -82,6 +93,7 @@ const draggableStyle = computed<{ top: string; left: string }>(() => ({
|
||||
}));
|
||||
|
||||
const isCommunityNode = computed<boolean>(() => isCommunityPackageName(props.nodeType.name));
|
||||
const isCommunityNodePreview = computed<boolean>(() => isNodePreviewKey(props.nodeType.name));
|
||||
|
||||
const displayName = computed<string>(() => {
|
||||
const trimmedDisplayName = props.nodeType.displayName.trimEnd();
|
||||
@@ -143,7 +155,10 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
|
||||
<NodeIcon :class="$style.nodeIcon" :node-type="nodeType" />
|
||||
</template>
|
||||
|
||||
<template v-if="isCommunityNode" #tooltip>
|
||||
<template
|
||||
v-if="isCommunityNode && !isCommunityNodePreview && !activeViewStack?.communityNodeDetails"
|
||||
#tooltip
|
||||
>
|
||||
<p
|
||||
v-n8n-html="
|
||||
i18n.baseText('generic.communityNode.tooltip', {
|
||||
@@ -192,6 +207,7 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
|
||||
width: 40px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.communityNodeIcon {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
@@ -23,11 +23,15 @@ import { useViewStacks } from '../composables/useViewStacks';
|
||||
|
||||
import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
|
||||
import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue';
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
import { type IDataObject } from 'n8n-workflow';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||
import OrderSwitcher from './../OrderSwitcher.vue';
|
||||
import { isNodePreviewKey } from '../utils';
|
||||
|
||||
import CommunityNodeInfo from '../Panel/CommunityNodeInfo.vue';
|
||||
import CommunityNodeFooter from '../Panel/CommunityNodeFooter.vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
nodeTypeSelected: [value: [actionKey: string, nodeName: string] | [nodeName: string]];
|
||||
@@ -90,6 +94,8 @@ const subcategory = computed(() => useViewStacks().activeViewStack.subcategory);
|
||||
|
||||
const rootView = computed(() => useViewStacks().activeViewStack.rootView);
|
||||
|
||||
const communityNodeDetails = computed(() => useViewStacks().activeViewStack?.communityNodeDetails);
|
||||
|
||||
const placeholderTriggerActions = getPlaceholderTriggerActions(subcategory.value || '');
|
||||
|
||||
const hasNoTriggerActions = computed(
|
||||
@@ -113,6 +119,16 @@ const containsAPIAction = computed(() => {
|
||||
|
||||
const isTriggerRootView = computed(() => rootView.value === TRIGGER_NODE_CREATOR_VIEW);
|
||||
|
||||
const shouldShowTriggers = computed(() => {
|
||||
if (communityNodeDetails.value && !parsedTriggerActions.value.length) {
|
||||
// do not show baseline trigger actions for community nodes if it is not installed
|
||||
return (
|
||||
!isNodePreviewKey(useViewStacks().activeViewStack?.items?.[0].key) && isTriggerRootView.value
|
||||
);
|
||||
}
|
||||
return isTriggerRootView.value || parsedTriggerActionsBaseline.value.length !== 0;
|
||||
});
|
||||
|
||||
registerKeyHook('ActionsKeyRight', {
|
||||
keyboardKeys: ['ArrowRight', 'Enter'],
|
||||
condition: (type) => type === 'action',
|
||||
@@ -157,6 +173,8 @@ function onSelected(actionCreateElement: INodeCreateElement) {
|
||||
(actionData?.value as IDataObject)?.operation === 'message'
|
||||
) {
|
||||
emit('nodeTypeSelected', [OPEN_AI_NODE_MESSAGE_ASSISTANT_TYPE]);
|
||||
} else if (isNodePreviewKey(actionData?.key)) {
|
||||
return;
|
||||
} else {
|
||||
emit('nodeTypeSelected', [actionData.key as string]);
|
||||
}
|
||||
@@ -216,10 +234,17 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<div
|
||||
:class="{
|
||||
[$style.container]: true,
|
||||
[$style.containerPaddingBottom]: !communityNodeDetails,
|
||||
}"
|
||||
>
|
||||
<CommunityNodeInfo v-if="communityNodeDetails" />
|
||||
<OrderSwitcher v-if="rootView" :root-view="rootView">
|
||||
<template v-if="isTriggerRootView || parsedTriggerActionsBaseline.length !== 0" #triggers>
|
||||
<template v-if="shouldShowTriggers" #triggers>
|
||||
<!-- Triggers Category -->
|
||||
|
||||
<CategorizedItemsRenderer
|
||||
v-memo="[search]"
|
||||
:elements="parsedTriggerActions"
|
||||
@@ -298,7 +323,7 @@ onMounted(() => {
|
||||
</CategorizedItemsRenderer>
|
||||
</template>
|
||||
</OrderSwitcher>
|
||||
<div v-if="containsAPIAction" :class="$style.apiHint">
|
||||
<div v-if="containsAPIAction && !communityNodeDetails" :class="$style.apiHint">
|
||||
<span
|
||||
v-n8n-html="
|
||||
i18n.baseText('nodeCreator.actionsList.apiCall', {
|
||||
@@ -308,6 +333,11 @@ onMounted(() => {
|
||||
@click.prevent="addHttpNode"
|
||||
/>
|
||||
</div>
|
||||
<CommunityNodeFooter
|
||||
:class="$style.communityNodeFooter"
|
||||
v-if="communityNodeDetails"
|
||||
:package-name="communityNodeDetails.packageName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -315,9 +345,17 @@ onMounted(() => {
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.containerPaddingBottom {
|
||||
padding-bottom: var(--spacing-3xl);
|
||||
}
|
||||
|
||||
.communityNodeFooter {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.resetSearch {
|
||||
cursor: pointer;
|
||||
line-height: var(--font-line-height-regular);
|
||||
|
||||
@@ -21,17 +21,27 @@ import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||
|
||||
import { TriggerView, RegularView, AIView, AINodesView } from '../viewsData';
|
||||
import { flattenCreateElements, transformNodeType } from '../utils';
|
||||
import {
|
||||
flattenCreateElements,
|
||||
filterAndSearchNodes,
|
||||
prepareCommunityNodeDetailsViewStack,
|
||||
transformNodeType,
|
||||
} from '../utils';
|
||||
import { useViewStacks } from '../composables/useViewStacks';
|
||||
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
|
||||
import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
|
||||
import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue';
|
||||
import NoResults from '../Panel/NoResults.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
import { getNodeIconSource } from '@/utils/nodeIcon';
|
||||
|
||||
import { useActions } from '../composables/useActions';
|
||||
import { SEND_AND_WAIT_OPERATION, type INodeParameters } from 'n8n-workflow';
|
||||
|
||||
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
|
||||
export interface Props {
|
||||
rootView: 'trigger' | 'action';
|
||||
}
|
||||
@@ -43,15 +53,36 @@ const emit = defineEmits<{
|
||||
const i18n = useI18n();
|
||||
|
||||
const { mergedNodes, actions, onSubcategorySelected } = useNodeCreatorStore();
|
||||
const { pushViewStack, popViewStack } = useViewStacks();
|
||||
const { pushViewStack, popViewStack, isAiSubcategoryView } = useViewStacks();
|
||||
const { setAddedNodeActionParameters } = useActions();
|
||||
|
||||
const { registerKeyHook } = useKeyboardNavigation();
|
||||
|
||||
const activeViewStack = computed(() => useViewStacks().activeViewStack);
|
||||
|
||||
const globalSearchItemsDiff = computed(() => useViewStacks().globalSearchItemsDiff);
|
||||
|
||||
function getFilteredActions(node: NodeCreateElement) {
|
||||
const communityNodesAndActions = computed(() => useNodeTypesStore().communityNodesAndActions);
|
||||
|
||||
const moreFromCommunity = computed(() => {
|
||||
return filterAndSearchNodes(
|
||||
communityNodesAndActions.value.mergedNodes,
|
||||
activeViewStack.value.search ?? '',
|
||||
isAiSubcategoryView(activeViewStack.value),
|
||||
);
|
||||
});
|
||||
|
||||
const isSearchResultEmpty = computed(() => {
|
||||
return (
|
||||
(activeViewStack.value.items || []).length === 0 &&
|
||||
globalSearchItemsDiff.value.length + moreFromCommunity.value.length === 0
|
||||
);
|
||||
});
|
||||
|
||||
function getFilteredActions(
|
||||
node: NodeCreateElement,
|
||||
actions: Record<string, ActionTypeDescription[]>,
|
||||
) {
|
||||
const nodeActions = actions?.[node.key] || [];
|
||||
if (activeViewStack.value.subcategory === HITL_SUBCATEGORY) {
|
||||
return getHumanInTheLoopActions(nodeActions);
|
||||
@@ -103,7 +134,23 @@ function onSelected(item: INodeCreateElement) {
|
||||
}
|
||||
|
||||
if (item.type === 'node') {
|
||||
const nodeActions = getFilteredActions(item);
|
||||
let nodeActions = getFilteredActions(item, actions);
|
||||
|
||||
if (isCommunityPackageName(item.key) && !activeViewStack.value.communityNodeDetails) {
|
||||
if (!nodeActions.length) {
|
||||
nodeActions = getFilteredActions(item, communityNodesAndActions.value.actions);
|
||||
}
|
||||
|
||||
const viewStack = prepareCommunityNodeDetailsViewStack(
|
||||
item,
|
||||
getNodeIconSource(item.properties),
|
||||
activeViewStack.value.rootView,
|
||||
nodeActions,
|
||||
);
|
||||
|
||||
pushViewStack(viewStack);
|
||||
return;
|
||||
}
|
||||
|
||||
// If there is only one action, use it
|
||||
if (nodeActions.length === 1) {
|
||||
@@ -176,7 +223,7 @@ function subcategoriesMapper(item: INodeCreateElement) {
|
||||
if (item.type !== 'node') return item;
|
||||
|
||||
const hasTriggerGroup = item.properties.group.includes('trigger');
|
||||
const nodeActions = getFilteredActions(item);
|
||||
const nodeActions = getFilteredActions(item, actions);
|
||||
const hasActions = nodeActions.length > 0;
|
||||
|
||||
if (hasTriggerGroup && hasActions) {
|
||||
@@ -197,7 +244,7 @@ function baseSubcategoriesFilter(item: INodeCreateElement): boolean {
|
||||
if (item.type !== 'node') return false;
|
||||
|
||||
const hasTriggerGroup = item.properties.group.includes('trigger');
|
||||
const nodeActions = getFilteredActions(item);
|
||||
const nodeActions = getFilteredActions(item, actions);
|
||||
const hasActions = nodeActions.length > 0;
|
||||
|
||||
const isTriggerRootView = activeViewStack.value.rootView === TRIGGER_NODE_CREATOR_VIEW;
|
||||
@@ -216,6 +263,7 @@ function onKeySelect(activeItemId: string) {
|
||||
const mergedItems = flattenCreateElements([
|
||||
...(activeViewStack.value.items ?? []),
|
||||
...(globalSearchItemsDiff.value ?? []),
|
||||
...(moreFromCommunity.value ?? []),
|
||||
]);
|
||||
|
||||
const item = mergedItems.find((i) => i.uuid === activeItemId);
|
||||
@@ -246,10 +294,7 @@ registerKeyHook('MainViewArrowLeft', {
|
||||
:class="$style.items"
|
||||
@selected="onSelected"
|
||||
>
|
||||
<template
|
||||
v-if="(activeViewStack.items || []).length === 0 && globalSearchItemsDiff.length === 0"
|
||||
#empty
|
||||
>
|
||||
<template v-if="isSearchResultEmpty" #empty>
|
||||
<NoResults
|
||||
:root-view="activeViewStack.rootView"
|
||||
show-icon
|
||||
@@ -259,12 +304,24 @@ registerKeyHook('MainViewArrowLeft', {
|
||||
/>
|
||||
</template>
|
||||
</ItemsRenderer>
|
||||
|
||||
<!-- Results in other categories -->
|
||||
<CategorizedItemsRenderer
|
||||
v-if="globalSearchItemsDiff.length > 0"
|
||||
:elements="globalSearchItemsDiff"
|
||||
:category="i18n.baseText('nodeCreator.categoryNames.otherCategories')"
|
||||
@selected="onSelected"
|
||||
:expanded="true"
|
||||
>
|
||||
</CategorizedItemsRenderer>
|
||||
|
||||
<!-- Results in community nodes -->
|
||||
<CategorizedItemsRenderer
|
||||
v-if="moreFromCommunity.length > 0"
|
||||
:elements="moreFromCommunity"
|
||||
:category="i18n.baseText('nodeCreator.categoryNames.moreFromCommunity')"
|
||||
@selected="onSelected"
|
||||
:expanded="true"
|
||||
>
|
||||
</CategorizedItemsRenderer>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { type TestingPinia, createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import CommunityNodeDetails from './CommunityNodeDetails.vue';
|
||||
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||
|
||||
const fetchCredentialTypes = vi.fn();
|
||||
const getCommunityNodeAttributes = vi.fn(() => ({ npmVersion: '1.0.0' }));
|
||||
const getNodeTypes = vi.fn();
|
||||
const installPackage = vi.fn();
|
||||
const getAllNodeCreateElements = vi.fn(() => [
|
||||
{
|
||||
key: 'n8n-nodes-test.OtherNode',
|
||||
properties: {
|
||||
defaults: {
|
||||
name: 'OtherNode',
|
||||
},
|
||||
description: 'Other node description',
|
||||
displayName: 'Other Node',
|
||||
group: ['transform'],
|
||||
name: 'n8n-nodes-test.OtherNode',
|
||||
outputs: ['main'],
|
||||
},
|
||||
subcategory: '*',
|
||||
type: 'node',
|
||||
uuid: 'n8n-nodes-test.OtherNode-32f238f0-2b05-47ce-b43d-7fab6d7ba3cb',
|
||||
},
|
||||
]);
|
||||
|
||||
const popViewStack = vi.fn();
|
||||
const pushViewStack = vi.fn();
|
||||
|
||||
const showError = vi.fn();
|
||||
|
||||
const removeNodeFromMergedNodes = vi.fn();
|
||||
|
||||
const usersStore = {
|
||||
isInstanceOwner: true,
|
||||
};
|
||||
|
||||
vi.mock('@/stores/credentials.store', () => ({
|
||||
useCredentialsStore: vi.fn(() => ({
|
||||
fetchCredentialTypes,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/nodeCreator.store', () => ({
|
||||
useNodeCreatorStore: vi.fn(() => ({
|
||||
actions: [],
|
||||
removeNodeFromMergedNodes,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/nodeTypes.store', () => ({
|
||||
useNodeTypesStore: vi.fn(() => ({
|
||||
getCommunityNodeAttributes,
|
||||
getNodeTypes,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/communityNodes.store', () => ({
|
||||
useCommunityNodesStore: vi.fn(() => ({
|
||||
installPackage,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/users.store', () => ({
|
||||
useUsersStore: vi.fn(() => usersStore),
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: vi.fn(() => ({
|
||||
showMessage: vi.fn(),
|
||||
showError,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../composables/useViewStacks', () => ({
|
||||
useViewStacks: vi.fn(() => ({
|
||||
activeViewStack: {
|
||||
communityNodeDetails: {
|
||||
description: 'Other node description',
|
||||
installed: false,
|
||||
key: 'n8n-nodes-preview-test.OtherNode',
|
||||
nodeIcon: undefined,
|
||||
packageName: 'n8n-nodes-test',
|
||||
title: 'Other Node',
|
||||
},
|
||||
hasSearch: false,
|
||||
items: [
|
||||
{
|
||||
key: 'n8n-nodes-preview-test.OtherNode',
|
||||
properties: {
|
||||
defaults: {
|
||||
name: 'OtherNode',
|
||||
},
|
||||
description: 'Other node description',
|
||||
displayName: 'Other Node',
|
||||
group: ['transform'],
|
||||
name: 'n8n-nodes-preview-test.OtherNode',
|
||||
outputs: ['main'],
|
||||
},
|
||||
subcategory: '*',
|
||||
type: 'node',
|
||||
uuid: 'n8n-nodes-preview-test.OtherNode-32f238f0-2b05-47ce-b43d-7fab6d7ba3cb',
|
||||
},
|
||||
],
|
||||
mode: 'community-node',
|
||||
rootView: undefined,
|
||||
subcategory: 'Other Node',
|
||||
title: 'Community node details',
|
||||
},
|
||||
pushViewStack,
|
||||
popViewStack,
|
||||
getAllNodeCreateElements,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('CommunityNodeDetails', () => {
|
||||
const renderComponent = createComponentRenderer(CommunityNodeDetails);
|
||||
let pinia: TestingPinia;
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should render correctly and install node', async () => {
|
||||
const wrapper = renderComponent({ pinia });
|
||||
|
||||
const installButton = wrapper.getByTestId('install-community-node-button');
|
||||
|
||||
expect(wrapper.container.querySelector('.title span')?.textContent).toEqual('Other Node');
|
||||
expect(installButton.querySelector('span')?.textContent).toEqual('Install Node');
|
||||
|
||||
await fireEvent.click(installButton);
|
||||
|
||||
await waitFor(() => expect(removeNodeFromMergedNodes).toHaveBeenCalled());
|
||||
|
||||
expect(getCommunityNodeAttributes).toHaveBeenCalledWith('n8n-nodes-preview-test.OtherNode');
|
||||
expect(installPackage).toHaveBeenCalledWith('n8n-nodes-test', true, '1.0.0');
|
||||
expect(fetchCredentialTypes).toHaveBeenCalledWith(true);
|
||||
expect(getAllNodeCreateElements).toHaveBeenCalled();
|
||||
expect(popViewStack).toHaveBeenCalled();
|
||||
expect(pushViewStack).toHaveBeenCalledWith(
|
||||
{
|
||||
communityNodeDetails: {
|
||||
description: 'Other node description',
|
||||
installed: true,
|
||||
key: 'n8n-nodes-test.OtherNode',
|
||||
nodeIcon: undefined,
|
||||
packageName: 'n8n-nodes-test',
|
||||
title: 'Other Node',
|
||||
},
|
||||
hasSearch: false,
|
||||
items: [
|
||||
{
|
||||
key: 'n8n-nodes-test.OtherNode',
|
||||
properties: {
|
||||
defaults: {
|
||||
name: 'OtherNode',
|
||||
},
|
||||
description: 'Other node description',
|
||||
displayName: 'Other Node',
|
||||
group: ['transform'],
|
||||
name: 'n8n-nodes-test.OtherNode',
|
||||
outputs: ['main'],
|
||||
},
|
||||
subcategory: '*',
|
||||
type: 'node',
|
||||
uuid: 'n8n-nodes-test.OtherNode-32f238f0-2b05-47ce-b43d-7fab6d7ba3cb',
|
||||
},
|
||||
],
|
||||
mode: 'community-node',
|
||||
rootView: undefined,
|
||||
subcategory: 'Other Node',
|
||||
title: 'Community node details',
|
||||
},
|
||||
{
|
||||
transitionDirection: 'none',
|
||||
},
|
||||
);
|
||||
expect(removeNodeFromMergedNodes).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors during node installation', async () => {
|
||||
installPackage.mockImplementation(() => {
|
||||
throw new Error('Installation failed');
|
||||
});
|
||||
|
||||
const wrapper = renderComponent({ pinia });
|
||||
|
||||
const installButton = wrapper.getByTestId('install-community-node-button');
|
||||
|
||||
await fireEvent.click(installButton);
|
||||
|
||||
expect(showError).toHaveBeenCalledWith(expect.any(Error), 'Error installing new package');
|
||||
expect(pushViewStack).not.toHaveBeenCalled();
|
||||
expect(popViewStack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not render install button if not instance owner', async () => {
|
||||
usersStore.isInstanceOwner = false;
|
||||
|
||||
const wrapper = renderComponent({ pinia });
|
||||
|
||||
expect(wrapper.queryByTestId('install-community-node-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useViewStacks } from '../composables/useViewStacks';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useCommunityNodesStore } from '@/stores/communityNodes.store';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { i18n } from '@/plugins/i18n';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
|
||||
import { getNodeIconSource } from '@/utils/nodeIcon';
|
||||
|
||||
import { prepareCommunityNodeDetailsViewStack, removePreviewToken } from '../utils';
|
||||
|
||||
import { N8nText } from '@n8n/design-system';
|
||||
|
||||
const { activeViewStack, pushViewStack, popViewStack, getAllNodeCreateElements } = useViewStacks();
|
||||
|
||||
const { communityNodeDetails } = activeViewStack;
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const communityNodesStore = useCommunityNodesStore();
|
||||
const nodeCreatorStore = useNodeCreatorStore();
|
||||
const toast = useToast();
|
||||
|
||||
const isOwner = computed(() => useUsersStore().isInstanceOwner);
|
||||
|
||||
const updateViewStack = (key: string) => {
|
||||
const installedNodeKey = removePreviewToken(key);
|
||||
const installedNode = getAllNodeCreateElements().find((node) => node.key === installedNodeKey);
|
||||
|
||||
if (installedNode) {
|
||||
const nodeActions = nodeCreatorStore.actions?.[installedNode.key] || [];
|
||||
|
||||
popViewStack();
|
||||
|
||||
const viewStack = prepareCommunityNodeDetailsViewStack(
|
||||
installedNode,
|
||||
getNodeIconSource(installedNode.properties),
|
||||
activeViewStack.rootView,
|
||||
nodeActions,
|
||||
);
|
||||
|
||||
pushViewStack(viewStack, {
|
||||
transitionDirection: 'none',
|
||||
});
|
||||
} else {
|
||||
const viewStack = { ...activeViewStack };
|
||||
viewStack.communityNodeDetails!.installed = true;
|
||||
|
||||
pushViewStack(activeViewStack, { resetStacks: true });
|
||||
}
|
||||
};
|
||||
|
||||
const updateStoresAndViewStack = async (key: string) => {
|
||||
await useNodeTypesStore().getNodeTypes();
|
||||
await useCredentialsStore().fetchCredentialTypes(true);
|
||||
updateViewStack(key);
|
||||
nodeCreatorStore.removeNodeFromMergedNodes(key);
|
||||
};
|
||||
|
||||
const getNpmVersion = async (key: string) => {
|
||||
const communityNodeAttributes = await useNodeTypesStore().getCommunityNodeAttributes(key);
|
||||
|
||||
if (communityNodeAttributes) {
|
||||
return communityNodeAttributes.npmVersion;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const onInstall = async () => {
|
||||
if (isOwner.value && activeViewStack.communityNodeDetails && !communityNodeDetails?.installed) {
|
||||
const { key, packageName } = activeViewStack.communityNodeDetails;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
await communityNodesStore.installPackage(packageName, true, await getNpmVersion(key));
|
||||
await updateStoresAndViewStack(key);
|
||||
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.communityNodes.messages.install.success'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('settings.communityNodes.messages.install.error'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.header">
|
||||
<div :class="$style.title">
|
||||
<NodeIcon
|
||||
v-if="communityNodeDetails?.nodeIcon"
|
||||
:class="$style.nodeIcon"
|
||||
:icon-source="communityNodeDetails.nodeIcon"
|
||||
:circle="false"
|
||||
:show-tooltip="false"
|
||||
/>
|
||||
<span>{{ communityNodeDetails?.title }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="communityNodeDetails?.installed" :class="$style.installed">
|
||||
<FontAwesomeIcon :class="$style.installedIcon" icon="cube" />
|
||||
<N8nText color="text-light" size="small" bold>
|
||||
{{ i18n.baseText('communityNodeDetails.installed') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nButton
|
||||
v-else-if="isOwner"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
label="Install Node"
|
||||
size="small"
|
||||
@click="onInstall"
|
||||
data-test-id="install-community-node-button"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
width: 100%;
|
||||
padding: var(--spacing-s);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--spacing-xs);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
.nodeIcon {
|
||||
--node-icon-size: 36px;
|
||||
margin-right: var(--spacing-s);
|
||||
}
|
||||
|
||||
.installedIcon {
|
||||
margin-right: var(--spacing-3xs);
|
||||
color: var(--color-text-base);
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
|
||||
.installed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n } from '@/plugins/i18n';
|
||||
|
||||
import { N8nText, N8nLink } from '@n8n/design-system';
|
||||
|
||||
export interface Props {
|
||||
packageName: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const openCommunityNodeDocsPage = () => {
|
||||
const newTab = window.open(`https://www.npmjs.com/package/${props.packageName}`, '_blank');
|
||||
if (newTab) newTab.opener = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nLink
|
||||
theme="text"
|
||||
@click="openCommunityNodeDocsPage"
|
||||
:class="$style.container"
|
||||
:title="i18n.baseText('communityNodesDocsLink.link.title')"
|
||||
>
|
||||
<N8nText size="small" bold style="margin-right: 5px">
|
||||
{{ i18n.baseText('communityNodesDocsLink.title') }}
|
||||
</N8nText>
|
||||
<FontAwesomeIcon icon="external-link-alt" />
|
||||
</N8nLink>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
padding-bottom: var(--spacing-5xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { VIEWS } from '@/constants';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { captureException } from '@sentry/vue';
|
||||
|
||||
import { N8nText, N8nLink } from '@n8n/design-system';
|
||||
|
||||
export interface Props {
|
||||
packageName: string;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const bugsUrl = ref<string>(`https://registry.npmjs.org/${props.packageName}`);
|
||||
|
||||
async function openSettingsPage() {
|
||||
await router.push({ name: VIEWS.COMMUNITY_NODES });
|
||||
}
|
||||
|
||||
async function openIssuesPage() {
|
||||
if (bugsUrl.value) {
|
||||
window.open(bugsUrl.value, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
async function getBugsUrl(packageName: string) {
|
||||
const url = `https://registry.npmjs.org/${packageName}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Could not get metadata for package');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.bugs?.url) {
|
||||
bugsUrl.value = data.bugs.url;
|
||||
}
|
||||
} catch (error) {
|
||||
captureException(error);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.packageName) {
|
||||
await getBugsUrl(props.packageName);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<N8nLink theme="text" @click="openSettingsPage">
|
||||
<N8nText size="small" color="primary" bold> Manage </N8nText>
|
||||
</N8nLink>
|
||||
<N8nText size="small" color="primary" bold>|</N8nText>
|
||||
<N8nLink theme="text" @click="openIssuesPage">
|
||||
<N8nText size="small" color="primary" bold> Report issue </N8nText>
|
||||
</N8nLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
padding-bottom: var(--spacing-s);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { type TestingPinia, createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import CommunityNodeInfo from './CommunityNodeInfo.vue';
|
||||
import type { PublicInstalledPackage } from 'n8n-workflow';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
|
||||
const getCommunityNodeAttributes = vi.fn();
|
||||
const communityNodesStore: { getInstalledPackages: PublicInstalledPackage[] } = {
|
||||
getInstalledPackages: [],
|
||||
};
|
||||
|
||||
vi.mock('@/stores/nodeTypes.store', () => ({
|
||||
useNodeTypesStore: vi.fn(() => ({
|
||||
getCommunityNodeAttributes,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/users.store', () => ({
|
||||
useUsersStore: vi.fn(() => ({
|
||||
isInstanceOwner: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/communityNodes.store', () => ({
|
||||
useCommunityNodesStore: vi.fn(() => communityNodesStore),
|
||||
}));
|
||||
|
||||
vi.mock('../composables/useViewStacks', () => ({
|
||||
useViewStacks: vi.fn(() => ({
|
||||
activeViewStack: {
|
||||
communityNodeDetails: {
|
||||
description: 'Other node description',
|
||||
installed: false,
|
||||
key: 'n8n-nodes-preview-test.OtherNode',
|
||||
nodeIcon: undefined,
|
||||
packageName: 'n8n-nodes-test',
|
||||
title: 'Other Node',
|
||||
},
|
||||
hasSearch: false,
|
||||
items: [
|
||||
{
|
||||
key: 'n8n-nodes-preview-test.OtherNode',
|
||||
properties: {
|
||||
defaults: {
|
||||
name: 'OtherNode',
|
||||
},
|
||||
description: 'Other node description',
|
||||
displayName: 'Other Node',
|
||||
group: ['transform'],
|
||||
name: 'n8n-nodes-preview-test.OtherNode',
|
||||
outputs: ['main'],
|
||||
},
|
||||
subcategory: '*',
|
||||
type: 'node',
|
||||
uuid: 'n8n-nodes-preview-test.OtherNode-32f238f0-2b05-47ce-b43d-7fab6d7ba3cb',
|
||||
},
|
||||
],
|
||||
mode: 'community-node',
|
||||
rootView: undefined,
|
||||
subcategory: 'Other Node',
|
||||
title: 'Community node details',
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('CommunityNodeInfo', () => {
|
||||
const renderComponent = createComponentRenderer(CommunityNodeInfo);
|
||||
let pinia: TestingPinia;
|
||||
let originalFetch: typeof global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
originalFetch = global.fetch;
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should render correctly with communityNodeAttributes', async () => {
|
||||
getCommunityNodeAttributes.mockResolvedValue({
|
||||
npmVersion: '1.0.0',
|
||||
authorName: 'contributor',
|
||||
numberOfDownloads: 9999,
|
||||
});
|
||||
communityNodesStore.getInstalledPackages = [
|
||||
{
|
||||
installedVersion: '1.0.0',
|
||||
packageName: 'n8n-nodes-test',
|
||||
} as PublicInstalledPackage,
|
||||
];
|
||||
const wrapper = renderComponent({ pinia });
|
||||
|
||||
await waitFor(() => expect(wrapper.queryByTestId('number-of-downloads')).toBeInTheDocument());
|
||||
|
||||
expect(wrapper.container.querySelector('.description')?.textContent).toEqual(
|
||||
'Other node description',
|
||||
);
|
||||
expect(wrapper.getByTestId('verified-tag').textContent).toEqual('Verified');
|
||||
expect(wrapper.getByTestId('number-of-downloads').textContent).toEqual('9,999 Downloads');
|
||||
expect(wrapper.getByTestId('publisher-name').textContent).toEqual('Published by contributor');
|
||||
});
|
||||
|
||||
it('should render correctly with fetched info', async () => {
|
||||
const packageData = {
|
||||
maintainers: [{ name: 'testAuthor' }],
|
||||
};
|
||||
|
||||
const downloadsData = {
|
||||
downloads: [
|
||||
{ downloads: 10, day: '2023-01-01' },
|
||||
{ downloads: 20, day: '2023-01-02' },
|
||||
{ downloads: 30, day: '2023-01-03' },
|
||||
],
|
||||
};
|
||||
|
||||
// Set up the fetch mock to return different responses based on URL
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: RequestInfo | URL) => {
|
||||
if (typeof url === 'string' && url.includes('registry.npmjs.org')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => packageData,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof url === 'string' && url.includes('api.npmjs.org/downloads')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => downloadsData,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
getCommunityNodeAttributes.mockResolvedValue(null);
|
||||
|
||||
const wrapper = renderComponent({ pinia });
|
||||
|
||||
await waitFor(() => expect(wrapper.queryByTestId('number-of-downloads')).toBeInTheDocument());
|
||||
|
||||
expect(wrapper.container.querySelector('.description')?.textContent).toEqual(
|
||||
'Other node description',
|
||||
);
|
||||
|
||||
expect(wrapper.getByTestId('number-of-downloads').textContent).toEqual('60 Downloads');
|
||||
expect(wrapper.getByTestId('publisher-name').textContent).toEqual('Published by testAuthor');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useViewStacks } from '../composables/useViewStacks';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { i18n } from '@/plugins/i18n';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useCommunityNodesStore } from '@/stores/communityNodes.store';
|
||||
import { captureException } from '@sentry/vue';
|
||||
import { N8nText, N8nTooltip, N8nIcon } from '@n8n/design-system';
|
||||
|
||||
const { activeViewStack } = useViewStacks();
|
||||
|
||||
const { communityNodeDetails } = activeViewStack;
|
||||
|
||||
interface DownloadData {
|
||||
downloads: Array<{ downloads: number }>;
|
||||
}
|
||||
|
||||
const publisherName = ref<string | undefined>(undefined);
|
||||
const downloads = ref<string | null>(null);
|
||||
const verified = ref(false);
|
||||
const communityNodesStore = useCommunityNodesStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
|
||||
const isOwner = computed(() => useUsersStore().isInstanceOwner);
|
||||
|
||||
const ownerEmailList = computed(() =>
|
||||
useUsersStore()
|
||||
.allUsers.filter((user) => user.role?.includes('owner'))
|
||||
.map((user) => user.email),
|
||||
);
|
||||
|
||||
const formatNumber = (number: number) => {
|
||||
if (!number) return null;
|
||||
return new Intl.NumberFormat('en-US').format(number);
|
||||
};
|
||||
|
||||
async function fetchPackageInfo(packageName: string) {
|
||||
const communityNodeAttributes = await nodeTypesStore.getCommunityNodeAttributes(
|
||||
activeViewStack.communityNodeDetails?.key || '',
|
||||
);
|
||||
|
||||
if (communityNodeAttributes) {
|
||||
publisherName.value = communityNodeAttributes.authorName;
|
||||
downloads.value = formatNumber(communityNodeAttributes.numberOfDownloads);
|
||||
const packageInfo = communityNodesStore.getInstalledPackages.find(
|
||||
(p) => p.packageName === communityNodeAttributes.packageName,
|
||||
);
|
||||
if (!packageInfo) {
|
||||
verified.value = true;
|
||||
} else {
|
||||
verified.value = packageInfo.installedVersion === communityNodeAttributes.npmVersion;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `https://registry.npmjs.org/${packageName}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
captureException(new Error('Could not get metadata for package'), { extra: { packageName } });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const publisher = data.maintainers?.[0]?.name as string | undefined;
|
||||
publisherName.value = publisher;
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const downloadsUrl = `https://api.npmjs.org/downloads/range/2022-01-01:${today}/${packageName}`;
|
||||
|
||||
const downloadsResponse = await fetch(downloadsUrl);
|
||||
|
||||
if (!downloadsResponse.ok) {
|
||||
captureException(new Error('Could not get downloads for package'), {
|
||||
extra: { packageName },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadsData: DownloadData = await downloadsResponse.json();
|
||||
if (!downloadsData.downloads || !downloadsData.downloads.length) return;
|
||||
|
||||
const total = downloadsData.downloads.reduce((sum, day) => sum + day.downloads, 0);
|
||||
|
||||
downloads.value = formatNumber(total);
|
||||
} catch (error) {
|
||||
captureException(error, { extra: { packageName } });
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (communityNodeDetails?.packageName) {
|
||||
await fetchPackageInfo(communityNodeDetails.packageName);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<N8nText :class="$style.description" color="text-base" size="medium">
|
||||
{{ communityNodeDetails?.description }}
|
||||
</N8nText>
|
||||
<div :class="$style.separator"></div>
|
||||
<div :class="$style.info">
|
||||
<N8nTooltip placement="top" v-if="verified">
|
||||
<template #content>{{ i18n.baseText('communityNodeInfo.approved') }}</template>
|
||||
<div>
|
||||
<FontAwesomeIcon :class="$style.tooltipIcon" icon="check-circle" />
|
||||
<N8nText color="text-light" size="xsmall" bold data-test-id="verified-tag">
|
||||
{{ i18n.baseText('communityNodeInfo.approved.label') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</N8nTooltip>
|
||||
|
||||
<N8nTooltip placement="top" v-else>
|
||||
<template #content>{{ i18n.baseText('communityNodeInfo.unverified') }}</template>
|
||||
<div>
|
||||
<FontAwesomeIcon :class="$style.tooltipIcon" icon="cube" />
|
||||
<N8nText color="text-light" size="xsmall" bold>
|
||||
{{ i18n.baseText('communityNodeInfo.unverified.label') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</N8nTooltip>
|
||||
|
||||
<div v-if="downloads">
|
||||
<FontAwesomeIcon :class="$style.tooltipIcon" icon="download" />
|
||||
<N8nText color="text-light" size="xsmall" bold data-test-id="number-of-downloads">
|
||||
{{ i18n.baseText('communityNodeInfo.downloads', { interpolate: { downloads } }) }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<div v-if="publisherName">
|
||||
<FontAwesomeIcon :class="$style.tooltipIcon" icon="user" />
|
||||
<N8nText color="text-light" size="xsmall" bold data-test-id="publisher-name">
|
||||
{{ i18n.baseText('communityNodeInfo.publishedBy', { interpolate: { publisherName } }) }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isOwner && !communityNodeDetails?.installed" :class="$style.contactOwnerHint">
|
||||
<N8nIcon color="text-light" icon="info-circle" size="large" />
|
||||
<nN8nText color="text-base" size="medium">
|
||||
<div style="padding-bottom: 8px">
|
||||
{{ i18n.baseText('communityNodeInfo.contact.admin') }}
|
||||
</div>
|
||||
<N8nText bold v-if="ownerEmailList.length">
|
||||
{{ ownerEmailList.join(', ') }}
|
||||
</N8nText>
|
||||
</nN8nText>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
width: 100%;
|
||||
padding: var(--spacing-s);
|
||||
padding-top: 0;
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nodeIcon {
|
||||
--node-icon-size: 36px;
|
||||
margin-right: var(--spacing-s);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: var(--spacing-m) 0;
|
||||
}
|
||||
.separator {
|
||||
height: var(--border-width-base);
|
||||
background: var(--color-foreground-base);
|
||||
margin-bottom: var(--spacing-m);
|
||||
}
|
||||
.info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: left;
|
||||
gap: var(--spacing-m);
|
||||
margin-bottom: var(--spacing-m);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.info div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3xs);
|
||||
}
|
||||
|
||||
.tooltipIcon {
|
||||
color: var(--color-text-light);
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
|
||||
.contactOwnerHint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
padding: var(--spacing-xs);
|
||||
border: var(--border-width-base) solid var(--color-foreground-base);
|
||||
border-radius: 0.25em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { N8nText, N8nIcon } from '@n8n/design-system';
|
||||
|
||||
export interface Props {
|
||||
hint: string;
|
||||
}
|
||||
|
||||
const isOwner = computed(() => useUsersStore().isInstanceOwner);
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isOwner" :class="$style.container">
|
||||
<N8nIcon color="text-light" icon="info-circle" size="large" />
|
||||
<N8nText color="text-base" size="medium"> {{ hint }} </N8nText>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
margin: var(--spacing-xs);
|
||||
margin-top: 0;
|
||||
padding: var(--spacing-xs);
|
||||
border: var(--border-width-base) solid var(--color-foreground-base);
|
||||
border-radius: 0.25em;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -21,6 +21,11 @@ import { useI18n } from '@/composables/useI18n';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import NodeIcon from '@/components/NodeIcon.vue';
|
||||
|
||||
import CommunityNodeDetails from './CommunityNodeDetails.vue';
|
||||
import CommunityNodeInfo from './CommunityNodeInfo.vue';
|
||||
import CommunityNodeDocsLink from './CommunityNodeDocsLink.vue';
|
||||
import CommunityNodeFooter from './CommunityNodeFooter.vue';
|
||||
|
||||
const i18n = useI18n();
|
||||
const { callDebounced } = useDebounce();
|
||||
|
||||
@@ -31,19 +36,39 @@ const nodeCreatorStore = useNodeCreatorStore();
|
||||
|
||||
const activeViewStack = computed(() => useViewStacks().activeViewStack);
|
||||
|
||||
const communityNodeDetails = computed(() => activeViewStack.value.communityNodeDetails);
|
||||
|
||||
const viewStacks = computed(() => useViewStacks().viewStacks);
|
||||
|
||||
const isActionsMode = computed(() => useViewStacks().activeViewStackMode === 'actions');
|
||||
const searchPlaceholder = computed(() =>
|
||||
isActionsMode.value
|
||||
? i18n.baseText('nodeCreator.actionsCategory.searchActions', {
|
||||
interpolate: { node: activeViewStack.value.title as string },
|
||||
})
|
||||
: i18n.baseText('nodeCreator.searchBar.searchNodes'),
|
||||
);
|
||||
|
||||
const searchPlaceholder = computed(() => {
|
||||
let node = activeViewStack.value?.title as string;
|
||||
|
||||
if (communityNodeDetails.value) {
|
||||
node = communityNodeDetails.value.title;
|
||||
}
|
||||
|
||||
if (isActionsMode.value) {
|
||||
return i18n.baseText('nodeCreator.actionsCategory.searchActions', {
|
||||
interpolate: { node },
|
||||
});
|
||||
}
|
||||
|
||||
return i18n.baseText('nodeCreator.searchBar.searchNodes');
|
||||
});
|
||||
|
||||
const showSearchBar = computed(() => {
|
||||
if (activeViewStack.value.communityNodeDetails) return false;
|
||||
return activeViewStack.value.hasSearch;
|
||||
});
|
||||
|
||||
const nodeCreatorView = computed(() => useNodeCreatorStore().selectedView);
|
||||
|
||||
const isCommunityNodeActionsMode = computed(() => {
|
||||
return communityNodeDetails.value && isActionsMode.value && activeViewStack.value.subcategory;
|
||||
});
|
||||
|
||||
function getDefaultActiveIndex(search: string = ''): number {
|
||||
if (activeViewStack.value.mode === 'actions') {
|
||||
// For actions, set the active focus to the first action, not category
|
||||
@@ -165,6 +190,11 @@ function onBackButton() {
|
||||
:size="20"
|
||||
/>
|
||||
<p v-if="activeViewStack.title" :class="$style.title" v-text="activeViewStack.title" />
|
||||
|
||||
<CommunityNodeDocsLink
|
||||
v-if="communityNodeDetails"
|
||||
:package-name="communityNodeDetails.packageName"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="activeViewStack.subtitle"
|
||||
@@ -172,8 +202,9 @@ function onBackButton() {
|
||||
v-text="activeViewStack.subtitle"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<SearchBar
|
||||
v-if="activeViewStack.hasSearch"
|
||||
v-if="showSearchBar"
|
||||
:class="$style.searchBar"
|
||||
:placeholder="
|
||||
searchPlaceholder ? searchPlaceholder : i18n.baseText('nodeCreator.searchBar.searchNodes')
|
||||
@@ -181,6 +212,10 @@ function onBackButton() {
|
||||
:model-value="activeViewStack.search"
|
||||
@update:model-value="onSearch"
|
||||
/>
|
||||
|
||||
<CommunityNodeDetails v-if="communityNodeDetails" />
|
||||
<CommunityNodeInfo v-if="communityNodeDetails && !isActionsMode" />
|
||||
|
||||
<div :class="$style.renderedItems">
|
||||
<n8n-notice
|
||||
v-if="activeViewStack.info && !activeViewStack.search"
|
||||
@@ -194,6 +229,11 @@ function onBackButton() {
|
||||
<!-- Nodes Mode -->
|
||||
<NodesRenderer v-else :root-view="nodeCreatorView" v-bind="$attrs" />
|
||||
</div>
|
||||
|
||||
<CommunityNodeFooter
|
||||
v-if="communityNodeDetails && !isCommunityNodeActionsMode"
|
||||
:package-name="communityNodeDetails.packageName"
|
||||
/>
|
||||
</aside>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +10,8 @@ import ItemsRenderer from './ItemsRenderer.vue';
|
||||
import CategoryItem from '../ItemTypes/CategoryItem.vue';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||
|
||||
import CommunityNodeInstallHint from '../Panel/CommunityNodeInstallHint.vue';
|
||||
|
||||
export interface Props {
|
||||
elements: INodeCreateElement[];
|
||||
category: string;
|
||||
@@ -20,18 +22,24 @@ export interface Props {
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
elements: () => [],
|
||||
});
|
||||
|
||||
const { popViewStack } = useViewStacks();
|
||||
const { popViewStack, activeViewStack } = useViewStacks();
|
||||
const { registerKeyHook } = useKeyboardNavigation();
|
||||
const { workflowId } = useWorkflowsStore();
|
||||
const nodeCreatorStore = useNodeCreatorStore();
|
||||
const i18n = useI18n();
|
||||
|
||||
const activeItemId = computed(() => useKeyboardNavigation()?.activeItemId);
|
||||
const actionCount = computed(() => props.elements.filter(({ type }) => type === 'action').length);
|
||||
const expanded = ref(props.expanded ?? false);
|
||||
const isPreview = computed(
|
||||
() => activeViewStack.communityNodeDetails && !activeViewStack.communityNodeDetails.installed,
|
||||
);
|
||||
|
||||
function toggleExpanded() {
|
||||
setExpanded(!expanded.value);
|
||||
@@ -115,12 +123,18 @@ registerKeyHook(`CategoryLeft_${props.category}`, {
|
||||
<div v-if="expanded && actionCount > 0 && $slots.default" :class="$style.contentSlot">
|
||||
<slot />
|
||||
</div>
|
||||
<CommunityNodeInstallHint
|
||||
v-if="isPreview"
|
||||
:hint="i18n.baseText('communityNodeItem.actions.hint')"
|
||||
/>
|
||||
|
||||
<!-- Pass through listeners & empty slot to ItemsRenderer -->
|
||||
<ItemsRenderer
|
||||
v-if="expanded"
|
||||
v-bind="$attrs"
|
||||
:elements="elements"
|
||||
:is-trigger="isTriggerCategory"
|
||||
:class="[{ [$style.preview]: isPreview }]"
|
||||
>
|
||||
<template #default> </template>
|
||||
<template #empty>
|
||||
@@ -153,4 +167,9 @@ registerKeyHook(`CategoryLeft_${props.category}`, {
|
||||
.categorizedItemsRenderer {
|
||||
padding-bottom: var(--spacing-s);
|
||||
}
|
||||
.preview {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,8 +9,11 @@ import LabelItem from '../ItemTypes/LabelItem.vue';
|
||||
import ActionItem from '../ItemTypes/ActionItem.vue';
|
||||
import ViewItem from '../ItemTypes/ViewItem.vue';
|
||||
import LinkItem from '../ItemTypes/LinkItem.vue';
|
||||
import CommunityNodeItem from '../ItemTypes/CommunityNodeItem.vue';
|
||||
import CategorizedItemsRenderer from './CategorizedItemsRenderer.vue';
|
||||
|
||||
import { useViewStacks } from '../composables/useViewStacks';
|
||||
|
||||
export interface Props {
|
||||
elements?: INodeCreateElement[];
|
||||
activeIndex?: number;
|
||||
@@ -33,9 +36,24 @@ const emit = defineEmits<{
|
||||
|
||||
const renderedItems = ref<INodeCreateElement[]>([]);
|
||||
const renderAnimationRequest = ref<number>(0);
|
||||
const { activeViewStack } = useViewStacks();
|
||||
|
||||
const activeItemId = computed(() => useKeyboardNavigation()?.activeItemId);
|
||||
|
||||
const communityNode = computed(() => activeViewStack.mode === 'community-node');
|
||||
|
||||
const isPreview = computed(() => {
|
||||
return communityNode.value && !activeViewStack.communityNodeDetails?.installed;
|
||||
});
|
||||
|
||||
const highlightActiveItem = computed(() => {
|
||||
if (activeViewStack.communityNodeDetails && !activeViewStack.communityNodeDetails.installed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Lazy render large items lists to prevent the browser from freezing
|
||||
// when loading many items.
|
||||
function renderItems() {
|
||||
@@ -145,9 +163,10 @@ watch(
|
||||
ref="iteratorItems"
|
||||
:class="{
|
||||
clickable: !disabled,
|
||||
[$style.active]: activeItemId === item.uuid,
|
||||
[$style.iteratorItem]: true,
|
||||
[$style.active]: activeItemId === item.uuid && highlightActiveItem,
|
||||
[$style.iteratorItem]: !communityNode,
|
||||
[$style[item.type]]: true,
|
||||
[$style.preview]: isPreview,
|
||||
// Borderless is only applied to views
|
||||
[$style.borderless]: item.type === 'view' && item.properties.borderless === true,
|
||||
}"
|
||||
@@ -157,10 +176,13 @@ watch(
|
||||
@click="wrappedEmit('selected', item)"
|
||||
>
|
||||
<LabelItem v-if="item.type === 'label'" :item="item" />
|
||||
|
||||
<SubcategoryItem v-if="item.type === 'subcategory'" :item="item.properties" />
|
||||
|
||||
<CommunityNodeItem v-if="communityNode" :is-preview="isPreview" />
|
||||
|
||||
<NodeItem
|
||||
v-if="item.type === 'node'"
|
||||
v-if="item.type === 'node' && !communityNode"
|
||||
:node-type="item.properties"
|
||||
:active="true"
|
||||
:subcategory="item.subcategory"
|
||||
@@ -282,4 +304,9 @@ watch(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -193,7 +193,7 @@ function triggersCategory(nodeTypeDescription: INodeTypeDescription): ActionType
|
||||
function resourceCategories(nodeTypeDescription: INodeTypeDescription): ActionTypeDescription[] {
|
||||
const transformedNodes: ActionTypeDescription[] = [];
|
||||
const matchedProperties = nodeTypeDescription.properties.filter(
|
||||
(property) => property.displayName?.toLowerCase() === 'resource',
|
||||
(property) => property.name === 'resource',
|
||||
);
|
||||
|
||||
matchedProperties.forEach((property) => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import difference from 'lodash-es/difference';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||
|
||||
import {
|
||||
extendItemsWithUUID,
|
||||
flattenCreateElements,
|
||||
groupItemsInSections,
|
||||
isAINode,
|
||||
@@ -43,11 +44,21 @@ import { AI_TRANSFORM_NODE_TYPE } from 'n8n-workflow';
|
||||
import type { NodeConnectionType, INodeInputFilter } from 'n8n-workflow';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
||||
export type CommunityNodeDetails = {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
packageName: string;
|
||||
installed: boolean;
|
||||
nodeIcon?: NodeIconSource;
|
||||
};
|
||||
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { type NodeIconSource } from '@/utils/nodeIcon';
|
||||
import { getThemedValue } from '@/utils/nodeTypesUtils';
|
||||
|
||||
interface ViewStack {
|
||||
export interface ViewStack {
|
||||
uuid?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
@@ -57,20 +68,21 @@ interface ViewStack {
|
||||
nodeIcon?: NodeIconSource;
|
||||
rootView?: NodeFilterType;
|
||||
activeIndex?: number;
|
||||
transitionDirection?: 'in' | 'out';
|
||||
transitionDirection?: 'in' | 'out' | 'none';
|
||||
hasSearch?: boolean;
|
||||
preventBack?: boolean;
|
||||
items?: INodeCreateElement[];
|
||||
baselineItems?: INodeCreateElement[];
|
||||
searchItems?: SimplifiedNodeType[];
|
||||
forceIncludeNodes?: string[];
|
||||
mode?: 'actions' | 'nodes';
|
||||
mode?: 'actions' | 'nodes' | 'community-node';
|
||||
hideActions?: boolean;
|
||||
baseFilter?: (item: INodeCreateElement) => boolean;
|
||||
itemsMapper?: (item: INodeCreateElement) => INodeCreateElement;
|
||||
actionsFilter?: (items: ActionTypeDescription[]) => ActionTypeDescription[];
|
||||
panelClass?: string;
|
||||
sections?: string[] | NodeViewItemSection[];
|
||||
communityNodeDetails?: CommunityNodeDetails;
|
||||
}
|
||||
|
||||
export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
|
||||
@@ -150,12 +162,18 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
|
||||
return viewStacks.value[viewStacks.value.length - 1];
|
||||
}
|
||||
|
||||
function getAllNodeCreateElements() {
|
||||
return nodeCreatorStore.mergedNodes.map((item) =>
|
||||
transformNodeType(item),
|
||||
) as NodeCreateElement[];
|
||||
}
|
||||
|
||||
// Generate a delta between the global search results(all nodes) and the stack search results
|
||||
const globalSearchItemsDiff = computed<INodeCreateElement[]>(() => {
|
||||
const stack = getLastActiveStack();
|
||||
if (!stack?.search || isAiSubcategoryView(stack)) return [];
|
||||
|
||||
const allNodes = nodeCreatorStore.mergedNodes.map((item) => transformNodeType(item));
|
||||
const allNodes = getAllNodeCreateElements();
|
||||
// Apply filtering for AI nodes if the current view is not the AI root view
|
||||
const filteredNodes = isAiRootView(stack) ? allNodes : filterOutAiNodes(allNodes);
|
||||
|
||||
@@ -417,14 +435,10 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
|
||||
updateCurrentViewStack({ baselineItems: stackItems });
|
||||
}
|
||||
|
||||
function extendItemsWithUUID(items: INodeCreateElement[]) {
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
uuid: `${item.key}-${uuid()}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function pushViewStack(stack: ViewStack, options: { resetStacks?: boolean } = {}) {
|
||||
function pushViewStack(
|
||||
stack: ViewStack,
|
||||
options: { resetStacks?: boolean; transitionDirection?: 'in' | 'out' | 'none' } = {},
|
||||
) {
|
||||
if (options.resetStacks) {
|
||||
resetViewStacks();
|
||||
}
|
||||
@@ -437,7 +451,7 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
|
||||
viewStacks.value.push({
|
||||
...stack,
|
||||
uuid: newStackUuid,
|
||||
transitionDirection: 'in',
|
||||
transitionDirection: options.transitionDirection ?? 'in',
|
||||
activeIndex: 0,
|
||||
});
|
||||
setStackBaselineItems();
|
||||
@@ -480,5 +494,6 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
|
||||
updateCurrentViewStack,
|
||||
pushViewStack,
|
||||
popViewStack,
|
||||
getAllNodeCreateElements,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { SectionCreateElement } from '@/Interface';
|
||||
import type {
|
||||
ActionTypeDescription,
|
||||
NodeCreateElement,
|
||||
SectionCreateElement,
|
||||
SimplifiedNodeType,
|
||||
} from '@/Interface';
|
||||
import {
|
||||
formatTriggerActionName,
|
||||
filterAndSearchNodes,
|
||||
groupItemsInSections,
|
||||
prepareCommunityNodeDetailsViewStack,
|
||||
removeTrailingTrigger,
|
||||
sortNodeCreateElements,
|
||||
} from './utils';
|
||||
@@ -11,6 +18,10 @@ import {
|
||||
mockSectionCreateElement,
|
||||
} from './__tests__/utils';
|
||||
|
||||
vi.mock('@/stores/settings.store', () => ({
|
||||
useSettingsStore: vi.fn(() => ({ settings: {}, isAskAiEnabled: true })),
|
||||
}));
|
||||
|
||||
describe('NodeCreator - utils', () => {
|
||||
describe('groupItemsInSections', () => {
|
||||
it('should handle multiple sections (with "other" section)', () => {
|
||||
@@ -82,6 +93,239 @@ describe('NodeCreator - utils', () => {
|
||||
expect(formatTriggerActionName(actionName)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterAndSearchNodes', () => {
|
||||
const mergedNodes: SimplifiedNodeType[] = [
|
||||
{
|
||||
displayName: 'Sample Node',
|
||||
defaults: {
|
||||
name: 'SampleNode',
|
||||
},
|
||||
description: 'Sample description',
|
||||
name: 'n8n-nodes-preview-test.SampleNode',
|
||||
group: ['transform'],
|
||||
outputs: ['main'],
|
||||
},
|
||||
{
|
||||
displayName: 'Other Node',
|
||||
defaults: {
|
||||
name: 'OtherNode',
|
||||
},
|
||||
description: 'Other node description',
|
||||
name: 'n8n-nodes-preview-test.OtherNode',
|
||||
group: ['transform'],
|
||||
outputs: ['main'],
|
||||
},
|
||||
];
|
||||
|
||||
test('should return only one node', () => {
|
||||
const result = filterAndSearchNodes(mergedNodes, 'sample', false);
|
||||
|
||||
expect(result.length).toEqual(1);
|
||||
expect(result[0].key).toEqual('n8n-nodes-preview-test.SampleNode');
|
||||
});
|
||||
|
||||
test('should return two nodes', () => {
|
||||
const result = filterAndSearchNodes(mergedNodes, 'node', false);
|
||||
|
||||
expect(result.length).toEqual(2);
|
||||
expect(result[1].key).toEqual('n8n-nodes-preview-test.SampleNode');
|
||||
expect(result[0].key).toEqual('n8n-nodes-preview-test.OtherNode');
|
||||
});
|
||||
});
|
||||
describe('prepareCommunityNodeDetailsViewStack', () => {
|
||||
const nodeCreateElement: NodeCreateElement = {
|
||||
key: 'n8n-nodes-preview-test.OtherNode',
|
||||
properties: {
|
||||
defaults: {
|
||||
name: 'OtherNode',
|
||||
},
|
||||
description: 'Other node description',
|
||||
displayName: 'Other Node',
|
||||
group: ['transform'],
|
||||
name: 'n8n-nodes-preview-test.OtherNode',
|
||||
outputs: ['main'],
|
||||
},
|
||||
subcategory: '*',
|
||||
type: 'node',
|
||||
uuid: 'n8n-nodes-preview-test.OtherNode-32f238f0-2b05-47ce-b43d-7fab6d7ba3cb',
|
||||
};
|
||||
|
||||
test('should return "community-node" view stack', () => {
|
||||
const result = prepareCommunityNodeDetailsViewStack(nodeCreateElement, undefined, undefined);
|
||||
|
||||
expect(result).toEqual({
|
||||
communityNodeDetails: {
|
||||
description: 'Other node description',
|
||||
installed: false,
|
||||
key: 'n8n-nodes-preview-test.OtherNode',
|
||||
nodeIcon: undefined,
|
||||
packageName: 'n8n-nodes-test',
|
||||
title: 'Other Node',
|
||||
},
|
||||
hasSearch: false,
|
||||
items: [
|
||||
{
|
||||
key: 'n8n-nodes-preview-test.OtherNode',
|
||||
properties: {
|
||||
defaults: {
|
||||
name: 'OtherNode',
|
||||
},
|
||||
description: 'Other node description',
|
||||
displayName: 'Other Node',
|
||||
group: ['transform'],
|
||||
name: 'n8n-nodes-preview-test.OtherNode',
|
||||
outputs: ['main'],
|
||||
},
|
||||
subcategory: '*',
|
||||
type: 'node',
|
||||
uuid: 'n8n-nodes-preview-test.OtherNode-32f238f0-2b05-47ce-b43d-7fab6d7ba3cb',
|
||||
},
|
||||
],
|
||||
mode: 'community-node',
|
||||
rootView: undefined,
|
||||
subcategory: 'Other Node',
|
||||
title: 'Community node details',
|
||||
});
|
||||
});
|
||||
|
||||
test('should return "actions" view stack', () => {
|
||||
const nodeActions: ActionTypeDescription[] = [
|
||||
{
|
||||
name: 'n8n-nodes-preview-test.OtherNode',
|
||||
group: ['trigger'],
|
||||
codex: {
|
||||
label: 'Log Actions',
|
||||
categories: ['Actions'],
|
||||
},
|
||||
iconUrl: 'icons/n8n-nodes-preview-test/dist/nodes/Test/test.svg',
|
||||
outputs: ['main'],
|
||||
defaults: {
|
||||
name: 'LogSnag',
|
||||
},
|
||||
actionKey: 'publish',
|
||||
description: 'Publish an event',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['log'],
|
||||
},
|
||||
},
|
||||
values: {
|
||||
operation: 'publish',
|
||||
},
|
||||
displayName: 'Publish an event',
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-preview-test.OtherNode',
|
||||
group: ['trigger'],
|
||||
codex: {
|
||||
label: 'Insight Actions',
|
||||
categories: ['Actions'],
|
||||
},
|
||||
iconUrl: 'icons/n8n-nodes-preview-test/dist/nodes/Test/test.svg',
|
||||
outputs: ['main'],
|
||||
defaults: {
|
||||
name: 'LogSnag',
|
||||
},
|
||||
actionKey: 'publish',
|
||||
description: 'Publish an insight',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['insight'],
|
||||
},
|
||||
},
|
||||
values: {
|
||||
operation: 'publish',
|
||||
},
|
||||
displayName: 'Publish an insight',
|
||||
},
|
||||
];
|
||||
const result = prepareCommunityNodeDetailsViewStack(
|
||||
nodeCreateElement,
|
||||
undefined,
|
||||
undefined,
|
||||
nodeActions,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
communityNodeDetails: {
|
||||
description: 'Other node description',
|
||||
installed: false,
|
||||
key: 'n8n-nodes-preview-test.OtherNode',
|
||||
nodeIcon: undefined,
|
||||
packageName: 'n8n-nodes-test',
|
||||
title: 'Other Node',
|
||||
},
|
||||
hasSearch: false,
|
||||
items: [
|
||||
{
|
||||
key: 'n8n-nodes-preview-test.OtherNode',
|
||||
properties: {
|
||||
actionKey: 'publish',
|
||||
codex: {
|
||||
categories: ['Actions'],
|
||||
label: 'Log Actions',
|
||||
},
|
||||
defaults: {
|
||||
name: 'LogSnag',
|
||||
},
|
||||
description: 'Publish an event',
|
||||
displayName: 'Publish an event',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['log'],
|
||||
},
|
||||
},
|
||||
group: ['trigger'],
|
||||
iconUrl: 'icons/n8n-nodes-preview-test/dist/nodes/Test/test.svg',
|
||||
name: 'n8n-nodes-preview-test.OtherNode',
|
||||
outputs: ['main'],
|
||||
values: {
|
||||
operation: 'publish',
|
||||
},
|
||||
},
|
||||
subcategory: 'Other Node',
|
||||
type: 'action',
|
||||
uuid: expect.any(String),
|
||||
},
|
||||
{
|
||||
key: 'n8n-nodes-preview-test.OtherNode',
|
||||
properties: {
|
||||
actionKey: 'publish',
|
||||
codex: {
|
||||
categories: ['Actions'],
|
||||
label: 'Insight Actions',
|
||||
},
|
||||
defaults: {
|
||||
name: 'LogSnag',
|
||||
},
|
||||
description: 'Publish an insight',
|
||||
displayName: 'Publish an insight',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['insight'],
|
||||
},
|
||||
},
|
||||
group: ['trigger'],
|
||||
iconUrl: 'icons/n8n-nodes-preview-test/dist/nodes/Test/test.svg',
|
||||
name: 'n8n-nodes-preview-test.OtherNode',
|
||||
outputs: ['main'],
|
||||
values: {
|
||||
operation: 'publish',
|
||||
},
|
||||
},
|
||||
subcategory: 'Other Node',
|
||||
type: 'action',
|
||||
uuid: expect.any(String),
|
||||
},
|
||||
],
|
||||
mode: 'actions',
|
||||
rootView: undefined,
|
||||
subcategory: 'Other Node',
|
||||
title: 'Community node details',
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('removeTrailingTrigger', () => {
|
||||
test.each([
|
||||
['Telegram Trigger', 'Telegram'],
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
SimplifiedNodeType,
|
||||
INodeCreateElement,
|
||||
SectionCreateElement,
|
||||
ActionTypeDescription,
|
||||
NodeFilterType,
|
||||
} from '@/Interface';
|
||||
import {
|
||||
AI_CATEGORY_AGENTS,
|
||||
@@ -25,6 +27,10 @@ import * as changeCase from 'change-case';
|
||||
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
|
||||
import type { NodeIconSource } from '../../../utils/nodeIcon';
|
||||
import type { CommunityNodeDetails, ViewStack } from './composables/useViewStacks';
|
||||
|
||||
const COMMUNITY_NODE_TYPE_PREVIEW_TOKEN = '-preview';
|
||||
|
||||
export function transformNodeType(
|
||||
node: SimplifiedNodeType,
|
||||
@@ -221,3 +227,76 @@ export const formatTriggerActionName = (actionPropertyName: string) => {
|
||||
}
|
||||
return changeCase.noCase(name);
|
||||
};
|
||||
|
||||
export const removePreviewToken = (key: string) =>
|
||||
key.replace(COMMUNITY_NODE_TYPE_PREVIEW_TOKEN, '');
|
||||
|
||||
export const isNodePreviewKey = (key = '') => key.includes(COMMUNITY_NODE_TYPE_PREVIEW_TOKEN);
|
||||
|
||||
export function extendItemsWithUUID(items: INodeCreateElement[]) {
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
uuid: `${item.key}-${uuidv4()}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export const filterAndSearchNodes = (
|
||||
mergedNodes: SimplifiedNodeType[],
|
||||
search: string,
|
||||
isAgentSubcategory: boolean,
|
||||
) => {
|
||||
if (!search || isAgentSubcategory) return [];
|
||||
|
||||
const vettedNodes = mergedNodes.map((item) => transformNodeType(item)) as NodeCreateElement[];
|
||||
|
||||
const searchResult: INodeCreateElement[] = extendItemsWithUUID(
|
||||
searchNodes(search || '', vettedNodes),
|
||||
);
|
||||
|
||||
return searchResult;
|
||||
};
|
||||
|
||||
export function prepareCommunityNodeDetailsViewStack(
|
||||
item: NodeCreateElement,
|
||||
nodeIcon: NodeIconSource | undefined,
|
||||
rootView: NodeFilterType | undefined,
|
||||
nodeActions: ActionTypeDescription[] = [],
|
||||
): ViewStack {
|
||||
const installed = !isNodePreviewKey(item.key);
|
||||
const packageName = removePreviewToken(item.key.split('.')[0]);
|
||||
|
||||
const communityNodeDetails: CommunityNodeDetails = {
|
||||
title: item.properties.displayName,
|
||||
description: item.properties.description,
|
||||
key: item.key,
|
||||
nodeIcon,
|
||||
installed,
|
||||
packageName,
|
||||
};
|
||||
|
||||
if (nodeActions.length) {
|
||||
const transformedActions = nodeActions?.map((a) =>
|
||||
transformNodeType(a, item.properties.displayName, 'action'),
|
||||
);
|
||||
|
||||
return {
|
||||
subcategory: item.properties.displayName,
|
||||
title: i18n.baseText('nodeSettings.communityNodeDetails.title'),
|
||||
rootView,
|
||||
hasSearch: false,
|
||||
mode: 'actions',
|
||||
items: transformedActions,
|
||||
communityNodeDetails,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
subcategory: item.properties.displayName,
|
||||
title: i18n.baseText('nodeSettings.communityNodeDetails.title'),
|
||||
rootView,
|
||||
hasSearch: false,
|
||||
items: [item],
|
||||
mode: 'community-node',
|
||||
communityNodeDetails,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user