fix(editor): Render node:* icons in credentials (#33887)

This commit is contained in:
yehorkardash
2026-07-10 11:28:51 +02:00
committed by GitHub
parent fccaace7f7
commit a7ca661ad9
8 changed files with 140 additions and 17 deletions
@@ -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);
});
});
@@ -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<string>(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))
);
}
@@ -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';
@@ -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];
@@ -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<NodeIconName, unknown>;
@@ -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<string, string> => {
};
});
const supportedIconName = computed((): IconName | undefined => {
const supportedIconName = computed((): IconName | NodeIconName | undefined => {
return isSupportedIconName(props.name) ? props.name : undefined;
});
</script>
@@ -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<ICredentialType>({
name: 'ftp',
icon: 'node:ftp',
}),
]);
const { container } = renderComponent({
pinia,
props: {
credentialTypeName: 'ftp',
},
});
expect(container.querySelector('.nodeIconPlaceholder')).not.toBeInTheDocument();
});
});
@@ -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;
}