diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/Icon.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/Icon.test.ts index 0d5f8146253..e2ae8883b2d 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/Icon.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/Icon.test.ts @@ -2,7 +2,7 @@ import { render, waitFor } from '@testing-library/vue'; import { vi } from 'vitest'; import Icon from './Icon.vue'; -import { deprecatedIconSet, type IconName } from './icons'; +import { deprecatedIconSet, isSupportedIconName, type IconName } from './icons'; import { IconBodyLoaderKey } from '../../composables/useIconBodyLoader'; const loaderStub = vi.fn(async (iconName: string) => @@ -85,3 +85,17 @@ describe('Icon', () => { expect(loaderStub).toHaveBeenCalledWith('app-window-mac'); }); }); + +describe('isSupportedIconName', () => { + it('returns true for supported icon names', () => { + expect(isSupportedIconName('check')).toBe(true); + expect(isSupportedIconName(Object.keys(deprecatedIconSet)[0])).toBe(true); + expect(isSupportedIconName('node:ftp')).toBe(true); + }); + + it('returns false for unsupported icon names', () => { + expect(isSupportedIconName()).toBe(false); + expect(isSupportedIconName('not-a-real-icon')).toBe(false); + expect(isSupportedIconName('node:not-a-real-node-icon')).toBe(false); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts index 1c40b54a6df..647d63dd237 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts @@ -39,7 +39,7 @@ import Toolbox from './custom/toolbox.svg'; import Triangle from './custom/triangle.svg'; import VectorSquare from './custom/vector-square.svg'; import Webhook from './custom/webhook.svg'; -import type { NodeIconName } from './node-icons'; +import { nodeIconNames, type NodeIconName } from './node-icon-names'; import IconLucideAlignRight from '~icons/lucide/align-right'; import IconLucideArchive from '~icons/lucide/archive'; @@ -780,19 +780,18 @@ export const updatedIconSet = { export type IconName = keyof typeof updatedIconSet; // only new icon names should be used moving forward -export { type NodeIconName } from './node-icons'; +export { type NodeIconName } from './node-icon-names'; const NODE_ICON_PREFIX = 'node:'; +const nodeIconNameSet = new Set(nodeIconNames); export function isNodeIcon(iconName?: string): iconName is NodeIconName { return typeof iconName === 'string' && iconName.startsWith(NODE_ICON_PREFIX); } -export function isSupportedIconName(iconName?: string): iconName is IconName { +export function isSupportedIconName(iconName?: string): iconName is IconName | NodeIconName { return ( typeof iconName === 'string' && - (iconName in updatedIconSet || - iconName in deprecatedIconSet || - iconName.startsWith(NODE_ICON_PREFIX)) + (iconName in updatedIconSet || iconName in deprecatedIconSet || nodeIconNameSet.has(iconName)) ); } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/index.ts index 3e3195fdb51..81b7fdf097d 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/index.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/index.ts @@ -1,4 +1,10 @@ import N8nIcon from './Icon.vue'; export default N8nIcon; -export { type IconName, type NodeIconName, isNodeIcon, updatedIconSet } from './icons'; +export { + type IconName, + type NodeIconName, + isNodeIcon, + isSupportedIconName, + updatedIconSet, +} from './icons'; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/node-icon-names.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/node-icon-names.ts new file mode 100644 index 00000000000..729250296d5 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/node-icon-names.ts @@ -0,0 +1,84 @@ +export const nodeIconNames = [ + 'node:aggregate', + 'node:ai-agent', + 'node:ai-transform', + 'node:basic-llm-chain', + 'node:calculator', + 'node:call-n8n-sub-workflow-tool', + 'node:character-text-splitter', + 'node:chat-memory-manager', + 'node:chat-trigger', + 'node:code', + 'node:compare-datasets', + 'node:compression', + 'node:contextual-compression-retriever', + 'node:convert-to-file', + 'node:crypto', + 'node:data-table', + 'node:date-and-time', + 'node:default-data-loader', + 'node:edit-fields', + 'node:edit-image', + 'node:email-trigger', + 'node:error-trigger', + 'node:execute-command', + 'node:execute-sub-workflow', + 'node:execution-data', + 'node:extract-from-file', + 'node:filter', + 'node:form-trigger', + 'node:ftp', + 'node:guardrails', + 'node:html', + 'node:http-request', + 'node:if', + 'node:information-extractor', + 'node:item-list-output-parser', + 'node:limit', + 'node:local-file-trigger', + 'node:loop-over-items', + 'node:manual-trigger', + 'node:markdown', + 'node:merge', + 'node:model-selector', + 'node:multiquery-retriever', + 'node:n8n', + 'node:n8n-trigger', + 'node:no-operation', + 'node:question-and-answer-chain', + 'node:read-write-files-from-disk', + 'node:recursive-character-text-splitter', + 'node:remove-duplicates', + 'node:rename-keys', + 'node:respond-to-webhook', + 'node:rss-feed-trigger', + 'node:rss-read', + 'node:schedule-trigger', + 'node:send-mail', + 'node:sentiment-analysis', + 'node:simple-memory', + 'node:simple-vector-store', + 'node:sort', + 'node:split-out', + 'node:sse-trigger', + 'node:ssh', + 'node:stop-and-error', + 'node:structured-output-parser', + 'node:sub-workflow-trigger', + 'node:summarization-chain', + 'node:summarize', + 'node:switch', + 'node:text-classifier', + 'node:think-tool', + 'node:token-splitter', + 'node:totp', + 'node:track-time-saved', + 'node:vector-store-question-answer-tool', + 'node:vector-store-retriever', + 'node:wait', + 'node:webhook', + 'node:workflow-retriever', + 'node:xml', +] as const; + +export type NodeIconName = (typeof nodeIconNames)[number]; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/node-icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/node-icons.ts index 3386bf16732..f0765fb8da9 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/node-icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/node-icons.ts @@ -1,3 +1,4 @@ +import type { NodeIconName } from './node-icon-names'; import NodeAggregate from './nodes/aggregate.svg'; import NodeAiAgent from './nodes/ai-agent.svg'; import NodeAiTransform from './nodes/ai-transform.svg'; @@ -160,6 +161,4 @@ export const nodeIconSet = { 'node:webhook': NodeWebhook, 'node:workflow-retriever': NodeWorkflowRetriever, 'node:xml': NodeXml, -} as const; - -export type NodeIconName = keyof typeof nodeIconSet; +} as const satisfies Record; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nNodeIcon/IconContent.vue b/packages/frontend/@n8n/design-system/src/components/N8nNodeIcon/IconContent.vue index 05977f0d22b..185de4acda6 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nNodeIcon/IconContent.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nNodeIcon/IconContent.vue @@ -3,7 +3,7 @@ import { computed } from 'vue'; import N8nNodeIcon from '.'; import N8nIcon from '../N8nIcon'; -import type { IconName } from '../N8nIcon/icons'; +import type { IconName, NodeIconName } from '../N8nIcon/icons'; import { isSupportedIconName } from '../N8nIcon/icons'; import N8nTooltip from '../N8nTooltip'; @@ -51,7 +51,7 @@ const badgeStyleData = computed((): Record => { }; }); -const supportedIconName = computed((): IconName | undefined => { +const supportedIconName = computed((): IconName | NodeIconName | undefined => { return isSupportedIconName(props.name) ? props.name : undefined; }); diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialIcon.test.ts b/packages/frontend/editor-ui/src/features/credentials/components/CredentialIcon.test.ts index 66b1ead6ff8..04969f7251d 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialIcon.test.ts +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialIcon.test.ts @@ -132,4 +132,22 @@ describe('CredentialIcon', () => { expect(baseElement.querySelector('.nodeIconPlaceholder')).toBeInTheDocument(); }); + + it('shows named icon when credential uses a node icon token directly', () => { + useCredentialsStore().setCredentialTypes([ + mock({ + name: 'ftp', + icon: 'node:ftp', + }), + ]); + + const { container } = renderComponent({ + pinia, + props: { + credentialTypeName: 'ftp', + }, + }); + + expect(container.querySelector('.nodeIconPlaceholder')).not.toBeInTheDocument(); + }); }); diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialIcon.vue b/packages/frontend/editor-ui/src/features/credentials/components/CredentialIcon.vue index d79f1dded18..e9167005362 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialIcon.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialIcon.vue @@ -4,7 +4,7 @@ import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; import { useRootStore } from '@n8n/stores/useRootStore'; import { useUIStore } from '@/app/stores/ui.store'; import { getThemedValue } from '@/app/utils/nodeTypesUtils'; -import { getNodeIconSource } from '@/app/utils/nodeIcon'; +import { getNodeIconSource, type NodeIconSource } from '@/app/utils/nodeIcon'; import type { ICredentialType } from 'n8n-workflow'; import { computed } from 'vue'; import { N8nNodeIcon } from '@n8n/design-system'; @@ -31,8 +31,12 @@ const referencedNodeIconSource = computed(() => { const icon = getThemedValue(credentialWithIcon.value?.icon, theme.value); if (!icon?.startsWith('node:')) return undefined; const nodeType = nodeTypesStore.getNodeType(icon.replace('node:', '')); - if (!nodeType) return undefined; - return getNodeIconSource(nodeType, null, null); + if (nodeType) return getNodeIconSource(nodeType, null, null); + + return { + type: 'icon', + name: icon, + } satisfies NodeIconSource; }); const iconSource = computed(() => { @@ -78,7 +82,6 @@ function getCredentialWithIcon(name: string | null): ICredentialType | null { } const type = credentialsStore.getCredentialTypeByName(name); - if (!type) { return null; }