feat: New package version available callout (#17097)

This commit is contained in:
Michael Kret
2025-07-16 16:38:11 +03:00
committed by GitHub
parent 5180869734
commit 49c84c2ce2
8 changed files with 162 additions and 70 deletions

View File

@@ -17,4 +17,5 @@ export type CommunityNodeType = {
companyName?: string; companyName?: string;
nodeDescription: INodeTypeDescription; nodeDescription: INodeTypeDescription;
isInstalled: boolean; isInstalled: boolean;
nodeVersions?: Array<{ npmVersion: string; checksum: string }>;
}; };

View File

@@ -2,31 +2,16 @@ import type { CommunityNodeType } from '@n8n/api-types';
import { Logger, inProduction } from '@n8n/backend-common'; import { Logger, inProduction } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { ensureError, type INodeTypeDescription } from 'n8n-workflow'; import { ensureError } from 'n8n-workflow';
import { CommunityPackagesService } from './community-packages.service'; 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; 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() @Service()
export class CommunityNodeTypesService { export class CommunityNodeTypesService {
private communityNodeTypes: Map<string, StrapiCommunityNodeType> = new Map(); private communityNodeTypes: Map<string, StrapiCommunityNodeType> = new Map();

View File

@@ -18,6 +18,7 @@ export type StrapiCommunityNodeType = {
isOfficialNode: boolean; isOfficialNode: boolean;
companyName?: string; companyName?: string;
nodeDescription: INodeTypeDescription; nodeDescription: INodeTypeDescription;
nodeVersions?: Array<{ npmVersion: string; checksum: string }>;
}; };
const N8N_VETTED_NODE_TYPES_STAGING_URL = 'https://api-staging.n8n.io/api/community-nodes'; const N8N_VETTED_NODE_TYPES_STAGING_URL = 'https://api-staging.n8n.io/api/community-nodes';

View File

@@ -3279,6 +3279,7 @@
"communityNodeInfo.downloads": "{downloads} Downloads", "communityNodeInfo.downloads": "{downloads} Downloads",
"communityNodeInfo.publishedBy": "Published by {publisherName}", "communityNodeInfo.publishedBy": "Published by {publisherName}",
"communityNodeInfo.contact.admin": "Please contact an administrator to install this community node:", "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.dismiss": "Dismiss",
"insights.upgradeModal.button.upgrade": "Upgrade", "insights.upgradeModal.button.upgrade": "Upgrade",
"insights.upgradeModal.content": "Viewing this time period requires an enterprise plan. Upgrade to Enterprise to unlock advanced features.", "insights.upgradeModal.content": "Viewing this time period requires an enterprise plan. Upgrade to Enterprise to unlock advanced features.",

View File

@@ -4,10 +4,14 @@ import { setActivePinia } from 'pinia';
import CommunityNodeInfo from './CommunityNodeInfo.vue'; import CommunityNodeInfo from './CommunityNodeInfo.vue';
import type { PublicInstalledPackage } from 'n8n-workflow'; import type { PublicInstalledPackage } from 'n8n-workflow';
import { waitFor } from '@testing-library/vue'; import { waitFor } from '@testing-library/vue';
import type { CommunityNodeDetails } from '../composables/useViewStacks';
const getCommunityNodeAttributes = vi.fn(); const getCommunityNodeAttributes = vi.fn();
const communityNodesStore: { getInstalledPackages: PublicInstalledPackage[] } = { const getInstalledPackage = vi.fn();
getInstalledPackages: [], const communityNodesStore: {
getInstalledPackage: (packageName: string) => Promise<PublicInstalledPackage>;
} = {
getInstalledPackage,
}; };
vi.mock('@/stores/nodeTypes.store', () => ({ vi.mock('@/stores/nodeTypes.store', () => ({
@@ -27,41 +31,7 @@ vi.mock('@/stores/communityNodes.store', () => ({
})); }));
vi.mock('../composables/useViewStacks', () => ({ vi.mock('../composables/useViewStacks', () => ({
useViewStacks: vi.fn(() => ({ 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',
},
})),
})); }));
describe('CommunityNodeInfo', () => { describe('CommunityNodeInfo', () => {
@@ -69,12 +39,51 @@ describe('CommunityNodeInfo', () => {
let pinia: TestingPinia; let pinia: TestingPinia;
let originalFetch: typeof global.fetch; 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(); pinia = createTestingPinia();
setActivePinia(pinia); setActivePinia(pinia);
originalFetch = global.fetch; originalFetch = global.fetch;
global.fetch = vi.fn(); global.fetch = vi.fn();
const { useViewStacks } = await import('../composables/useViewStacks');
vi.mocked(useViewStacks).mockReturnValue({
activeViewStack: defaultViewStack,
} as ReturnType<typeof useViewStacks>);
}); });
afterEach(() => { afterEach(() => {
@@ -87,13 +96,13 @@ describe('CommunityNodeInfo', () => {
npmVersion: '1.0.0', npmVersion: '1.0.0',
authorName: 'contributor', authorName: 'contributor',
numberOfDownloads: 9999, numberOfDownloads: 9999,
nodeVersions: [{ npmVersion: '1.0.0' }],
}); });
communityNodesStore.getInstalledPackages = [ getInstalledPackage.mockResolvedValue({
{ installedVersion: '1.0.0',
installedVersion: '1.0.0', packageName: 'n8n-nodes-test',
packageName: 'n8n-nodes-test', } as PublicInstalledPackage);
} as PublicInstalledPackage,
];
const wrapper = renderComponent({ pinia }); const wrapper = renderComponent({ pinia });
await waitFor(() => expect(wrapper.queryByTestId('number-of-downloads')).toBeInTheDocument()); 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'); 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<typeof useViewStacks>);
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 () => { it('should render correctly with fetched info', async () => {
const packageData = { const packageData = {
maintainers: [{ name: 'testAuthor' }], maintainers: [{ name: 'testAuthor' }],

View File

@@ -8,6 +8,7 @@ import { useCommunityNodesStore } from '@/stores/communityNodes.store';
import { captureException } from '@sentry/vue'; import { captureException } from '@sentry/vue';
import { N8nText, N8nTooltip, N8nIcon } from '@n8n/design-system'; import { N8nText, N8nTooltip, N8nIcon } from '@n8n/design-system';
import ShieldIcon from 'virtual:icons/fa-solid/shield-alt'; import ShieldIcon from 'virtual:icons/fa-solid/shield-alt';
import type { PublicInstalledPackage } from 'n8n-workflow';
const { activeViewStack } = useViewStacks(); const { activeViewStack } = useViewStacks();
@@ -21,6 +22,8 @@ const publisherName = ref<string | undefined>(undefined);
const downloads = ref<string | null>(null); const downloads = ref<string | null>(null);
const verified = ref(false); const verified = ref(false);
const official = ref(false); const official = ref(false);
const installedPackage = ref<PublicInstalledPackage | undefined>(undefined);
const communityNodesStore = useCommunityNodesStore(); const communityNodesStore = useCommunityNodesStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
@@ -42,17 +45,22 @@ async function fetchPackageInfo(packageName: string) {
activeViewStack.communityNodeDetails?.key || '', activeViewStack.communityNodeDetails?.key || '',
); );
if (communityNodeDetails?.installed) {
installedPackage.value = await communityNodesStore.getInstalledPackage(
communityNodeDetails.packageName,
);
}
if (communityNodeAttributes) { if (communityNodeAttributes) {
publisherName.value = communityNodeAttributes.companyName ?? communityNodeAttributes.authorName; publisherName.value = communityNodeAttributes.companyName ?? communityNodeAttributes.authorName;
downloads.value = formatNumber(communityNodeAttributes.numberOfDownloads); downloads.value = formatNumber(communityNodeAttributes.numberOfDownloads);
official.value = communityNodeAttributes.isOfficialNode; official.value = communityNodeAttributes.isOfficialNode;
const packageInfo = communityNodesStore.getInstalledPackages.find(
(p) => p.packageName === communityNodeAttributes.packageName, if (!installedPackage.value) {
);
if (!packageInfo) {
verified.value = true; verified.value = true;
} else { } else {
verified.value = packageInfo.installedVersion === communityNodeAttributes.npmVersion; const verifiedVersions = communityNodeAttributes.nodeVersions?.map((v) => v.npmVersion) ?? [];
verified.value = verifiedVersions.includes(installedPackage.value.installedVersion);
} }
return; return;
@@ -107,7 +115,11 @@ onMounted(async () => {
<N8nText :class="$style.description" color="text-base" size="medium"> <N8nText :class="$style.description" color="text-base" size="medium">
{{ communityNodeDetails?.description }} {{ communityNodeDetails?.description }}
</N8nText> </N8nText>
<div :class="$style.separator"></div> <CommunityNodeUpdateInfo
v-if="isOwner && installedPackage?.updateAvailable"
data-test-id="update-available"
/>
<div v-else :class="$style.separator"></div>
<div :class="$style.info"> <div :class="$style.info">
<N8nTooltip v-if="verified" placement="top"> <N8nTooltip v-if="verified" placement="top">
<template #content>{{ <template #content>{{

View File

@@ -0,0 +1,35 @@
<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)',
};
});
</script>
<template>
<N8nNotice
theme="info"
:style="{
marginTop: '0',
...noticeStyles,
}"
>
{{ i18n.baseText('communityNodeUpdateInfo.available') }}
</N8nNotice>
</template>
<style lang="scss" module></style>

View File

@@ -96,8 +96,17 @@ export const useCommunityNodesStore = defineStore(STORES.COMMUNITY_NODES, () =>
updatePackageObject(updatedPackage); updatePackageObject(updatedPackage);
}; };
const getInstalledPackage = async (packageName: string) => {
if (!getInstalledPackages.value.length) {
await fetchInstalledPackages();
}
return getInstalledPackages.value.find((p) => p.packageName === packageName);
};
return { return {
installedPackages, installedPackages,
getInstalledPackage,
getInstalledPackages, getInstalledPackages,
availablePackageCount, availablePackageCount,
fetchAvailableCommunityPackageCount, fetchAvailableCommunityPackageCount,