feat(editor): Add experimental NDV pane in canvas (no-changelog) (#16419)

This commit is contained in:
Suguru Inoue
2025-06-19 09:18:05 +02:00
committed by GitHub
parent c0d1ff6e4c
commit d0eb7a45ad
8 changed files with 141 additions and 10 deletions

View File

@@ -54,6 +54,7 @@ import { ProjectTypes } from '@/types/projects.types';
import { updateDynamicConnections } from '@/utils/nodeSettingsUtils'; import { updateDynamicConnections } from '@/utils/nodeSettingsUtils';
import FreeAiCreditsCallout from '@/components/FreeAiCreditsCallout.vue'; import FreeAiCreditsCallout from '@/components/FreeAiCreditsCallout.vue';
import { useCanvasOperations } from '@/composables/useCanvasOperations'; import { useCanvasOperations } from '@/composables/useCanvasOperations';
import { N8nIconButton } from '@n8n/design-system';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -67,6 +68,8 @@ const props = withDefaults(
executable: boolean; executable: boolean;
inputSize: number; inputSize: number;
activeNode?: INodeUi; activeNode?: INodeUi;
canExpand?: boolean;
hideConnections?: boolean;
}>(), }>(),
{ {
foreignCredentials: () => [], foreignCredentials: () => [],
@@ -74,6 +77,9 @@ const props = withDefaults(
executable: true, executable: true,
inputSize: 0, inputSize: 0,
blockUI: false, blockUI: false,
activeNode: undefined,
canExpand: false,
hideConnections: false,
}, },
); );
@@ -85,6 +91,7 @@ const emit = defineEmits<{
openConnectionNodeCreator: [nodeName: string, connectionType: NodeConnectionType]; openConnectionNodeCreator: [nodeName: string, connectionType: NodeConnectionType];
activate: []; activate: [];
execute: []; execute: [];
expand: [];
}>(); }>();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
@@ -977,7 +984,9 @@ onMounted(() => {
populateSettings(); populateSettings();
setNodeValues(); setNodeValues();
props.eventBus?.on('openSettings', openSettings); props.eventBus?.on('openSettings', openSettings);
nodeHelpers.updateNodeParameterIssues(node.value as INodeUi, props.nodeType); if (node.value !== null) {
nodeHelpers.updateNodeParameterIssues(node.value, props.nodeType);
}
importCurlEventBus.on('setHttpNodeParameters', setHttpNodeParameters); importCurlEventBus.on('setHttpNodeParameters', setHttpNodeParameters);
ndvEventBus.on('updateParameterValue', valueChanged); ndvEventBus.on('updateParameterValue', valueChanged);
}); });
@@ -1019,9 +1028,9 @@ function displayCredentials(credentialTypeDescription: INodeCredentialDescriptio
:read-only="isReadOnly" :read-only="isReadOnly"
@update:model-value="nameChanged" @update:model-value="nameChanged"
></NodeTitle> ></NodeTitle>
<div v-if="isExecutable"> <div v-if="isExecutable || props.canExpand" :class="$style.headerActions">
<NodeExecuteButton <NodeExecuteButton
v-if="!blockUI && node && nodeValid" v-if="isExecutable && !blockUI && node && nodeValid"
data-test-id="node-execute-button" data-test-id="node-execute-button"
:node-name="node.name" :node-name="node.name"
:disabled="outputPanelEditMode.enabled && !isTriggerNode" :disabled="outputPanelEditMode.enabled && !isTriggerNode"
@@ -1032,6 +1041,16 @@ function displayCredentials(credentialTypeDescription: INodeCredentialDescriptio
@stop-execution="onStopExecution" @stop-execution="onStopExecution"
@value-changed="valueChanged" @value-changed="valueChanged"
/> />
<N8nIconButton
v-if="props.canExpand"
icon="expand"
type="secondary"
text
size="mini"
icon-size="large"
aria-label="Expand"
@click="emit('expand')"
/>
</div> </div>
</div> </div>
<NodeSettingsTabs <NodeSettingsTabs
@@ -1174,7 +1193,7 @@ function displayCredentials(credentialTypeDescription: INodeCredentialDescriptio
</div> </div>
</div> </div>
<NDVSubConnections <NDVSubConnections
v-if="node" v-if="node && !props.hideConnections"
ref="subConnections" ref="subConnections"
:root-node="node" :root-node="node"
@switch-selected-node="onSwitchSelectedNode" @switch-selected-node="onSwitchSelectedNode"
@@ -1189,6 +1208,12 @@ function displayCredentials(credentialTypeDescription: INodeCredentialDescriptio
background-color: var(--color-background-base); background-color: var(--color-background-base);
} }
.headerActions {
display: flex;
gap: var(--spacing-4xs);
align-items: center;
}
.warningIcon { .warningIcon {
color: var(--color-text-lighter); color: var(--color-text-lighter);
font-size: var(--font-size-2xl); font-size: var(--font-size-2xl);

View File

@@ -53,7 +53,7 @@ import CanvasBackground from './elements/background/CanvasBackground.vue';
import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue'; import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue';
import Edge from './elements/edges/CanvasEdge.vue'; import Edge from './elements/edges/CanvasEdge.vue';
import Node from './elements/nodes/CanvasNode.vue'; import Node from './elements/nodes/CanvasNode.vue';
import { useViewportAutoAdjust } from '@/components/canvas/composables/useViewportAutoAdjust'; import { useViewportAutoAdjust } from './composables/useViewportAutoAdjust';
import { isOutsideSelected } from '@/utils/htmlUtils'; import { isOutsideSelected } from '@/utils/htmlUtils';
const $style = useCssModule(); const $style = useCssModule();

View File

@@ -9,6 +9,8 @@ import { createEventBus } from '@n8n/utils/event-bus';
import type { CanvasEventBusEvents } from '@/types'; import type { CanvasEventBusEvents } from '@/types';
import { useVueFlow } from '@vue-flow/core'; import { useVueFlow } from '@vue-flow/core';
import { throttledRef } from '@vueuse/core'; import { throttledRef } from '@vueuse/core';
import { useSettingsStore } from '@/stores/settings.store';
import ExperimentalNodeDetailsDrawer from './components/ExperimentalNodeDetailsDrawer.vue';
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
@@ -34,8 +36,9 @@ const props = withDefaults(
); );
const $style = useCssModule(); const $style = useCssModule();
const settingsStore = useSettingsStore();
const { onNodesInitialized } = useVueFlow({ id: props.id }); const { onNodesInitialized, getSelectedNodes } = useVueFlow({ id: props.id });
const workflow = toRef(props, 'workflow'); const workflow = toRef(props, 'workflow');
const workflowObject = toRef(props, 'workflowObject'); const workflowObject = toRef(props, 'workflowObject');
@@ -79,12 +82,16 @@ const mappedConnectionsThrottled = throttledRef(mappedConnections, 200);
/> />
</div> </div>
<slot /> <slot />
<ExperimentalNodeDetailsDrawer
v-if="settingsStore.experimental__dockedNodeSettingsEnabled && !props.readOnly"
:selected-nodes="getSelectedNodes"
/>
</div> </div>
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.wrapper { .wrapper {
display: block; display: flex;
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -96,5 +103,7 @@ const mappedConnectionsThrottled = throttledRef(mappedConnections, 200);
height: 100%; height: 100%;
position: relative; position: relative;
display: block; display: block;
align-items: stretch;
justify-content: stretch;
} }
</style> </style>

View File

@@ -1,15 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import NodeSettings from '@/components/NodeSettings.vue'; import NodeSettings from '@/components/NodeSettings.vue';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import { type IUpdateInformation } from '@/Interface';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { createEventBus } from '@n8n/utils/event-bus'; import { createEventBus } from '@n8n/utils/event-bus';
import { computed } from 'vue'; import { computed } from 'vue';
const { nodeId } = defineProps<{ nodeId: string }>(); const { nodeId, canOpenNdv } = defineProps<{ nodeId: string; canOpenNdv?: boolean }>();
const settingsEventBus = createEventBus(); const settingsEventBus = createEventBus();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const { setActiveNodeName } = useNDVStore();
const { renameNode } = useCanvasOperations();
const activeNode = computed(() => workflowsStore.getNodeById(nodeId)); const activeNode = computed(() => workflowsStore.getNodeById(nodeId));
const activeNodeType = computed(() => { const activeNodeType = computed(() => {
@@ -18,10 +23,23 @@ const activeNodeType = computed(() => {
} }
return null; return null;
}); });
function handleOpenNdv() {
if (activeNode.value) {
setActiveNodeName(activeNode.value.name);
}
}
function handleValueChanged(parameterData: IUpdateInformation) {
if (parameterData.name === 'name' && parameterData.oldValue) {
void renameNode(parameterData.oldValue as string, parameterData.value as string);
}
}
</script> </script>
<template> <template>
<NodeSettings <NodeSettings
:can-expand="canOpenNdv"
:event-bus="settingsEventBus" :event-bus="settingsEventBus"
:dragging="false" :dragging="false"
:active-node="activeNode" :active-node="activeNode"
@@ -32,5 +50,8 @@ const activeNodeType = computed(() => {
:block-u-i="false" :block-u-i="false"
:executable="false" :executable="false"
:input-size="0" :input-size="0"
hide-connections
@expand="handleOpenNdv"
@value-changed="handleValueChanged"
/> />
</template> </template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { type CanvasNode } from '@/types';
import ExperimentalCanvasNodeSettings from './ExperimentalCanvasNodeSettings.vue';
import { N8nText } from '@n8n/design-system';
import { computed, watch, ref } from 'vue';
const { selectedNodes } = defineProps<{ selectedNodes: CanvasNode[] }>();
const content = computed(() =>
selectedNodes.length > 1
? `${selectedNodes.length} nodes selected`
: selectedNodes.length > 0
? selectedNodes[0]
: undefined,
);
const lastContent = ref<string | CanvasNode | undefined>();
// Sync lastContent to be "last defined content" (for drawer animation)
watch(
content,
(newContent) => {
if (newContent !== undefined) {
lastContent.value = newContent;
}
},
{ immediate: true },
);
</script>
<template>
<div :class="[$style.component, content === undefined ? $style.closed : '']">
<N8nText v-if="typeof lastContent === 'string'" color="text-base">
{{ lastContent }}
</N8nText>
<ExperimentalCanvasNodeSettings
v-else-if="lastContent !== undefined"
:node-id="lastContent.id"
can-open-ndv
/>
</div>
</template>
<style lang="scss" module>
.component {
position: absolute;
right: 0;
z-index: 10;
flex-grow: 0;
flex-shrink: 0;
border-left: var(--border-base);
background-color: var(--color-background-xlight);
width: #{$node-creator-width};
height: 100%;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease;
&.closed {
transform: translateX(100%);
}
}
</style>

View File

@@ -7,7 +7,7 @@ import { NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants';
import type { CanvasNodeDefaultRender } from '@/types'; import type { CanvasNodeDefaultRender } from '@/types';
import { useCanvas } from '@/composables/useCanvas'; import { useCanvas } from '@/composables/useCanvas';
import { useNodeSettingsInCanvas } from '@/components/canvas/composables/useNodeSettingsInCanvas'; import { useNodeSettingsInCanvas } from '@/components/canvas/composables/useNodeSettingsInCanvas';
import CanvasNodeNodeSettings from './parts/CanvasNodeNodeSettings.vue'; import ExperimentalCanvasNodeSettings from '../../../components/ExperimentalCanvasNodeSettings.vue';
const $style = useCssModule(); const $style = useCssModule();
const i18n = useI18n(); const i18n = useI18n();
@@ -153,7 +153,7 @@ function onActivate(event: MouseEvent) {
@contextmenu="openContextMenu" @contextmenu="openContextMenu"
@dblclick.stop="onActivate" @dblclick.stop="onActivate"
> >
<CanvasNodeNodeSettings v-if="nodeSettingsZoom !== undefined" :node-id="id" /> <ExperimentalCanvasNodeSettings v-if="nodeSettingsZoom !== undefined" :node-id="id" />
<template v-else> <template v-else>
<CanvasNodeTooltip v-if="renderOptions.tooltip" :visible="showTooltip" /> <CanvasNodeTooltip v-if="renderOptions.tooltip" :visible="showTooltip" />
<NodeIcon :icon-source="iconSource" :size="iconSize" :shrink="false" :disabled="isDisabled" /> <NodeIcon :icon-source="iconSource" :size="iconSize" :shrink="false" :disabled="isDisabled" />

View File

@@ -488,6 +488,8 @@ export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL_SUB_NODE = 'N8N_LOGS_DETAILS
export const LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY = 'N8N_WORKFLOWS_LIST_PREFERENCES'; export const LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY = 'N8N_WORKFLOWS_LIST_PREFERENCES';
export const LOCAL_STORAGE_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS = export const LOCAL_STORAGE_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS =
'N8N_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS'; 'N8N_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS';
export const LOCAL_STORAGE_EXPERIMENTAL_DOCKED_NODE_SETTINGS =
'N8N_EXPERIMENTAL_DOCKED_NODE_SETTINGS';
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename='; export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
export const COMMUNITY_PLUS_DOCS_URL = export const COMMUNITY_PLUS_DOCS_URL =
'https://docs.n8n.io/hosting/community-edition-features/#registered-community-edition'; 'https://docs.n8n.io/hosting/community-edition-features/#registered-community-edition';

View File

@@ -13,6 +13,7 @@ import * as promptsApi from '@n8n/rest-api-client/api/prompts';
import { testHealthEndpoint } from '@/api/templates'; import { testHealthEndpoint } from '@/api/templates';
import { import {
INSECURE_CONNECTION_WARNING, INSECURE_CONNECTION_WARNING,
LOCAL_STORAGE_EXPERIMENTAL_DOCKED_NODE_SETTINGS,
LOCAL_STORAGE_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS, LOCAL_STORAGE_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS,
} from '@/constants'; } from '@/constants';
import { STORES } from '@n8n/stores'; import { STORES } from '@n8n/stores';
@@ -347,6 +348,15 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
{ writeDefaults: false }, { writeDefaults: false },
); );
/**
* (Experimental) If set to true, show node settings for a selected node in docked pane
*/
const experimental__dockedNodeSettingsEnabled = useLocalStorage(
LOCAL_STORAGE_EXPERIMENTAL_DOCKED_NODE_SETTINGS,
false,
{ writeDefaults: false },
);
return { return {
settings, settings,
userManagement, userManagement,
@@ -405,6 +415,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isAiCreditsEnabled, isAiCreditsEnabled,
aiCreditsQuota, aiCreditsQuota,
experimental__minZoomNodeSettingsInCanvas, experimental__minZoomNodeSettingsInCanvas,
experimental__dockedNodeSettingsEnabled,
partialExecutionVersion, partialExecutionVersion,
reset, reset,
getTimezones, getTimezones,