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

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