From 070d9fea4e3d731cdddc006db8abebd17ff9698d Mon Sep 17 00:00:00 2001 From: RomanDavydchuk Date: Fri, 25 Sep 2026 14:02:07 +0000 Subject: [PATCH] fix(editor): Show when update available for community packages if only verified packages are enabled (#39424) --- .../ndv/settings/components/NodeSettings.vue | 5 +- .../components/NodeSettingsTabs.test.ts | 40 +- .../settings/components/NodeSettingsTabs.vue | 5 +- .../communityNodes.utils.test.ts | 129 +++---- .../communityNodes/communityNodes.utils.ts | 62 +-- .../components/CommunityPackageCard.test.ts | 48 ++- .../components/CommunityPackageCard.vue | 56 ++- ...CommunityPackageManageConfirmModal.test.ts | 79 +++- .../CommunityPackageManageConfirmModal.vue | 20 +- .../nodeCreator/CommunityNodeFooter.test.ts | 52 ++- .../nodeCreator/CommunityNodeFooter.vue | 7 +- .../nodeCreator/CommunityNodeInfo.test.ts | 29 +- .../nodeCreator/CommunityNodeInfo.vue | 7 +- .../useInstalledCommunityPackage.test.ts | 354 +++++++----------- .../useInstalledCommunityPackage.ts | 51 ++- .../components/Modes/ActionsMode.vue | 1 + .../components/Panel/NodesListPanel.vue | 1 + 17 files changed, 443 insertions(+), 503 deletions(-) diff --git a/packages/frontend/editor-ui/src/features/ndv/settings/components/NodeSettings.vue b/packages/frontend/editor-ui/src/features/ndv/settings/components/NodeSettings.vue index 8322ceed5e0..cc6a0dc22ec 100644 --- a/packages/frontend/editor-ui/src/features/ndv/settings/components/NodeSettings.vue +++ b/packages/frontend/editor-ui/src/features/ndv/settings/components/NodeSettings.vue @@ -189,7 +189,7 @@ const nodeType = computed(() => const { areAllCredentialsSet } = useNodeCredentialOptions(node, nodeType, ''); const nodeTypeName = computed(() => node.value?.type); -const { installedPackage, isUpdateCheckAvailable } = useInstalledCommunityPackage(nodeTypeName); +const { canUpdatePackage, hasUpdateAvailable } = useInstalledCommunityPackage(nodeTypeName); const isTriggerNode = computed(() => !!node.value && nodeTypesStore.isTriggerNode(node.value.type)); @@ -827,7 +827,7 @@ function handleSelectAction(params: INodeParameters) {
diff --git a/packages/frontend/editor-ui/src/features/ndv/settings/components/NodeSettingsTabs.test.ts b/packages/frontend/editor-ui/src/features/ndv/settings/components/NodeSettingsTabs.test.ts index 8b5fe5fc117..cec3776d1c9 100644 --- a/packages/frontend/editor-ui/src/features/ndv/settings/components/NodeSettingsTabs.test.ts +++ b/packages/frontend/editor-ui/src/features/ndv/settings/components/NodeSettingsTabs.test.ts @@ -1,29 +1,33 @@ 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 './NodeSettingsTabs.vue'; import { ref } from 'vue'; -import type { ExtendedPublicInstalledPackage } from '@/features/settings/communityNodes/communityNodes.utils'; -import { useInstalledCommunityPackage } from '@/features/settings/communityNodes/composables/useInstalledCommunityPackage'; +import type { PublicInstalledPackage } from 'n8n-workflow'; const renderComponent = createComponentRenderer(NodeSettingsTabs); +const installedPackage = ref(); +const isCommunityNode = ref(false); +const canUpdatePackage = ref(false); +const hasUpdateAvailable = ref(false); vi.mock('@/features/settings/communityNodes/composables/useInstalledCommunityPackage', () => ({ useInstalledCommunityPackage: vi.fn(() => ({ - installedPackage: ref(undefined), - isCommunityNode: ref(false), - isUpdateCheckAvailable: ref(false), + installedPackage, + isCommunityNode, + canUpdatePackage, + hasUpdateAvailable, initInstalledPackage: vi.fn(), })), })); -let installedCommunityPackage: ReturnType; - describe('NodeSettingsTabs', () => { beforeEach(() => { createTestingPinia({ stubActions: false }); - installedCommunityPackage = useInstalledCommunityPackage(); + installedPackage.value = undefined; + isCommunityNode.value = false; + canUpdatePackage.value = false; + hasUpdateAvailable.value = false; }); afterEach(() => { @@ -37,24 +41,16 @@ describe('NodeSettingsTabs', () => { expect(getByText('Parameters')).toBeInTheDocument(); }); - it('displays notification when updateAvailable', async () => { - vi.spyOn(installedCommunityPackage.isUpdateCheckAvailable, 'value', 'get').mockReturnValue( - true, - ); - vi.spyOn(installedCommunityPackage.installedPackage, 'value', 'get').mockReturnValue( - mock({ - packageName: 'test-package', - installedVersion: '1.0.0', - updateAvailable: '1.0.1', - }), - ); + it('displays notification when an update is available', async () => { + canUpdatePackage.value = true; + hasUpdateAvailable.value = true; const { findByTestId } = renderComponent({ props: {}, }); - const notification = await findByTestId('tab-settings'); - expect(notification).toBeDefined(); + const tab = await findByTestId('tab-settings'); + expect(tab.querySelector('.notification')).toBeInTheDocument(); }); it('does not display notification when not updateAvailable', () => { diff --git a/packages/frontend/editor-ui/src/features/ndv/settings/components/NodeSettingsTabs.vue b/packages/frontend/editor-ui/src/features/ndv/settings/components/NodeSettingsTabs.vue index 6d4d736aaf8..a4a6aa23f0c 100644 --- a/packages/frontend/editor-ui/src/features/ndv/settings/components/NodeSettingsTabs.vue +++ b/packages/frontend/editor-ui/src/features/ndv/settings/components/NodeSettingsTabs.vue @@ -45,7 +45,7 @@ const { docsUrl } = useNodeDocsUrl({ nodeType: () => props.nodeType }); const activeNode = computed(() => ndvStore.value.activeNode); const nodeTypeName = computed(() => props.nodeType?.name); -const { installedPackage, isCommunityNode, isUpdateCheckAvailable } = +const { isCommunityNode, canUpdatePackage, hasUpdateAvailable } = useInstalledCommunityPackage(nodeTypeName); const packageName = computed(() => props.nodeType?.name.split('.')[0] ?? ''); @@ -89,8 +89,7 @@ const options = computed(() => { }, { value: 'settings', - notification: - isUpdateCheckAvailable.value && installedPackage.value?.updateAvailable ? true : undefined, + notification: canUpdatePackage.value && hasUpdateAvailable.value ? true : undefined, ...(props.compact ? { icon: 'settings', align: 'right', tooltip: i18n.baseText('nodeSettings.settings') } : { label: i18n.baseText('nodeSettings.settings') }), diff --git a/packages/frontend/editor-ui/src/features/settings/communityNodes/communityNodes.utils.test.ts b/packages/frontend/editor-ui/src/features/settings/communityNodes/communityNodes.utils.test.ts index d0728aa2cc4..62ef1b61de7 100644 --- a/packages/frontend/editor-ui/src/features/settings/communityNodes/communityNodes.utils.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/communityNodes/communityNodes.utils.test.ts @@ -1,98 +1,53 @@ -import { describe, it, expect, vi } from 'vitest'; -import { fetchInstalledPackageInfo, isNodesApiVersionError } from './communityNodes.utils'; -import { useCommunityNodesStore } from './communityNodes.store'; -import { type NodeTypesStore, useNodeTypesStore } from '@/app/stores/nodeTypes.store'; -import type { PublicInstalledPackage } from 'n8n-workflow'; -import type { CommunityNodeType } from '@n8n/api-types'; -import { createTestingPinia } from '@pinia/testing'; +import { describe, it, expect } from 'vitest'; +import { isCommunityPackageUpdateAvailable, isNodesApiVersionError } from './communityNodes.utils'; -vi.mock('./communityNodes.store', () => ({ - useCommunityNodesStore: vi.fn(() => ({ - getInstalledPackage: vi.fn(), - })), -})); +describe('isCommunityPackageUpdateAvailable', () => { + const defaults = { + installedVersion: '1.0.0', + latestVerifiedVersion: '1.0.0', + isCommunityNodesFeatureEnabled: true, + isUnverifiedPackagesEnabled: false, + isManagedByEnv: false, + }; -vi.mock('@/app/stores/nodeTypes.store', () => ({ - useNodeTypesStore: vi.fn(() => ({ - communityNodeType: vi.fn(), - })), -})); - -type CommunityNodesStore = ReturnType; - -const mockCommunityNodesStore = (mock: Partial) => { - vi.mocked(useCommunityNodesStore).mockImplementation( - () => - ({ - getInstalledPackage: vi.fn(), - ...mock, - }) as unknown as CommunityNodesStore, - ); -}; - -const mockNodeTypesStore = (mock: Partial) => { - vi.mocked(useNodeTypesStore).mockImplementation( - () => - ({ - communityNodeType: vi.fn(), - ...mock, - }) as unknown as NodeTypesStore, - ); -}; - -describe('fetchInstalledPackageInfo', () => { - beforeEach(() => { - createTestingPinia({ stubActions: false }); + it('returns true when a newer verified version is available', () => { + expect( + isCommunityPackageUpdateAvailable({ + ...defaults, + latestVerifiedVersion: '1.1.0', + }), + ).toBe(true); }); - afterEach(() => { - vi.clearAllMocks(); + it('ignores a newer npm version when unverified packages are disabled', () => { + expect( + isCommunityPackageUpdateAvailable({ + ...defaults, + updateAvailable: '1.1.0', + }), + ).toBe(false); }); - it('should return undefined if no installed package is found', async () => { - const packageName = 'test-package'; - mockCommunityNodesStore({ - getInstalledPackage: vi.fn().mockResolvedValue(undefined), - }); - - const result = await fetchInstalledPackageInfo(packageName); - expect(result).toBeUndefined(); + it('returns true for a newer npm version when unverified packages are enabled', () => { + expect( + isCommunityPackageUpdateAvailable({ + ...defaults, + updateAvailable: '1.1.0', + isUnverifiedPackagesEnabled: true, + }), + ).toBe(true); }); - it('should return package info with unverifiedUpdate as false if no update is available', async () => { - const packageName = 'test-package'; - const installedPackage = { packageName, updateAvailable: null }; - mockCommunityNodesStore({ - getInstalledPackage: vi - .fn() - .mockResolvedValue(installedPackage as unknown as PublicInstalledPackage), - }); - mockNodeTypesStore({ - communityNodeType: vi - .fn() - .mockReturnValue({ npmVersion: '1.0.0' } as unknown as CommunityNodeType), - }); - - const result = await fetchInstalledPackageInfo(packageName); - expect(result).toEqual({ ...installedPackage, unverifiedUpdate: false }); - }); - - it('should return package info with unverifiedUpdate as true if an update is available', async () => { - const packageName = 'test-package'; - const installedPackage = { packageName, updateAvailable: '1.1.0' }; - mockCommunityNodesStore({ - getInstalledPackage: vi - .fn() - .mockResolvedValue(installedPackage as unknown as PublicInstalledPackage), - }); - mockNodeTypesStore({ - communityNodeType: vi - .fn() - .mockReturnValue({ npmVersion: '1.0.0' } as unknown as CommunityNodeType), - }); - - const result = await fetchInstalledPackageInfo(packageName); - expect(result).toEqual({ ...installedPackage, unverifiedUpdate: true }); + it('returns false when community packages are managed by the environment', () => { + expect( + isCommunityPackageUpdateAvailable({ + ...defaults, + latestVerifiedVersion: '1.1.0', + updateAvailable: '1.2.0', + isUnverifiedPackagesEnabled: true, + isManagedByEnv: true, + }), + ).toBe(false); }); }); diff --git a/packages/frontend/editor-ui/src/features/settings/communityNodes/communityNodes.utils.ts b/packages/frontend/editor-ui/src/features/settings/communityNodes/communityNodes.utils.ts index 8cab464611f..b85c285864a 100644 --- a/packages/frontend/editor-ui/src/features/settings/communityNodes/communityNodes.utils.ts +++ b/packages/frontend/editor-ui/src/features/settings/communityNodes/communityNodes.utils.ts @@ -1,11 +1,44 @@ -import { useCommunityNodesStore } from './communityNodes.store'; -import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; -import { type PublicInstalledPackage } from 'n8n-workflow'; +import type { CommunityNodeType } from '@n8n/api-types'; import semver from 'semver'; -export type ExtendedPublicInstalledPackage = PublicInstalledPackage & { - unverifiedUpdate: boolean; -}; +export async function findVettedCommunityNodeAttributes( + nodeTypes: string[], + getCommunityNodeAttributes: (nodeType: string) => Promise, +) { + for (const nodeType of nodeTypes) { + const attributes = await getCommunityNodeAttributes(nodeType); + if (attributes) return attributes; + } + + return null; +} + +interface CommunityPackageUpdateAvailability { + installedVersion: string; + updateAvailable?: string; + latestVerifiedVersion?: string; + isCommunityNodesFeatureEnabled: boolean; + isUnverifiedPackagesEnabled: boolean; + isManagedByEnv: boolean; +} + +export function isCommunityPackageUpdateAvailable({ + installedVersion, + updateAvailable, + latestVerifiedVersion, + isCommunityNodesFeatureEnabled, + isUnverifiedPackagesEnabled, + isManagedByEnv, +}: CommunityPackageUpdateAvailability) { + if (isManagedByEnv) return false; + if (isUnverifiedPackagesEnabled && updateAvailable) return true; + + return Boolean( + isCommunityNodesFeatureEnabled && + latestVerifiedVersion && + semver.gt(latestVerifiedVersion, installedVersion), + ); +} interface IncompatibleNodesApiVersionErrorResponse { httpStatusCode: number; @@ -23,20 +56,3 @@ export const isNodesApiVersionError = ( const e = error as IncompatibleNodesApiVersionErrorResponse | undefined; return e?.httpStatusCode === 400 && 'requiredNodesApiVersion' in (e.meta ?? {}); }; - -export async function fetchInstalledPackageInfo( - packageName: string, -): Promise { - const installedPackage: PublicInstalledPackage | undefined = - await useCommunityNodesStore().getInstalledPackage(packageName); - const communityNodeType = useNodeTypesStore().communityNodeType(packageName); - if (!installedPackage) { - return undefined; - } - const checkIsUnverifiedUpdate = () => { - if (!installedPackage?.updateAvailable || !communityNodeType) return false; - return semver.gt(installedPackage.updateAvailable, communityNodeType.npmVersion); - }; - - return { ...installedPackage, unverifiedUpdate: checkIsUnverifiedUpdate() }; -} diff --git a/packages/frontend/editor-ui/src/features/settings/communityNodes/components/CommunityPackageCard.test.ts b/packages/frontend/editor-ui/src/features/settings/communityNodes/components/CommunityPackageCard.test.ts index 332798b1a4c..5c81e9c4ff1 100644 --- a/packages/frontend/editor-ui/src/features/settings/communityNodes/components/CommunityPackageCard.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/communityNodes/components/CommunityPackageCard.test.ts @@ -9,7 +9,11 @@ import type { PublicInstalledNode, PublicInstalledPackage } from 'n8n-workflow'; const communityPackage: PublicInstalledPackage = { packageName: 'n8n-nodes-test', installedVersion: '1.0.0', - installedNodes: [{ name: 'TestNode' } as PublicInstalledNode], + installedNodes: [ + { name: 'OldNode', type: 'n8n-nodes-test.oldNode' } as PublicInstalledNode, + { name: 'TestNode', type: 'n8n-nodes-test.testNode' } as PublicInstalledNode, + { name: 'OtherNode', type: 'n8n-nodes-test.otherNode' } as PublicInstalledNode, + ], createdAt: new Date(0), updatedAt: new Date(0), }; @@ -43,18 +47,18 @@ describe('CommunityPackageCard', () => { let nodeTypesStore: ReturnType; beforeEach(() => { - setupPinia(); + setupPinia({ communityNodesEnabled: true }); nodeTypesStore = useNodeTypesStore(); }); - it('should call nodeTypesStore methods and update latestVerifiedVersion when packageName changes', async () => { - Object.defineProperty(nodeTypesStore, 'visibleNodeTypes', { - get: () => [{ name: 'n8n-nodes-test' }], - }); - nodeTypesStore.loadNodeTypesIfNotLoaded = vi.fn().mockResolvedValue(undefined); - nodeTypesStore.getCommunityNodeAttributes = vi.fn().mockResolvedValue({ npmVersion: '2.0.0' }); + it('skips stale node types when loading the verified package version', async () => { + nodeTypesStore.getCommunityNodeAttributes = vi + .fn() + .mockImplementation(async (nodeType) => + nodeType === 'n8n-nodes-test.testNode' ? { npmVersion: '2.0.0' } : null, + ); - renderComponent({ + const { getByText } = renderComponent({ props: { communityPackage, }, @@ -62,8 +66,30 @@ describe('CommunityPackageCard', () => { await flushPromises(); - expect(nodeTypesStore.loadNodeTypesIfNotLoaded).toHaveBeenCalled(); - expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenCalledWith('n8n-nodes-test'); + expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenNthCalledWith( + 1, + 'n8n-nodes-test.oldNode', + ); + expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenNthCalledWith( + 2, + 'n8n-nodes-test.testNode', + ); + expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenCalledTimes(2); + expect(getByText('Update')).toBeInTheDocument(); + }); + + it('does not load verified package data without an installed node type', async () => { + nodeTypesStore.getCommunityNodeAttributes = vi.fn(); + + renderComponent({ + props: { + communityPackage: { ...communityPackage, installedNodes: [] }, + }, + }); + + await flushPromises(); + + expect(nodeTypesStore.getCommunityNodeAttributes).not.toHaveBeenCalled(); }); describe('uninstall action visibility', () => { diff --git a/packages/frontend/editor-ui/src/features/settings/communityNodes/components/CommunityPackageCard.vue b/packages/frontend/editor-ui/src/features/settings/communityNodes/components/CommunityPackageCard.vue index beebff13dc7..3db55978b42 100644 --- a/packages/frontend/editor-ui/src/features/settings/communityNodes/components/CommunityPackageCard.vue +++ b/packages/frontend/editor-ui/src/features/settings/communityNodes/components/CommunityPackageCard.vue @@ -9,7 +9,10 @@ import { useSettingsStore } from '@n8n/stores/settings.store'; import type { UserAction } from '@n8n/design-system'; import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; import { computed, ref, watch } from 'vue'; -import semver from 'semver'; +import { + findVettedCommunityNodeAttributes, + isCommunityPackageUpdateAvailable, +} from '../communityNodes.utils'; import { N8nActionToggle, @@ -38,26 +41,20 @@ const settingsStore = useSettingsStore(); const nodeTypesStore = useNodeTypesStore(); const latestVerifiedVersion = ref(); -const currVersion = computed(() => props.communityPackage?.installedVersion || ''); - const isManagedByEnv = computed( (): boolean => settingsStore.settings.communityNodesManagedByEnv ?? false, ); +const hasUpdateAvailable = computed(() => { + if (!props.communityPackage) return false; -const hasUnverifiedPackagesUpdate = computed(() => { - return ( - !isManagedByEnv.value && - settingsStore.isUnverifiedPackagesEnabled && - props.communityPackage?.updateAvailable - ); -}); - -const hasVerifiedPackageUpdate = computed(() => { - if (isManagedByEnv.value) return false; - const canUpdate = - latestVerifiedVersion.value && semver.gt(latestVerifiedVersion.value || '', currVersion.value); - - return settingsStore.isCommunityNodesFeatureEnabled && canUpdate; + return isCommunityPackageUpdateAvailable({ + installedVersion: props.communityPackage.installedVersion, + updateAvailable: props.communityPackage.updateAvailable, + latestVerifiedVersion: latestVerifiedVersion.value, + isCommunityNodesFeatureEnabled: settingsStore.isCommunityNodesFeatureEnabled, + isUnverifiedPackagesEnabled: settingsStore.isUnverifiedPackagesEnabled, + isManagedByEnv: isManagedByEnv.value, + }); }); const packageActions = computed>>(() => { @@ -101,19 +98,13 @@ function onUpdateClick() { } watch( - () => props.communityPackage?.packageName, - async (packageName) => { - if (packageName) { - await nodeTypesStore.loadNodeTypesIfNotLoaded(); - const nodeType = nodeTypesStore.visibleNodeTypes.find((node) => - node.name.includes(packageName), - ); - - const attributes = await nodeTypesStore.getCommunityNodeAttributes(nodeType?.name || ''); - if (attributes?.npmVersion) { - latestVerifiedVersion.value = attributes.npmVersion; - } - } + () => props.communityPackage?.installedNodes.map((node) => node.type) ?? [], + async (nodeTypes) => { + const attributes = await findVettedCommunityNodeAttributes( + nodeTypes, + nodeTypesStore.getCommunityNodeAttributes, + ); + latestVerifiedVersion.value = attributes?.npmVersion; }, { immediate: true }, ); @@ -158,10 +149,7 @@ watch( - +