fix(editor): Show when update available for community packages if only verified packages are enabled (#39424)

This commit is contained in:
RomanDavydchuk 2026-09-25 14:02:07 +00:00 committed by GitHub
parent f459f3c400
commit 070d9fea4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 443 additions and 503 deletions

View File

@ -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) {
</div>
<div v-show="openPanel === 'settings'">
<CommunityNodeUpdateInfo
v-if="isUpdateCheckAvailable && installedPackage?.updateAvailable"
v-if="canUpdatePackage && hasUpdateAvailable"
data-test-id="update-available"
:package-name="packageName"
style="margin-top: var(--spacing--sm)"
@ -887,6 +887,7 @@ function handleSelectAction(params: INodeParameters) {
<CommunityNodeFooter
v-if="openPanel === 'settings' && isCommunityNode"
:package-name="packageName"
:node-type-name="nodeTypeName"
:show-manage="useUsersStore().isAdminOrOwner"
/>
</div>

View File

@ -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<PublicInstalledPackage>();
const isCommunityNode = ref(false);
const canUpdatePackage = ref(false);
const hasUpdateAvailable = ref(false);
vi.mock('@/features/settings/communityNodes/composables/useInstalledCommunityPackage', () => ({
useInstalledCommunityPackage: vi.fn(() => ({
installedPackage: ref<ExtendedPublicInstalledPackage | undefined>(undefined),
isCommunityNode: ref(false),
isUpdateCheckAvailable: ref(false),
installedPackage,
isCommunityNode,
canUpdatePackage,
hasUpdateAvailable,
initInstalledPackage: vi.fn(),
})),
}));
let installedCommunityPackage: ReturnType<typeof useInstalledCommunityPackage>;
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<ExtendedPublicInstalledPackage>({
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', () => {

View File

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

View File

@ -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<typeof useCommunityNodesStore>;
const mockCommunityNodesStore = (mock: Partial<CommunityNodesStore>) => {
vi.mocked(useCommunityNodesStore).mockImplementation(
() =>
({
getInstalledPackage: vi.fn(),
...mock,
}) as unknown as CommunityNodesStore,
);
};
const mockNodeTypesStore = (mock: Partial<NodeTypesStore>) => {
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);
});
});

View File

@ -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<CommunityNodeType | null>,
) {
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<ExtendedPublicInstalledPackage | undefined> {
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() };
}

View File

@ -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<typeof useNodeTypesStore>;
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', () => {

View File

@ -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<string>();
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<Array<UserAction<IUser>>>(() => {
@ -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(
</template>
<N8nIcon icon="triangle-alert" color="danger" size="large" />
</N8nTooltip>
<N8nTooltip
v-else-if="hasUnverifiedPackagesUpdate || hasVerifiedPackageUpdate"
placement="top"
>
<N8nTooltip v-else-if="hasUpdateAvailable" placement="top">
<template #content>
<div>
{{ i18n.baseText('settings.communityNodes.updateAvailable.tooltip') }}

View File

@ -4,10 +4,11 @@ import CommunityPackageManageConfirmModal from './CommunityPackageManageConfirmM
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { defaultSettings } from '@n8n/frontend-test-utils';
import { mockNodeTypeDescription } from '@/__tests__/mocks';
import { createTestingPinia } from '@pinia/testing';
import { STORES } from '@n8n/stores';
import { COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY } from '../communityNodes.constants';
import { useCommunityNodesStore } from '../communityNodes.store';
import { fireEvent } from '@testing-library/vue';
const fetchWorkflowsWithNodesIncluded = vi.fn();
vi.mock('@/app/stores/workflowsList.store', () => ({
@ -35,16 +36,10 @@ const renderComponent = createComponentRenderer(CommunityPackageManageConfirmMod
packageName: 'n8n-nodes-test',
installedVersion: '1.0.0',
updateAvailable: '2.0.0',
installedNodes: [{ name: 'TestNode' }],
},
},
},
[STORES.NODE_TYPES]: {
nodeTypes: {
['n8n-nodes-test.test']: {
1: mockNodeTypeDescription({
name: 'n8n-nodes-test.test',
}),
installedNodes: [
{ name: 'OldNode', type: 'n8n-nodes-test.old' },
{ name: 'TestNode', type: 'n8n-nodes-test.test' },
],
},
},
},
@ -65,12 +60,18 @@ describe('CommunityPackageManageConfirmModal', () => {
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
beforeEach(() => {
useSettingsStore().$patch({
settings: { ...defaultSettings, communityNodesEnabled: true },
});
nodeTypesStore = useNodeTypesStore();
});
it('should call nodeTypesStore methods and update latestVerifiedVersion on mount', async () => {
nodeTypesStore.loadNodeTypesIfNotLoaded = vi.fn().mockResolvedValue(undefined);
nodeTypesStore.getCommunityNodeAttributes = vi.fn().mockResolvedValue({ npmVersion: '2.0.0' });
it('skips stale node types when loading package information', async () => {
nodeTypesStore.getCommunityNodeAttributes = vi
.fn()
.mockImplementation(async (nodeType) =>
nodeType === 'n8n-nodes-test.test' ? { npmVersion: '2.0.0' } : null,
);
renderComponent({
props: {
@ -82,8 +83,50 @@ describe('CommunityPackageManageConfirmModal', () => {
await flushPromises();
expect(nodeTypesStore.loadNodeTypesIfNotLoaded).toHaveBeenCalled();
expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenCalledWith('n8n-nodes-test.test');
expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenNthCalledWith(
1,
'n8n-nodes-test.old',
);
expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenNthCalledWith(
2,
'n8n-nodes-test.test',
);
expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenCalledTimes(2);
});
it('uses the exact package version and checksum for a verified-only update', async () => {
useSettingsStore().$patch({
settings: {
...defaultSettings,
communityNodesEnabled: true,
unverifiedCommunityNodesEnabled: false,
},
});
const communityNodesStore = useCommunityNodesStore();
nodeTypesStore.getCommunityNodeAttributes = vi
.fn()
.mockImplementation(async (nodeType) =>
nodeType === 'n8n-nodes-test.test'
? { npmVersion: '2.0.5', checksum: 'correct-checksum' }
: null,
);
const { getByRole } = renderComponent({
props: {
modalName: 'test-modal',
activePackageName: 'n8n-nodes-test',
mode: 'update',
},
});
await flushPromises();
await fireEvent.click(getByRole('button', { name: 'Confirm update' }));
expect(communityNodesStore.updatePackage).toHaveBeenCalledWith(
'n8n-nodes-test',
'2.0.5',
'correct-checksum',
);
});
it('should call nodeTypesStore methods and update latestVerifiedVersion on mount', async () => {
@ -162,7 +205,7 @@ describe('CommunityPackageManageConfirmModal', () => {
expect(screen.getByText('Active')).toBeInTheDocument();
expect(screen.getByText('Confirm update')).toBeInTheDocument();
expect(screen.getByText('Cancel')).toBeInTheDocument();
expect(screen.getByText('Package includes: TestNode')).toBeInTheDocument();
expect(screen.getByText('Package includes: OldNode, TestNode')).toBeInTheDocument();
});
it('should not include table with affected workflows', async () => {
@ -188,7 +231,7 @@ describe('CommunityPackageManageConfirmModal', () => {
const testId = screen.getByTestId('communityPackageManageConfirmModal-warning');
expect(testId).toBeInTheDocument();
expect(screen.getByText('Package includes: TestNode')).toBeInTheDocument();
expect(screen.getByText('Package includes: OldNode, TestNode')).toBeInTheDocument();
expect(
screen.getByText('Nodes from this package are not used in any workflows'),

View File

@ -6,7 +6,7 @@ import {
} from '../communityNodes.constants';
import { useToast } from '@n8n/composables/useToast';
import { useCommunityNodesStore } from '../communityNodes.store';
import { isNodesApiVersionError } from '../communityNodes.utils';
import { findVettedCommunityNodeAttributes, isNodesApiVersionError } from '../communityNodes.utils';
import { createEventBus } from '@n8n/utils/event-bus';
import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@n8n/composables/useTelemetry';
@ -195,17 +195,13 @@ const onUpdate = async () => {
}
};
async function fetchPackageInfo(packageName: string) {
await nodeTypesStore.loadNodeTypesIfNotLoaded();
const nodeType = nodeTypesStore.visibleNodeTypes.find((nodeType) =>
nodeType.name.includes(packageName),
async function fetchPackageInfo() {
const nodeTypes = communityStorePackage.value?.installedNodes.map((node) => node.type) ?? [];
const communityNodeAttributes = await findVettedCommunityNodeAttributes(
nodeTypes,
nodeTypesStore.getCommunityNodeAttributes,
);
if (nodeType) {
const communityNodeAttributes = await nodeTypesStore.getCommunityNodeAttributes(nodeType?.name);
nodeTypeStorePackage.value = communityNodeAttributes ?? undefined;
}
nodeTypeStorePackage.value = communityNodeAttributes ?? undefined;
}
function setIsVerifiedLatestPackage() {
@ -233,7 +229,7 @@ const onClick = async () => {
onMounted(async () => {
if (props.activePackageName) {
await fetchPackageInfo(props.activePackageName);
await fetchPackageInfo();
}
if (communityStorePackage.value?.installedNodes.length) {

View File

@ -6,15 +6,17 @@ import CommunityNodeFooter from './CommunityNodeFooter.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { vi } from 'vitest';
import { ref } from 'vue';
import type { ExtendedPublicInstalledPackage } from '../../communityNodes.utils';
import type { PublicInstalledPackage } from 'n8n-workflow';
// Mock the useInstalledCommunityPackage composable
const mockInstalledPackage = ref<ExtendedPublicInstalledPackage | undefined>(undefined);
const mockInstalledPackage = ref<PublicInstalledPackage>();
const mockHasUpdateAvailable = ref(false);
vi.mock('../../composables/useInstalledCommunityPackage', () => ({
useInstalledCommunityPackage: vi.fn(() => ({
installedPackage: mockInstalledPackage,
isUpdateCheckAvailable: ref(false),
canUpdatePackage: ref(false),
hasUpdateAvailable: mockHasUpdateAvailable,
isCommunityNode: ref(true),
initInstalledPackage: vi.fn(),
})),
@ -48,6 +50,7 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
// Reset the mock installed package before each test
mockInstalledPackage.value = undefined;
mockHasUpdateAvailable.value = false;
});
afterEach(() => {
@ -56,7 +59,11 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
it('calls router.push to open settings page when "Manage" is clicked', async () => {
const { getByText } = createComponentRenderer(CommunityNodeFooter)({
props: { packageName: 'n8n-nodes-test', showManage: true },
props: {
packageName: 'n8n-nodes-test',
nodeTypeName: 'n8n-nodes-test.test',
showManage: true,
},
});
const manageLink = getByText('Manage');
@ -67,26 +74,30 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
it('Manage should not be in the footer', () => {
const { queryByText } = createComponentRenderer(CommunityNodeFooter)({
props: { packageName: 'n8n-nodes-test', showManage: false },
props: {
packageName: 'n8n-nodes-test',
nodeTypeName: 'n8n-nodes-test.test',
showManage: false,
},
});
expect(queryByText('Manage')).not.toBeInTheDocument();
});
it('displays "Legacy" when updateAvailable', () => {
it('displays "Legacy" when an update is available', () => {
mockInstalledPackage.value = {
packageName: 'n8n-nodes-test',
installedVersion: '1.0.0',
updateAvailable: '1.0.1',
unverifiedUpdate: false,
installedNodes: [],
createdAt: new Date(),
updatedAt: new Date(),
};
mockHasUpdateAvailable.value = true;
const { getByText } = createComponentRenderer(CommunityNodeFooter)({
props: {
packageName: 'n8n-nodes-test',
nodeTypeName: 'n8n-nodes-test.test',
showManage: false,
},
});
@ -94,31 +105,10 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
expect(getByText('Package version 1.0.0 (Legacy)')).toBeInTheDocument();
});
it('displays "Latest" when not updateAvailable', () => {
it('displays "Latest" when no update is available', () => {
mockInstalledPackage.value = {
packageName: 'n8n-nodes-test',
installedVersion: '1.0.0',
unverifiedUpdate: false,
installedNodes: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const { getByText } = createComponentRenderer(CommunityNodeFooter)({
props: {
packageName: 'n8n-nodes-test',
showManage: false,
},
});
expect(getByText('Package version 1.0.0 (Latest)')).toBeInTheDocument();
});
it('displays "Latest" when only unverified update is available', () => {
mockInstalledPackage.value = {
packageName: 'n8n-nodes-test',
installedVersion: '1.0.0',
unverifiedUpdate: true,
installedNodes: [],
createdAt: new Date(),
updatedAt: new Date(),
@ -127,6 +117,7 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
const { getByText } = createComponentRenderer(CommunityNodeFooter)({
props: {
packageName: 'n8n-nodes-test',
nodeTypeName: 'n8n-nodes-test.test',
showManage: false,
},
});
@ -139,6 +130,7 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
const { queryByText } = createComponentRenderer(CommunityNodeFooter)({
props: {
packageName: 'n8n-nodes-test',
nodeTypeName: 'n8n-nodes-test.test',
showManage: false,
},
});

View File

@ -10,6 +10,7 @@ import { i18n } from '@n8n/i18n';
import { N8nLink, N8nText } from '@n8n/design-system';
export interface Props {
packageName: string;
nodeTypeName: string | undefined;
showManage: boolean;
}
const props = defineProps<Props>();
@ -17,7 +18,9 @@ const props = defineProps<Props>();
const router = useRouter();
const bugsUrl = ref<string>(`https://registry.npmjs.org/${props.packageName}`);
const { installedPackage } = useInstalledCommunityPackage(props.packageName);
const { installedPackage, hasUpdateAvailable } = useInstalledCommunityPackage(
() => props.nodeTypeName,
);
async function openSettingsPage() {
await router.push({ name: VIEWS.COMMUNITY_NODES });
@ -62,7 +65,7 @@ onMounted(async () => {
<div :class="$style.container">
<N8nText v-if="installedPackage" size="small" color="text-light" style="margin-right: auto">
Package version {{ installedPackage.installedVersion }} ({{
installedPackage.updateAvailable && !installedPackage.unverifiedUpdate
hasUpdateAvailable
? i18n.baseText('communityNodeFooter.legacy')
: i18n.baseText('nodeSettings.latest')
}})

View File

@ -1,23 +1,20 @@
import { createComponentRenderer } from '@/__tests__/render';
import { useInstalledCommunityPackage } from '../../composables/useInstalledCommunityPackage';
import type { ExtendedPublicInstalledPackage } from '../../communityNodes.utils';
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 { PublicInstalledPackage } from 'n8n-workflow';
import type { CommunityNodeDetails } from '@/features/shared/nodeCreator/composables/useViewStacks';
import CommunityNodeInfo from './CommunityNodeInfo.vue';
// 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),
}) as ComputedRef<PublicInstalledPackage>,
canUpdatePackage: ref(false),
hasUpdateAvailable: ref(false),
isCommunityNode: ref(true),
initInstalledPackage: vi.fn(),
} as unknown as ReturnType<typeof useInstalledCommunityPackage>;
@ -134,8 +131,7 @@ describe('CommunityNodeInfo', () => {
installedPackage: ref({
installedVersion: '1.0.0',
packageName: 'n8n-nodes-test',
unverifiedUpdate: false,
}) as ComputedRef<ExtendedPublicInstalledPackage>,
}) as ComputedRef<PublicInstalledPackage>,
});
const wrapper = renderComponent({ pinia });
@ -172,13 +168,13 @@ describe('CommunityNodeInfo', () => {
});
vi.mocked(useInstalledCommunityPackage).mockReturnValue({
...defaultUseInstalledCommunityPackage,
isUpdateCheckAvailable: ref(true) as ComputedRef<boolean>,
canUpdatePackage: ref(true) as ComputedRef<boolean>,
hasUpdateAvailable: ref(true) as ComputedRef<boolean>,
installedPackage: ref({
installedVersion: '0.0.9',
packageName: 'n8n-nodes-test',
updateAvailable: '1.0.1',
unverifiedUpdate: false,
}) as ComputedRef<ExtendedPublicInstalledPackage>,
}) as ComputedRef<PublicInstalledPackage>,
});
const wrapper = renderComponent({ pinia });
@ -196,7 +192,7 @@ describe('CommunityNodeInfo', () => {
).toEqual('A new node package version is available');
});
it('should NOT display update notice for unverified update', async () => {
it('should not display update notice when the user cannot update packages', async () => {
const { useViewStacks } = await import(
'@/features/shared/nodeCreator/composables/useViewStacks'
);
@ -218,12 +214,12 @@ describe('CommunityNodeInfo', () => {
});
vi.mocked(useInstalledCommunityPackage).mockReturnValue({
...defaultUseInstalledCommunityPackage,
hasUpdateAvailable: ref(true) as ComputedRef<boolean>,
installedPackage: ref({
installedVersion: '0.0.9',
packageName: 'n8n-nodes-test',
updateAvailable: '1.0.1',
unverifiedUpdate: true,
}) as ComputedRef<ExtendedPublicInstalledPackage>,
}) as ComputedRef<PublicInstalledPackage>,
});
const wrapper = renderComponent({ pinia });
@ -299,8 +295,7 @@ describe('CommunityNodeInfo', () => {
installedPackage: ref({
installedVersion: '1.0.0',
packageName: 'n8n-nodes-test',
unverifiedUpdate: false,
}) as ComputedRef<ExtendedPublicInstalledPackage>,
}) as ComputedRef<PublicInstalledPackage>,
});
});

View File

@ -26,8 +26,9 @@ const downloads = ref<string | null>(null);
const verified = ref(false);
const official = ref(false);
const packageName = computed(() => communityNodeDetails?.packageName);
const { installedPackage, initInstalledPackage, isUpdateCheckAvailable } =
useInstalledCommunityPackage(packageName);
const nodeTypeName = computed(() => communityNodeDetails?.key);
const { installedPackage, initInstalledPackage, canUpdatePackage, hasUpdateAvailable } =
useInstalledCommunityPackage(nodeTypeName);
const { getQuickConnectOptionByPackageName } = useQuickConnect();
const quickConnect = computed(() => {
const pkg = packageName.value;
@ -118,7 +119,7 @@ onMounted(async () => {
{{ communityNodeDetails?.description }}
</N8nText>
<CommunityNodeUpdateInfo
v-if="isUpdateCheckAvailable && installedPackage?.updateAvailable"
v-if="canUpdatePackage && hasUpdateAvailable"
data-test-id="update-available"
:package-name="communityNodeDetails?.packageName"
source="node creator panel"

View File

@ -1,13 +1,17 @@
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 type { CommunityNodeType } from '@n8n/api-types';
import type { PublicInstalledPackage } from 'n8n-workflow';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { nextTick, ref } from 'vue';
import { useInstalledCommunityPackage } from './useInstalledCommunityPackage';
import { useCommunityNodesStore } from '../communityNodes.store';
import { mockedStore } from '@/__tests__/utils';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useUsersStore } from '@n8n/stores/users.store';
import type { ExtendedPublicInstalledPackage } from '../communityNodes.utils';
import { useCommunityNodesStore } from '../communityNodes.store';
import { useInstalledCommunityPackage } from './useInstalledCommunityPackage';
import type * as n8nWorkflow from 'n8n-workflow';
vi.mock('n8n-workflow', async (importOriginal) => {
@ -18,16 +22,16 @@ vi.mock('n8n-workflow', async (importOriginal) => {
};
});
vi.mock('../communityNodes.utils', () => ({
fetchInstalledPackageInfo: vi.fn(),
}));
// Import mocked functions
import { isCommunityPackageName } from 'n8n-workflow';
import { fetchInstalledPackageInfo } from '../communityNodes.utils';
const mockIsCommunityPackageName = vi.mocked(isCommunityPackageName);
const mockFetchInstalledPackageInfo = vi.mocked(fetchInstalledPackageInfo);
const communityPackage = (): PublicInstalledPackage => ({
packageName: '@test/n8n-nodes-test',
installedVersion: '1.0.0',
installedNodes: [],
createdAt: new Date(),
updatedAt: new Date(),
});
describe('useInstalledCommunityPackage', () => {
beforeEach(() => {
@ -35,228 +39,134 @@ describe('useInstalledCommunityPackage', () => {
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('identifies community nodes', () => {
mockIsCommunityPackageName.mockReturnValue(true);
it('should return false for isCommunityNode when nodeTypeName is undefined', () => {
const composable = useInstalledCommunityPackage();
expect(composable.isCommunityNode.value).toBe(false);
});
const { isCommunityNode } = useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
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.isAdminOrOwner = 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.isAdminOrOwner = 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.isAdminOrOwner = true;
mockIsCommunityPackageName.mockReturnValue(false);
const { isUpdateCheckAvailable } = useInstalledCommunityPackage('n8n-nodes-base.HttpRequest');
expect(isUpdateCheckAvailable.value).toBe(false);
});
expect(isCommunityNode.value).toBe(true);
expect(mockIsCommunityPackageName).toHaveBeenCalledWith('@test/n8n-nodes-test.TestNode');
});
describe('initInstalledPackage', () => {
it('should return undefined when packageName is empty', async () => {
const { initInstalledPackage } = useInstalledCommunityPackage();
it('does not identify built-in or missing node types as community nodes', () => {
mockIsCommunityPackageName.mockReturnValue(false);
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();
});
expect(useInstalledCommunityPackage('n8n-nodes-base.HttpRequest').isCommunityNode.value).toBe(
false,
);
expect(useInstalledCommunityPackage().isCommunityNode.value).toBe(false);
});
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,
};
it('allows owners and admins to update community packages', () => {
const usersStore = mockedStore(useUsersStore);
usersStore.isAdminOrOwner = true;
mockIsCommunityPackageName.mockReturnValue(true);
mockIsCommunityPackageName.mockReturnValue(true);
mockFetchInstalledPackageInfo.mockResolvedValue(mockPackage);
const { canUpdatePackage } = useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
// 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();
});
expect(canUpdatePackage.value).toBe(true);
});
describe('return values', () => {
it('should return all expected properties and functions', () => {
const result = useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
it('does not allow members to update community packages', () => {
const usersStore = mockedStore(useUsersStore);
usersStore.isAdminOrOwner = false;
mockIsCommunityPackageName.mockReturnValue(true);
expect(result).toHaveProperty('installedPackage');
expect(result).toHaveProperty('isUpdateCheckAvailable');
expect(result).toHaveProperty('isCommunityNode');
expect(result).toHaveProperty('initInstalledPackage');
const { canUpdatePackage } = useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
expect(typeof result.initInstalledPackage).toBe('function');
expect(canUpdatePackage.value).toBe(false);
});
it('reacts when a newer verified version becomes available', async () => {
const communityNodesStore = mockedStore(useCommunityNodesStore);
const nodeTypesStore = mockedStore(useNodeTypesStore);
const settingsStore = mockedStore(useSettingsStore);
const latestVerifiedVersion = ref('1.0.0');
mockIsCommunityPackageName.mockReturnValue(true);
communityNodesStore.getInstalledPackage.mockResolvedValue(communityPackage());
vi.spyOn(nodeTypesStore, 'communityNodeType', 'get').mockReturnValue(
vi.fn(() => mock<CommunityNodeType>({ npmVersion: latestVerifiedVersion.value })),
);
Object.defineProperty(settingsStore, 'isCommunityNodesFeatureEnabled', {
get: () => true,
});
Object.defineProperty(settingsStore, 'isUnverifiedPackagesEnabled', {
get: () => false,
});
settingsStore.settings.communityNodesManagedByEnv = false;
const { hasUpdateAvailable, initInstalledPackage } = useInstalledCommunityPackage(
'@test/n8n-nodes-test.TestNode',
);
await initInstalledPackage();
expect(hasUpdateAvailable.value).toBe(false);
latestVerifiedVersion.value = '1.1.0';
await nextTick();
expect(hasUpdateAvailable.value).toBe(true);
});
it('fetches the installed package for a community node', async () => {
const communityNodesStore = mockedStore(useCommunityNodesStore);
const installed = communityPackage();
mockIsCommunityPackageName.mockReturnValue(true);
communityNodesStore.getInstalledPackage.mockResolvedValue(installed);
const { initInstalledPackage, installedPackage } = useInstalledCommunityPackage(
'@test/n8n-nodes-test.TestNode',
);
const result = await initInstalledPackage();
expect(communityNodesStore.getInstalledPackage).toHaveBeenCalledWith('@test/n8n-nodes-test');
expect(result).toStrictEqual(installed);
expect(installedPackage.value).toStrictEqual(installed);
});
it('does not fetch an installed package without a community node type', async () => {
const communityNodesStore = mockedStore(useCommunityNodesStore);
mockIsCommunityPackageName.mockReturnValue(false);
expect(await useInstalledCommunityPackage().initInstalledPackage()).toBeUndefined();
expect(
await useInstalledCommunityPackage('n8n-nodes-base.HttpRequest').initInstalledPackage(),
).toBeUndefined();
expect(communityNodesStore.getInstalledPackage).not.toHaveBeenCalled();
});
it('updates when the installed package changes', async () => {
const communityNodesStore = mockedStore(useCommunityNodesStore);
const installed = communityPackage();
mockIsCommunityPackageName.mockReturnValue(true);
communityNodesStore.getInstalledPackage.mockResolvedValue(installed);
const { installedPackage } = useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
communityNodesStore.installedPackages = {
'@test/n8n-nodes-test': installed,
};
await nextTick();
expect(installedPackage.value).toStrictEqual(installed);
});
it('clears the installed package when the node type changes', async () => {
const communityNodesStore = mockedStore(useCommunityNodesStore);
const installed = communityPackage();
const nodeTypeName = ref('@test/n8n-nodes-test.TestNode');
mockIsCommunityPackageName.mockReturnValue(true);
communityNodesStore.getInstalledPackage.mockResolvedValue(installed);
communityNodesStore.installedPackages = {
'@test/n8n-nodes-test': installed,
};
const { installedPackage, initInstalledPackage } = useInstalledCommunityPackage(nodeTypeName);
await initInstalledPackage();
expect(installedPackage.value).toStrictEqual(installed);
nodeTypeName.value = '@test/n8n-nodes-other.OtherNode';
await nextTick();
expect(installedPackage.value).toBeUndefined();
});
});

View File

@ -1,17 +1,18 @@
import { useCommunityNodesStore } from '../communityNodes.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useUsersStore } from '@n8n/stores/users.store';
import { isCommunityPackageName } from 'n8n-workflow';
import {
type ExtendedPublicInstalledPackage,
fetchInstalledPackageInfo,
} from '../communityNodes.utils';
import { isCommunityPackageName, type PublicInstalledPackage } from 'n8n-workflow';
import { isCommunityPackageUpdateAvailable } from '../communityNodes.utils';
import { computed, type MaybeRefOrGetter, onMounted, ref, watch, toValue } from 'vue';
export function useInstalledCommunityPackage(nodeTypeName?: MaybeRefOrGetter<string | undefined>) {
const communityNodesStore = useCommunityNodesStore();
const nodeTypesStore = useNodeTypesStore();
const settingsStore = useSettingsStore();
const usersStore = useUsersStore();
const installedPackage = ref<ExtendedPublicInstalledPackage | undefined>(undefined);
const installedPackage = ref<PublicInstalledPackage>();
const packageName = computed(() => toValue(nodeTypeName)?.split('.')[0] ?? '');
const isCommunityNode = computed(() => {
@ -24,15 +25,18 @@ export function useInstalledCommunityPackage(nodeTypeName?: MaybeRefOrGetter<str
const initInstalledPackage = async () => {
if (!packageName.value || !isCommunityNode.value) return undefined;
installedPackage.value = await fetchInstalledPackageInfo(packageName.value);
installedPackage.value = await communityNodesStore.getInstalledPackage(packageName.value);
return installedPackage.value;
};
// update when installed package changes if it's defined
// Keep package data in sync when the store or selected node changes.
watch(
() => communityNodesStore.installedPackages[packageName.value],
async (changedPackage) => {
if (!packageName.value || !changedPackage) return;
if (!packageName.value || !changedPackage) {
installedPackage.value = undefined;
return;
}
await initInstalledPackage();
},
{ deep: true },
@ -45,20 +49,33 @@ export function useInstalledCommunityPackage(nodeTypeName?: MaybeRefOrGetter<str
});
/**
* True when the node is a community node, the user has rights to update the package and the package is not an unverified update.
* True when the node is a community node and the user has rights to update the package.
* Update dialogs and button should not be shown when this is false.
*/
const isUpdateCheckAvailable = computed(() => {
return (
isCommunityNode.value &&
usersStore.isAdminOrOwner &&
!installedPackage.value?.unverifiedUpdate
);
const canUpdatePackage = computed(() => {
return isCommunityNode.value && usersStore.isAdminOrOwner;
});
const hasUpdateAvailable = computed(() => {
const packageInfo = installedPackage.value;
if (!packageInfo) return false;
const communityNodeType = nodeTypesStore.communityNodeType(toValue(nodeTypeName) ?? '');
return isCommunityPackageUpdateAvailable({
installedVersion: packageInfo.installedVersion,
updateAvailable: packageInfo.updateAvailable,
latestVerifiedVersion: communityNodeType?.npmVersion,
isCommunityNodesFeatureEnabled: settingsStore.isCommunityNodesFeatureEnabled,
isUnverifiedPackagesEnabled: settingsStore.isUnverifiedPackagesEnabled,
isManagedByEnv: settingsStore.settings.communityNodesManagedByEnv ?? false,
});
});
return {
installedPackage,
isUpdateCheckAvailable,
canUpdatePackage,
hasUpdateAvailable,
isCommunityNode,
initInstalledPackage,
};

View File

@ -371,6 +371,7 @@ const callouts = computed<INodeCreateElement[]>(() => []);
v-if="communityNodeDetails"
:class="$style.communityNodeFooter"
:package-name="communityNodeDetails.packageName"
:node-type-name="communityNodeDetails.key"
:show-manage="communityNodeDetails.installed && usersStore.isAdminOrOwner"
/>
</div>

View File

@ -332,6 +332,7 @@ function onBackButton() {
<CommunityNodeFooter
v-if="communityNodeDetails && !isCommunityNodeActionsMode"
:package-name="communityNodeDetails.packageName"
:node-type-name="communityNodeDetails.key"
:show-manage="communityNodeDetails.installed && isAdminOrOwner"
/>
</aside>