should render node correctly 1`] = `
style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
>
-
-
+
trigger > should render trigger node correctly 1`]
style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
>
-
-
+
{
const pinia = createTestingPinia({
@@ -147,6 +148,10 @@ describe('useCanvasMapping', () => {
options: {
configurable: false,
configuration: false,
+ icon: {
+ src: '/nodes/test-node/icon.svg',
+ type: 'file',
+ },
trigger: true,
inputs: {
labelSize: 'small',
@@ -280,12 +285,19 @@ describe('useCanvasMapping', () => {
workflowObject: ref(workflowObject) as Ref,
});
+ const rootStore = mockedStore(useRootStore);
+ rootStore.baseUrl = 'http://test.local/';
+
expect(mappedNodes.value[0]?.data?.render).toEqual({
type: CanvasNodeRenderType.Default,
options: {
configurable: false,
configuration: false,
trigger: true,
+ icon: {
+ src: 'http://test.local/nodes/test-node/icon.svg',
+ type: 'file',
+ },
inputs: {
labelSize: 'small',
},
diff --git a/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts b/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts
index 4cc9d1d294..ceff397deb 100644
--- a/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts
+++ b/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts
@@ -50,6 +50,7 @@ import { MarkerType } from '@vue-flow/core';
import { useNodeHelpers } from './useNodeHelpers';
import { getTriggerNodeServiceName } from '@/utils/nodeTypesUtils';
import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
+import { getNodeIconSource } from '../utils/nodeIcon';
export function useCanvasMapping({
nodes,
@@ -86,6 +87,8 @@ export function useCanvasMapping({
}
function createDefaultNodeRenderType(node: INodeUi): CanvasNodeDefaultRender {
+ const nodeType = nodeTypeDescriptionByNodeId.value[node.id];
+ const icon = getNodeIconSource(nodeType);
return {
type: CanvasNodeRenderType.Default,
options: {
@@ -100,6 +103,7 @@ export function useCanvasMapping({
},
tooltip: nodeTooltipById.value[node.id],
dirtiness: dirtinessByName.value[node.name],
+ icon,
},
};
}
diff --git a/packages/frontend/editor-ui/src/types/canvas.ts b/packages/frontend/editor-ui/src/types/canvas.ts
index c1c05e4966..0fe29d33e6 100644
--- a/packages/frontend/editor-ui/src/types/canvas.ts
+++ b/packages/frontend/editor-ui/src/types/canvas.ts
@@ -11,6 +11,7 @@ import type {
import type { IExecutionResponse, INodeUi } from '@/Interface';
import type { ComputedRef, Ref } from 'vue';
import type { EventBus } from '@n8n/utils/event-bus';
+import type { NodeIconSource } from '../utils/nodeIcon';
export const enum CanvasConnectionMode {
Input = 'inputs',
@@ -71,6 +72,7 @@ export type CanvasNodeDefaultRender = {
};
tooltip?: string;
dirtiness?: CanvasNodeDirtinessType;
+ icon?: NodeIconSource;
}>;
};
diff --git a/packages/frontend/editor-ui/src/utils/nodeIcon.test.ts b/packages/frontend/editor-ui/src/utils/nodeIcon.test.ts
new file mode 100644
index 0000000000..906d8568c5
--- /dev/null
+++ b/packages/frontend/editor-ui/src/utils/nodeIcon.test.ts
@@ -0,0 +1,140 @@
+import { mock } from 'vitest-mock-extended';
+import {
+ getNodeIcon,
+ getNodeIconUrl,
+ getBadgeIconUrl,
+ getNodeIconSource,
+ type IconNodeType,
+} from './nodeIcon';
+
+vi.mock('../stores/root.store', () => ({
+ useRootStore: vi.fn(() => ({
+ baseUrl: 'https://example.com/',
+ })),
+}));
+
+vi.mock('../stores/ui.store', () => ({
+ useUIStore: vi.fn(() => ({
+ appliedTheme: 'light',
+ })),
+}));
+
+vi.mock('./nodeTypesUtils', () => ({
+ getThemedValue: vi.fn((value, theme) => {
+ if (typeof value === 'object' && value !== null) {
+ return value[theme] || value.dark || value.light || null;
+ }
+ return value;
+ }),
+}));
+
+describe('util: Node Icon', () => {
+ describe('getNodeIcon', () => {
+ it('should return the icon from nodeType', () => {
+ expect(getNodeIcon(mock({ icon: 'user', iconUrl: undefined }))).toBe('user');
+ });
+
+ it('should return null if no icon is present', () => {
+ expect(
+ getNodeIcon(mock({ icon: undefined, iconUrl: '/test.svg' })),
+ ).toBeUndefined();
+ });
+ });
+
+ describe('getNodeIconUrl', () => {
+ it('should return the iconUrl from nodeType', () => {
+ expect(
+ getNodeIconUrl(
+ mock({
+ iconUrl: { light: 'images/light-icon.svg', dark: 'images/dark-icon.svg' },
+ }),
+ ),
+ ).toBe('images/light-icon.svg');
+ });
+
+ it('should return null if no iconUrl is present', () => {
+ expect(
+ getNodeIconUrl(mock({ icon: 'foo', iconUrl: undefined })),
+ ).toBeUndefined();
+ });
+ });
+
+ describe('getBadgeIconUrl', () => {
+ it('should return the badgeIconUrl from nodeType', () => {
+ expect(getBadgeIconUrl({ badgeIconUrl: 'images/badge.svg' })).toBe('images/badge.svg');
+ });
+
+ it('should return null if no badgeIconUrl is present', () => {
+ expect(getBadgeIconUrl({ badgeIconUrl: undefined })).toBeUndefined();
+ });
+ });
+
+ describe('getNodeIconSource', () => {
+ it('should return undefined if nodeType is null or undefined', () => {
+ expect(getNodeIconSource(null)).toBeUndefined();
+ expect(getNodeIconSource(undefined)).toBeUndefined();
+ });
+
+ it('should create an icon source from iconData.icon if available', () => {
+ const result = getNodeIconSource(
+ mock({ iconData: { type: 'icon', icon: 'pencil' } }),
+ );
+ expect(result).toEqual({
+ type: 'icon',
+ name: 'pencil',
+ color: undefined,
+ badge: undefined,
+ });
+ });
+
+ it('should create a file source from iconData.fileBuffer if available', () => {
+ const result = getNodeIconSource(
+ mock({
+ iconData: {
+ type: 'file',
+ icon: undefined,
+ fileBuffer: 'data://foo',
+ },
+ }),
+ );
+ expect(result).toEqual({
+ type: 'file',
+ src: 'data://foo',
+ badge: undefined,
+ });
+ });
+
+ it('should create a file source from iconUrl if available', () => {
+ const result = getNodeIconSource(mock({ iconUrl: 'images/node-icon.svg' }));
+ expect(result).toEqual({
+ type: 'file',
+ src: 'https://example.com/images/node-icon.svg',
+ badge: undefined,
+ });
+ });
+
+ it('should create an icon source from icon if available', () => {
+ const result = getNodeIconSource(
+ mock({
+ icon: 'icon:user',
+ iconColor: 'blue',
+ iconData: undefined,
+ iconUrl: undefined,
+ }),
+ );
+ expect(result).toEqual({
+ type: 'icon',
+ name: 'user',
+ color: 'var(--color-node-icon-blue)',
+ });
+ });
+
+ it('should include badge if available', () => {
+ const result = getNodeIconSource(mock({ badgeIconUrl: 'images/badge.svg' }));
+ expect(result?.badge).toEqual({
+ type: 'file',
+ src: 'https://example.com/images/badge.svg',
+ });
+ });
+ });
+});
diff --git a/packages/frontend/editor-ui/src/utils/nodeIcon.ts b/packages/frontend/editor-ui/src/utils/nodeIcon.ts
new file mode 100644
index 0000000000..1f79f1fd4e
--- /dev/null
+++ b/packages/frontend/editor-ui/src/utils/nodeIcon.ts
@@ -0,0 +1,109 @@
+import { type INodeTypeDescription } from 'n8n-workflow';
+import type { IVersionNode } from '../Interface';
+import { useRootStore } from '../stores/root.store';
+import { useUIStore } from '../stores/ui.store';
+import { getThemedValue } from './nodeTypesUtils';
+
+type NodeIconSourceIcon = { type: 'icon'; name: string; color?: string };
+type NodeIconSourceFile = {
+ type: 'file';
+ src: string;
+};
+
+type BaseNodeIconSource = NodeIconSourceIcon | NodeIconSourceFile;
+export type NodeIconSource = BaseNodeIconSource & { badge?: BaseNodeIconSource };
+
+export type NodeIconType = 'file' | 'icon' | 'unknown';
+
+type IconNodeTypeDescription = Pick<
+ INodeTypeDescription,
+ 'icon' | 'iconUrl' | 'iconColor' | 'defaults' | 'badgeIconUrl'
+>;
+type IconVersionNode = Pick;
+export type IconNodeType = IconNodeTypeDescription | IconVersionNode;
+
+export const getNodeIcon = (nodeType: IconNodeType): string | null => {
+ return getThemedValue(nodeType.icon, useUIStore().appliedTheme);
+};
+
+export const getNodeIconUrl = (nodeType: IconNodeType): string | null => {
+ return getThemedValue(nodeType.iconUrl, useUIStore().appliedTheme);
+};
+
+export const getBadgeIconUrl = (
+ nodeType: Pick,
+): string | null => {
+ return getThemedValue(nodeType.badgeIconUrl, useUIStore().appliedTheme);
+};
+
+function getNodeIconColor(nodeType: IconNodeType) {
+ if ('iconColor' in nodeType && nodeType.iconColor) {
+ return `var(--color-node-icon-${nodeType.iconColor})`;
+ }
+ return nodeType?.defaults?.color?.toString();
+}
+
+function prefixBaseUrl(url: string) {
+ return useRootStore().baseUrl + url;
+}
+
+export function getNodeIconSource(nodeType?: IconNodeType | null): NodeIconSource | undefined {
+ if (!nodeType) return undefined;
+ const createFileIconSource = (src: string): NodeIconSource => ({
+ type: 'file',
+ src,
+ badge: getNodeBadgeIconSource(nodeType),
+ });
+ const createNamedIconSource = (name: string): NodeIconSource => ({
+ type: 'icon',
+ name,
+ color: getNodeIconColor(nodeType),
+ badge: getNodeBadgeIconSource(nodeType),
+ });
+
+ // If node type has icon data, use it
+ if ('iconData' in nodeType && nodeType.iconData) {
+ if (nodeType.iconData.icon) {
+ return createNamedIconSource(nodeType.iconData.icon);
+ }
+
+ if (nodeType.iconData.fileBuffer) {
+ return createFileIconSource(nodeType.iconData.fileBuffer);
+ }
+ }
+
+ const iconUrl = getNodeIconUrl(nodeType);
+ if (iconUrl) {
+ return createFileIconSource(prefixBaseUrl(iconUrl));
+ }
+
+ // Otherwise, extract it from icon prop
+ if (nodeType.icon) {
+ const icon = getNodeIcon(nodeType);
+
+ if (icon) {
+ const [type, iconName] = icon.split(':');
+ if (type === 'file') {
+ return undefined;
+ }
+
+ return createNamedIconSource(iconName);
+ }
+ }
+
+ return undefined;
+}
+
+function getNodeBadgeIconSource(nodeType: IconNodeType): BaseNodeIconSource | undefined {
+ if (nodeType && 'badgeIconUrl' in nodeType && nodeType.badgeIconUrl) {
+ const badgeUrl = getBadgeIconUrl(nodeType);
+
+ if (!badgeUrl) return undefined;
+ return {
+ type: 'file',
+ src: prefixBaseUrl(badgeUrl),
+ };
+ }
+
+ return undefined;
+}
diff --git a/packages/frontend/editor-ui/src/utils/nodeTypesUtils.ts b/packages/frontend/editor-ui/src/utils/nodeTypesUtils.ts
index 3b732dcda6..8ced5b0242 100644
--- a/packages/frontend/editor-ui/src/utils/nodeTypesUtils.ts
+++ b/packages/frontend/editor-ui/src/utils/nodeTypesUtils.ts
@@ -3,9 +3,7 @@ import type {
INodeUi,
INodeUpdatePropertiesInformation,
ITemplatesNode,
- IVersionNode,
NodeAuthenticationOption,
- SimplifiedNodeType,
} from '@/Interface';
import {
CORE_NODES_CATEGORY,
@@ -502,33 +500,3 @@ export const getThemedValue = (
return value[theme];
};
-
-export const getNodeIcon = (
- nodeType: INodeTypeDescription | SimplifiedNodeType | IVersionNode,
- theme: AppliedThemeOption = 'light',
-): string | null => {
- return getThemedValue(nodeType.icon, theme);
-};
-
-export const getNodeIconUrl = (
- nodeType: INodeTypeDescription | SimplifiedNodeType | IVersionNode,
- theme: AppliedThemeOption = 'light',
-): string | null => {
- return getThemedValue(nodeType.iconUrl, theme);
-};
-
-export const getBadgeIconUrl = (
- nodeType: INodeTypeDescription | SimplifiedNodeType,
- theme: AppliedThemeOption = 'light',
-): string | null => {
- return getThemedValue(nodeType.badgeIconUrl, theme);
-};
-
-export const getNodeIconColor = (
- nodeType?: INodeTypeDescription | SimplifiedNodeType | IVersionNode | null,
-) => {
- if (nodeType && 'iconColor' in nodeType && nodeType.iconColor) {
- return `var(--color-node-icon-${nodeType.iconColor})`;
- }
- return nodeType?.defaults?.color?.toString();
-};