mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(editor): Mark community packages with blocked node types (no-changelog) (#41105)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Guillaume Jacquart <jacquart.guillaume@gmail.com>
This commit is contained in:
parent
21221adc78
commit
6f3b397a1e
|
|
@ -3558,6 +3558,8 @@
|
|||
"settings.communityNodes.uninstallAction.label": "Uninstall package",
|
||||
"settings.communityNodes.upToDate.tooltip": "You are up to date",
|
||||
"settings.communityNodes.failedToLoad.tooltip": "This package failed to load. It may be corrupted, or it may need a newer version of n8n. Try uninstalling and reinstalling it, or upgrade n8n.",
|
||||
"settings.communityNodes.restricted.badge.label": "Restricted",
|
||||
"settings.communityNodes.restricted.tooltip.heading": "Blocked by an instance policy",
|
||||
"settings.communityNodes.managedByEnv": "Community packages are managed via environment variables and cannot be modified from the UI.",
|
||||
"settings.communityNodes.fetchError.title": "Problem fetching installed packages",
|
||||
"settings.communityNodes.fetchError.message": "There may be a problem with your internet connection or your n8n instance",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import { STORES } from '@n8n/stores';
|
|||
import CommunityPackageCard from './CommunityPackageCard.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import type { PublicInstalledNode, PublicInstalledPackage } from 'n8n-workflow';
|
||||
import { useTypeAvailabilityPoliciesStore } from '@n8n/frontend-module-type-availability-policies';
|
||||
import type { NodeTypeAvailability } from '@n8n/api-types';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
|
||||
const communityPackage: PublicInstalledPackage = {
|
||||
packageName: 'n8n-nodes-test',
|
||||
|
|
@ -113,4 +116,72 @@ describe('CommunityPackageCard', () => {
|
|||
expect(queryByText('Uninstall package')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('restricted badge', () => {
|
||||
const mockNodeAvailability = (
|
||||
unavailable: Record<string, Pick<NodeTypeAvailability, 'scope' | 'optInAvailable'>>,
|
||||
) => {
|
||||
vi.spyOn(useTypeAvailabilityPoliciesStore(), 'getNodeTypeAvailability').mockImplementation(
|
||||
(name: string) =>
|
||||
name in unavailable
|
||||
? { name, available: false, ...unavailable[name] }
|
||||
: { name, available: true },
|
||||
);
|
||||
};
|
||||
|
||||
// Reka UI only mounts tooltip content once the trigger is hovered.
|
||||
function hoverTrigger(element: Element) {
|
||||
element.dispatchEvent(
|
||||
new PointerEvent('pointermove', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerType: 'mouse',
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it('shows the badge and lists instance-blocked node types in the tooltip', async () => {
|
||||
mockNodeAvailability({ 'n8n-nodes-test.oldNode': { scope: 'instance' } });
|
||||
|
||||
const { getByText } = renderComponent({ props: { communityPackage } });
|
||||
|
||||
const badge = getByText('Restricted');
|
||||
expect(badge).toBeInTheDocument();
|
||||
|
||||
hoverTrigger(badge);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-dismissable-layer]')).toHaveTextContent('OldNode');
|
||||
});
|
||||
expect(document.querySelector('[data-dismissable-layer]')).not.toHaveTextContent('TestNode');
|
||||
});
|
||||
|
||||
it('does not show the badge when no node type is blocked', () => {
|
||||
mockNodeAvailability({});
|
||||
|
||||
const { queryByText } = renderComponent({ props: { communityPackage } });
|
||||
|
||||
expect(queryByText('Restricted')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ignores a project-scoped restriction', () => {
|
||||
mockNodeAvailability({ 'n8n-nodes-test.oldNode': { scope: 'project' } });
|
||||
|
||||
const { queryByText } = renderComponent({ props: { communityPackage } });
|
||||
|
||||
expect(queryByText('Restricted')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ignores an instance restriction that projects can opt into', () => {
|
||||
mockNodeAvailability({
|
||||
'n8n-nodes-test.oldNode': { scope: 'instance', optInAvailable: true },
|
||||
});
|
||||
|
||||
const { queryByText } = renderComponent({ props: { communityPackage } });
|
||||
|
||||
expect(queryByText('Restricted')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts" setup>
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import type { IUser, PublicInstalledPackage } from 'n8n-workflow';
|
||||
import type { IUser, PublicInstalledNode, PublicInstalledPackage } from 'n8n-workflow';
|
||||
import { COMMUNITY_PACKAGE_MANAGE_ACTIONS } from '../communityNodes.constants';
|
||||
import { NPM_PACKAGE_DOCS_BASE_URL } from '@/app/constants';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
|
@ -8,6 +8,7 @@ import { useTelemetry } from '@n8n/composables/useTelemetry';
|
|||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import type { UserAction } from '@n8n/design-system';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { getNodeTypeRestriction } from '@n8n/frontend-module-type-availability-policies';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
findVettedCommunityNodeAttributes,
|
||||
|
|
@ -16,6 +17,7 @@ import {
|
|||
|
||||
import {
|
||||
N8nActionToggle,
|
||||
N8nBadge,
|
||||
N8nButton,
|
||||
N8nIcon,
|
||||
N8nLoading,
|
||||
|
|
@ -57,6 +59,17 @@ const hasUpdateAvailable = computed(() => {
|
|||
});
|
||||
});
|
||||
|
||||
// Packages are installed instance-wide, so only a hard instance block counts. A delegated
|
||||
// type (`optInAvailable`) is not blocked, because any project can allow it.
|
||||
const blockedNodes = computed<PublicInstalledNode[]>(() => {
|
||||
if (!props.communityPackage) return [];
|
||||
|
||||
return props.communityPackage.installedNodes.filter((node) => {
|
||||
const restriction = getNodeTypeRestriction(node.type);
|
||||
return restriction?.scope === 'instance' && !restriction.optInAvailable;
|
||||
});
|
||||
});
|
||||
|
||||
const packageActions = computed<Array<UserAction<IUser>>>(() => {
|
||||
const actions: Array<UserAction<IUser>> = [
|
||||
{
|
||||
|
|
@ -120,6 +133,25 @@ watch(
|
|||
<div :class="$style.cardInfoContainer">
|
||||
<div :class="$style.cardTitle">
|
||||
<N8nText :bold="true" size="large">{{ communityPackage.packageName }}</N8nText>
|
||||
<N8nTooltip v-if="blockedNodes.length > 0" placement="top">
|
||||
<template #content>
|
||||
<div>
|
||||
<N8nText tag="p" size="small" :bold="true">
|
||||
{{ i18n.baseText('settings.communityNodes.restricted.tooltip.heading') }}
|
||||
</N8nText>
|
||||
<ul :class="$style.blockedNodesList">
|
||||
<li v-for="node in blockedNodes" :key="node.type">{{ node.name }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<N8nBadge
|
||||
variant="filled"
|
||||
leading-icon="lock"
|
||||
data-test-id="community-package-restricted-badge"
|
||||
>
|
||||
{{ i18n.baseText('settings.communityNodes.restricted.badge.label') }}
|
||||
</N8nBadge>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
<div :class="$style.cardSubtitle">
|
||||
<N8nText :bold="true" size="small" color="text-light">
|
||||
|
|
@ -216,6 +248,9 @@ watch(
|
|||
}
|
||||
|
||||
.cardTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
flex-basis: 100%;
|
||||
|
||||
span {
|
||||
|
|
@ -223,6 +258,11 @@ watch(
|
|||
}
|
||||
}
|
||||
|
||||
.blockedNodesList {
|
||||
margin: var(--spacing--4xs) 0 0;
|
||||
padding-left: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.cardSubtitle {
|
||||
margin-top: 2px;
|
||||
padding-right: var(--spacing--md);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import { setActivePinia } from 'pinia';
|
|||
import { STORES } from '@n8n/stores';
|
||||
import { useCommunityNodesStore } from '../communityNodes.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { useTypeAvailabilityPoliciesStore } from '@n8n/frontend-module-type-availability-policies';
|
||||
import { COMMUNITY_PACKAGE_INSTALL_MODAL_KEY } from '../communityNodes.constants';
|
||||
import type { PublicInstalledPackage, PublicInstalledNode } from 'n8n-workflow';
|
||||
|
||||
|
|
@ -111,6 +114,7 @@ const setup = (
|
|||
availablePackageCount?: number;
|
||||
communityNodesManagedByEnv?: boolean;
|
||||
} = {},
|
||||
{ beforeRender }: { beforeRender?: () => void } = {},
|
||||
) => {
|
||||
const {
|
||||
packages = [],
|
||||
|
|
@ -140,6 +144,8 @@ const setup = (
|
|||
|
||||
uiStore.openModal = vi.fn();
|
||||
|
||||
beforeRender?.();
|
||||
|
||||
return {
|
||||
...renderComponent({ pinia }),
|
||||
communityNodesStore,
|
||||
|
|
@ -282,4 +288,23 @@ describe('SettingsCommunityNodesView', () => {
|
|||
expect(uiStore.openModal).toHaveBeenCalledWith(COMMUNITY_PACKAGE_INSTALL_MODAL_KEY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('node type availability', () => {
|
||||
it("loads the policy for the user's personal project", async () => {
|
||||
const PERSONAL_PROJECT_ID = 'personal-project-1';
|
||||
let fetchForProject: ReturnType<typeof vi.fn>;
|
||||
|
||||
setup(undefined, {
|
||||
beforeRender: () => {
|
||||
mockedStore(useProjectsStore).personalProject = { id: PERSONAL_PROJECT_ID } as never;
|
||||
fetchForProject = vi
|
||||
.spyOn(useTypeAvailabilityPoliciesStore(), 'fetchForProject')
|
||||
.mockResolvedValue(undefined);
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(fetchForProject!).toHaveBeenCalledWith(PERSONAL_PROJECT_ID);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import type { PublicInstalledPackage } from 'n8n-workflow';
|
|||
|
||||
import { useCommunityNodesStore } from '../communityNodes.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { onBeforeUnmount, ref, computed, onBeforeMount, onMounted } from 'vue';
|
||||
import { onBeforeUnmount, ref, computed, onBeforeMount, onMounted, watch } from 'vue';
|
||||
import { useExternalHooks } from '@/app/composables/useExternalHooks';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { usePushConnection } from '@/app/composables/usePushConnection';
|
||||
|
|
@ -18,6 +18,8 @@ import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
|
|||
import { useI18n } from '@n8n/i18n';
|
||||
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import { useTypeAvailabilityPoliciesStore } from '@n8n/frontend-module-type-availability-policies';
|
||||
|
||||
import { N8nEmptyState, N8nButton, N8nHeading, N8nNotice } from '@n8n/design-system';
|
||||
const PACKAGE_COUNT_THRESHOLD = 31;
|
||||
|
|
@ -36,6 +38,8 @@ const documentTitle = useDocumentTitle();
|
|||
const communityNodesStore = useCommunityNodesStore();
|
||||
const uiStore = useUIStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const typeAvailabilityPoliciesStore = useTypeAvailabilityPoliciesStore();
|
||||
|
||||
const isManagedByEnv = computed((): boolean => {
|
||||
return settingsStore.settings.communityNodesManagedByEnv ?? false;
|
||||
|
|
@ -154,6 +158,16 @@ onBeforeUnmount(() => {
|
|||
pushStore.pushDisconnect();
|
||||
pushConnection.terminate();
|
||||
});
|
||||
|
||||
// Node type availability is project-scoped, but this page has no project context of its own.
|
||||
// The personal project is only used to carry the instance-wide verdicts the cards need.
|
||||
watch(
|
||||
() => projectsStore.personalProject?.id,
|
||||
(projectId) => {
|
||||
if (projectId) void typeAvailabilityPoliciesStore.fetchForProject(projectId);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user