From 49c84c2ce29b9ff1c10b59565c28a65a767af6cb Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Wed, 16 Jul 2025 16:38:11 +0300 Subject: [PATCH] feat: New package version available callout (#17097) --- .../api-types/src/community-node-types.ts | 1 + .../services/community-node-types.service.ts | 25 +--- .../src/utils/community-node-types-utils.ts | 1 + .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../Panel/CommunityNodeInfo.test.ts | 136 ++++++++++++------ .../NodeCreator/Panel/CommunityNodeInfo.vue | 24 +++- .../Panel/CommunityNodeUpdateInfo.vue | 35 +++++ .../src/stores/communityNodes.store.ts | 9 ++ 8 files changed, 162 insertions(+), 70 deletions(-) create mode 100644 packages/frontend/editor-ui/src/components/Node/NodeCreator/Panel/CommunityNodeUpdateInfo.vue diff --git a/packages/@n8n/api-types/src/community-node-types.ts b/packages/@n8n/api-types/src/community-node-types.ts index 5261c4440e..90968aa9e7 100644 --- a/packages/@n8n/api-types/src/community-node-types.ts +++ b/packages/@n8n/api-types/src/community-node-types.ts @@ -17,4 +17,5 @@ export type CommunityNodeType = { companyName?: string; nodeDescription: INodeTypeDescription; isInstalled: boolean; + nodeVersions?: Array<{ npmVersion: string; checksum: string }>; }; diff --git a/packages/cli/src/services/community-node-types.service.ts b/packages/cli/src/services/community-node-types.service.ts index c0e1703583..0b3aa10ed1 100644 --- a/packages/cli/src/services/community-node-types.service.ts +++ b/packages/cli/src/services/community-node-types.service.ts @@ -2,31 +2,16 @@ import type { CommunityNodeType } from '@n8n/api-types'; import { Logger, inProduction } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import { Service } from '@n8n/di'; -import { ensureError, type INodeTypeDescription } from 'n8n-workflow'; +import { ensureError } from 'n8n-workflow'; import { CommunityPackagesService } from './community-packages.service'; -import { getCommunityNodeTypes } from '../utils/community-node-types-utils'; +import { + getCommunityNodeTypes, + StrapiCommunityNodeType, +} from '../utils/community-node-types-utils'; const UPDATE_INTERVAL = 8 * 60 * 60 * 1000; -export type StrapiCommunityNodeType = { - authorGithubUrl: string; - authorName: string; - checksum: string; - description: string; - displayName: string; - name: string; - numberOfStars: number; - numberOfDownloads: number; - packageName: string; - createdAt: string; - updatedAt: string; - npmVersion: string; - isOfficialNode: boolean; - companyName?: string; - nodeDescription: INodeTypeDescription; -}; - @Service() export class CommunityNodeTypesService { private communityNodeTypes: Map = new Map(); diff --git a/packages/cli/src/utils/community-node-types-utils.ts b/packages/cli/src/utils/community-node-types-utils.ts index afc352a7cd..9e13f33998 100644 --- a/packages/cli/src/utils/community-node-types-utils.ts +++ b/packages/cli/src/utils/community-node-types-utils.ts @@ -18,6 +18,7 @@ export type StrapiCommunityNodeType = { isOfficialNode: boolean; companyName?: string; nodeDescription: INodeTypeDescription; + nodeVersions?: Array<{ npmVersion: string; checksum: string }>; }; const N8N_VETTED_NODE_TYPES_STAGING_URL = 'https://api-staging.n8n.io/api/community-nodes'; diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 32dd71caaf..a38bb58ab2 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -3279,6 +3279,7 @@ "communityNodeInfo.downloads": "{downloads} Downloads", "communityNodeInfo.publishedBy": "Published by {publisherName}", "communityNodeInfo.contact.admin": "Please contact an administrator to install this community node:", + "communityNodeUpdateInfo.available": "A new node package version is available", "insights.upgradeModal.button.dismiss": "Dismiss", "insights.upgradeModal.button.upgrade": "Upgrade", "insights.upgradeModal.content": "Viewing this time period requires an enterprise plan. Upgrade to Enterprise to unlock advanced features.", diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/Panel/CommunityNodeInfo.test.ts b/packages/frontend/editor-ui/src/components/Node/NodeCreator/Panel/CommunityNodeInfo.test.ts index b0a0e94f6b..89c2c48703 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/Panel/CommunityNodeInfo.test.ts +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/Panel/CommunityNodeInfo.test.ts @@ -4,10 +4,14 @@ import { setActivePinia } from 'pinia'; import CommunityNodeInfo from './CommunityNodeInfo.vue'; import type { PublicInstalledPackage } from 'n8n-workflow'; import { waitFor } from '@testing-library/vue'; +import type { CommunityNodeDetails } from '../composables/useViewStacks'; const getCommunityNodeAttributes = vi.fn(); -const communityNodesStore: { getInstalledPackages: PublicInstalledPackage[] } = { - getInstalledPackages: [], +const getInstalledPackage = vi.fn(); +const communityNodesStore: { + getInstalledPackage: (packageName: string) => Promise; +} = { + getInstalledPackage, }; vi.mock('@/stores/nodeTypes.store', () => ({ @@ -27,41 +31,7 @@ vi.mock('@/stores/communityNodes.store', () => ({ })); 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: 'Node details', - }, - })), + useViewStacks: vi.fn(), })); describe('CommunityNodeInfo', () => { @@ -69,12 +39,51 @@ describe('CommunityNodeInfo', () => { let pinia: TestingPinia; let originalFetch: typeof global.fetch; - beforeEach(() => { + const defaultViewStack = { + 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: 'Node details', + }; + + beforeEach(async () => { pinia = createTestingPinia(); setActivePinia(pinia); originalFetch = global.fetch; global.fetch = vi.fn(); + + const { useViewStacks } = await import('../composables/useViewStacks'); + vi.mocked(useViewStacks).mockReturnValue({ + activeViewStack: defaultViewStack, + } as ReturnType); }); afterEach(() => { @@ -87,13 +96,13 @@ describe('CommunityNodeInfo', () => { npmVersion: '1.0.0', authorName: 'contributor', numberOfDownloads: 9999, + nodeVersions: [{ npmVersion: '1.0.0' }], }); - communityNodesStore.getInstalledPackages = [ - { - installedVersion: '1.0.0', - packageName: 'n8n-nodes-test', - } as PublicInstalledPackage, - ]; + getInstalledPackage.mockResolvedValue({ + installedVersion: '1.0.0', + packageName: 'n8n-nodes-test', + } as PublicInstalledPackage); + const wrapper = renderComponent({ pinia }); await waitFor(() => expect(wrapper.queryByTestId('number-of-downloads')).toBeInTheDocument()); @@ -106,6 +115,45 @@ 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 () => { + const { useViewStacks } = await import('../composables/useViewStacks'); + vi.mocked(useViewStacks).mockReturnValue({ + activeViewStack: { + ...defaultViewStack, + communityNodeDetails: { + ...defaultViewStack.communityNodeDetails, + installed: true, + } as CommunityNodeDetails, + }, + } as ReturnType); + + getCommunityNodeAttributes.mockResolvedValue({ + npmVersion: '1.0.0', + authorName: 'contributor', + numberOfDownloads: 9999, + nodeVersions: [{ npmVersion: '1.0.0' }, { npmVersion: '0.0.9' }], + }); + getInstalledPackage.mockResolvedValue({ + installedVersion: '0.0.9', + packageName: 'n8n-nodes-test', + updateAvailable: '1.0.1', + } 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'); + expect(wrapper.getByTestId('update-available').textContent).toEqual( + 'A new node package version is available', + ); + }); + it('should render correctly with fetched info', async () => { const packageData = { maintainers: [{ name: 'testAuthor' }], diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/Panel/CommunityNodeInfo.vue b/packages/frontend/editor-ui/src/components/Node/NodeCreator/Panel/CommunityNodeInfo.vue index 84be7d7bee..562942db58 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/Panel/CommunityNodeInfo.vue +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/Panel/CommunityNodeInfo.vue @@ -8,6 +8,7 @@ import { useCommunityNodesStore } from '@/stores/communityNodes.store'; import { captureException } from '@sentry/vue'; import { N8nText, N8nTooltip, N8nIcon } from '@n8n/design-system'; import ShieldIcon from 'virtual:icons/fa-solid/shield-alt'; +import type { PublicInstalledPackage } from 'n8n-workflow'; const { activeViewStack } = useViewStacks(); @@ -21,6 +22,8 @@ const publisherName = ref(undefined); const downloads = ref(null); const verified = ref(false); const official = ref(false); +const installedPackage = ref(undefined); + const communityNodesStore = useCommunityNodesStore(); const nodeTypesStore = useNodeTypesStore(); @@ -42,17 +45,22 @@ async function fetchPackageInfo(packageName: string) { activeViewStack.communityNodeDetails?.key || '', ); + if (communityNodeDetails?.installed) { + installedPackage.value = await communityNodesStore.getInstalledPackage( + communityNodeDetails.packageName, + ); + } + if (communityNodeAttributes) { publisherName.value = communityNodeAttributes.companyName ?? communityNodeAttributes.authorName; downloads.value = formatNumber(communityNodeAttributes.numberOfDownloads); official.value = communityNodeAttributes.isOfficialNode; - const packageInfo = communityNodesStore.getInstalledPackages.find( - (p) => p.packageName === communityNodeAttributes.packageName, - ); - if (!packageInfo) { + + if (!installedPackage.value) { verified.value = true; } else { - verified.value = packageInfo.installedVersion === communityNodeAttributes.npmVersion; + const verifiedVersions = communityNodeAttributes.nodeVersions?.map((v) => v.npmVersion) ?? []; + verified.value = verifiedVersions.includes(installedPackage.value.installedVersion); } return; @@ -107,7 +115,11 @@ onMounted(async () => { {{ communityNodeDetails?.description }} -
+ +