diff --git a/packages/frontend/editor-ui/src/__tests__/mocks.ts b/packages/frontend/editor-ui/src/__tests__/mocks.ts index 80e8de9fb6..c59fa6f893 100644 --- a/packages/frontend/editor-ui/src/__tests__/mocks.ts +++ b/packages/frontend/editor-ui/src/__tests__/mocks.ts @@ -82,6 +82,7 @@ export const mockNodeTypeDescription = ({ codex, credentials, documentationUrl: 'https://docs', + iconUrl: 'nodes/test-node/icon.svg', webhooks: undefined, }); diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/Modes/NodesMode.vue b/packages/frontend/editor-ui/src/components/Node/NodeCreator/Modes/NodesMode.vue index 1cba28c4ed..d74e559945 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/Modes/NodesMode.vue +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/Modes/NodesMode.vue @@ -18,7 +18,6 @@ import { } from '@/constants'; import type { BaseTextKey } from '@/plugins/i18n'; -import { useRootStore } from '@/stores/root.store'; import { useNodeCreatorStore } from '@/stores/nodeCreator.store'; import { TriggerView, RegularView, AIView, AINodesView } from '../viewsData'; @@ -29,8 +28,7 @@ import ItemsRenderer from '../Renderers/ItemsRenderer.vue'; import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue'; import NoResults from '../Panel/NoResults.vue'; import { useI18n } from '@/composables/useI18n'; -import { getNodeIcon, getNodeIconColor, getNodeIconUrl } from '@/utils/nodeTypesUtils'; -import { useUIStore } from '@/stores/ui.store'; +import { getNodeIconSource } from '@/utils/nodeIcon'; import { useActions } from '../composables/useActions'; import { SEND_AND_WAIT_OPERATION, type INodeParameters } from 'n8n-workflow'; @@ -43,8 +41,6 @@ const emit = defineEmits<{ }>(); const i18n = useI18n(); -const uiStore = useUIStore(); -const rootStore = useRootStore(); const { mergedNodes, actions, onSubcategorySelected } = useNodeCreatorStore(); const { pushViewStack, popViewStack } = useViewStacks(); @@ -83,20 +79,16 @@ function onSelected(item: INodeCreateElement) { const infoKey = `nodeCreator.subcategoryInfos.${subcategoryKey}` as BaseTextKey; const info = i18n.baseText(infoKey); const extendedInfo = info !== infoKey ? { info } : {}; + const nodeIcon = item.properties.icon + ? ({ type: 'icon', name: item.properties.icon } as const) + : undefined; pushViewStack({ subcategory: item.key, mode: 'nodes', title, + nodeIcon, ...extendedInfo, - ...(item.properties.icon - ? { - nodeIcon: { - icon: item.properties.icon, - iconType: 'icon', - }, - } - : {}), ...(item.properties.panelClass ? { panelClass: item.properties.panelClass } : {}), rootView: activeViewStack.value.rootView, forceIncludeNodes: item.properties.forceIncludeNodes, @@ -130,11 +122,6 @@ function onSelected(item: INodeCreateElement) { return; } - const iconUrl = getNodeIconUrl(item.properties, uiStore.appliedTheme); - const icon = iconUrl - ? rootStore.baseUrl + iconUrl - : getNodeIcon(item.properties, uiStore.appliedTheme)?.split(':')[1]; - const transformedActions = nodeActions?.map((a) => transformNodeType(a, item.properties.displayName, 'action'), ); @@ -142,12 +129,7 @@ function onSelected(item: INodeCreateElement) { pushViewStack({ subcategory: item.properties.displayName, title: item.properties.displayName, - nodeIcon: { - color: getNodeIconColor(item.properties), - icon, - iconType: iconUrl ? 'file' : 'icon', - }, - + nodeIcon: getNodeIconSource(item.properties), rootView: activeViewStack.value.rootView, hasSearch: true, mode: 'actions', diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue b/packages/frontend/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue index 061c6ea244..e70711ff60 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue @@ -19,6 +19,7 @@ import ActionsRenderer from '../Modes/ActionsMode.vue'; import NodesRenderer from '../Modes/NodesMode.vue'; import { useI18n } from '@/composables/useI18n'; import { useDebounce } from '@/composables/useDebounce'; +import NodeIcon from '@/components/NodeIcon.vue'; const i18n = useI18n(); const { callDebounced } = useDebounce(); @@ -155,13 +156,10 @@ function onBackButton() { > - ; - color?: string; - }; - iconUrl?: string; + nodeIcon?: NodeIconSource; rootView?: NodeFilterType; activeIndex?: number; transitionDirection?: 'in' | 'out'; @@ -314,6 +312,7 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => { await nextTick(); + const iconName = getThemedValue(relatedAIView?.properties.icon, useUIStore().appliedTheme); pushViewStack( { title: relatedAIView?.properties.title, @@ -321,11 +320,13 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => { rootView: AI_OTHERS_NODE_CREATOR_VIEW, mode: 'nodes', items: nodeCreatorStore.allNodeCreatorNodes, - nodeIcon: { - iconType: 'icon', - icon: relatedAIView?.properties.icon, - color: relatedAIView?.properties.iconProps?.color, - }, + nodeIcon: iconName + ? { + type: 'icon', + name: iconName, + color: relatedAIView?.properties.iconProps?.color, + } + : undefined, panelClass: relatedAIView?.properties.panelClass, baseFilter: (i: INodeCreateElement) => { // AI Code node could have any connection type so we don't want to display it diff --git a/packages/frontend/editor-ui/src/components/NodeIcon.vue b/packages/frontend/editor-ui/src/components/NodeIcon.vue index 62631cca4a..9a534d915c 100644 --- a/packages/frontend/editor-ui/src/components/NodeIcon.vue +++ b/packages/frontend/editor-ui/src/components/NodeIcon.vue @@ -1,24 +1,10 @@ diff --git a/packages/frontend/editor-ui/src/components/__snapshots__/VirtualSchema.test.ts.snap b/packages/frontend/editor-ui/src/components/__snapshots__/VirtualSchema.test.ts.snap index 6f4d10bf69..638a1feffb 100644 --- a/packages/frontend/editor-ui/src/components/__snapshots__/VirtualSchema.test.ts.snap +++ b/packages/frontend/editor-ui/src/components/__snapshots__/VirtualSchema.test.ts.snap @@ -74,6 +74,7 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1 > @@ -424,6 +425,7 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1 > @@ -810,6 +812,7 @@ exports[`VirtualSchema.vue > renders previous nodes schema for AI tools 1`] = ` > @@ -882,6 +885,7 @@ exports[`VirtualSchema.vue > renders schema for correct output branch 1`] = ` > @@ -1414,6 +1418,7 @@ exports[`VirtualSchema.vue > renders schema with spaces and dots 1`] = ` > @@ -1976,6 +1981,7 @@ exports[`VirtualSchema.vue > renders schema with spaces and dots 1`] = ` > @@ -2168,6 +2174,7 @@ exports[`VirtualSchema.vue > renders variables and context section 1`] = ` > diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue index f0ac39c9f7..326c05d97b 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue @@ -17,8 +17,6 @@ import type { CanvasEventBusEvents, } from '@/types'; import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types'; -import NodeIcon from '@/components/NodeIcon.vue'; -import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeToolbar.vue'; import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRenderer.vue'; import CanvasHandleRenderer from '@/components/canvas/elements/handles/CanvasHandleRenderer.vue'; @@ -71,7 +69,6 @@ const style = useCssModule(); const props = defineProps(); -const nodeTypesStore = useNodeTypesStore(); const contextMenu = useContextMenu(); const { connectingHandle } = useCanvas(); @@ -98,10 +95,6 @@ const { const isDisabled = computed(() => props.data.disabled); -const nodeTypeDescription = computed(() => { - return nodeTypesStore.getNodeType(props.data.type, props.data.typeVersion); -}); - const classes = computed(() => ({ [style.canvasNode]: true, [style.showToolbar]: showToolbar.value, @@ -156,14 +149,6 @@ const mappedOutputs = computed(() => { ].filter((endpoint) => !!endpoint); }); -/** - * Node icon - */ - -const nodeIconSize = computed(() => - 'configuration' in data.value.render.options && data.value.render.options.configuration ? 30 : 40, -); - /** * Endpoints */ @@ -388,7 +373,7 @@ onBeforeUnmount(() => { { @move="onMove" @update="onUpdate" @open:contextmenu="onOpenContextMenuFromNode" - > - - - + /> - +
@@ -171,6 +175,7 @@ function onActivate() { --configurable-node--icon-size: 30px; --trigger-node--border-radius: 36px; --canvas-node--status-icons-offset: var(--spacing-3xs); + --node-icon-color: var(--color-foreground-dark); position: relative; height: var(--canvas-node--height); diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap index 71b7c37c5c..cd5075b450 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap @@ -7,8 +7,24 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr style="--configurable-node--input-count: 0; --canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;" > - - +
+
+ + +
+ ? +
+ +
+
configuration > should render configurable configur style="--configurable-node--input-count: 0; --canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;" > - - +
+
+ + +
+ ? +
+ +
+
configuration > should render configuration node co style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;" > - - +
+
+ + +
+ ? +
+ +
+
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(); -};