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:
Michael Kret
2025-05-09 13:14:41 +03:00
committed by GitHub
parent 345b60e8d3
commit 24638420bd
55 changed files with 2548 additions and 152 deletions

View File

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

View File

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

View File

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

View File

@@ -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', {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'],

View File

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