feat(editor): Show node update button in ndv and nodecreator (#19696)

This commit is contained in:
yehorkardash 2025-09-26 09:35:46 +00:00 committed by GitHub
parent 35a4a35358
commit ef5ec8a688
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 571 additions and 173 deletions

View File

@ -96,6 +96,7 @@
"generic.unsavedWork.confirmMessage.cancelButtonText": "Leave without saving",
"generic.upgrade": "Upgrade",
"generic.upgradeNow": "Upgrade now",
"generic.update": "Update",
"generic.credential": "Credential | {count} Credential | {count} Credentials",
"generic.credentials": "Credentials",
"generic.workflow": "Workflow | {count} Workflow | {count} Workflows",
@ -588,6 +589,9 @@
"codeNodeEditor.defaultsTo": "Defaults to {default}.",
"collectionParameter.choose": "Choose...",
"collectionParameter.noProperties": "No properties",
"communityNodeFooter.legacy": "Legacy",
"communityNodeFooter.manage": "Manage",
"communityNodeFooter.reportIssue": "Report issue",
"credentialEdit.credentialConfig.accountConnected": "Account connected",
"credentialEdit.credentialConfig.clickToCopy": "Click To Copy",
"credentialEdit.credentialConfig.connectionTestedSuccessfully": "Connection tested successfully",

View File

@ -18,6 +18,12 @@ import { useBuilderStore } from '@/stores/builder.store';
import type { NodeTypeSelectedPayload } from '@/Interface';
import { onClickOutside } from '@vueuse/core';
// elements that should not trigger onClickOutside
const OUTSIDE_CLICK_WHITELIST = [
// different modals
'.el-overlay-dialog',
];
export interface Props {
active?: boolean;
onNodeTypeSelected?: (value: NodeTypeSelectedPayload[]) => void;
@ -153,7 +159,13 @@ onBeforeUnmount(() => {
unBindOnMouseUpOutside();
});
onClickOutside(nodeCreator, () => emit('closeNodeCreator'));
onClickOutside(
nodeCreator,
() => {
emit('closeNodeCreator');
},
{ ignore: OUTSIDE_CLICK_WHITELIST },
);
</script>
<template>

View File

@ -1,18 +1,25 @@
import { fireEvent, waitFor } from '@testing-library/vue';
import { fireEvent } from '@testing-library/vue';
import { VIEWS } from '@/constants';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import CommunityNodeFooter from './CommunityNodeFooter.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { vi } from 'vitest';
import { type ExtendedPublicInstalledPackage, fetchInstalledPackageInfo } from './utils';
import { ref } from 'vue';
import type { ExtendedPublicInstalledPackage } from '@/utils/communityNodeUtils';
vi.mock('./utils', () => ({
fetchInstalledPackageInfo: vi.fn(),
// Mock the useInstalledCommunityPackage composable
const mockInstalledPackage = ref<ExtendedPublicInstalledPackage | undefined>(undefined);
vi.mock('@/composables/useInstalledCommunityPackage', () => ({
useInstalledCommunityPackage: vi.fn(() => ({
installedPackage: mockInstalledPackage,
isUpdateCheckAvailable: ref(false),
isCommunityNode: ref(true),
initInstalledPackage: vi.fn(),
})),
}));
const mockedFetchInstalledPackageInfo = vi.mocked(fetchInstalledPackageInfo);
const push = vi.fn();
vi.mock('vue-router', async (importOriginal) => {
@ -38,6 +45,9 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
});
vi.stubGlobal('fetch', fetchMock);
// Reset the mock installed package before each test
mockInstalledPackage.value = undefined;
});
afterEach(() => {
@ -55,7 +65,7 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
expect(push).toHaveBeenCalledWith({ name: VIEWS.COMMUNITY_NODES });
});
it('Manage should not be in the footer', async () => {
it('Manage should not be in the footer', () => {
const { queryByText } = createComponentRenderer(CommunityNodeFooter)({
props: { packageName: 'n8n-nodes-test', showManage: false },
});
@ -63,28 +73,37 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
expect(queryByText('Manage')).not.toBeInTheDocument();
});
it('displays "Legacy" when updateAvailable', async () => {
mockedFetchInstalledPackageInfo.mockResolvedValue({
it('displays "Legacy" when updateAvailable', () => {
mockInstalledPackage.value = {
packageName: 'n8n-nodes-test',
installedVersion: '1.0.0',
updateAvailable: '1.0.1',
unverifiedUpdate: false,
} as ExtendedPublicInstalledPackage);
installedNodes: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const { getByText } = createComponentRenderer(CommunityNodeFooter)({
props: {
packageName: 'n8n-nodes-test',
showManage: false,
},
});
await waitFor(() => expect(mockedFetchInstalledPackageInfo).toHaveBeenCalled());
expect(getByText('Package version 1.0.0 (Legacy)')).toBeInTheDocument();
});
it('displays "Latest" when not updateAvailable', async () => {
mockedFetchInstalledPackageInfo.mockResolvedValue({
it('displays "Latest" when not updateAvailable', () => {
mockInstalledPackage.value = {
packageName: 'n8n-nodes-test',
installedVersion: '1.0.0',
unverifiedUpdate: false,
} as ExtendedPublicInstalledPackage);
installedNodes: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const { getByText } = createComponentRenderer(CommunityNodeFooter)({
props: {
packageName: 'n8n-nodes-test',
@ -92,16 +111,19 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
},
});
await waitFor(() => expect(mockedFetchInstalledPackageInfo).toHaveBeenCalled());
expect(getByText('Package version 1.0.0 (Latest)')).toBeInTheDocument();
});
it('displays "Latest" when only unverified update is available', async () => {
mockedFetchInstalledPackageInfo.mockResolvedValue({
it('displays "Latest" when only unverified update is available', () => {
mockInstalledPackage.value = {
packageName: 'n8n-nodes-test',
installedVersion: '1.0.0',
unverifiedUpdate: true,
} as ExtendedPublicInstalledPackage);
installedNodes: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const { getByText } = createComponentRenderer(CommunityNodeFooter)({
props: {
packageName: 'n8n-nodes-test',
@ -109,8 +131,18 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
},
});
await waitFor(() => expect(mockedFetchInstalledPackageInfo).toHaveBeenCalled());
expect(getByText('Package version 1.0.0 (Latest)')).toBeInTheDocument();
});
it('does not display package version when installedPackage is undefined', () => {
// mockInstalledPackage.value is already undefined from beforeEach
const { queryByText } = createComponentRenderer(CommunityNodeFooter)({
props: {
packageName: 'n8n-nodes-test',
showManage: false,
},
});
expect(queryByText(/Package version/)).not.toBeInTheDocument();
});
});

View File

@ -5,7 +5,8 @@ import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { N8nLink, N8nText } from '@n8n/design-system';
import { fetchInstalledPackageInfo, type ExtendedPublicInstalledPackage } from './utils';
import { useInstalledCommunityPackage } from '@/composables/useInstalledCommunityPackage';
import { i18n } from '@n8n/i18n';
export interface Props {
packageName: string;
@ -16,7 +17,7 @@ const props = defineProps<Props>();
const router = useRouter();
const bugsUrl = ref<string>(`https://registry.npmjs.org/${props.packageName}`);
const installedPackage = ref<ExtendedPublicInstalledPackage | undefined>(undefined);
const { installedPackage } = useInstalledCommunityPackage(props.packageName);
async function openSettingsPage() {
await router.push({ name: VIEWS.COMMUNITY_NODES });
@ -51,7 +52,6 @@ async function getBugsUrl(packageName: string) {
onMounted(async () => {
if (props.packageName) {
await getBugsUrl(props.packageName);
installedPackage.value = await fetchInstalledPackageInfo(props.packageName);
}
});
</script>
@ -63,18 +63,22 @@ onMounted(async () => {
<N8nText v-if="installedPackage" size="small" color="text-light" style="margin-right: auto">
Package version {{ installedPackage.installedVersion }} ({{
installedPackage.updateAvailable && !installedPackage.unverifiedUpdate
? 'Legacy'
: 'Latest'
? i18n.baseText('communityNodeFooter.legacy')
: i18n.baseText('nodeSettings.latest')
}})
</N8nText>
<template v-if="props.showManage">
<N8nLink theme="text" @click="openSettingsPage">
<N8nText size="small" color="primary" bold> Manage </N8nText>
<N8nText size="small" color="primary" bold>
{{ i18n.baseText('communityNodeFooter.manage') }}
</N8nText>
</N8nLink>
<N8nText size="small" style="color: var(--color-foreground-base)" bold>|</N8nText>
</template>
<N8nLink theme="text" @click="openIssuesPage">
<N8nText size="small" color="primary" bold> Report issue </N8nText>
<N8nText size="small" color="primary" bold>
{{ i18n.baseText('communityNodeFooter.reportIssue') }}
</N8nText>
</N8nLink>
</div>
</div>

View File

@ -1,16 +1,34 @@
import { createComponentRenderer } from '@/__tests__/render';
import { useInstalledCommunityPackage } from '@/composables/useInstalledCommunityPackage';
import type { ExtendedPublicInstalledPackage } from '@/utils/communityNodeUtils';
import { type TestingPinia, createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
import { setActivePinia } from 'pinia';
import { type ComputedRef, ref } from 'vue';
import type { CommunityNodeDetails } from '../composables/useViewStacks';
import CommunityNodeInfo from './CommunityNodeInfo.vue';
import { type ExtendedPublicInstalledPackage, fetchInstalledPackageInfo } from './utils';
vi.mock('./utils', () => ({
fetchInstalledPackageInfo: vi.fn(),
}));
const mockedFetchInstalledPackageInfo = vi.mocked(fetchInstalledPackageInfo);
// const mockInstalledPackage = ref<ExtendedPublicInstalledPackage | undefined>(undefined);
// const isUpdateCheckAvailable = ref(false);
const defaultUseInstalledCommunityPackage = {
installedPackage: ref({
installedVersion: '1.0.0',
packageName: 'n8n-nodes-test',
unverifiedUpdate: false,
}) as ComputedRef<ExtendedPublicInstalledPackage>,
isUpdateCheckAvailable: ref(false),
isCommunityNode: ref(true),
initInstalledPackage: vi.fn(),
} as unknown as ReturnType<typeof useInstalledCommunityPackage>;
vi.mock('@/composables/useInstalledCommunityPackage', () => ({
useInstalledCommunityPackage: vi.fn(() => defaultUseInstalledCommunityPackage),
}));
const getCommunityNodeAttributes = vi.fn();
@ -94,11 +112,15 @@ describe('CommunityNodeInfo', () => {
numberOfDownloads: 9999,
nodeVersions: [{ npmVersion: '1.0.0' }],
});
mockedFetchInstalledPackageInfo.mockResolvedValue({
installedVersion: '1.0.0',
packageName: 'n8n-nodes-test',
unverifiedUpdate: false,
} as ExtendedPublicInstalledPackage);
vi.mocked(useInstalledCommunityPackage).mockReturnValue({
...defaultUseInstalledCommunityPackage,
installedPackage: ref({
installedVersion: '1.0.0',
packageName: 'n8n-nodes-test',
unverifiedUpdate: false,
}) as ComputedRef<ExtendedPublicInstalledPackage>,
});
const wrapper = renderComponent({ pinia });
@ -112,7 +134,7 @@ describe('CommunityNodeInfo', () => {
expect(wrapper.getByTestId('publisher-name').textContent).toEqual('Published by contributor');
});
it('should display update notice, should show verified batch for older versions', async () => {
it('should display update notice, should show verified badge for older versions', async () => {
const { useViewStacks } = await import('../composables/useViewStacks');
vi.mocked(useViewStacks).mockReturnValue({
activeViewStack: {
@ -130,12 +152,16 @@ describe('CommunityNodeInfo', () => {
numberOfDownloads: 9999,
nodeVersions: [{ npmVersion: '1.0.0' }, { npmVersion: '0.0.9' }],
});
mockedFetchInstalledPackageInfo.mockResolvedValue({
installedVersion: '0.0.9',
packageName: 'n8n-nodes-test',
updateAvailable: '1.0.1',
unverifiedUpdate: false,
} as ExtendedPublicInstalledPackage);
vi.mocked(useInstalledCommunityPackage).mockReturnValue({
...defaultUseInstalledCommunityPackage,
isUpdateCheckAvailable: ref(true) as ComputedRef<boolean>,
installedPackage: ref({
installedVersion: '0.0.9',
packageName: 'n8n-nodes-test',
updateAvailable: '1.0.1',
unverifiedUpdate: false,
}) as ComputedRef<ExtendedPublicInstalledPackage>,
});
const wrapper = renderComponent({ pinia });
@ -147,9 +173,9 @@ describe('CommunityNodeInfo', () => {
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');
expect(wrapper.getByTestId('update-available').textContent).toEqual(
'A new node package version is available',
);
expect(
wrapper.getByTestId('update-available').querySelector('.n8n-text')?.textContent?.trim(),
).toEqual('A new node package version is available');
});
it('should NOT display update notice for unverified update', async () => {
@ -170,12 +196,15 @@ describe('CommunityNodeInfo', () => {
numberOfDownloads: 9999,
nodeVersions: [{ npmVersion: '1.0.0' }, { npmVersion: '0.0.9' }],
});
mockedFetchInstalledPackageInfo.mockResolvedValue({
installedVersion: '0.0.9',
packageName: 'n8n-nodes-test',
updateAvailable: '1.0.1',
unverifiedUpdate: true,
} as ExtendedPublicInstalledPackage);
vi.mocked(useInstalledCommunityPackage).mockReturnValue({
...defaultUseInstalledCommunityPackage,
installedPackage: ref({
installedVersion: '0.0.9',
packageName: 'n8n-nodes-test',
updateAvailable: '1.0.1',
unverifiedUpdate: true,
}) as ComputedRef<ExtendedPublicInstalledPackage>,
});
const wrapper = renderComponent({ pinia });

View File

@ -7,7 +7,7 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { captureException } from '@sentry/vue';
import { N8nText, N8nTooltip, N8nIcon } from '@n8n/design-system';
import ShieldIcon from 'virtual:icons/fa-solid/shield-alt';
import { type ExtendedPublicInstalledPackage, fetchInstalledPackageInfo } from './utils';
import { useInstalledCommunityPackage } from '@/composables/useInstalledCommunityPackage';
const { activeViewStack } = useViewStacks();
@ -21,7 +21,9 @@ const publisherName = ref<string | undefined>(undefined);
const downloads = ref<string | null>(null);
const verified = ref(false);
const official = ref(false);
const installedPackage = ref<ExtendedPublicInstalledPackage | undefined>(undefined);
const packageName = computed(() => communityNodeDetails?.packageName);
const { installedPackage, initInstalledPackage, isUpdateCheckAvailable } =
useInstalledCommunityPackage(packageName);
const nodeTypesStore = useNodeTypesStore();
@ -42,9 +44,9 @@ async function fetchPackageInfo(packageName: string) {
const communityNodeAttributes = await nodeTypesStore.getCommunityNodeAttributes(
activeViewStack.communityNodeDetails?.key || '',
);
if (communityNodeDetails?.installed) {
installedPackage.value = await fetchInstalledPackageInfo(communityNodeDetails.packageName);
let packageInfo = installedPackage.value;
if (communityNodeDetails?.installed && !packageInfo) {
packageInfo = await initInstalledPackage();
}
if (communityNodeAttributes) {
@ -52,11 +54,11 @@ async function fetchPackageInfo(packageName: string) {
downloads.value = formatNumber(communityNodeAttributes.numberOfDownloads);
official.value = communityNodeAttributes.isOfficialNode;
if (!installedPackage.value) {
if (!packageInfo) {
verified.value = true;
} else {
const verifiedVersions = communityNodeAttributes.nodeVersions?.map((v) => v.npmVersion) ?? [];
verified.value = verifiedVersions.includes(installedPackage.value.installedVersion);
verified.value = verifiedVersions.includes(packageInfo.installedVersion);
}
return;
@ -112,8 +114,9 @@ onMounted(async () => {
{{ communityNodeDetails?.description }}
</N8nText>
<CommunityNodeUpdateInfo
v-if="isOwner && installedPackage?.updateAvailable && !installedPackage.unverifiedUpdate"
v-if="isUpdateCheckAvailable && installedPackage?.updateAvailable"
data-test-id="update-available"
:package-name="communityNodeDetails?.packageName"
/>
<div v-else :class="$style.separator"></div>
<div :class="$style.info">

View File

@ -1,32 +1,29 @@
<script setup lang="ts">
import { N8nNotice } from '@n8n/design-system';
import { i18n } from '@n8n/i18n';
import { useUIStore } from '@/stores/ui.store';
import { computed } from 'vue';
const noticeStyles = computed(() => {
const isDark = useUIStore().appliedTheme === 'dark';
if (isDark) {
return {
borderColor: 'var(--color-callout-secondary-border)',
backgroundColor: 'var(--color-callout-secondary-background)',
color: 'var(--color-callout-secondary-font)',
};
}
return {
borderColor: 'var(--color-secondary)',
backgroundColor: 'var(--color-secondary-tint-3)',
};
});
import { N8nButton, N8nCallout } from '@n8n/design-system';
import { i18n } from '@n8n/i18n';
interface Props {
packageName?: string;
}
const props = defineProps<Props>();
const { openCommunityPackageUpdateConfirmModal } = useUIStore();
const onUpdate = () => {
if (!props.packageName) return;
openCommunityPackageUpdateConfirmModal(props.packageName);
};
</script>
<template>
<N8nNotice
theme="info"
:style="{
marginTop: '0',
...noticeStyles,
}"
>
<N8nCallout theme="secondary" :iconless="true" style="margin-bottom: var(--spacing-s)">
{{ i18n.baseText('communityNodeUpdateInfo.available') }}
</N8nNotice>
<template v-if="props.packageName" #trailingContent>
<N8nButton type="secondary" @click="onUpdate">
{{ i18n.baseText('generic.update') }}
</N8nButton>
</template>
</N8nCallout>
</template>

View File

@ -1,64 +1,63 @@
<script setup lang="ts">
import { useTemplateRef, computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type {
INodeParameters,
NodeConnectionType,
NodeParameterValue,
INodeCredentialDescription,
PublicInstalledPackage,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeHelpers, deepCopy } from 'n8n-workflow';
import type {
CurlToJSONResponse,
INodeUi,
INodeUpdatePropertiesInformation,
IUpdateInformation,
} from '@/Interface';
import type {
INodeCredentialDescription,
INodeParameters,
NodeConnectionType,
NodeParameterValue,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeHelpers, deepCopy } from 'n8n-workflow';
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
import { BASE_NODE_SURVEY_URL } from '@/constants';
import ParameterInputList from '@/components/ParameterInputList.vue';
import NDVSubConnections from '@/components/NDVSubConnections.vue';
import NodeCredentials from '@/components/NodeCredentials.vue';
import NodeSettingsHeader from '@/components/NodeSettingsHeader.vue';
import NodeSettingsTabs from '@/components/NodeSettingsTabs.vue';
import NodeWebhooks from '@/components/NodeWebhooks.vue';
import NDVSubConnections from '@/components/NDVSubConnections.vue';
import NodeSettingsHeader from '@/components/NodeSettingsHeader.vue';
import ParameterInputList from '@/components/ParameterInputList.vue';
import get from 'lodash/get';
import NodeExecuteButton from './NodeExecuteButton.vue';
import {
collectSettings,
createCommonNodeSettings,
nameIsParameter,
getNodeSettingsInitialValues,
collectParametersByTab,
} from '@/utils/nodeSettingsUtils';
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useHistoryStore } from '@/stores/history.store';
import { RenameNodeCommand } from '@/models/history';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useCommunityNodesStore } from '@/stores/communityNodes.store';
import { useUsersStore } from '@/stores/users.store';
import type { EventBus } from '@n8n/utils/event-bus';
import ExperimentalEmbeddedNdvHeader from '@/components/canvas/experimental/components/ExperimentalEmbeddedNdvHeader.vue';
import FreeAiCreditsCallout from '@/components/FreeAiCreditsCallout.vue';
import NodeActionsList from '@/components/NodeActionsList.vue';
import NodeSettingsInvalidNodeWarning from '@/components/NodeSettingsInvalidNodeWarning.vue';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useInstalledCommunityPackage } from '@/composables/useInstalledCommunityPackage';
import { useNodeCredentialOptions } from '@/composables/useNodeCredentialOptions';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useI18n } from '@n8n/i18n';
import { useNodeSettingsParameters } from '@/composables/useNodeSettingsParameters';
import { useTelemetry } from '@/composables/useTelemetry';
import { importCurlEventBus, ndvEventBus } from '@/event-bus';
import { ProjectTypes } from '@/types/projects.types';
import FreeAiCreditsCallout from '@/components/FreeAiCreditsCallout.vue';
import NodeStorageLimitCallout from '@/features/dataStore/components/NodeStorageLimitCallout.vue';
import { useResizeObserver } from '@vueuse/core';
import { useNodeSettingsParameters } from '@/composables/useNodeSettingsParameters';
import { N8nBlockUi, N8nIcon, N8nNotice, N8nText } from '@n8n/design-system';
import ExperimentalEmbeddedNdvHeader from '@/components/canvas/experimental/components/ExperimentalEmbeddedNdvHeader.vue';
import NodeSettingsInvalidNodeWarning from '@/components/NodeSettingsInvalidNodeWarning.vue';
import { RenameNodeCommand } from '@/models/history';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useHistoryStore } from '@/stores/history.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { NodeSettingsTab } from '@/types/nodeSettings';
import NodeActionsList from '@/components/NodeActionsList.vue';
import { useNodeCredentialOptions } from '@/composables/useNodeCredentialOptions';
import { ProjectTypes } from '@/types/projects.types';
import {
collectParametersByTab,
collectSettings,
createCommonNodeSettings,
getNodeSettingsInitialValues,
nameIsParameter,
} from '@/utils/nodeSettingsUtils';
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
import { N8nBlockUi, N8nIcon, N8nNotice, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { EventBus } from '@n8n/utils/event-bus';
import { useResizeObserver } from '@vueuse/core';
import NodeExecuteButton from './NodeExecuteButton.vue';
const props = withDefaults(
defineProps<{
@ -143,8 +142,6 @@ const nodeValuesInitialized = ref(false);
const hiddenIssuesInputs = ref<string[]>([]);
const subConnections = ref<InstanceType<typeof NDVSubConnections> | null>(null);
const installedPackage = ref<PublicInstalledPackage | undefined>(undefined);
const currentWorkflow = computed(
() => workflowsStore.getWorkflowById(workflowsStore.workflowObject.id), // @TODO check if we actually need workflowObject here
);
@ -163,6 +160,9 @@ const nodeType = computed(() =>
const { areAllCredentialsSet } = useNodeCredentialOptions(node, nodeType, '');
const nodeTypeName = computed(() => node.value?.type);
const { installedPackage, isUpdateCheckAvailable } = useInstalledCommunityPackage(nodeTypeName);
const isTriggerNode = computed(() => !!node.value && nodeTypesStore.isTriggerNode(node.value.type));
const isToolNode = computed(() => !!node.value && nodeTypesStore.isToolNode(node.value.type));
@ -541,10 +541,6 @@ onMounted(async () => {
}
importCurlEventBus.on('setHttpNodeParameters', setHttpNodeParameters);
ndvEventBus.on('updateParameterValue', valueChanged);
if (isCommunityNode.value && useUsersStore().isInstanceOwner) {
installedPackage.value = await useCommunityNodesStore().getInstalledPackage(packageName.value);
}
});
onBeforeUnmount(() => {
@ -748,8 +744,10 @@ function handleSelectAction(params: INodeParameters) {
</div>
<div v-show="openPanel === 'settings'">
<CommunityNodeUpdateInfo
v-if="isCommunityNode && installedPackage?.updateAvailable"
v-if="isUpdateCheckAvailable && installedPackage?.updateAvailable"
data-test-id="update-available"
:package-name="packageName"
style="margin-top: var(--spacing-s)"
/>
<ParameterInputList
:parameters="parametersByTab.settings"

View File

@ -1,21 +1,29 @@
import { describe, it, expect, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import NodeSettingsTabs from '@/components/NodeSettingsTabs.vue';
import { useCommunityNodesStore } from '@/stores/communityNodes.store';
import type { PublicInstalledPackage } from 'n8n-workflow';
import { ref } from 'vue';
import type { ExtendedPublicInstalledPackage } from '@/utils/communityNodeUtils';
import { useInstalledCommunityPackage } from '@/composables/useInstalledCommunityPackage';
const renderComponent = createComponentRenderer(NodeSettingsTabs);
vi.mock('@/stores/communityNodes.store', () => ({
useCommunityNodesStore: vi.fn(() => ({
getInstalledPackage: vi.fn(),
vi.mock('@/composables/useInstalledCommunityPackage', () => ({
useInstalledCommunityPackage: vi.fn(() => ({
installedPackage: ref<ExtendedPublicInstalledPackage | undefined>(undefined),
isCommunityNode: ref(false),
isUpdateCheckAvailable: ref(false),
initInstalledPackage: vi.fn(),
})),
}));
let installedCommunityPackage: ReturnType<typeof useInstalledCommunityPackage>;
describe('NodeSettingsTabs', () => {
beforeEach(() => {
createTestingPinia({ stubActions: false });
installedCommunityPackage = useInstalledCommunityPackage();
});
afterEach(() => {
@ -30,10 +38,16 @@ describe('NodeSettingsTabs', () => {
});
it('displays notification when updateAvailable', async () => {
const communityNodesStore = useCommunityNodesStore();
vi.spyOn(communityNodesStore, 'getInstalledPackage').mockResolvedValue({
updateAvailable: '1.0.1',
} as PublicInstalledPackage);
vi.spyOn(installedCommunityPackage.isUpdateCheckAvailable, 'value', 'get').mockReturnValue(
true,
);
vi.spyOn(installedCommunityPackage.installedPackage, 'value', 'get').mockReturnValue(
mock<ExtendedPublicInstalledPackage>({
packageName: 'test-package',
installedVersion: '1.0.0',
updateAvailable: '1.0.1',
}),
);
const { findByTestId } = renderComponent({
props: {},
@ -43,12 +57,8 @@ describe('NodeSettingsTabs', () => {
expect(notification).toBeDefined();
});
it('does not display notification when not updateAvailable', async () => {
const communityNodesStore = useCommunityNodesStore();
vi.spyOn(communityNodesStore, 'getInstalledPackage').mockResolvedValue(
{} as PublicInstalledPackage,
);
it('does not display notification when not updateAvailable', () => {
// Default mock values (from beforeEach) should not trigger notification
const { queryByTestId } = renderComponent({
props: {},
});

View File

@ -3,19 +3,17 @@ import type { ITab } from '@/Interface';
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL } from '@/constants';
import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { INodeTypeDescription, PublicInstalledPackage } from 'n8n-workflow';
import type { INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { computed, onMounted, ref } from 'vue';
import { computed } from 'vue';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
import { N8nTabs } from '@n8n/design-system';
import { useInstalledCommunityPackage } from '@/composables/useInstalledCommunityPackage';
import { useNodeDocsUrl } from '@/composables/useNodeDocsUrl';
import { useCommunityNodesStore } from '@/stores/communityNodes.store';
import { useUsersStore } from '@/stores/users.store';
import { useTelemetry } from '@/composables/useTelemetry';
import type { NodeSettingsTab } from '@/types/nodeSettings';
import { N8nTabs } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
type Props = {
modelValue?: NodeSettingsTab;
@ -46,19 +44,12 @@ const workflowsStore = useWorkflowsStore();
const i18n = useI18n();
const telemetry = useTelemetry();
const { docsUrl } = useNodeDocsUrl({ nodeType: () => props.nodeType });
const communityNodesStore = useCommunityNodesStore();
const activeNode = computed(() => ndvStore.activeNode);
const installedPackage = ref<PublicInstalledPackage | undefined>(undefined);
const isCommunityNode = computed(() => {
const nodeType = props.nodeType;
if (nodeType) {
return isCommunityPackageName(nodeType.name);
}
return false;
});
const nodeTypeName = computed(() => props.nodeType?.name);
const { installedPackage, isCommunityNode, isUpdateCheckAvailable } =
useInstalledCommunityPackage(nodeTypeName);
const packageName = computed(() => props.nodeType?.name.split('.')[0] ?? '');
@ -101,7 +92,8 @@ const options = computed(() => {
},
{
value: 'settings',
notification: installedPackage.value?.updateAvailable ? true : undefined,
notification:
isUpdateCheckAvailable.value && installedPackage.value?.updateAvailable ? true : undefined,
...(props.compact
? { icon: 'settings', align: 'right', tooltip: i18n.baseText('nodeSettings.settings') }
: { label: i18n.baseText('nodeSettings.settings') }),
@ -169,12 +161,6 @@ function onTooltipClick(tab: NodeSettingsTab, event: MouseEvent) {
telemetry.track('user clicked cnr docs link', { source: 'node details view' });
}
}
onMounted(async () => {
if (isCommunityNode.value && useUsersStore().isInstanceOwner) {
installedPackage.value = await communityNodesStore.getInstalledPackage(packageName.value);
}
});
</script>
<template>

View File

@ -0,0 +1,258 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { nextTick } from 'vue';
import { mockedStore } from '@/__tests__/utils';
import { useInstalledCommunityPackage } from './useInstalledCommunityPackage';
import { useCommunityNodesStore } from '@/stores/communityNodes.store';
import { useUsersStore } from '@/stores/users.store';
import type { ExtendedPublicInstalledPackage } from '@/utils/communityNodeUtils';
// Mock the utility functions
vi.mock('@/utils/nodeTypesUtils', () => ({
isCommunityPackageName: vi.fn(),
}));
vi.mock('@/utils/communityNodeUtils', () => ({
fetchInstalledPackageInfo: vi.fn(),
}));
// Import mocked functions
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
import { fetchInstalledPackageInfo } from '@/utils/communityNodeUtils';
const mockIsCommunityPackageName = vi.mocked(isCommunityPackageName);
const mockFetchInstalledPackageInfo = vi.mocked(fetchInstalledPackageInfo);
describe('useInstalledCommunityPackage', () => {
beforeEach(() => {
setActivePinia(createTestingPinia());
vi.clearAllMocks();
});
describe('computed properties', () => {
it('should handle nodeTypeName parsing correctly', () => {
mockIsCommunityPackageName.mockReturnValue(true);
const composable = useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
expect(composable.isCommunityNode.value).toBe(true);
});
it('should return false for isCommunityNode when nodeTypeName is undefined', () => {
const composable = useInstalledCommunityPackage();
expect(composable.isCommunityNode.value).toBe(false);
});
it('should compute isCommunityNode correctly when nodeTypeName is provided and is community', () => {
mockIsCommunityPackageName.mockReturnValue(true);
const { isCommunityNode } = useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
expect(isCommunityNode.value).toBe(true);
expect(mockIsCommunityPackageName).toHaveBeenCalledWith('@test/n8n-nodes-test.TestNode');
});
it('should compute isCommunityNode as false when nodeTypeName is provided but not community', () => {
mockIsCommunityPackageName.mockReturnValue(false);
const { isCommunityNode } = useInstalledCommunityPackage('n8n-nodes-base.HttpRequest');
expect(isCommunityNode.value).toBe(false);
expect(mockIsCommunityPackageName).toHaveBeenCalledWith('n8n-nodes-base.HttpRequest');
});
it('should compute isCommunityNode as false when nodeTypeName is undefined', () => {
const { isCommunityNode } = useInstalledCommunityPackage();
expect(isCommunityNode.value).toBe(false);
expect(mockIsCommunityPackageName).not.toHaveBeenCalled();
});
it('should compute isUpdateCheckAvailable correctly when user is instance owner and node is community', () => {
const usersStore = mockedStore(useUsersStore);
usersStore.isInstanceOwner = true;
mockIsCommunityPackageName.mockReturnValue(true);
const { isUpdateCheckAvailable } = useInstalledCommunityPackage(
'@test/n8n-nodes-test.TestNode',
);
expect(isUpdateCheckAvailable.value).toBe(true);
});
it('should compute isUpdateCheckAvailable as false when user is not instance owner', () => {
const usersStore = mockedStore(useUsersStore);
usersStore.isInstanceOwner = false;
mockIsCommunityPackageName.mockReturnValue(true);
const { isUpdateCheckAvailable } = useInstalledCommunityPackage(
'@test/n8n-nodes-test.TestNode',
);
expect(isUpdateCheckAvailable.value).toBe(false);
});
it('should compute isUpdateCheckAvailable as false when node is not community', () => {
const usersStore = mockedStore(useUsersStore);
usersStore.isInstanceOwner = true;
mockIsCommunityPackageName.mockReturnValue(false);
const { isUpdateCheckAvailable } = useInstalledCommunityPackage('n8n-nodes-base.HttpRequest');
expect(isUpdateCheckAvailable.value).toBe(false);
});
});
describe('initInstalledPackage', () => {
it('should return undefined when packageName is empty', async () => {
const { initInstalledPackage } = useInstalledCommunityPackage();
const result = await initInstalledPackage();
expect(result).toBeUndefined();
expect(mockFetchInstalledPackageInfo).not.toHaveBeenCalled();
});
it('should return undefined when node is not a community node', async () => {
mockIsCommunityPackageName.mockReturnValue(false);
const { initInstalledPackage } = useInstalledCommunityPackage('n8n-nodes-base.HttpRequest');
const result = await initInstalledPackage();
expect(result).toBeUndefined();
expect(mockFetchInstalledPackageInfo).not.toHaveBeenCalled();
});
it('should fetch and set installedPackage when conditions are met', async () => {
const mockPackage: ExtendedPublicInstalledPackage = {
packageName: '@test/n8n-nodes-test',
installedVersion: '1.0.0',
installedNodes: [],
createdAt: new Date(),
updatedAt: new Date(),
unverifiedUpdate: false,
};
mockIsCommunityPackageName.mockReturnValue(true);
mockFetchInstalledPackageInfo.mockResolvedValue(mockPackage);
const { initInstalledPackage, installedPackage } = useInstalledCommunityPackage(
'@test/n8n-nodes-test.TestNode',
);
const result = await initInstalledPackage();
expect(mockFetchInstalledPackageInfo).toHaveBeenCalledWith('@test/n8n-nodes-test');
expect(result).toStrictEqual(mockPackage);
expect(installedPackage.value).toStrictEqual(mockPackage);
});
it('should handle fetchInstalledPackageInfo returning undefined', async () => {
mockIsCommunityPackageName.mockReturnValue(true);
mockFetchInstalledPackageInfo.mockResolvedValue(undefined);
const { initInstalledPackage, installedPackage } = useInstalledCommunityPackage(
'@test/n8n-nodes-test.TestNode',
);
const result = await initInstalledPackage();
expect(mockFetchInstalledPackageInfo).toHaveBeenCalledWith('@test/n8n-nodes-test');
expect(result).toBeUndefined();
expect(installedPackage.value).toBeUndefined();
});
});
describe('watcher functionality', () => {
it('should call initInstalledPackage when installedPackages changes', async () => {
const communityNodesStore = mockedStore(useCommunityNodesStore);
const mockPackage: ExtendedPublicInstalledPackage = {
packageName: '@test/n8n-nodes-test',
installedVersion: '1.0.0',
installedNodes: [],
createdAt: new Date(),
updatedAt: new Date(),
unverifiedUpdate: false,
};
mockIsCommunityPackageName.mockReturnValue(true);
mockFetchInstalledPackageInfo.mockResolvedValue(mockPackage);
// Initialize the composable
const { installedPackage } = useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
// Simulate store change
communityNodesStore.installedPackages = {
'@test/n8n-nodes-test': mockPackage,
};
await nextTick();
expect(mockFetchInstalledPackageInfo).toHaveBeenCalledWith('@test/n8n-nodes-test');
expect(installedPackage.value).toStrictEqual(mockPackage);
});
it('should not call initInstalledPackage when packageName is empty', async () => {
const communityNodesStore = mockedStore(useCommunityNodesStore);
// Initialize the composable without nodeTypeName
useInstalledCommunityPackage();
// Simulate store change
const mockSomePackage: ExtendedPublicInstalledPackage = {
packageName: 'some-package',
installedVersion: '1.0.0',
installedNodes: [],
createdAt: new Date(),
updatedAt: new Date(),
unverifiedUpdate: false,
};
communityNodesStore.installedPackages = {
'some-package': mockSomePackage,
};
await nextTick();
expect(mockFetchInstalledPackageInfo).not.toHaveBeenCalled();
});
it('should not call initInstalledPackage when package is not in installedPackages', async () => {
const communityNodesStore = mockedStore(useCommunityNodesStore);
mockIsCommunityPackageName.mockReturnValue(true);
// Initialize the composable
useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
// Simulate store change with different package
const mockDifferentPackage: ExtendedPublicInstalledPackage = {
packageName: 'different-package',
installedVersion: '1.0.0',
installedNodes: [],
createdAt: new Date(),
updatedAt: new Date(),
unverifiedUpdate: false,
};
communityNodesStore.installedPackages = {
'different-package': mockDifferentPackage,
};
await nextTick();
expect(mockFetchInstalledPackageInfo).not.toHaveBeenCalled();
});
});
describe('return values', () => {
it('should return all expected properties and functions', () => {
const result = useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
expect(result).toHaveProperty('installedPackage');
expect(result).toHaveProperty('isUpdateCheckAvailable');
expect(result).toHaveProperty('isCommunityNode');
expect(result).toHaveProperty('initInstalledPackage');
expect(typeof result.initInstalledPackage).toBe('function');
});
});
});

View File

@ -0,0 +1,65 @@
import { useCommunityNodesStore } from '@/stores/communityNodes.store';
import { useUsersStore } from '@/stores/users.store';
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
import {
type ExtendedPublicInstalledPackage,
fetchInstalledPackageInfo,
} from '@/utils/communityNodeUtils';
import { computed, type MaybeRefOrGetter, onMounted, ref, watch, toValue } from 'vue';
export function useInstalledCommunityPackage(nodeTypeName?: MaybeRefOrGetter<string | undefined>) {
const communityNodesStore = useCommunityNodesStore();
const usersStore = useUsersStore();
const installedPackage = ref<ExtendedPublicInstalledPackage | undefined>(undefined);
const packageName = computed(() => toValue(nodeTypeName)?.split('.')[0] ?? '');
const isCommunityNode = computed(() => {
const nodeType = toValue(nodeTypeName);
if (nodeType) {
return isCommunityPackageName(nodeType);
}
return false;
});
const initInstalledPackage = async () => {
if (!packageName.value || !isCommunityNode.value) return undefined;
installedPackage.value = await fetchInstalledPackageInfo(packageName.value);
return installedPackage.value;
};
// update when installed package changes if it's defined
watch(
() => communityNodesStore.installedPackages[packageName.value],
async (changedPackage) => {
if (!packageName.value || !changedPackage) return;
await initInstalledPackage();
},
{ deep: true },
);
onMounted(async () => {
if (!packageName.value || !isCommunityNode.value) return;
await initInstalledPackage();
});
/**
* True when the node is a community node, the user has rights to update the package and the package is not an unverified update.
* Update dialogs and button should not be shown when this is false.
*/
const isUpdateCheckAvailable = computed(() => {
return (
isCommunityNode.value &&
usersStore.isInstanceOwner &&
!installedPackage.value?.unverifiedUpdate
);
});
return {
installedPackage,
isUpdateCheckAvailable,
isCommunityNode,
initInstalledPackage,
};
}

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { fetchInstalledPackageInfo } from './utils';
import { fetchInstalledPackageInfo } from './communityNodeUtils';
import { useCommunityNodesStore } from '@/stores/communityNodes.store';
import { type NodeTypesStore, useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { PublicInstalledPackage } from 'n8n-workflow';