feat(editor): Merge experimental params pane into focus pane (no-changelog) (#18337)

This commit is contained in:
Suguru Inoue
2025-08-27 11:04:24 +02:00
committed by GitHub
parent 98bde4f478
commit 6d306c53dd
26 changed files with 317 additions and 179 deletions

View File

@@ -16,7 +16,8 @@ import { startCompletion } from '@codemirror/autocomplete';
import type { EditorState, SelectionRange } from '@codemirror/state';
import type { IDataObject } from 'n8n-workflow';
import { createEventBus, type EventBus } from '@n8n/utils/event-bus';
import { CanvasKey, ExpressionLocalResolveContextSymbol } from '@/constants';
import { CanvasKey } from '@/constants';
import { useIsInExperimentalNdv } from '@/components/canvas/experimental/composables/useIsInExperimentalNdv';
const isFocused = ref(false);
const segments = ref<Segment[]>([]);
@@ -56,8 +57,7 @@ const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const canvas = inject(CanvasKey, undefined);
const expressionLocalResolveCtx = inject(ExpressionLocalResolveContextSymbol, undefined);
const isInExperimentalNdv = computed(() => expressionLocalResolveCtx?.value !== undefined);
const isInExperimentalNdv = useIsInExperimentalNdv();
const isDragging = computed(() => ndvStore.isDraggableDragging);
const isOutputPopoverVisible = computed(
@@ -236,7 +236,7 @@ defineExpose({ focus, select });
:segments="segments"
:is-read-only="isReadOnly"
:virtual-ref="container"
:append-to="isInExperimentalNdv ? '#canvas' : undefined"
:append-to="isInExperimentalNdv ? 'body' : undefined"
/>
</div>
</template>

View File

@@ -26,12 +26,16 @@ import {
import { useEnvironmentsStore } from '@/stores/environments.ee.store';
import { htmlEditorEventBus } from '@/event-bus';
import { hasFocusOnInput, isFocusableEl } from '@/utils/typesUtils';
import type { ResizeData, TargetNodeParameterContext } from '@/Interface';
import type { INodeUi, ResizeData, TargetNodeParameterContext } from '@/Interface';
import { useTelemetry } from '@/composables/useTelemetry';
import { useThrottleFn } from '@vueuse/core';
import { useStyles } from '@/composables/useStyles';
import { useExecutionData } from '@/composables/useExecutionData';
import { useWorkflowsStore } from '@/stores/workflows.store';
import ExperimentalNodeDetailsDrawer from '@/components/canvas/experimental/components/ExperimentalNodeDetailsDrawer.vue';
import { useExperimentalNdvStore } from '@/components/canvas/experimental/experimentalNdv.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useVueFlow } from '@vue-flow/core';
import ExperimentalFocusPanelHeader from '@/components/canvas/experimental/components/ExperimentalFocusPanelHeader.vue';
defineOptions({ name: 'FocusPanel' });
@@ -56,8 +60,10 @@ const nodeTypesStore = useNodeTypesStore();
const telemetry = useTelemetry();
const nodeSettingsParameters = useNodeSettingsParameters();
const environmentsStore = useEnvironmentsStore();
const experimentalNdvStore = useExperimentalNdvStore();
const ndvStore = useNDVStore();
const deviceSupport = useDeviceSupport();
const styles = useStyles();
const vueFlow = useVueFlow(workflowsStore.workflowId);
const focusedNodeParameter = computed(() => focusPanelStore.focusedNodeParameters[0]);
const resolvedParameter = computed(() =>
@@ -99,23 +105,28 @@ const isDisplayed = computed(() => {
);
});
const node = computed<INodeUi | undefined>(() => {
if (!experimentalNdvStore.isNdvInFocusPanelEnabled || resolvedParameter.value) {
return resolvedParameter.value?.node;
}
const selected = vueFlow.getSelectedNodes.value[0]?.id;
return selected ? workflowsStore.allNodes.find((n) => n.id === selected) : undefined;
});
const multipleNodesSelected = computed(() => vueFlow.getSelectedNodes.value.length > 1);
const isExecutable = computed(() => {
if (!resolvedParameter.value) return false;
if (!node.value) return false;
if (!isDisplayed.value) return false;
const foreignCredentials = nodeHelpers.getForeignCredentialsIfSharingEnabled(
resolvedParameter.value.node.credentials,
);
return nodeHelpers.isNodeExecutable(
resolvedParameter.value.node,
!props.isCanvasReadOnly,
foreignCredentials,
node.value.credentials,
);
return nodeHelpers.isNodeExecutable(node.value, !props.isCanvasReadOnly, foreignCredentials);
});
const node = computed(() => resolvedParameter.value?.node);
const { workflowRunData } = useExecutionData({ node });
const hasNodeRun = computed(() => {
@@ -275,6 +286,11 @@ function optionSelected(command: string) {
}
function closeFocusPanel() {
if (experimentalNdvStore.isNdvInFocusPanelEnabled && resolvedParameter.value) {
focusPanelStore.unsetParameters();
return;
}
telemetry.track('User closed focus panel', {
source: 'closeIcon',
parameters: focusPanelStore.focusedNodeParametersInTelemetryFormat,
@@ -354,6 +370,12 @@ function onResize(event: ResizeData) {
}
const onResizeThrottle = useThrottleFn(onResize, 10);
function onOpenNdv() {
if (node.value) {
ndvStore.setActiveNodeName(node.value.name);
}
}
</script>
<template>
@@ -362,14 +384,23 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
:width="focusPanelWidth"
:supported-directions="['left']"
:min-width="300"
:max-width="1000"
:max-width="experimentalNdvStore.isNdvInFocusPanelEnabled ? undefined : 1000"
:grid-size="8"
:style="{ width: `${focusPanelWidth}px`, zIndex: styles.APP_Z_INDEXES.FOCUS_PANEL }"
:style="{ width: `${focusPanelWidth}px` }"
@resize="onResizeThrottle"
>
<div :class="$style.container">
<ExperimentalFocusPanelHeader
v-if="experimentalNdvStore.isNdvInFocusPanelEnabled && node && !multipleNodesSelected"
:node="node"
:parameter="resolvedParameter?.parameter"
:is-executable="isExecutable"
@execute="onExecute"
@open-ndv="onOpenNdv"
@clear-parameter="closeFocusPanel"
/>
<div v-if="resolvedParameter" :class="$style.content">
<div :class="$style.tabHeader">
<div v-if="!experimentalNdvStore.isNdvInFocusPanelEnabled" :class="$style.tabHeader">
<div :class="$style.tabHeaderText">
<N8nText color="text-dark" size="small">
{{ resolvedParameter.parameter.displayName }}
@@ -513,6 +544,12 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
</div>
</div>
</div>
<ExperimentalNodeDetailsDrawer
v-else-if="node && experimentalNdvStore.isNdvInFocusPanelEnabled"
:node="node"
:nodes="vueFlow.getSelectedNodes.value"
@open-ndv="onOpenNdv"
/>
<div v-else :class="[$style.content, $style.emptyContent]">
<div :class="$style.emptyText">
<div :class="$style.focusParameterWrapper">
@@ -552,11 +589,14 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
<style lang="scss" module>
.wrapper {
display: flex;
flex-direction: row nowrap;
flex-direction: row;
flex-wrap: nowrap;
border-left: 1px solid var(--color-foreground-base);
background: var(--color-background-xlight);
overflow-y: hidden;
height: 100%;
flex-grow: 0;
flex-shrink: 0;
}
.container {

View File

@@ -25,6 +25,7 @@ import { useAssistantStore } from '@/stores/assistant.store';
type Props = {
nodeViewScale: number;
createNodeActive?: boolean;
focusPanelActive: boolean;
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@@ -135,7 +136,14 @@ function onAskAssistantButtonClick() {
:shortcut="{ keys: ['f'], shiftKey: true }"
placement="left"
>
<n8n-icon-button type="tertiary" size="large" icon="panel-right" @click="toggleFocusPanel" />
<n8n-icon-button
type="tertiary"
size="large"
icon="panel-right"
:class="focusPanelActive ? $style.activeButton : ''"
:active="focusPanelActive"
@click="toggleFocusPanel"
/>
</KeyboardShortcutTooltip>
<n8n-tooltip v-if="assistantStore.canShowAssistantButtonsOnCanvas" placement="left">
<template #content> {{ i18n.baseText('aiAssistant.tooltip') }}</template>
@@ -185,4 +193,8 @@ function onAskAssistantButtonClick() {
display: block;
}
}
.activeButton {
background-color: var(--button-hover-background-color) !important;
}
</style>

View File

@@ -152,7 +152,7 @@ describe('NodeDetailsView', () => {
test('should unregister keydown listener on unmount', async () => {
const { pinia, workflowObject, nodeName } = await createPiniaStore(false);
const ndvStore = useNDVStore();
const ndvStore = useNDVStore(pinia);
const renderComponent = createComponentRenderer(NodeDetailsView, {
props: {

View File

@@ -799,6 +799,7 @@ onBeforeUnmount(() => {
:executable="!readOnly"
:input-size="inputSize"
:class="$style.settings"
is-ndv-v2
@execute="onNodeExecute"
@stop-execution="onStopExecution"
@activate="onWorkflowActivate"

View File

@@ -15,7 +15,7 @@ import type {
IUpdateInformation,
} from '@/Interface';
import { BASE_NODE_SURVEY_URL, NDV_UI_OVERHAUL_EXPERIMENT } from '@/constants';
import { BASE_NODE_SURVEY_URL } from '@/constants';
import ParameterInputList from '@/components/ParameterInputList.vue';
import NodeCredentials from '@/components/NodeCredentials.vue';
@@ -50,7 +50,6 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { importCurlEventBus, ndvEventBus } from '@/event-bus';
import { ProjectTypes } from '@/types/projects.types';
import FreeAiCreditsCallout from '@/components/FreeAiCreditsCallout.vue';
import { usePostHog } from '@/stores/posthog.store';
import { useResizeObserver } from '@vueuse/core';
import { useNodeSettingsParameters } from '@/composables/useNodeSettingsParameters';
import { N8nBlockUi, N8nIcon, N8nNotice, N8nText } from '@n8n/design-system';
@@ -75,12 +74,20 @@ const props = withDefaults(
subTitle?: string;
extraTabsClassName?: string;
extraParameterWrapperClassName?: string;
isNdvV2?: boolean;
hideExecute?: boolean;
hideDocs?: boolean;
hideSubConnections?: boolean;
}>(),
{
inputSize: 0,
activeNode: undefined,
isEmbeddedInCanvas: false,
subTitle: undefined,
isNdvV2: false,
hideExecute: false,
hideDocs: true,
hideSubConnections: false,
},
);
@@ -108,7 +115,6 @@ const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const credentialsStore = useCredentialsStore();
const historyStore = useHistoryStore();
const posthogStore = usePostHog();
const telemetry = useTelemetry();
const nodeHelpers = useNodeHelpers();
@@ -252,13 +258,6 @@ const credentialOwnerName = computed(() => {
return credentialsStore.getCredentialOwnerName(credential);
});
const isNDVV2 = computed(() =>
posthogStore.isVariantEnabled(
NDV_UI_OVERHAUL_EXPERIMENT.name,
NDV_UI_OVERHAUL_EXPERIMENT.variant,
),
);
const featureRequestUrl = computed(() => {
if (!nodeType.value) {
return '';
@@ -611,7 +610,7 @@ function handleSelectAction(params: INodeParameters) {
<slot name="actions" />
</template>
</ExperimentalEmbeddedNdvHeader>
<div v-else-if="!isNDVV2" :class="$style.header">
<div v-else-if="!isNdvV2" :class="$style.header">
<div class="header-side-menu">
<NodeTitle
v-if="node"
@@ -648,9 +647,10 @@ function handleSelectAction(params: INodeParameters) {
:node-name="node.name"
:node-type="nodeType"
:execute-button-tooltip="executeButtonTooltip"
:hide-execute="!isExecutable || blockUI || !node || !nodeValid"
:hide-execute="props.hideExecute || !isExecutable || blockUI || !node || !nodeValid"
:disable-execute="outputPanelEditMode.enabled && !isTriggerNode"
:hide-tabs="!nodeValid"
:hide-docs="props.hideDocs"
:push-ref="pushRef"
@execute="onNodeExecute"
@stop-execution="onStopExecution"
@@ -666,7 +666,7 @@ function handleSelectAction(params: INodeParameters) {
:class="[
'node-parameters-wrapper',
shouldShowStaticScrollbar ? 'with-static-scrollbar' : '',
{ 'ndv-v2': isNDVV2 },
{ 'ndv-v2': isNdvV2 },
extraParameterWrapperClassName ?? '',
]"
data-test-id="node-parameters"
@@ -782,7 +782,7 @@ function handleSelectAction(params: INodeParameters) {
</div>
</div>
<div
v-if="isNDVV2 && featureRequestUrl && !isEmbeddedInCanvas"
v-if="isNdvV2 && featureRequestUrl && !isEmbeddedInCanvas"
:class="$style.featureRequest"
>
<a target="_blank" @click="onFeatureRequestClick">
@@ -792,7 +792,7 @@ function handleSelectAction(params: INodeParameters) {
</div>
</div>
<NDVSubConnections
v-if="node && !props.isEmbeddedInCanvas"
v-if="node && !hideSubConnections"
ref="subConnections"
:root-node="node"
@switch-selected-node="onSwitchSelectedNode"

View File

@@ -8,6 +8,7 @@ import type { NodeSettingsTab } from '@/types/nodeSettings';
type Props = {
nodeName: string;
hideExecute: boolean;
hideDocs: boolean;
hideTabs: boolean;
disableExecute: boolean;
executeButtonTooltip: string;
@@ -30,7 +31,7 @@ const emit = defineEmits<{
<div :class="$style.header">
<NodeSettingsTabs
v-if="!hideTabs"
hide-docs
:hide-docs="hideDocs"
:model-value="selectedTab"
:node-type="nodeType"
:push-ref="pushRef"
@@ -61,11 +62,14 @@ const emit = defineEmits<{
display: flex;
align-items: center;
min-height: 40px;
padding-right: var(--spacing-s);
border-bottom: var(--border-base);
}
.execute {
margin-right: var(--spacing-s);
}
.tabs {
align-self: flex-end;
}

View File

@@ -10,6 +10,7 @@ import { computed } from 'vue';
import { useNDVStore } from '@/stores/ndv.store';
import { AI_TRANSFORM_NODE_TYPE } from '@/constants';
import { getParameterTypeOption } from '@/utils/nodeSettingsUtils';
import { useIsInExperimentalNdv } from '@/components/canvas/experimental/composables/useIsInExperimentalNdv';
interface Props {
parameter: INodeProperties;
@@ -51,13 +52,14 @@ const isHtmlEditor = computed(
const shouldShowExpressionSelector = computed(
() => !props.parameter.noDataExpression && props.showExpressionSelector && !props.isReadOnly,
);
const isInEmbeddedNdv = useIsInExperimentalNdv();
const canBeOpenedInFocusPanel = computed(
() =>
!props.parameter.isNodeSetting &&
!props.isReadOnly &&
!props.isContentOverridden &&
activeNode.value && // checking that it's inside ndv
(activeNode.value || isInEmbeddedNdv.value) && // checking that it's inside ndv
(props.parameter.type === 'string' || props.parameter.type === 'json'),
);

View File

@@ -922,7 +922,7 @@ provide(CanvasKey, {
snap-to-grid
:snap-grid="[GRID_SIZE, GRID_SIZE]"
:min-zoom="0"
:max-zoom="experimentalNdvStore.isEnabled ? experimentalNdvStore.maxCanvasZoom : 4"
:max-zoom="experimentalNdvStore.isZoomedViewEnabled ? experimentalNdvStore.maxCanvasZoom : 4"
:selection-key-code="selectionKeyCode"
:zoom-activation-key-code="panningKeyCode"
:pan-activation-key-code="panningKeyCode"

View File

@@ -9,8 +9,6 @@ import { createEventBus } from '@n8n/utils/event-bus';
import type { CanvasEventBusEvents } from '@/types';
import { useVueFlow } from '@vue-flow/core';
import { throttledRef } from '@vueuse/core';
import { useSettingsStore } from '@/stores/settings.store';
import ExperimentalNodeDetailsDrawer from './experimental/components/ExperimentalNodeDetailsDrawer.vue';
defineOptions({
inheritAttrs: false,
@@ -36,9 +34,8 @@ const props = withDefaults(
);
const $style = useCssModule();
const settingsStore = useSettingsStore();
const { onNodesInitialized, getSelectedNodes } = useVueFlow(props.id);
const { onNodesInitialized } = useVueFlow(props.id);
const workflow = toRef(props, 'workflow');
const workflowObject = toRef(props, 'workflowObject');
@@ -83,10 +80,6 @@ const mappedConnectionsThrottled = throttledRef(mappedConnections, 200);
/>
</div>
<slot />
<ExperimentalNodeDetailsDrawer
v-if="settingsStore.experimental__dockedNodeSettingsEnabled && !props.readOnly"
:selected-nodes="getSelectedNodes"
/>
</div>
</template>

View File

@@ -33,7 +33,7 @@ const experimentalNdvStore = useExperimentalNdvStore();
const isExperimentalNdvActive = computed(() => experimentalNdvStore.isActive(props.zoom));
const isToggleZoomVisible = computed(() => experimentalNdvStore.isEnabled);
const isToggleZoomVisible = computed(() => experimentalNdvStore.isZoomedViewEnabled);
const isResetZoomVisible = computed(() => !isToggleZoomVisible.value && props.zoom !== 1);

View File

@@ -66,7 +66,7 @@ const isDisableNodeVisible = computed(() => {
const isDeleteNodeVisible = computed(() => !props.readOnly);
const isFocusNodeVisible = computed(() => experimentalNdvStore.isEnabled);
const isFocusNodeVisible = computed(() => experimentalNdvStore.isZoomedViewEnabled);
const isStickyNoteChangeColorVisible = computed(
() => !props.readOnly && render.value.type === CanvasNodeRenderType.StickyNote,

View File

@@ -8,10 +8,11 @@ import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { computed } from 'vue';
const { nodeId, isReadOnly, subTitle } = defineProps<{
const { nodeId, isReadOnly, subTitle, isEmbeddedInCanvas } = defineProps<{
nodeId: string;
isReadOnly?: boolean;
subTitle?: string;
isEmbeddedInCanvas?: boolean;
}>();
defineSlots<{ actions?: {} }>();
@@ -75,10 +76,14 @@ function handleCaptureWheelEvent(event: WheelEvent) {
:read-only="isReadOnly"
:block-u-i="blockUi"
:executable="!isReadOnly"
is-embedded-in-canvas
:is-embedded-in-canvas="isEmbeddedInCanvas"
:sub-title="subTitle"
extra-tabs-class-name="nodrag"
extra-parameter-wrapper-class-name="nodrag"
is-ndv-v2
hide-execute
:hide-docs="false"
hide-sub-connections
@value-changed="handleValueChanged"
@capture-wheel-body="handleCaptureWheelEvent"
@dblclick-header="handleDoubleClickHeader"

View File

@@ -41,7 +41,7 @@ defineExpose({
:popper-class="$style.component"
:width="360"
:offset="8"
append-to="#canvas"
append-to="body"
:popper-options="{
modifiers: [{ name: 'flip', enabled: false }],
}"

View File

@@ -1,10 +1,8 @@
<script setup lang="ts">
import { ExpressionLocalResolveContextSymbol } from '@/constants';
import { useEnvironmentsStore } from '@/stores/environments.ee.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { ExpressionLocalResolveContext } from '@/types/expressions';
import { N8nText } from '@n8n/design-system';
import { useVueFlow } from '@vue-flow/core';
import { watchOnce } from '@vueuse/core';
@@ -12,11 +10,11 @@ import { computed, provide, ref } from 'vue';
import { useExperimentalNdvStore } from '../experimentalNdv.store';
import ExperimentalCanvasNodeSettings from './ExperimentalCanvasNodeSettings.vue';
import { useI18n } from '@n8n/i18n';
import type { Workflow } from 'n8n-workflow';
import NodeIcon from '@/components/NodeIcon.vue';
import { getNodeSubTitleText } from '@/components/canvas/experimental/experimentalNdv.utils';
import ExperimentalEmbeddedNdvActions from '@/components/canvas/experimental/components/ExperimentalEmbeddedNdvActions.vue';
import { useCanvas } from '@/composables/useCanvas';
import { useExpressionResolveCtx } from '@/components/canvas/experimental/composables/useExpressionResolveCtx';
const { nodeId, isReadOnly } = defineProps<{
nodeId: string;
@@ -56,54 +54,10 @@ const subTitle = computed(() =>
? getNodeSubTitleText(node.value, nodeType.value, !isExpanded.value, i18n)
: undefined,
);
const expressionResolveCtx = computed<ExpressionLocalResolveContext | undefined>(() => {
if (!node.value) {
return undefined;
}
const runIndex = 0; // not changeable for now
const execution = workflowsStore.workflowExecutionData;
const nodeName = node.value.name;
function findInputNode(): ExpressionLocalResolveContext['inputNode'] {
const taskData = (execution?.data?.resultData.runData[nodeName] ?? [])[runIndex];
const source = taskData?.source[0];
if (source) {
return {
name: source.previousNode,
branchIndex: source.previousNodeOutput ?? 0,
runIndex: source.previousNodeRun ?? 0,
};
}
const inputs = workflowObject.value.getParentNodesByDepth(nodeName, 1);
if (inputs.length > 0) {
return {
name: inputs[0].name,
branchIndex: inputs[0].indicies[0] ?? 0,
runIndex: 0,
};
}
return undefined;
}
return {
localResolve: true,
envVars: useEnvironmentsStore().variablesAsObject,
workflow: workflowObject.value,
execution,
nodeName,
additionalKeys: {},
inputNode: findInputNode(),
connections: workflowsStore.connectionsBySourceNode,
};
});
const maxHeightOnFocus = computed(() => vf.dimensions.value.height * 0.8);
const workflowObject = computed(() => workflowsStore.workflowObject as Workflow);
const expressionResolveCtx = useExpressionResolveCtx(node);
function handleToggleExpand() {
experimentalNdvStore.setNodeExpanded(nodeId);
@@ -143,6 +97,7 @@ watchOnce(isVisible, (visible) => {
:is-read-only="isReadOnly"
:sub-title="subTitle"
:input-node-name="expressionResolveCtx?.inputNode?.name"
is-embedded-in-canvas
>
<template #actions>
<ExperimentalEmbeddedNdvActions

View File

@@ -0,0 +1,86 @@
<script lang="ts" setup>
import NodeExecuteButton from '@/components/NodeExecuteButton.vue';
import NodeIcon from '@/components/NodeIcon.vue';
import { type INodeUi } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { N8nIconButton, N8nText } from '@n8n/design-system';
import { type INodeProperties } from 'n8n-workflow';
import { computed } from 'vue';
const { node, parameter, isExecutable } = defineProps<{
node: INodeUi;
parameter?: INodeProperties;
isExecutable: boolean;
}>();
const nodeTypesStore = useNodeTypesStore();
const nodeType = computed(() => nodeTypesStore.getNodeType(node.type, node.typeVersion));
const emit = defineEmits<{
execute: [];
openNdv: [];
clearParameter: [];
}>();
</script>
<template>
<N8nText tag="div" size="small" bold :class="$style.component">
<NodeIcon :node-type="nodeType" :size="16" />
<div :class="$style.breadcrumbs">
<template v-if="parameter">
<N8nText size="small" color="text-base" bold>
{{ node.name }}
</N8nText>
<N8nText size="small" color="text-light">/</N8nText>
{{ parameter.displayName }}
</template>
<template v-else>{{ node.name }}</template>
</div>
<N8nIconButton
v-if="parameter"
icon="x"
size="small"
type="tertiary"
text
@click="emit('clearParameter')"
/>
<N8nIconButton
v-else
icon="maximize-2"
size="small"
type="tertiary"
text
@click="emit('openNdv')"
/>
<NodeExecuteButton
v-if="isExecutable"
data-test-id="node-execute-button"
:node-name="node.name"
:tooltip="`Execute ${node.name}`"
size="small"
icon="play"
:square="true"
:hide-label="true"
telemetry-source="focus"
@execute="emit('execute')"
/>
</N8nText>
</template>
<style lang="scss" module>
.component {
display: flex;
align-items: center;
padding: var(--spacing-2xs);
gap: var(--spacing-2xs);
border-bottom: var(--border-base);
}
.breadcrumbs {
display: flex;
align-items: center;
gap: var(--spacing-4xs);
flex-grow: 1;
flex-shrink: 1;
}
</style>

View File

@@ -1,50 +1,25 @@
<script setup lang="ts">
import { type CanvasNode } from '@/types';
import ExperimentalCanvasNodeSettings from './ExperimentalCanvasNodeSettings.vue';
import { useExpressionResolveCtx } from '@/components/canvas/experimental/composables/useExpressionResolveCtx';
import { ExpressionLocalResolveContextSymbol } from '@/constants';
import { type INodeUi } from '@/Interface';
import { N8nText } from '@n8n/design-system';
import { computed, watch, ref } from 'vue';
import { useNDVStore } from '@/stores/ndv.store';
import { type GraphNode } from '@vue-flow/core';
import { computed, provide } from 'vue';
import ExperimentalCanvasNodeSettings from './ExperimentalCanvasNodeSettings.vue';
const { selectedNodes } = defineProps<{ selectedNodes: CanvasNode[] }>();
const { node, nodes } = defineProps<{ node: INodeUi; nodes: GraphNode[] }>();
const content = computed(() =>
selectedNodes.length > 1
? `${selectedNodes.length} nodes selected`
: selectedNodes.length > 0
? selectedNodes[0]
: undefined,
);
const lastContent = ref<string | CanvasNode | undefined>();
const { setActiveNodeName } = useNDVStore();
const emit = defineEmits<{ openNdv: [] }>();
function handleOpenNdv() {
if (typeof content.value === 'object' && content.value.data) {
setActiveNodeName(content.value.data.name);
}
}
const expressionResolveCtx = useExpressionResolveCtx(computed(() => node));
// Sync lastContent to be "last defined content" (for drawer animation)
watch(
content,
(newContent) => {
if (newContent !== undefined) {
lastContent.value = newContent;
}
},
{ immediate: true },
);
provide(ExpressionLocalResolveContextSymbol, expressionResolveCtx);
</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"
:key="lastContent.id"
:node-id="lastContent.id"
>
<div :class="$style.component">
<N8nText v-if="nodes.length > 1" color="text-base"> {{ nodes.length }} nodes selected </N8nText>
<ExperimentalCanvasNodeSettings v-else-if="node" :key="node.id" :node-id="node.id">
<template #actions>
<N8nIconButton
icon="maximize-2"
@@ -53,7 +28,7 @@ watch(
size="mini"
icon-size="large"
aria-label="Expand"
@click="handleOpenNdv"
@click="emit('openNdv')"
/>
</template>
</ExperimentalCanvasNodeSettings>
@@ -62,22 +37,10 @@ watch(
<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%);
}
height: 100%;
overflow: auto;
}
</style>

View File

@@ -0,0 +1,58 @@
import type { INodeUi } from '@/Interface';
import useEnvironmentsStore from '@/stores/environments.ee.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { ExpressionLocalResolveContext } from '@/types/expressions';
import type { Workflow } from 'n8n-workflow';
import { computed, type ComputedRef } from 'vue';
export function useExpressionResolveCtx(node: ComputedRef<INodeUi | null | undefined>) {
const environmentsStore = useEnvironmentsStore();
const workflowsStore = useWorkflowsStore();
const workflowObject = computed(() => workflowsStore.workflowObject as Workflow);
return computed<ExpressionLocalResolveContext | undefined>(() => {
if (!node.value) {
return undefined;
}
const runIndex = 0; // not changeable for now
const execution = workflowsStore.workflowExecutionData;
const nodeName = node.value.name;
function findInputNode(): ExpressionLocalResolveContext['inputNode'] {
const taskData = (execution?.data?.resultData.runData[nodeName] ?? [])[runIndex];
const source = taskData?.source[0];
if (source) {
return {
name: source.previousNode,
branchIndex: source.previousNodeOutput ?? 0,
runIndex: source.previousNodeRun ?? 0,
};
}
const inputs = workflowObject.value.getParentNodesByDepth(nodeName, 1);
if (inputs.length > 0) {
return {
name: inputs[0].name,
branchIndex: inputs[0].indicies[0] ?? 0,
runIndex: 0,
};
}
return undefined;
}
return {
localResolve: true,
envVars: environmentsStore.variablesAsObject,
workflow: workflowObject.value,
execution,
nodeName,
additionalKeys: {},
inputNode: findInputNode(),
connections: workflowsStore.connectionsBySourceNode,
};
});
}

View File

@@ -0,0 +1,9 @@
import { ExpressionLocalResolveContextSymbol } from '@/constants';
import { computed, inject } from 'vue';
export function useIsInExperimentalNdv() {
const expressionLocalResolveCtx = inject(ExpressionLocalResolveContextSymbol, undefined);
// This condition is correct as long as ExpressionLocalResolveContext is used only in experimental NDV
return computed(() => expressionLocalResolveCtx?.value !== undefined);
}

View File

@@ -12,17 +12,22 @@ import {
} from '@vue-flow/core';
import { CanvasNodeRenderType, type CanvasNodeData } from '@/types';
import { usePostHog } from '@/stores/posthog.store';
import { CANVAS_ZOOMED_VIEW_EXPERIMENT } from '@/constants';
import { CANVAS_ZOOMED_VIEW_EXPERIMENT, NDV_IN_FOCUS_PANEL_EXPERIMENT } from '@/constants';
export const useExperimentalNdvStore = defineStore('experimentalNdv', () => {
const workflowStore = useWorkflowsStore();
const postHogStore = usePostHog();
const isEnabled = computed(
const isZoomedViewEnabled = computed(
() =>
postHogStore.getVariant(CANVAS_ZOOMED_VIEW_EXPERIMENT.name) ===
CANVAS_ZOOMED_VIEW_EXPERIMENT.variant,
);
const maxCanvasZoom = computed(() => (isEnabled.value ? 2 : 4));
const isNdvInFocusPanelEnabled = computed(
() =>
postHogStore.getVariant(NDV_IN_FOCUS_PANEL_EXPERIMENT.name) ===
NDV_IN_FOCUS_PANEL_EXPERIMENT.variant,
);
const maxCanvasZoom = computed(() => (isZoomedViewEnabled.value ? 2 : 4));
const previousViewport = ref<ViewportTransform>();
const collapsedNodes = shallowRef<Partial<Record<string, boolean>>>({});
@@ -50,7 +55,7 @@ export const useExperimentalNdvStore = defineStore('experimentalNdv', () => {
}
function isActive(canvasZoom: number) {
return isEnabled.value && Math.abs(canvasZoom - maxCanvasZoom.value) < 0.000001;
return isZoomedViewEnabled.value && Math.abs(canvasZoom - maxCanvasZoom.value) < 0.000001;
}
function setNodeNameToBeFocused(nodeName: string) {
@@ -139,7 +144,8 @@ export const useExperimentalNdvStore = defineStore('experimentalNdv', () => {
}
return {
isEnabled,
isZoomedViewEnabled,
isNdvInFocusPanelEnabled,
maxCanvasZoom,
previousZoom: computed(() => previousViewport.value),
collapsedNodes: computed(() => collapsedNodes.value),

View File

@@ -113,6 +113,7 @@ import { isChatNode } from '@/utils/aiUtils';
import cloneDeep from 'lodash/cloneDeep';
import uniq from 'lodash/uniq';
import { useExperimentalNdvStore } from '@/components/canvas/experimental/experimentalNdv.store';
import { useFocusPanelStore } from '@/stores/focusPanel.store';
type AddNodeData = Partial<INodeUi> & {
type: string;
@@ -158,6 +159,7 @@ export function useCanvasOperations() {
const projectsStore = useProjectsStore();
const logsStore = useLogsStore();
const experimentalNdvStore = useExperimentalNdvStore();
const focusPanelStore = useFocusPanelStore();
const i18n = useI18n();
const toast = useToast();
@@ -793,7 +795,13 @@ export function useCanvasOperations() {
void externalHooks.run('nodeView.addNodeButton', { nodeTypeName: nodeData.type });
if (options.openNDV && !preventOpeningNDV) {
if (experimentalNdvStore.isEnabled) {
if (
experimentalNdvStore.isNdvInFocusPanelEnabled &&
focusPanelStore.focusPanelActive &&
focusPanelStore.focusedNodeParameters.length === 0
) {
// Do nothing. The added node get selected and the details are shown in the focus panel
} else if (experimentalNdvStore.isZoomedViewEnabled) {
experimentalNdvStore.setNodeNameToBeFocused(nodeData.name);
} else {
ndvStore.setActiveNodeName(nodeData.name);

View File

@@ -7,7 +7,6 @@ const APP_Z_INDEXES = {
APP_SIDEBAR: 999,
CANVAS_SELECT_BOX: 100,
TOP_BANNERS: 999,
FOCUS_PANEL: 1600,
NODE_CREATOR: 1700,
NDV: 1800,
MODALS: 2000,

View File

@@ -506,8 +506,6 @@ export const LOCAL_STORAGE_LOGS_SYNC_SELECTION = 'N8N_LOGS_SYNC_SELECTION_ENABLE
export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL = 'N8N_LOGS_DETAILS_PANEL';
export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL_SUB_NODE = 'N8N_LOGS_DETAILS_PANEL_SUB_NODE';
export const LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY = 'N8N_WORKFLOWS_LIST_PREFERENCES';
export const LOCAL_STORAGE_EXPERIMENTAL_DOCKED_NODE_SETTINGS =
'N8N_EXPERIMENTAL_DOCKED_NODE_SETTINGS';
export const LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES = 'N8N_READ_WHATS_NEW_ARTICLES';
export const LOCAL_STORAGE_DISMISSED_WHATS_NEW_CALLOUT = 'N8N_DISMISSED_WHATS_NEW_CALLOUT';
export const LOCAL_STORAGE_NDV_PANEL_WIDTH = 'N8N_NDV_PANEL_WIDTH';
@@ -756,6 +754,12 @@ export const CANVAS_ZOOMED_VIEW_EXPERIMENT = {
variant: 'variant',
};
export const NDV_IN_FOCUS_PANEL_EXPERIMENT = {
name: 'ndv_in_focus_panel',
control: 'control',
variant: 'variant',
};
export const NDV_UI_OVERHAUL_EXPERIMENT = {
name: '029_ndv_ui_overhaul',
control: 'control',

View File

@@ -147,6 +147,10 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
_setOptions({ isActive: false });
}
function unsetParameters() {
_setOptions({ parameters: [] });
}
function toggleFocusPanel() {
_setOptions({ isActive: !focusPanelActive.value });
}
@@ -193,5 +197,6 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
toggleFocusPanel,
onNewWorkflowSave,
updateWidth,
unsetParameters,
};
});

View File

@@ -10,10 +10,7 @@ import * as eventsApi from '@n8n/rest-api-client/api/events';
import * as settingsApi from '@n8n/rest-api-client/api/settings';
import * as moduleSettingsApi from '@n8n/rest-api-client/api/module-settings';
import { testHealthEndpoint } from '@n8n/rest-api-client/api/templates';
import {
INSECURE_CONNECTION_WARNING,
LOCAL_STORAGE_EXPERIMENTAL_DOCKED_NODE_SETTINGS,
} from '@/constants';
import { INSECURE_CONNECTION_WARNING } from '@/constants';
import { STORES } from '@n8n/stores';
import { UserManagementAuthenticationMethod } from '@/Interface';
import type { IDataObject, WorkflowSettings } from 'n8n-workflow';
@@ -315,15 +312,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
moduleSettings.value = fetched;
};
/**
* (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 {
settings,
userManagement,
@@ -380,7 +368,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isAskAiEnabled,
isAiCreditsEnabled,
aiCreditsQuota,
experimental__dockedNodeSettingsEnabled,
partialExecutionVersion,
reset,
getTimezones,

View File

@@ -2160,6 +2160,7 @@ onBeforeUnmount(() => {
v-if="!isCanvasReadOnly"
:create-node-active="nodeCreatorStore.isCreateNodeActive"
:node-view-scale="viewportTransform.zoom"
:focus-panel-active="focusPanelStore.focusPanelActive"
@toggle-node-creator="onToggleNodeCreator"
@add-nodes="onAddNodesAndConnections"
/>