refactor: Support rendering NodeIcon without a full node type object (no-changelog) (#14097)

This commit is contained in:
Elias Meire
2025-03-24 14:42:43 +01:00
committed by GitHub
parent 22ddf1b644
commit 380d032d9a
16 changed files with 431 additions and 202 deletions

View File

@@ -82,6 +82,7 @@ export const mockNodeTypeDescription = ({
codex,
credentials,
documentationUrl: 'https://docs',
iconUrl: 'nodes/test-node/icon.svg',
webhooks: undefined,
});

View File

@@ -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',

View File

@@ -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() {
>
<font-awesome-icon :class="$style.backButtonIcon" icon="arrow-left" size="2x" />
</button>
<n8n-node-icon
<NodeIcon
v-if="activeViewStack.nodeIcon"
:class="$style.nodeIcon"
:type="activeViewStack.nodeIcon.iconType || 'unknown'"
:src="activeViewStack.nodeIcon.icon"
:name="activeViewStack.nodeIcon.icon"
:color="activeViewStack.nodeIcon.color"
:icon-source="activeViewStack.nodeIcon"
:circle="false"
:show-tooltip="false"
:size="20"

View File

@@ -39,9 +39,12 @@ import { useKeyboardNavigation } from './useKeyboardNavigation';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { AI_TRANSFORM_NODE_TYPE } from 'n8n-workflow';
import type { NodeConnectionType, INodeInputFilter, Themed } from 'n8n-workflow';
import type { NodeConnectionType, INodeInputFilter } from 'n8n-workflow';
import { useCanvasStore } from '@/stores/canvas.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { type NodeIconSource } from '@/utils/nodeIcon';
import { getThemedValue } from '@/utils/nodeTypesUtils';
interface ViewStack {
uuid?: string;
@@ -50,12 +53,7 @@ interface ViewStack {
search?: string;
subcategory?: string;
info?: string;
nodeIcon?: {
iconType?: string;
icon?: Themed<string>;
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

View File

@@ -1,24 +1,10 @@
<script setup lang="ts">
import type { IVersionNode, SimplifiedNodeType } from '@/Interface';
import { useRootStore } from '@/stores/root.store';
import { useUIStore } from '@/stores/ui.store';
import {
getBadgeIconUrl,
getNodeIcon,
getNodeIconColor,
getNodeIconUrl,
} from '@/utils/nodeTypesUtils';
import type { INodeTypeDescription } from 'n8n-workflow';
import { getNodeIconSource, type NodeIconSource } from '@/utils/nodeIcon';
import { N8nNodeIcon } from '@n8n/design-system';
import { computed } from 'vue';
interface NodeIconSource {
path?: string;
fileBuffer?: string;
icon?: string;
}
type Props = {
nodeType?: INodeTypeDescription | SimplifiedNodeType | IVersionNode | null;
size?: number;
disabled?: boolean;
circle?: boolean;
@@ -26,10 +12,15 @@ type Props = {
showTooltip?: boolean;
tooltipPosition?: 'top' | 'bottom' | 'left' | 'right';
nodeName?: string;
// NodeIcon needs iconSource OR nodeType, would be better with an intersection type
// but it breaks Vue template type checking
iconSource?: NodeIconSource;
nodeType?: SimplifiedNodeType | IVersionNode | null;
};
const props = withDefaults(defineProps<Props>(), {
nodeType: undefined,
iconSource: undefined,
size: undefined,
circle: false,
disabled: false,
@@ -37,97 +28,57 @@ const props = withDefaults(defineProps<Props>(), {
tooltipPosition: 'top',
colorDefault: '',
nodeName: '',
badgeIconUrl: undefined,
});
const emit = defineEmits<{
click: [];
}>();
const rootStore = useRootStore();
const uiStore = useUIStore();
const iconType = computed(() => {
const nodeType = props.nodeType;
if (nodeType) {
if (nodeType.iconUrl) return 'file';
if ('iconData' in nodeType && nodeType.iconData) {
return nodeType.iconData.type;
}
if (nodeType.icon) {
const icon = getNodeIcon(nodeType, uiStore.appliedTheme);
return icon && icon.split(':')[0] === 'file' ? 'file' : 'icon';
}
}
return 'unknown';
const iconSource = computed(() => {
if (props.iconSource) return props.iconSource;
return getNodeIconSource(props.nodeType);
});
const color = computed(() => getNodeIconColor(props.nodeType) ?? props.colorDefault ?? '');
const iconType = computed(() => iconSource.value?.type ?? 'unknown');
const src = computed(() => {
if (iconSource.value?.type !== 'file') return;
return iconSource.value.src;
});
const iconSource = computed<NodeIconSource>(() => {
const nodeType = props.nodeType;
const baseUrl = rootStore.baseUrl;
const iconName = computed(() => {
if (iconSource.value?.type !== 'icon') return;
return iconSource.value.name;
});
if (nodeType) {
// If node type has icon data, use it
if ('iconData' in nodeType && nodeType.iconData) {
return {
icon: nodeType.iconData.icon,
fileBuffer: nodeType.iconData.fileBuffer,
};
}
const iconUrl = getNodeIconUrl(nodeType, uiStore.appliedTheme);
if (iconUrl) {
return { path: baseUrl + iconUrl };
}
// Otherwise, extract it from icon prop
if (nodeType.icon) {
const icon = getNodeIcon(nodeType, uiStore.appliedTheme);
if (icon) {
const [type, path] = icon.split(':');
if (type === 'file') {
throw new Error(`Unexpected icon: ${icon}`);
}
return { icon: path };
}
}
}
return {};
const iconColor = computed(() => {
if (iconSource.value?.type !== 'icon') return;
return iconSource.value.color ?? props.colorDefault;
});
const badge = computed(() => {
const nodeType = props.nodeType;
if (nodeType && 'badgeIconUrl' in nodeType && nodeType.badgeIconUrl) {
return {
type: 'file',
src: rootStore.baseUrl + getBadgeIconUrl(nodeType, uiStore.appliedTheme),
};
}
return undefined;
if (iconSource.value?.badge?.type !== 'file') return;
return iconSource.value.badge;
});
const nodeTypeName = computed(() => props.nodeName ?? props.nodeType?.displayName);
</script>
<template>
<n8n-node-icon
<N8nNodeIcon
:type="iconType"
:src="iconSource.path || iconSource.fileBuffer"
:name="iconSource.icon"
:color="color"
:src="src"
:name="iconName"
:color="iconColor"
:disabled="disabled"
:size="size"
:circle="circle"
:node-type-name="nodeName ? nodeName : nodeType?.displayName"
:node-type-name="nodeTypeName"
:show-tooltip="showTooltip"
:tooltip-position="tooltipPosition"
:badge="badge"
@click="emit('click')"
></n8n-node-icon>
></N8nNodeIcon>
</template>
<style lang="scss" module></style>

View File

@@ -74,6 +74,7 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1
>
<img
class="nodeIconImage"
src="/nodes/test-node/icon.svg"
/>
<!--v-if-->
</div>
@@ -424,6 +425,7 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1
>
<img
class="nodeIconImage"
src="/nodes/test-node/icon.svg"
/>
<!--v-if-->
</div>
@@ -810,6 +812,7 @@ exports[`VirtualSchema.vue > renders previous nodes schema for AI tools 1`] = `
>
<img
class="nodeIconImage"
src="/nodes/test-node/icon.svg"
/>
<!--v-if-->
</div>
@@ -882,6 +885,7 @@ exports[`VirtualSchema.vue > renders schema for correct output branch 1`] = `
>
<img
class="nodeIconImage"
src="/nodes/test-node/icon.svg"
/>
<!--v-if-->
</div>
@@ -1414,6 +1418,7 @@ exports[`VirtualSchema.vue > renders schema with spaces and dots 1`] = `
>
<img
class="nodeIconImage"
src="/nodes/test-node/icon.svg"
/>
<!--v-if-->
</div>
@@ -1976,6 +1981,7 @@ exports[`VirtualSchema.vue > renders schema with spaces and dots 1`] = `
>
<img
class="nodeIconImage"
src="/nodes/test-node/icon.svg"
/>
<!--v-if-->
</div>
@@ -2168,6 +2174,7 @@ exports[`VirtualSchema.vue > renders variables and context section 1`] = `
>
<img
class="nodeIconImage"
src="/nodes/test-node/icon.svg"
/>
<!--v-if-->
</div>

View File

@@ -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<Props>();
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(() => {
</template>
<CanvasNodeToolbar
v-else-if="nodeTypeDescription"
v-else
data-test-id="canvas-node-toolbar"
:read-only="readOnly"
:class="$style.canvasNodeToolbar"
@@ -405,15 +390,7 @@ onBeforeUnmount(() => {
@move="onMove"
@update="onUpdate"
@open:contextmenu="onOpenContextMenuFromNode"
>
<NodeIcon
:node-type="nodeTypeDescription"
:size="nodeIconSize"
:shrink="false"
:disabled="isDisabled"
/>
<!-- @TODO :color-default="iconColorDefault"-->
</CanvasNodeRenderer>
/>
<CanvasNodeTrigger
v-if="

View File

@@ -8,10 +8,6 @@ import { CanvasNodeRenderType } from '@/types';
const node = inject(CanvasNodeKey);
const slots = defineSlots<{
default?: () => unknown;
}>();
const Render = () => {
const renderType = node?.data.value.render.type ?? CanvasNodeRenderType.Default;
let Component;
@@ -27,13 +23,9 @@ const Render = () => {
Component = CanvasNodeDefault;
}
return h(
Component,
{
'data-canvas-node-render-type': renderType,
},
slots.default,
);
return h(Component, {
'data-canvas-node-render-type': renderType,
});
};
</script>

View File

@@ -107,6 +107,10 @@ const isStrikethroughVisible = computed(() => {
return isDisabled.value && isSingleMainInputNode && isSingleMainOutputNode;
});
const iconSize = computed(() => (renderOptions.value.configuration ? 30 : 40));
const iconSource = computed(() => renderOptions.value.icon);
const showTooltip = ref(false);
watch(initialized, () => {
@@ -140,7 +144,7 @@ function onActivate() {
@dblclick.stop="onActivate"
>
<CanvasNodeTooltip v-if="renderOptions.tooltip" :visible="showTooltip" />
<slot />
<NodeIcon :icon-source="iconSource" :size="iconSize" :shrink="false" :disabled="isDisabled" />
<CanvasNodeStatusIcons v-if="!isDisabled" :class="$style.statusIcons" />
<CanvasNodeDisabledStrikeThrough v-if="isStrikethroughVisible" />
<div :class="$style.description">
@@ -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);

View File

@@ -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;"
>
<!--v-if-->
<div
class="n8n-node-icon"
shrink="false"
>
<div
class="nodeIconWrapper"
style="width: 40px; height: 40px; font-size: 40px; line-height: 40px;"
>
<!-- ElementUI tooltip is prone to memory-leaking so we only render it if we really need it -->
<div
class="nodeIconPlaceholder"
>
?
</div>
</div>
</div>
<!--v-if-->
<!--v-if-->
<div
@@ -36,8 +52,24 @@ exports[`CanvasNodeDefault > configuration > should render configurable configur
style="--configurable-node--input-count: 0; --canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
>
<!--v-if-->
<div
class="n8n-node-icon"
shrink="false"
>
<div
class="nodeIconWrapper"
style="width: 30px; height: 30px; font-size: 30px; line-height: 30px;"
>
<!-- ElementUI tooltip is prone to memory-leaking so we only render it if we really need it -->
<div
class="nodeIconPlaceholder"
>
?
</div>
</div>
</div>
<!--v-if-->
<!--v-if-->
<div
@@ -65,8 +97,24 @@ exports[`CanvasNodeDefault > configuration > should render configuration node co
style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
>
<!--v-if-->
<div
class="n8n-node-icon"
shrink="false"
>
<div
class="nodeIconWrapper"
style="width: 30px; height: 30px; font-size: 30px; line-height: 30px;"
>
<!-- ElementUI tooltip is prone to memory-leaking so we only render it if we really need it -->
<div
class="nodeIconPlaceholder"
>
?
</div>
</div>
</div>
<!--v-if-->
<!--v-if-->
<div
@@ -94,8 +142,24 @@ exports[`CanvasNodeDefault > should render node correctly 1`] = `
style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
>
<!--v-if-->
<div
class="n8n-node-icon"
shrink="false"
>
<div
class="nodeIconWrapper"
style="width: 40px; height: 40px; font-size: 40px; line-height: 40px;"
>
<!-- ElementUI tooltip is prone to memory-leaking so we only render it if we really need it -->
<div
class="nodeIconPlaceholder"
>
?
</div>
</div>
</div>
<!--v-if-->
<!--v-if-->
<div
@@ -123,8 +187,24 @@ exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`]
style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
>
<!--v-if-->
<div
class="n8n-node-icon"
shrink="false"
>
<div
class="nodeIconWrapper"
style="width: 40px; height: 40px; font-size: 40px; line-height: 40px;"
>
<!-- ElementUI tooltip is prone to memory-leaking so we only render it if we really need it -->
<div
class="nodeIconPlaceholder"
>
?
</div>
</div>
</div>
<!--v-if-->
<!--v-if-->
<div

View File

@@ -21,6 +21,7 @@ import { MarkerType } from '@vue-flow/core';
import { createTestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils';
import { mock } from 'vitest-mock-extended';
import { useRootStore } from '../stores/root.store';
beforeEach(() => {
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<Workflow>,
});
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',
},

View File

@@ -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,
},
};
}

View File

@@ -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;
}>;
};

View File

@@ -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<IconNodeType>({ icon: 'user', iconUrl: undefined }))).toBe('user');
});
it('should return null if no icon is present', () => {
expect(
getNodeIcon(mock<IconNodeType>({ icon: undefined, iconUrl: '/test.svg' })),
).toBeUndefined();
});
});
describe('getNodeIconUrl', () => {
it('should return the iconUrl from nodeType', () => {
expect(
getNodeIconUrl(
mock<IconNodeType>({
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<IconNodeType>({ 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<IconNodeType>({ 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<IconNodeType>({
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<IconNodeType>({ 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<IconNodeType>({
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<IconNodeType>({ badgeIconUrl: 'images/badge.svg' }));
expect(result?.badge).toEqual({
type: 'file',
src: 'https://example.com/images/badge.svg',
});
});
});
});

View File

@@ -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<IVersionNode, 'icon' | 'iconUrl' | 'iconData' | 'defaults'>;
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<IconNodeTypeDescription, 'badgeIconUrl'>,
): 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;
}

View File

@@ -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 = <T extends string>(
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();
};