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,10 @@
<svg width="13" height="12" viewBox="0 0 13 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_650_18839)">
<path d="M5.4989 0.351562C5.96754 -0.117074 6.72753 -0.117074 7.19617 0.351562L11.996 5.15137C12.0955 5.25089 12.1731 5.36418 12.2303 5.48438C12.2567 5.53953 12.279 5.59683 12.2968 5.65625C12.3136 5.71272 12.3255 5.77017 12.3339 5.82812C12.3419 5.88433 12.3475 5.94156 12.3475 6C12.3475 6.06071 12.3416 6.12043 12.3329 6.17871C12.3299 6.19893 12.3272 6.21919 12.3231 6.23926C12.3023 6.34228 12.2669 6.43964 12.2216 6.53125C12.2163 6.54195 12.2116 6.55291 12.2059 6.56348C12.198 6.57845 12.1891 6.59284 12.1805 6.60742C12.1682 6.62838 12.1562 6.64959 12.1425 6.66992C12.0992 6.73406 12.0505 6.79412 11.996 6.84863L7.19617 11.6484C6.72753 12.1171 5.96754 12.1171 5.4989 11.6484C5.03045 11.1798 5.03033 10.4197 5.4989 9.95117L8.25085 7.2002H1.54773C0.884978 7.2002 0.347534 6.66275 0.347534 6C0.347702 5.33739 0.885082 4.80078 1.54773 4.80078H8.25183L5.4989 2.04883C5.03033 1.58025 5.03045 0.820214 5.4989 0.351562Z" fill="#909398"/>
</g>
<defs>
<clipPath id="clip0_650_18839">
<rect width="12" height="12" fill="white" transform="translate(0.347534)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,10 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_650_18836)">
<path d="M4.73335 9.60167C5.30603 9.89454 5.57535 10.5792 5.33688 11.1915C5.08244 11.8446 4.34649 12.1675 3.69339 11.913C2.89755 11.6029 2.15254 11.127 1.51297 10.4874C0.953268 9.9277 0.518901 9.28719 0.211269 8.60257L0.087408 8.30693L0.0459264 8.18365C-0.125896 7.56369 0.196633 6.90193 0.808955 6.66337C1.42121 6.4249 2.10584 6.69422 2.39869 7.26692L2.45186 7.38553L2.52664 7.56198C2.71023 7.97058 2.96972 8.35447 3.30778 8.69254C3.69419 9.07896 4.14008 9.36356 4.61475 9.5485L4.73335 9.60167ZM1.51297 1.51303C2.15254 0.873444 2.89755 0.397506 3.69339 0.0874113L3.81666 0.0459281C4.4366 -0.1259 5.09833 0.19664 5.33688 0.808985C5.59124 1.46209 5.26782 2.19752 4.61475 2.45195C4.14008 2.63689 3.69419 2.92149 3.30778 3.30791C2.96972 3.64599 2.71023 4.02987 2.52664 4.43847L2.45186 4.61492L2.39869 4.73353C2.10584 5.30623 1.42121 5.57556 0.808955 5.33708C0.15585 5.08263 -0.167031 4.34666 0.087408 3.69353C0.397491 2.89766 0.873411 2.15262 1.51297 1.51303ZM8.69222 8.69254C9.03028 8.35447 9.28977 7.97058 9.47336 7.56198L9.54814 7.38553L9.60131 7.26692C9.89416 6.69422 10.5788 6.4249 11.191 6.66337C11.8034 6.90193 12.1259 7.56369 11.9541 8.18365L11.9126 8.30693L11.7887 8.60257C11.4811 9.28719 11.0467 9.9277 10.487 10.4874C9.84746 11.127 9.10245 11.6029 8.30661 11.913C7.65351 12.1675 6.91756 11.8446 6.66312 11.1915C6.40876 10.5384 6.73218 9.80294 7.38525 9.5485L7.5617 9.47371C7.97028 9.29012 8.35415 9.03062 8.69222 8.69254ZM10.487 1.51303C11.1266 2.15262 11.6025 2.89766 11.9126 3.69353C12.167 4.34666 11.8441 5.08263 11.191 5.33708C10.5788 5.57556 9.89416 5.30623 9.60131 4.73353L9.54814 4.61492L9.47336 4.43847C9.28977 4.02987 9.03028 3.64599 8.69222 3.30791C8.30581 2.92149 7.85992 2.63689 7.38525 2.45195C6.73218 2.19752 6.40876 1.46209 6.66312 0.808985C6.91756 0.155856 7.65351 -0.167037 8.30661 0.0874113L8.60224 0.211277C9.28683 0.51892 9.92732 0.953304 10.487 1.51303Z" fill="#909398"/>
</g>
<defs>
<clipPath id="clip0_650_18836">
<rect width="12" height="12" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,10 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_650_18838)">
<path d="M10.3926 5.23047C11.0302 5.23047 11.5477 5.74714 11.5479 6.38477V8.69434C11.5479 9.75721 10.6859 10.6191 9.62305 10.6191H4.23438V11.6143C4.23438 11.9572 3.81963 12.1292 3.57715 11.8867L1.42676 9.73633C1.27682 9.58605 1.27675 9.34261 1.42676 9.19238L3.57715 7.04199C3.81965 6.79969 4.23438 6.97159 4.23438 7.31445V8.30957H9.23828V6.38477C9.23843 5.7472 9.755 5.23056 10.3926 5.23047ZM7.31348 0.385742C7.31348 0.0428174 7.72822 -0.129203 7.9707 0.113281L10.1201 2.26367C10.2704 2.41399 10.2704 2.6573 10.1201 2.80762L7.9707 4.95801C7.72822 5.20049 7.31348 5.02847 7.31348 4.68555V3.69043H2.30957V5.61523C2.30957 6.25299 1.79205 6.77051 1.1543 6.77051C0.516755 6.77027 0 6.25284 0 5.61523V3.30566C0.000146327 2.24287 0.86198 1.38184 1.9248 1.38184H7.31348V0.385742Z" fill="#909398"/>
</g>
<defs>
<clipPath id="clip0_650_18838">
<rect width="12" height="12" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.52246 0.00585938C7.12758 0.0672969 7.59961 0.578867 7.59961 1.2002V9.60059H9.59961C10.2623 9.60059 10.7996 10.1372 10.7998 10.7998C10.7998 11.4625 10.2624 12 9.59961 12H3.2002C2.53745 12 2 11.4625 2 10.7998C2.00016 10.1372 2.53755 9.60059 3.2002 9.60059H5.2002V2.40039H3.2002C2.53752 2.40039 2.00011 1.86284 2 1.2002C2 0.537456 2.53745 0 3.2002 0H6.40039L6.52246 0.00585938Z" fill="#909398"/>
</svg>

After

Width:  |  Height:  |  Size: 508 B

View File

@@ -1,8 +1,12 @@
import Binary from './custom/binary.svg';
import BoltFilled from './custom/bolt-filled.svg';
import Continue from './custom/Continue.svg';
import EmptyOutput from './custom/EmptyOutput.svg';
import GripLinesVertical from './custom/grip-lines-vertical.svg';
import Json from './custom/json.svg';
import PopOut from './custom/pop-out.svg';
import Retry from './custom/Retry.svg';
import RunOnce from './custom/RunOnce.svg';
import Schema from './custom/schema.svg';
import Spinner from './custom/spinner.svg';
import StatusCanceled from './custom/status-canceled.svg';
@@ -406,6 +410,10 @@ export const updatedIconSet = {
'status-unknown': StatusUnknown,
'status-warning': StatusWarning,
'vector-square': VectorSquare,
'continue-on-error': Continue,
'always-output-data': EmptyOutput,
'retry-on-fail': Retry,
'execute-once': RunOnce,
schema: Schema,
json: Json,
binary: Binary,

View File

@@ -1252,6 +1252,11 @@
"node.discovery.pinData.canvas": "You can pin this output instead of waiting for a test event. Open node to do so.",
"node.discovery.pinData.ndv": "You can pin this output instead of waiting for a test event.",
"node.executionError.openNode": "Open node",
"node.settings.continuesOnError": "Execution will continue even if the node fails",
"node.settings.continuesOnError.title": "Continue On Fail",
"node.settings.retriesOnFailure": "This node will automatically retry if it fails",
"node.settings.executeOnce": "This node executes only once, no matter how many input items there are",
"node.settings.alwaysOutputData": "This node will output an empty item if nothing would normally be returned",
"nodeBase.clickToAddNodeOrDragToConnect": "Click to add node \n or drag to connect",
"nodeCreator.actionsPlaceholderNode.scheduleTrigger": "On a Schedule",
"nodeCreator.actionsPlaceholderNode.webhook": "On a Webhook call",
@@ -2429,6 +2434,15 @@
"ndv.search.noMatchSchema.description": "To search field values, switch to table or JSON view. {link}",
"ndv.search.noMatchSchema.description.link": "Clear filter",
"ndv.search.items": "{matched} of {count} item | {matched} of {count} items",
"ndv.nodeHints.disabled": "This node is disabled, and will simply pass the input through",
"ndv.nodeHints.alwaysOutputData": "This node will output an empty item if nothing would normally be returned",
"ndv.nodeHints.alwaysOutputData.short": "output an empty item if nothing would normally be returned",
"ndv.nodeHints.executeOnce": "This node will execute only once, no matter how many input items there are",
"ndv.nodeHints.executeOnce.short": "execute only once, no matter how many input items there are",
"ndv.nodeHints.retryOnFail": "This node will automatically retry if it fails",
"ndv.nodeHints.retryOnFail.short": "automatically retry if it fails",
"ndv.nodeHints.continueOnError": "Execution will continue even if the node fails",
"ndv.nodeHints.continueOnError.short": "continue executing even if the node fails",
"updatesPanel.andIs": "and is",
"updatesPanel.behindTheLatest": "behind the latest and greatest n8n",
"updatesPanel.howToUpdateYourN8nVersion": "How to update your n8n version",

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>