mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
refactor: Support rendering NodeIcon without a full node type object (no-changelog) (#14097)
This commit is contained in:
@@ -82,6 +82,7 @@ export const mockNodeTypeDescription = ({
|
||||
codex,
|
||||
credentials,
|
||||
documentationUrl: 'https://docs',
|
||||
iconUrl: 'nodes/test-node/icon.svg',
|
||||
webhooks: undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
};
|
||||
|
||||
|
||||
140
packages/frontend/editor-ui/src/utils/nodeIcon.test.ts
Normal file
140
packages/frontend/editor-ui/src/utils/nodeIcon.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
109
packages/frontend/editor-ui/src/utils/nodeIcon.ts
Normal file
109
packages/frontend/editor-ui/src/utils/nodeIcon.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user