feat(editor): Add settings icons to the node on canvas (#15467)

This commit is contained in:
Shireen Missi
2025-07-23 14:32:28 +01:00
committed by GitHub
parent 5524b2137a
commit a2f21a7615
14 changed files with 371 additions and 2 deletions

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from '@n8n/i18n';
import type { INodeUi } from '@/Interface';
import type { IconName } from '@n8n/design-system/src/components/N8nIcon/icons';
interface Props {
node: INodeUi | null;
}
const props = defineProps<Props>();
const i18n = useI18n();
const activeSettings = computed(() => {
if (!props.node || props.node.disabled) {
return props.node?.disabled
? [
{
key: 'disabled',
message: i18n.baseText('ndv.nodeHints.disabled'),
icon: 'power-off' as IconName,
},
]
: [];
}
const settings = [];
if (props.node.alwaysOutputData) {
settings.push({
key: 'alwaysOutputData',
message: i18n.baseText('ndv.nodeHints.alwaysOutputData'),
icon: 'always-output-data' as IconName,
});
}
if (props.node.executeOnce) {
settings.push({
key: 'executeOnce',
message: i18n.baseText('ndv.nodeHints.executeOnce'),
icon: 'execute-once' as IconName,
});
}
if (props.node.retryOnFail) {
settings.push({
key: 'retryOnFail',
message: i18n.baseText('ndv.nodeHints.retryOnFail'),
icon: 'retry-on-fail' as IconName,
});
}
if (
props.node.onError === 'continueRegularOutput' ||
props.node.onError === 'continueErrorOutput'
) {
settings.push({
key: 'continueOnError',
message: i18n.baseText('ndv.nodeHints.continueOnError'),
icon: 'continue-on-error' as IconName,
});
}
return settings;
});
</script>
<template>
<div v-if="activeSettings.length > 0" :class="$style.settingsHint">
<div v-for="setting in activeSettings" :key="setting.key" :class="$style.settingItem">
<div :class="$style.iconWrapper">
<FontAwesomeIcon v-if="setting.icon === 'power'" icon="power" :class="$style.icon" />
<N8nIcon v-else :icon="setting.icon" :class="$style.icon" />
</div>
<N8nText size="small" :class="$style.message">
{{ setting.message }}
</N8nText>
</div>
</div>
</template>
<style lang="scss" module>
.settingsHint {
background-color: var(--color-callout-info-background);
border-radius: var(--border-radius-base);
border: var(--border-width-base) var(--border-style-base);
border-color: var(--color-callout-info-border);
color: var(--color-callout-info-font);
margin-top: var(--spacing-2xs);
margin-bottom: var(--spacing-xs);
margin-left: var(--spacing-s);
margin-right: var(--spacing-s);
padding: var(--spacing-xs);
}
.settingItem {
display: flex;
align-items: flex-start;
gap: var(--spacing-xs);
&:not(:last-child) {
margin-bottom: var(--spacing-xs);
}
}
.iconWrapper {
display: flex;
align-items: center;
flex-shrink: 0;
margin-top: 1px;
}
.icon {
color: var(--color-callout-info-icon);
font-size: var(--font-size-s);
line-height: 1;
}
.message {
line-height: var(--font-line-height-regular);
flex: 1;
}
</style>

View File

@@ -2,6 +2,7 @@
import { ViewableMimeTypes } from '@n8n/api-types';
import { useStorage } from '@/composables/useStorage';
import { saveAs } from 'file-saver';
import NodeSettingsHint from '@/components/NodeSettingsHint.vue';
import type {
IBinaryData,
IConnectedNode,
@@ -818,7 +819,6 @@ function getNodeHints(): NodeHint[] {
return [];
}
function onItemHover(itemIndex: number | null) {
if (itemIndex === null) {
emit('itemHover', null);
@@ -1575,7 +1575,7 @@ defineExpose({ enterEditMode });
</slot>
</N8nCallout>
</div>
<NodeSettingsHint v-if="props.paneType === 'output'" :node="node" />
<N8nCallout
v-for="hint in getNodeHints()"
:key="hint.message"
@@ -2347,6 +2347,42 @@ defineExpose({ enterEditMode });
padding: 0 var(--ndv-spacing);
}
.messageSection {
display: flex;
align-items: center;
width: 100%;
}
.singleIcon {
flex-direction: row;
align-items: center;
}
.multipleIcons {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-2xs, 8px);
}
.multipleIcons .iconStack {
margin-right: 0;
margin-bottom: 0;
}
.iconStack {
display: flex;
align-items: center;
gap: var(--spacing-4xs, 4px);
flex-shrink: 0;
margin-right: var(--spacing-xs);
}
.icon {
color: var(--color-callout-info-icon);
line-height: 1;
font-size: var(--font-size-xs);
}
.executingMessage {
.compact & {
color: var(--color-text-light);

View File

@@ -165,6 +165,7 @@ exports[`InputPanel > should render 1`] = `
<!--v-if-->
<!--v-if-->
<!--v-if-->
<!--v-if-->
<!--v-if-->

View File

@@ -4,12 +4,17 @@ import { createCanvasNodeProvide, createCanvasProvide } from '@/__tests__/data';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { CanvasNodeRenderType } from '@/types';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createTestWorkflowObject } from '@/__tests__/mocks';
const renderComponent = createComponentRenderer(CanvasNodeRenderer);
beforeEach(() => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const workflowsStore = useWorkflowsStore();
const workflowObject = createTestWorkflowObject(workflowsStore.workflow);
workflowsStore.getCurrentWorkflow = vi.fn().mockReturnValue(workflowObject);
});
describe('CanvasNodeRenderer', () => {

View File

@@ -6,6 +6,8 @@ import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
import { fireEvent } from '@testing-library/vue';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createTestWorkflowObject } from '@/__tests__/mocks';
const renderComponent = createComponentRenderer(CanvasNodeDefault, {
global: {
@@ -18,6 +20,9 @@ const renderComponent = createComponentRenderer(CanvasNodeDefault, {
beforeEach(() => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const workflowsStore = useWorkflowsStore();
const workflowObject = createTestWorkflowObject(workflowsStore.workflow);
workflowsStore.getCurrentWorkflow = vi.fn().mockReturnValue(workflowObject);
});
describe('CanvasNodeDefault', () => {

View File

@@ -5,6 +5,8 @@ import { useI18n } from '@n8n/i18n';
import { useCanvasNode } from '@/composables/useCanvasNode';
import type { CanvasNodeDefaultRender } from '@/types';
import { useCanvas } from '@/composables/useCanvas';
import CanvasNodeSettingsIcons from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeSettingsIcons.vue';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { calculateNodeSize } from '@/utils/nodeViewUtils';
import ExperimentalInPlaceNodeSettings from '@/components/canvas/experimental/components/ExperimentalEmbeddedNodeDetails.vue';
@@ -43,6 +45,7 @@ const { mainOutputs, mainOutputConnections, mainInputs, mainInputConnections, no
connections,
});
const nodeHelpers = useNodeHelpers();
const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']);
const classes = computed(() => {
@@ -153,6 +156,13 @@ function onActivate(event: MouseEvent) {
:disabled="isDisabled"
:class="$style.icon"
/>
<CanvasNodeSettingsIcons
v-if="
!renderOptions.configuration &&
!isDisabled &&
!(hasPinnedData && !nodeHelpers.isProductionExecutionPreview.value)
"
/>
<CanvasNodeDisabledStrikeThrough v-if="isStrikethroughVisible" />
<div :class="$style.description">
<div v-if="label" :class="$style.label">

View File

@@ -25,6 +25,14 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr
</div>
</div>
<div
class="settingsIcons"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
<!--v-if-->
<div
class="description"
@@ -71,6 +79,7 @@ exports[`CanvasNodeDefault > configuration > should render configurable configur
</div>
</div>
<!--v-if-->
<!--v-if-->
<div
class="description"
>
@@ -116,6 +125,7 @@ exports[`CanvasNodeDefault > configuration > should render configuration node co
</div>
</div>
<!--v-if-->
<!--v-if-->
<div
class="description"
>
@@ -160,6 +170,14 @@ exports[`CanvasNodeDefault > should render node correctly 1`] = `
</div>
</div>
<div
class="settingsIcons"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
<!--v-if-->
<div
class="description"
@@ -205,6 +223,14 @@ exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`]
</div>
</div>
<div
class="settingsIcons"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
<!--v-if-->
<div
class="description"

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useCanvasNode } from '@/composables/useCanvasNode';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useI18n } from '@n8n/i18n';
import { N8nIcon } from '@n8n/design-system';
const { name } = useCanvasNode();
const i18n = useI18n();
const workflowHelpers = useWorkflowHelpers();
const workflow = computed(() => workflowHelpers.getCurrentWorkflow());
const node = computed(() => workflow.value.getNode(name.value));
</script>
<template>
<div :class="$style.settingsIcons">
<N8nTooltip v-if="node?.alwaysOutputData">
<template #content>
<div :class="$style.tooltipHeader">
<N8nIcon icon="always-output-data" />
<strong :class="$style.tooltipTitle">{{
i18n.baseText('nodeSettings.alwaysOutputData.displayName')
}}</strong>
</div>
<div>
{{ i18n.baseText('node.settings.alwaysOutputData') }}
</div>
</template>
<div data-test-id="canvas-node-status-always-output-data">
<N8nIcon icon="always-output-data" />
</div>
</N8nTooltip>
<N8nTooltip v-if="node?.executeOnce">
<template #content>
<div :class="$style.tooltipHeader">
<N8nIcon icon="execute-once" />
<strong :class="$style.tooltipTitle">{{
i18n.baseText('nodeSettings.executeOnce.displayName')
}}</strong>
</div>
<div>
{{ i18n.baseText('node.settings.executeOnce') }}
</div>
</template>
<div data-test-id="canvas-node-status-execute-once">
<N8nIcon icon="execute-once" />
</div>
</N8nTooltip>
<N8nTooltip v-if="node?.retryOnFail">
<template #content>
<div :class="$style.tooltipHeader">
<N8nIcon icon="retry-on-fail" />
<strong :class="$style.tooltipTitle">{{
i18n.baseText('nodeSettings.retryOnFail.displayName')
}}</strong>
</div>
<div>
{{ i18n.baseText('node.settings.retriesOnFailure') }}
</div>
</template>
<div data-test-id="canvas-node-status-retry-on-fail">
<N8nIcon icon="retry-on-fail" />
</div>
</N8nTooltip>
<N8nTooltip
v-if="node?.onError === 'continueRegularOutput' || node?.onError === 'continueErrorOutput'"
>
<template #content>
<div :class="$style.tooltipHeader">
<N8nIcon icon="continue-on-error" />
<strong :class="$style.tooltipTitle">{{
i18n.baseText('node.settings.continuesOnError.title')
}}</strong>
</div>
<div>
{{ i18n.baseText('node.settings.continuesOnError') }}
</div>
</template>
<div data-test-id="canvas-node-status-continue-on-error">
<N8nIcon icon="continue-on-error" />
</div>
</N8nTooltip>
</div>
</template>
<style lang="scss" module>
.settingsIcons {
position: absolute;
top: var(--canvas-node--status-icons-offset);
right: var(--canvas-node--status-icons-offset);
display: flex;
flex-direction: row;
}
.tooltipHeader {
display: flex;
gap: 2px;
}
.tooltipTitle {
font-weight: 600;
font-size: inherit;
line-height: inherit;
}
</style>