feat(editor): Allow users to update verified nodes from the node settings panel (#16447)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Michael Kret <michael.k@radency.com>
This commit is contained in:
Dana
2025-06-30 15:43:26 +02:00
committed by GitHub
parent 585295c89f
commit 6edd47dd65
13 changed files with 374 additions and 37 deletions

View File

@@ -0,0 +1,45 @@
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import CommunityPackageCard from './CommunityPackageCard.vue';
import { createComponentRenderer } from '@/__tests__/render';
const communityPackage = {
packageName: 'n8n-nodes-test',
installedVersion: '1.0.0',
installedNodes: [{ name: 'TestNode' }],
};
const renderComponent = createComponentRenderer(CommunityPackageCard);
const flushPromises = async () => await new Promise(setImmediate);
describe('CommunityPackageCard', () => {
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
beforeEach(() => {
const pinia = createTestingPinia();
setActivePinia(pinia);
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' });
renderComponent({
props: {
communityPackage,
},
});
await flushPromises();
expect(nodeTypesStore.loadNodeTypesIfNotLoaded).toHaveBeenCalled();
expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenCalledWith('n8n-nodes-test');
});
});

View File

@@ -6,6 +6,9 @@ import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useSettingsStore } from '@/stores/settings.store';
import type { UserAction } from '@n8n/design-system';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { computed, ref, watch } from 'vue';
import semver from 'semver';
interface Props {
communityPackage?: PublicInstalledPackage | null;
@@ -21,7 +24,23 @@ const { openCommunityPackageUpdateConfirmModal, openCommunityPackageUninstallCon
useUIStore();
const i18n = useI18n();
const telemetry = useTelemetry();
const settingsStore = useSettingsStore();
const nodeTypesStore = useNodeTypesStore();
const latestVerifiedVersion = ref<string>();
const currVersion = computed(() => props.communityPackage?.installedVersion || '');
const hasUnverifiedPackagesUpdate = computed(() => {
return settingsStore.isUnverifiedPackagesEnabled && props.communityPackage?.updateAvailable;
});
const hasVerifiedPackageUpdate = computed(() => {
const canUpdate =
latestVerifiedVersion.value && semver.gt(latestVerifiedVersion.value || '', currVersion.value);
return settingsStore.isCommunityNodesFeatureEnabled && canUpdate;
});
const packageActions: Array<UserAction<IUser>> = [
{
@@ -57,6 +76,24 @@ function onUpdateClick() {
if (!props.communityPackage) return;
openCommunityPackageUpdateConfirmModal(props.communityPackage.packageName);
}
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;
}
}
},
{ immediate: true },
);
</script>
<template>
@@ -99,7 +136,7 @@ function onUpdateClick() {
<n8n-icon icon="exclamation-triangle" color="danger" size="large" />
</n8n-tooltip>
<n8n-tooltip
v-else-if="settingsStore.isUnverifiedPackagesEnabled && communityPackage.updateAvailable"
v-else-if="hasUnverifiedPackagesUpdate || hasVerifiedPackageUpdate"
placement="top"
>
<template #content>

View File

@@ -0,0 +1,106 @@
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { createComponentRenderer } from '@/__tests__/render';
import CommunityPackageManageConfirmModal from './CommunityPackageManageConfirmModal.vue';
import { cleanupAppModals, createAppModals, SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import { useSettingsStore } from '@/stores/settings.store';
import { defaultSettings } from '@/__tests__/defaults';
import { mockNodeTypeDescription } from '@/__tests__/mocks';
import { createTestingPinia } from '@pinia/testing';
import { STORES } from '@n8n/stores';
import { COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY } from '@/constants';
const renderComponent = createComponentRenderer(CommunityPackageManageConfirmModal, {
data() {
return {
packageName: 'n8n-nodes-hello',
};
},
pinia: createTestingPinia({
initialState: {
[STORES.UI]: {
modalsById: {
[COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY]: { open: true },
},
},
[STORES.COMMUNITY_NODES]: {
installedPackages: {
'n8n-nodes-test': {
packageName: 'n8n-nodes-test',
installedVersion: '1.0.0',
updateAvailable: '2.0.0',
},
},
},
[STORES.NODE_TYPES]: {
nodeTypes: {
['n8n-nodes-test.test']: {
1: mockNodeTypeDescription({
name: 'n8n-nodes-test.test',
}),
},
},
},
[STORES.SETTINGS]: {
...SETTINGS_STORE_DEFAULT_STATE,
settings: {
...SETTINGS_STORE_DEFAULT_STATE.settings,
communityNodesEnabled: true,
},
},
},
}),
});
const flushPromises = async () => await new Promise(setImmediate);
describe('CommunityPackageManageConfirmModal', () => {
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
beforeEach(() => {
createAppModals();
nodeTypesStore = useNodeTypesStore();
});
afterEach(() => {
cleanupAppModals();
});
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' });
renderComponent({
props: {
modalName: 'test-modal',
activePackageName: 'n8n-nodes-test',
mode: 'update',
},
});
await flushPromises();
expect(nodeTypesStore.loadNodeTypesIfNotLoaded).toHaveBeenCalled();
expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenCalledWith('n8n-nodes-test.test');
});
it('should call nodeTypesStore methods and update latestVerifiedVersion on mount', async () => {
useSettingsStore().setSettings({ ...defaultSettings, communityNodesEnabled: true });
nodeTypesStore.loadNodeTypesIfNotLoaded = vi.fn().mockResolvedValue(undefined);
nodeTypesStore.getCommunityNodeAttributes = vi.fn().mockResolvedValue({ npmVersion: '1.5.0' });
const { getByTestId } = renderComponent({
props: {
modalName: 'test-modal',
activePackageName: 'n8n-nodes-test',
mode: 'update',
},
});
await flushPromises();
const testId = getByTestId('communityPackageManageConfirmModal-warning');
expect(testId).toBeInTheDocument();
});
});

View File

@@ -6,8 +6,11 @@ import { useCommunityNodesStore } from '@/stores/communityNodes.store';
import { createEventBus } from '@n8n/utils/event-bus';
import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { computed, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { CommunityNodeType } from '@n8n/api-types';
import { useSettingsStore } from '@/stores/settings.store';
import semver from 'semver';
export type CommunityPackageManageMode = 'uninstall' | 'update' | 'view-documentation';
@@ -20,6 +23,8 @@ interface Props {
const props = defineProps<Props>();
const communityNodesStore = useCommunityNodesStore();
const nodeTypesStore = useNodeTypesStore();
const settingsStore = useSettingsStore();
const modalBus = createEventBus();
@@ -29,9 +34,24 @@ const telemetry = useTelemetry();
const loading = ref(false);
const activePackage = computed(
const isUsingVerifiedAndUnverifiedPackages =
settingsStore.isCommunityNodesFeatureEnabled && settingsStore.isUnverifiedPackagesEnabled;
const isUsingVerifiedPackagesOnly =
settingsStore.isCommunityNodesFeatureEnabled && !settingsStore.isUnverifiedPackagesEnabled;
const communityStorePackage = computed(
() => communityNodesStore.installedPackages[props.activePackageName],
);
const updateVersion = computed(() => {
return settingsStore.isUnverifiedPackagesEnabled
? communityStorePackage.value.updateAvailable
: nodeTypeStorePackage.value?.npmVersion;
});
const nodeTypeStorePackage = ref<CommunityNodeType>();
const isLatestPackageVerified = ref<boolean>(true);
const packageVersion = ref<string>(communityStorePackage.value.updateAvailable ?? '');
const getModalContent = computed(() => {
if (props.mode === COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL) {
@@ -55,10 +75,11 @@ const getModalContent = computed(() => {
},
}),
description: i18n.baseText('settings.communityNodes.confirmModal.update.description'),
warning: i18n.baseText('settings.communityNodes.confirmModal.update.warning'),
message: i18n.baseText('settings.communityNodes.confirmModal.update.message', {
interpolate: {
packageName: props.activePackageName,
version: activePackage.value.updateAvailable ?? '',
version: packageVersion.value,
},
}),
buttonLabel: i18n.baseText('settings.communityNodes.confirmModal.update.buttonLabel'),
@@ -83,11 +104,11 @@ const onConfirmButtonClick = async () => {
const onUninstall = async () => {
try {
telemetry.track('user started cnr package deletion', {
package_name: activePackage.value.packageName,
package_node_names: activePackage.value.installedNodes.map((node) => node.name),
package_version: activePackage.value.installedVersion,
package_author: activePackage.value.authorName,
package_author_email: activePackage.value.authorEmail,
package_name: communityStorePackage.value.packageName,
package_node_names: communityStorePackage.value.installedNodes.map((node) => node.name),
package_version: communityStorePackage.value.installedVersion,
package_author: communityStorePackage.value.authorName,
package_author_email: communityStorePackage.value.authorEmail,
});
loading.value = true;
await communityNodesStore.uninstallPackage(props.activePackageName);
@@ -107,23 +128,34 @@ const onUninstall = async () => {
const onUpdate = async () => {
try {
telemetry.track('user started cnr package update', {
package_name: activePackage.value.packageName,
package_node_names: activePackage.value.installedNodes.map((node) => node.name),
package_version_current: activePackage.value.installedVersion,
package_version_new: activePackage.value.updateAvailable,
package_author: activePackage.value.authorName,
package_author_email: activePackage.value.authorEmail,
package_name: communityStorePackage.value.packageName,
package_node_names: communityStorePackage.value.installedNodes.map((node) => node.name),
package_version_current: communityStorePackage.value.installedVersion,
package_version_new: communityStorePackage.value.updateAvailable,
package_author: communityStorePackage.value.authorName,
package_author_email: communityStorePackage.value.authorEmail,
});
loading.value = true;
const updatedVersion = activePackage.value.updateAvailable;
await communityNodesStore.updatePackage(props.activePackageName);
if (settingsStore.isUnverifiedPackagesEnabled) {
await communityNodesStore.updatePackage(props.activePackageName);
} else if (settingsStore.isCommunityNodesFeatureEnabled) {
await communityNodesStore.updatePackage(
props.activePackageName,
updateVersion.value,
nodeTypeStorePackage.value?.checksum,
);
} else {
throw new Error('Community nodes feature is not correctly enabled.');
}
await useNodeTypesStore().getNodeTypes();
toast.showMessage({
title: i18n.baseText('settings.communityNodes.messages.update.success.title'),
message: i18n.baseText('settings.communityNodes.messages.update.success.message', {
interpolate: {
packageName: props.activePackageName,
version: updatedVersion ?? '',
version: updateVersion.value ?? '',
},
}),
type: 'success',
@@ -135,6 +167,47 @@ const onUpdate = async () => {
modalBus.emit('close');
}
};
async function fetchPackageInfo(packageName: string) {
await nodeTypesStore.loadNodeTypesIfNotLoaded();
const nodeType = nodeTypesStore.visibleNodeTypes.find((nodeType) =>
nodeType.name.includes(packageName),
);
if (nodeType) {
const communityNodeAttributes = await nodeTypesStore.getCommunityNodeAttributes(nodeType?.name);
nodeTypeStorePackage.value = communityNodeAttributes ?? undefined;
}
}
function setIsVerifiedLatestPackage() {
if (
isUsingVerifiedAndUnverifiedPackages &&
nodeTypeStorePackage.value?.npmVersion &&
communityStorePackage.value.updateAvailable
) {
isLatestPackageVerified.value = semver.eq(
nodeTypeStorePackage.value.npmVersion,
communityStorePackage.value.updateAvailable,
);
}
}
function setPackageVersion() {
if (isUsingVerifiedPackagesOnly) {
packageVersion.value = nodeTypeStorePackage.value?.npmVersion ?? packageVersion.value;
}
}
onMounted(async () => {
if (props.activePackageName) {
await fetchPackageInfo(props.activePackageName);
}
setIsVerifiedLatestPackage();
setPackageVersion();
});
</script>
<template>
@@ -156,6 +229,11 @@ const onUpdate = async () => {
<n8n-info-tip theme="info" type="note" :bold="false">
<span v-text="getModalContent.description"></span>
</n8n-info-tip>
<n8n-notice
data-test-id="communityPackageManageConfirmModal-warning"
v-if="!isLatestPackageVerified"
:content="getModalContent.warning"
/>
</div>
</template>
<template #footer>
@@ -175,6 +253,7 @@ const onUpdate = async () => {
.descriptionContainer {
display: flex;
margin: var(--spacing-s) 0;
flex-direction: column;
}
.descriptionIcon {

View File

@@ -81,11 +81,17 @@ export const useCommunityNodesStore = defineStore(STORES.COMMUNITY_NODES, () =>
installedPackages.value[newPackage.packageName] = newPackage;
};
const updatePackage = async (packageName: string): Promise<void> => {
const updatePackage = async (
packageName: string,
version?: string,
checksum?: string,
): Promise<void> => {
const packageToUpdate = installedPackages.value[packageName];
const updatedPackage = await communityNodesApi.updatePackage(
rootStore.restApiContext,
packageToUpdate.packageName,
version,
checksum,
);
updatePackageObject(updatedPackage);
};
@@ -99,5 +105,6 @@ export const useCommunityNodesStore = defineStore(STORES.COMMUNITY_NODES, () =>
installPackage,
uninstallPackage,
updatePackage,
setInstalledPackages,
};
});