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, codex,
credentials, credentials,
documentationUrl: 'https://docs', documentationUrl: 'https://docs',
iconUrl: 'nodes/test-node/icon.svg',
webhooks: undefined, webhooks: undefined,
}); });

View File

@@ -18,7 +18,6 @@ import {
} from '@/constants'; } from '@/constants';
import type { BaseTextKey } from '@/plugins/i18n'; import type { BaseTextKey } from '@/plugins/i18n';
import { useRootStore } from '@/stores/root.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store'; import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { TriggerView, RegularView, AIView, AINodesView } from '../viewsData'; import { TriggerView, RegularView, AIView, AINodesView } from '../viewsData';
@@ -29,8 +28,7 @@ import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue'; import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue';
import NoResults from '../Panel/NoResults.vue'; import NoResults from '../Panel/NoResults.vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { getNodeIcon, getNodeIconColor, getNodeIconUrl } from '@/utils/nodeTypesUtils'; import { getNodeIconSource } from '@/utils/nodeIcon';
import { useUIStore } from '@/stores/ui.store';
import { useActions } from '../composables/useActions'; import { useActions } from '../composables/useActions';
import { SEND_AND_WAIT_OPERATION, type INodeParameters } from 'n8n-workflow'; import { SEND_AND_WAIT_OPERATION, type INodeParameters } from 'n8n-workflow';
@@ -43,8 +41,6 @@ const emit = defineEmits<{
}>(); }>();
const i18n = useI18n(); const i18n = useI18n();
const uiStore = useUIStore();
const rootStore = useRootStore();
const { mergedNodes, actions, onSubcategorySelected } = useNodeCreatorStore(); const { mergedNodes, actions, onSubcategorySelected } = useNodeCreatorStore();
const { pushViewStack, popViewStack } = useViewStacks(); const { pushViewStack, popViewStack } = useViewStacks();
@@ -83,20 +79,16 @@ function onSelected(item: INodeCreateElement) {
const infoKey = `nodeCreator.subcategoryInfos.${subcategoryKey}` as BaseTextKey; const infoKey = `nodeCreator.subcategoryInfos.${subcategoryKey}` as BaseTextKey;
const info = i18n.baseText(infoKey); const info = i18n.baseText(infoKey);
const extendedInfo = info !== infoKey ? { info } : {}; const extendedInfo = info !== infoKey ? { info } : {};
const nodeIcon = item.properties.icon
? ({ type: 'icon', name: item.properties.icon } as const)
: undefined;
pushViewStack({ pushViewStack({
subcategory: item.key, subcategory: item.key,
mode: 'nodes', mode: 'nodes',
title, title,
nodeIcon,
...extendedInfo, ...extendedInfo,
...(item.properties.icon
? {
nodeIcon: {
icon: item.properties.icon,
iconType: 'icon',
},
}
: {}),
...(item.properties.panelClass ? { panelClass: item.properties.panelClass } : {}), ...(item.properties.panelClass ? { panelClass: item.properties.panelClass } : {}),
rootView: activeViewStack.value.rootView, rootView: activeViewStack.value.rootView,
forceIncludeNodes: item.properties.forceIncludeNodes, forceIncludeNodes: item.properties.forceIncludeNodes,
@@ -130,11 +122,6 @@ function onSelected(item: INodeCreateElement) {
return; 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) => const transformedActions = nodeActions?.map((a) =>
transformNodeType(a, item.properties.displayName, 'action'), transformNodeType(a, item.properties.displayName, 'action'),
); );
@@ -142,12 +129,7 @@ function onSelected(item: INodeCreateElement) {
pushViewStack({ pushViewStack({
subcategory: item.properties.displayName, subcategory: item.properties.displayName,
title: item.properties.displayName, title: item.properties.displayName,
nodeIcon: { nodeIcon: getNodeIconSource(item.properties),
color: getNodeIconColor(item.properties),
icon,
iconType: iconUrl ? 'file' : 'icon',
},
rootView: activeViewStack.value.rootView, rootView: activeViewStack.value.rootView,
hasSearch: true, hasSearch: true,
mode: 'actions', mode: 'actions',

View File

@@ -19,6 +19,7 @@ import ActionsRenderer from '../Modes/ActionsMode.vue';
import NodesRenderer from '../Modes/NodesMode.vue'; import NodesRenderer from '../Modes/NodesMode.vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useDebounce } from '@/composables/useDebounce'; import { useDebounce } from '@/composables/useDebounce';
import NodeIcon from '@/components/NodeIcon.vue';
const i18n = useI18n(); const i18n = useI18n();
const { callDebounced } = useDebounce(); const { callDebounced } = useDebounce();
@@ -155,13 +156,10 @@ function onBackButton() {
> >
<font-awesome-icon :class="$style.backButtonIcon" icon="arrow-left" size="2x" /> <font-awesome-icon :class="$style.backButtonIcon" icon="arrow-left" size="2x" />
</button> </button>
<n8n-node-icon <NodeIcon
v-if="activeViewStack.nodeIcon" v-if="activeViewStack.nodeIcon"
:class="$style.nodeIcon" :class="$style.nodeIcon"
:type="activeViewStack.nodeIcon.iconType || 'unknown'" :icon-source="activeViewStack.nodeIcon"
:src="activeViewStack.nodeIcon.icon"
:name="activeViewStack.nodeIcon.icon"
:color="activeViewStack.nodeIcon.color"
:circle="false" :circle="false"
:show-tooltip="false" :show-tooltip="false"
:size="20" :size="20"

View File

@@ -39,9 +39,12 @@ import { useKeyboardNavigation } from './useKeyboardNavigation';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { AI_TRANSFORM_NODE_TYPE } from 'n8n-workflow'; 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 { useCanvasStore } from '@/stores/canvas.store';
import { useSettingsStore } from '@/stores/settings.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 { interface ViewStack {
uuid?: string; uuid?: string;
@@ -50,12 +53,7 @@ interface ViewStack {
search?: string; search?: string;
subcategory?: string; subcategory?: string;
info?: string; info?: string;
nodeIcon?: { nodeIcon?: NodeIconSource;
iconType?: string;
icon?: Themed<string>;
color?: string;
};
iconUrl?: string;
rootView?: NodeFilterType; rootView?: NodeFilterType;
activeIndex?: number; activeIndex?: number;
transitionDirection?: 'in' | 'out'; transitionDirection?: 'in' | 'out';
@@ -314,6 +312,7 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
await nextTick(); await nextTick();
const iconName = getThemedValue(relatedAIView?.properties.icon, useUIStore().appliedTheme);
pushViewStack( pushViewStack(
{ {
title: relatedAIView?.properties.title, title: relatedAIView?.properties.title,
@@ -321,11 +320,13 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
rootView: AI_OTHERS_NODE_CREATOR_VIEW, rootView: AI_OTHERS_NODE_CREATOR_VIEW,
mode: 'nodes', mode: 'nodes',
items: nodeCreatorStore.allNodeCreatorNodes, items: nodeCreatorStore.allNodeCreatorNodes,
nodeIcon: { nodeIcon: iconName
iconType: 'icon', ? {
icon: relatedAIView?.properties.icon, type: 'icon',
color: relatedAIView?.properties.iconProps?.color, name: iconName,
}, color: relatedAIView?.properties.iconProps?.color,
}
: undefined,
panelClass: relatedAIView?.properties.panelClass, panelClass: relatedAIView?.properties.panelClass,
baseFilter: (i: INodeCreateElement) => { baseFilter: (i: INodeCreateElement) => {
// AI Code node could have any connection type so we don't want to display it // 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"> <script setup lang="ts">
import type { IVersionNode, SimplifiedNodeType } from '@/Interface'; import type { IVersionNode, SimplifiedNodeType } from '@/Interface';
import { useRootStore } from '@/stores/root.store'; import { getNodeIconSource, type NodeIconSource } from '@/utils/nodeIcon';
import { useUIStore } from '@/stores/ui.store'; import { N8nNodeIcon } from '@n8n/design-system';
import {
getBadgeIconUrl,
getNodeIcon,
getNodeIconColor,
getNodeIconUrl,
} from '@/utils/nodeTypesUtils';
import type { INodeTypeDescription } from 'n8n-workflow';
import { computed } from 'vue'; import { computed } from 'vue';
interface NodeIconSource {
path?: string;
fileBuffer?: string;
icon?: string;
}
type Props = { type Props = {
nodeType?: INodeTypeDescription | SimplifiedNodeType | IVersionNode | null;
size?: number; size?: number;
disabled?: boolean; disabled?: boolean;
circle?: boolean; circle?: boolean;
@@ -26,10 +12,15 @@ type Props = {
showTooltip?: boolean; showTooltip?: boolean;
tooltipPosition?: 'top' | 'bottom' | 'left' | 'right'; tooltipPosition?: 'top' | 'bottom' | 'left' | 'right';
nodeName?: string; 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>(), { const props = withDefaults(defineProps<Props>(), {
nodeType: undefined, nodeType: undefined,
iconSource: undefined,
size: undefined, size: undefined,
circle: false, circle: false,
disabled: false, disabled: false,
@@ -37,97 +28,57 @@ const props = withDefaults(defineProps<Props>(), {
tooltipPosition: 'top', tooltipPosition: 'top',
colorDefault: '', colorDefault: '',
nodeName: '', nodeName: '',
badgeIconUrl: undefined,
}); });
const emit = defineEmits<{ const emit = defineEmits<{
click: []; click: [];
}>(); }>();
const rootStore = useRootStore(); const iconSource = computed(() => {
const uiStore = useUIStore(); if (props.iconSource) return props.iconSource;
return getNodeIconSource(props.nodeType);
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 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 iconName = computed(() => {
const nodeType = props.nodeType; if (iconSource.value?.type !== 'icon') return;
const baseUrl = rootStore.baseUrl; return iconSource.value.name;
});
if (nodeType) { const iconColor = computed(() => {
// If node type has icon data, use it if (iconSource.value?.type !== 'icon') return;
if ('iconData' in nodeType && nodeType.iconData) { return iconSource.value.color ?? props.colorDefault;
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 badge = computed(() => { const badge = computed(() => {
const nodeType = props.nodeType; if (iconSource.value?.badge?.type !== 'file') return;
if (nodeType && 'badgeIconUrl' in nodeType && nodeType.badgeIconUrl) { return iconSource.value.badge;
return {
type: 'file',
src: rootStore.baseUrl + getBadgeIconUrl(nodeType, uiStore.appliedTheme),
};
}
return undefined;
}); });
const nodeTypeName = computed(() => props.nodeName ?? props.nodeType?.displayName);
</script> </script>
<template> <template>
<n8n-node-icon <N8nNodeIcon
:type="iconType" :type="iconType"
:src="iconSource.path || iconSource.fileBuffer" :src="src"
:name="iconSource.icon" :name="iconName"
:color="color" :color="iconColor"
:disabled="disabled" :disabled="disabled"
:size="size" :size="size"
:circle="circle" :circle="circle"
:node-type-name="nodeName ? nodeName : nodeType?.displayName" :node-type-name="nodeTypeName"
:show-tooltip="showTooltip" :show-tooltip="showTooltip"
:tooltip-position="tooltipPosition" :tooltip-position="tooltipPosition"
:badge="badge" :badge="badge"
@click="emit('click')" @click="emit('click')"
></n8n-node-icon> ></N8nNodeIcon>
</template> </template>
<style lang="scss" module></style> <style lang="scss" module></style>

View File

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

View File

@@ -17,8 +17,6 @@ import type {
CanvasEventBusEvents, CanvasEventBusEvents,
} from '@/types'; } from '@/types';
import { CanvasConnectionMode, CanvasNodeRenderType } 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 CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeToolbar.vue';
import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRenderer.vue'; import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRenderer.vue';
import CanvasHandleRenderer from '@/components/canvas/elements/handles/CanvasHandleRenderer.vue'; import CanvasHandleRenderer from '@/components/canvas/elements/handles/CanvasHandleRenderer.vue';
@@ -71,7 +69,6 @@ const style = useCssModule();
const props = defineProps<Props>(); const props = defineProps<Props>();
const nodeTypesStore = useNodeTypesStore();
const contextMenu = useContextMenu(); const contextMenu = useContextMenu();
const { connectingHandle } = useCanvas(); const { connectingHandle } = useCanvas();
@@ -98,10 +95,6 @@ const {
const isDisabled = computed(() => props.data.disabled); const isDisabled = computed(() => props.data.disabled);
const nodeTypeDescription = computed(() => {
return nodeTypesStore.getNodeType(props.data.type, props.data.typeVersion);
});
const classes = computed(() => ({ const classes = computed(() => ({
[style.canvasNode]: true, [style.canvasNode]: true,
[style.showToolbar]: showToolbar.value, [style.showToolbar]: showToolbar.value,
@@ -156,14 +149,6 @@ const mappedOutputs = computed(() => {
].filter((endpoint) => !!endpoint); ].filter((endpoint) => !!endpoint);
}); });
/**
* Node icon
*/
const nodeIconSize = computed(() =>
'configuration' in data.value.render.options && data.value.render.options.configuration ? 30 : 40,
);
/** /**
* Endpoints * Endpoints
*/ */
@@ -388,7 +373,7 @@ onBeforeUnmount(() => {
</template> </template>
<CanvasNodeToolbar <CanvasNodeToolbar
v-else-if="nodeTypeDescription" v-else
data-test-id="canvas-node-toolbar" data-test-id="canvas-node-toolbar"
:read-only="readOnly" :read-only="readOnly"
:class="$style.canvasNodeToolbar" :class="$style.canvasNodeToolbar"
@@ -405,15 +390,7 @@ onBeforeUnmount(() => {
@move="onMove" @move="onMove"
@update="onUpdate" @update="onUpdate"
@open:contextmenu="onOpenContextMenuFromNode" @open:contextmenu="onOpenContextMenuFromNode"
> />
<NodeIcon
:node-type="nodeTypeDescription"
:size="nodeIconSize"
:shrink="false"
:disabled="isDisabled"
/>
<!-- @TODO :color-default="iconColorDefault"-->
</CanvasNodeRenderer>
<CanvasNodeTrigger <CanvasNodeTrigger
v-if=" v-if="

View File

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

View File

@@ -107,6 +107,10 @@ const isStrikethroughVisible = computed(() => {
return isDisabled.value && isSingleMainInputNode && isSingleMainOutputNode; return isDisabled.value && isSingleMainInputNode && isSingleMainOutputNode;
}); });
const iconSize = computed(() => (renderOptions.value.configuration ? 30 : 40));
const iconSource = computed(() => renderOptions.value.icon);
const showTooltip = ref(false); const showTooltip = ref(false);
watch(initialized, () => { watch(initialized, () => {
@@ -140,7 +144,7 @@ function onActivate() {
@dblclick.stop="onActivate" @dblclick.stop="onActivate"
> >
<CanvasNodeTooltip v-if="renderOptions.tooltip" :visible="showTooltip" /> <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" /> <CanvasNodeStatusIcons v-if="!isDisabled" :class="$style.statusIcons" />
<CanvasNodeDisabledStrikeThrough v-if="isStrikethroughVisible" /> <CanvasNodeDisabledStrikeThrough v-if="isStrikethroughVisible" />
<div :class="$style.description"> <div :class="$style.description">
@@ -171,6 +175,7 @@ function onActivate() {
--configurable-node--icon-size: 30px; --configurable-node--icon-size: 30px;
--trigger-node--border-radius: 36px; --trigger-node--border-radius: 36px;
--canvas-node--status-icons-offset: var(--spacing-3xs); --canvas-node--status-icons-offset: var(--spacing-3xs);
--node-icon-color: var(--color-foreground-dark);
position: relative; position: relative;
height: var(--canvas-node--height); 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;" style="--configurable-node--input-count: 0; --canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
> >
<!--v-if--> <!--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-->
<!--v-if--> <!--v-if-->
<div <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;" style="--configurable-node--input-count: 0; --canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
> >
<!--v-if--> <!--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-->
<!--v-if--> <!--v-if-->
<div <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;" style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
> >
<!--v-if--> <!--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-->
<!--v-if--> <!--v-if-->
<div <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;" style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
> >
<!--v-if--> <!--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-->
<!--v-if--> <!--v-if-->
<div <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;" style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
> >
<!--v-if--> <!--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-->
<!--v-if--> <!--v-if-->
<div <div

View File

@@ -21,6 +21,7 @@ import { MarkerType } from '@vue-flow/core';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import { useRootStore } from '../stores/root.store';
beforeEach(() => { beforeEach(() => {
const pinia = createTestingPinia({ const pinia = createTestingPinia({
@@ -147,6 +148,10 @@ describe('useCanvasMapping', () => {
options: { options: {
configurable: false, configurable: false,
configuration: false, configuration: false,
icon: {
src: '/nodes/test-node/icon.svg',
type: 'file',
},
trigger: true, trigger: true,
inputs: { inputs: {
labelSize: 'small', labelSize: 'small',
@@ -280,12 +285,19 @@ describe('useCanvasMapping', () => {
workflowObject: ref(workflowObject) as Ref<Workflow>, workflowObject: ref(workflowObject) as Ref<Workflow>,
}); });
const rootStore = mockedStore(useRootStore);
rootStore.baseUrl = 'http://test.local/';
expect(mappedNodes.value[0]?.data?.render).toEqual({ expect(mappedNodes.value[0]?.data?.render).toEqual({
type: CanvasNodeRenderType.Default, type: CanvasNodeRenderType.Default,
options: { options: {
configurable: false, configurable: false,
configuration: false, configuration: false,
trigger: true, trigger: true,
icon: {
src: 'http://test.local/nodes/test-node/icon.svg',
type: 'file',
},
inputs: { inputs: {
labelSize: 'small', labelSize: 'small',
}, },

View File

@@ -50,6 +50,7 @@ import { MarkerType } from '@vue-flow/core';
import { useNodeHelpers } from './useNodeHelpers'; import { useNodeHelpers } from './useNodeHelpers';
import { getTriggerNodeServiceName } from '@/utils/nodeTypesUtils'; import { getTriggerNodeServiceName } from '@/utils/nodeTypesUtils';
import { useNodeDirtiness } from '@/composables/useNodeDirtiness'; import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
import { getNodeIconSource } from '../utils/nodeIcon';
export function useCanvasMapping({ export function useCanvasMapping({
nodes, nodes,
@@ -86,6 +87,8 @@ export function useCanvasMapping({
} }
function createDefaultNodeRenderType(node: INodeUi): CanvasNodeDefaultRender { function createDefaultNodeRenderType(node: INodeUi): CanvasNodeDefaultRender {
const nodeType = nodeTypeDescriptionByNodeId.value[node.id];
const icon = getNodeIconSource(nodeType);
return { return {
type: CanvasNodeRenderType.Default, type: CanvasNodeRenderType.Default,
options: { options: {
@@ -100,6 +103,7 @@ export function useCanvasMapping({
}, },
tooltip: nodeTooltipById.value[node.id], tooltip: nodeTooltipById.value[node.id],
dirtiness: dirtinessByName.value[node.name], dirtiness: dirtinessByName.value[node.name],
icon,
}, },
}; };
} }

View File

@@ -11,6 +11,7 @@ import type {
import type { IExecutionResponse, INodeUi } from '@/Interface'; import type { IExecutionResponse, INodeUi } from '@/Interface';
import type { ComputedRef, Ref } from 'vue'; import type { ComputedRef, Ref } from 'vue';
import type { EventBus } from '@n8n/utils/event-bus'; import type { EventBus } from '@n8n/utils/event-bus';
import type { NodeIconSource } from '../utils/nodeIcon';
export const enum CanvasConnectionMode { export const enum CanvasConnectionMode {
Input = 'inputs', Input = 'inputs',
@@ -71,6 +72,7 @@ export type CanvasNodeDefaultRender = {
}; };
tooltip?: string; tooltip?: string;
dirtiness?: CanvasNodeDirtinessType; 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, INodeUi,
INodeUpdatePropertiesInformation, INodeUpdatePropertiesInformation,
ITemplatesNode, ITemplatesNode,
IVersionNode,
NodeAuthenticationOption, NodeAuthenticationOption,
SimplifiedNodeType,
} from '@/Interface'; } from '@/Interface';
import { import {
CORE_NODES_CATEGORY, CORE_NODES_CATEGORY,
@@ -502,33 +500,3 @@ export const getThemedValue = <T extends string>(
return value[theme]; 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();
};