mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +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,
|
codex,
|
||||||
credentials,
|
credentials,
|
||||||
documentationUrl: 'https://docs',
|
documentationUrl: 'https://docs',
|
||||||
|
iconUrl: 'nodes/test-node/icon.svg',
|
||||||
webhooks: undefined,
|
webhooks: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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="
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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,
|
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();
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user