diff --git a/packages/frontend/@n8n/design-system/src/components/N8nTabs/Tabs.vue b/packages/frontend/@n8n/design-system/src/components/N8nTabs/Tabs.vue index f0dda8872b..0104b138a8 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nTabs/Tabs.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nTabs/Tabs.vue @@ -116,7 +116,10 @@ const scrollRight = () => scroll(50); @click="() => handleTabClick(option.value)" > - {{ option.label }} + {{ option.label }} +
@@ -211,6 +214,26 @@ const scrollRight = () => scroll(50); font-weight: var(--font-weight-bold); } +.notificationContainer { + display: flex; + position: relative; +} + +.notification { + display: flex; + position: absolute; + right: -0.5em; + align-items: center; + justify-content: center; + + div { + height: 0.3em; + width: 0.3em; + background-color: var(--color-primary); + border-radius: 50%; + } +} + .back { composes: tab; composes: button; diff --git a/packages/frontend/@n8n/design-system/src/types/tabs.ts b/packages/frontend/@n8n/design-system/src/types/tabs.ts index 9739882f56..5d85d06af3 100644 --- a/packages/frontend/@n8n/design-system/src/types/tabs.ts +++ b/packages/frontend/@n8n/design-system/src/types/tabs.ts @@ -10,4 +10,5 @@ export interface TabOptions { tooltip?: string; align?: 'left' | 'right'; to?: RouteLocationRaw; + notification?: boolean; } diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 4cb8e280a0..8ee8df79b6 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -1076,6 +1076,7 @@ export interface ITab { icon?: IconName; align?: 'right'; tooltip?: string; + notification?: boolean; } export interface ITabBarItem { diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/Panel/CommunityNodeUpdateInfo.vue b/packages/frontend/editor-ui/src/components/Node/NodeCreator/Panel/CommunityNodeUpdateInfo.vue index cb9309eab5..08a9aa6006 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/Panel/CommunityNodeUpdateInfo.vue +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/Panel/CommunityNodeUpdateInfo.vue @@ -3,7 +3,6 @@ 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) { diff --git a/packages/frontend/editor-ui/src/components/NodeSettings.vue b/packages/frontend/editor-ui/src/components/NodeSettings.vue index 4788bb1a92..53d6cd0b43 100644 --- a/packages/frontend/editor-ui/src/components/NodeSettings.vue +++ b/packages/frontend/editor-ui/src/components/NodeSettings.vue @@ -6,6 +6,7 @@ import type { NodeConnectionType, NodeParameterValue, INodeCredentialDescription, + PublicInstalledPackage, } from 'n8n-workflow'; import { NodeConnectionTypes, NodeHelpers, deepCopy } from 'n8n-workflow'; import type { @@ -39,6 +40,8 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useHistoryStore } from '@/stores/history.store'; import { RenameNodeCommand } from '@/models/history'; import { useCredentialsStore } from '@/stores/credentials.store'; +import { useCommunityNodesStore } from '@/stores/communityNodes.store'; +import { useUsersStore } from '@/stores/users.store'; import type { EventBus } from '@n8n/utils/event-bus'; import { useExternalHooks } from '@/composables/useExternalHooks'; import { useNodeHelpers } from '@/composables/useNodeHelpers'; @@ -142,6 +145,8 @@ const hiddenIssuesInputs = ref([]); const nodeSettings = ref([]); const subConnections = ref | null>(null); +const installedPackage = ref(undefined); + const currentWorkflowInstance = computed(() => workflowsStore.getCurrentWorkflow()); const currentWorkflow = computed(() => workflowsStore.getWorkflowById(currentWorkflowInstance.value.id), @@ -240,6 +245,7 @@ const showNoParametersNotice = computed( const outputPanelEditMode = computed(() => ndvStore.outputPanelEditMode); const isCommunityNode = computed(() => !!node.value && isCommunityPackageName(node.value.type)); +const packageName = computed(() => node.value?.type.split('.')[0] ?? ''); const usedCredentials = computed(() => Object.values(workflowsStore.usedCredentials).filter((credential) => @@ -771,7 +777,7 @@ watch(node, () => { setNodeValues(); }); -onMounted(() => { +onMounted(async () => { populateHiddenIssuesSet(); populateSettings(); setNodeValues(); @@ -781,6 +787,10 @@ onMounted(() => { } importCurlEventBus.on('setHttpNodeParameters', setHttpNodeParameters); ndvEventBus.on('updateParameterValue', valueChanged); + + if (isCommunityNode.value && useUsersStore().isInstanceOwner) { + installedPackage.value = await useCommunityNodesStore().getInstalledPackage(packageName.value); + } }); onBeforeUnmount(() => { @@ -978,6 +988,10 @@ function handleWheelEvent(event: WheelEvent) {
+ ({ + useCommunityNodesStore: vi.fn(() => ({ + getInstalledPackage: vi.fn(), + })), +})); + +describe('NodeSettingsTabs', () => { + beforeEach(() => { + createTestingPinia({ stubActions: false }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders the component', () => { + const { getByText } = renderComponent({ + props: {}, + }); + expect(getByText('Parameters')).toBeInTheDocument(); + }); + + it('displays notification when updateAvailable', async () => { + const communityNodesStore = useCommunityNodesStore(); + vi.spyOn(communityNodesStore, 'getInstalledPackage').mockResolvedValue({ + updateAvailable: '1.0.1', + } as PublicInstalledPackage); + + const { findByTestId } = renderComponent({ + props: {}, + }); + + const notification = await findByTestId('tab-settings'); + expect(notification).toBeDefined(); + }); + + it('does not display notification when not updateAvailable', async () => { + const communityNodesStore = useCommunityNodesStore(); + vi.spyOn(communityNodesStore, 'getInstalledPackage').mockResolvedValue( + {} as PublicInstalledPackage, + ); + + const { queryByTestId } = renderComponent({ + props: {}, + }); + + const tab = queryByTestId('tab-settings'); + const notification = tab?.querySelector('.notification'); + expect(notification).toBeNull(); + }); +}); diff --git a/packages/frontend/editor-ui/src/components/NodeSettingsTabs.vue b/packages/frontend/editor-ui/src/components/NodeSettingsTabs.vue index e522116b05..abdf6bb43c 100644 --- a/packages/frontend/editor-ui/src/components/NodeSettingsTabs.vue +++ b/packages/frontend/editor-ui/src/components/NodeSettingsTabs.vue @@ -3,9 +3,9 @@ import type { ITab } from '@/Interface'; import { COMMUNITY_NODES_INSTALLATION_DOCS_URL } from '@/constants'; import { useNDVStore } from '@/stores/ndv.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; -import type { INodeTypeDescription } from 'n8n-workflow'; +import type { INodeTypeDescription, PublicInstalledPackage } from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow'; -import { computed } from 'vue'; +import { computed, onMounted, ref } from 'vue'; import { useExternalHooks } from '@/composables/useExternalHooks'; import { useI18n } from '@n8n/i18n'; @@ -13,6 +13,8 @@ import { useTelemetry } from '@/composables/useTelemetry'; import { isCommunityPackageName } from '@/utils/nodeTypesUtils'; import { N8nTabs } from '@n8n/design-system'; import { useNodeDocsUrl } from '@/composables/useNodeDocsUrl'; +import { useCommunityNodesStore } from '@/stores/communityNodes.store'; +import { useUsersStore } from '@/stores/users.store'; export type Tab = 'settings' | 'params' | 'communityNode' | 'docs'; type Props = { @@ -37,9 +39,12 @@ const workflowsStore = useWorkflowsStore(); const i18n = useI18n(); const telemetry = useTelemetry(); const { docsUrl } = useNodeDocsUrl({ nodeType: () => props.nodeType }); +const communityNodesStore = useCommunityNodesStore(); const activeNode = computed(() => ndvStore.activeNode); +const installedPackage = ref(undefined); + const isCommunityNode = computed(() => { const nodeType = props.nodeType; if (nodeType) { @@ -67,6 +72,7 @@ const options = computed(() => { { label: i18n.baseText('nodeSettings.settings'), value: 'settings', + notification: installedPackage.value?.updateAvailable ? true : undefined, }, ]; @@ -129,6 +135,12 @@ function onTooltipClick(tab: string | number, event: MouseEvent) { telemetry.track('user clicked cnr docs link', { source: 'node details view' }); } } + +onMounted(async () => { + if (isCommunityNode.value && useUsersStore().isInstanceOwner) { + installedPackage.value = await communityNodesStore.getInstalledPackage(packageName.value); + } +});