feat(editor): NDV in focus panel experiment feedback (no-changelog) (#19304)

This commit is contained in:
Suguru Inoue
2025-09-15 12:30:17 +02:00
committed by GitHub
parent 3576443a01
commit c15e9437ee
32 changed files with 543 additions and 127 deletions

View File

@@ -86,6 +86,7 @@ import IconLucideEarth from '~icons/lucide/earth';
import IconLucideEllipsis from '~icons/lucide/ellipsis'; import IconLucideEllipsis from '~icons/lucide/ellipsis';
import IconLucideEllipsisVertical from '~icons/lucide/ellipsis-vertical'; import IconLucideEllipsisVertical from '~icons/lucide/ellipsis-vertical';
import IconLucideEqual from '~icons/lucide/equal'; import IconLucideEqual from '~icons/lucide/equal';
import IconLucideExpand from '~icons/lucide/expand';
import IconLucideExternalLink from '~icons/lucide/external-link'; import IconLucideExternalLink from '~icons/lucide/external-link';
import IconLucideEye from '~icons/lucide/eye'; import IconLucideEye from '~icons/lucide/eye';
import IconLucideEyeOff from '~icons/lucide/eye-off'; import IconLucideEyeOff from '~icons/lucide/eye-off';
@@ -501,6 +502,7 @@ export const updatedIconSet = {
ellipsis: IconLucideEllipsis, ellipsis: IconLucideEllipsis,
'ellipsis-vertical': IconLucideEllipsisVertical, 'ellipsis-vertical': IconLucideEllipsisVertical,
equal: IconLucideEqual, equal: IconLucideEqual,
expand: IconLucideExpand,
'external-link': IconLucideExternalLink, 'external-link': IconLucideExternalLink,
eye: IconLucideEye, eye: IconLucideEye,
'eye-off': IconLucideEyeOff, 'eye-off': IconLucideEyeOff,

View File

@@ -1533,6 +1533,8 @@
"nodeView.focusPanel.noExecutionData": "Execute previous node for autocomplete", "nodeView.focusPanel.noExecutionData": "Execute previous node for autocomplete",
"nodeView.focusPanel.noParameters.title": "Show a node parameter here, to iterate easily", "nodeView.focusPanel.noParameters.title": "Show a node parameter here, to iterate easily",
"nodeView.focusPanel.noParameters.subtitle": "For example, keep your prompt always visible so you can run the workflow while tweaking it", "nodeView.focusPanel.noParameters.subtitle": "For example, keep your prompt always visible so you can run the workflow while tweaking it",
"nodeView.focusPanel.v2.noParameters.title": "Select a node to edit it here",
"nodeView.focusPanel.v2.noParameters.subtitle": "Or show single node parameter youd like to iterate on by clicking this button next to it:",
"nodeView.focusPanel.missingParameter": "This parameter is no longer visible on the node. A related parameter was likely changed, removing this one.", "nodeView.focusPanel.missingParameter": "This parameter is no longer visible on the node. A related parameter was likely changed, removing this one.",
"nodeView.itLooksLikeYouHaveBeenEditingSomething": "It looks like you made some edits. If you leave before saving, your changes will be lost.", "nodeView.itLooksLikeYouHaveBeenEditingSomething": "It looks like you made some edits. If you leave before saving, your changes will be lost.",
"nodeView.loadingTemplate": "Loading template", "nodeView.loadingTemplate": "Loading template",

View File

@@ -30,6 +30,7 @@ import {
import type { IExecutionResponse, INodeUi, IWorkflowDb } from '@/Interface'; import type { IExecutionResponse, INodeUi, IWorkflowDb } from '@/Interface';
import { CanvasNodeRenderType } from '@/types'; import { CanvasNodeRenderType } from '@/types';
import type { FrontendSettings } from '@n8n/api-types'; import type { FrontendSettings } from '@n8n/api-types';
import type { ExpressionLocalResolveContext } from '@/types/expressions';
export const mockNode = ({ export const mockNode = ({
id = uuid(), id = uuid(),
@@ -69,22 +70,7 @@ export const mockNodeTypeDescription = ({
description, description,
webhooks, webhooks,
eventTriggerDescription, eventTriggerDescription,
}: { }: Partial<INodeTypeDescription> = {}) =>
name?: INodeTypeDescription['name'];
displayName?: INodeTypeDescription['displayName'];
icon?: INodeTypeDescription['icon'];
version?: INodeTypeDescription['version'];
credentials?: INodeTypeDescription['credentials'];
inputs?: INodeTypeDescription['inputs'];
outputs?: INodeTypeDescription['outputs'];
codex?: INodeTypeDescription['codex'];
properties?: INodeTypeDescription['properties'];
group?: INodeTypeDescription['group'];
hidden?: INodeTypeDescription['hidden'];
description?: INodeTypeDescription['description'];
webhooks?: INodeTypeDescription['webhooks'];
eventTriggerDescription?: INodeTypeDescription['eventTriggerDescription'];
} = {}) =>
mock<INodeTypeDescription>({ mock<INodeTypeDescription>({
name, name,
icon, icon,
@@ -294,3 +280,21 @@ export function createTestWorkflowExecutionResponse(
...data, ...data,
}; };
} }
export function createTestExpressionLocalResolveContext(
data: Partial<ExpressionLocalResolveContext> = {},
): ExpressionLocalResolveContext {
const workflow = data.workflow ?? createTestWorkflowObject();
return {
localResolve: true,
workflow,
nodeName: 'n0',
inputNode: { name: 'n1', runIndex: 0, branchIndex: 0 },
envVars: {},
additionalKeys: {},
connections: workflow.connectionsBySourceNode,
execution: null,
...data,
};
}

View File

@@ -0,0 +1,134 @@
import { createCanvasGraphNode } from '@/__tests__/data';
import { createTestNode, createTestWorkflow, mockNodeTypeDescription } from '@/__tests__/mocks';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { SET_NODE_TYPE } from '@/constants';
import { useFocusPanelStore } from '@/stores/focusPanel.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createTestingPinia } from '@pinia/testing';
import { useVueFlow } from '@vue-flow/core';
import type { INodeProperties } from 'n8n-workflow';
import { setActivePinia } from 'pinia';
import { reactive } from 'vue';
import { useExperimentalNdvStore } from './canvas/experimental/experimentalNdv.store';
import FocusPanel from './FocusPanel.vue';
vi.mock('vue-router', () => ({
useRouter: () => ({}),
useRoute: () => reactive({}),
RouterLink: vi.fn(),
}));
describe('FocusPanel', () => {
const renderComponent = createComponentRenderer(FocusPanel, {
props: {
isCanvasReadOnly: false,
},
});
const parameter0: INodeProperties = {
displayName: 'P0',
name: 'p0',
type: 'string',
default: '',
description: '',
validateType: 'string',
};
const parameter1: INodeProperties = {
displayName: 'P1',
name: 'p1',
type: 'string',
default: '',
description: '',
validateType: 'string',
};
let experimentalNdvStore: ReturnType<typeof mockedStore<typeof useExperimentalNdvStore>>;
let focusPanelStore: ReturnType<typeof useFocusPanelStore>;
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
beforeEach(() => {
const pinia = setActivePinia(createTestingPinia({ stubActions: false }));
localStorage.clear();
nodeTypesStore = useNodeTypesStore(pinia);
nodeTypesStore.setNodeTypes([
mockNodeTypeDescription({
name: SET_NODE_TYPE,
properties: [parameter0, parameter1],
}),
]);
workflowsStore = useWorkflowsStore(pinia);
workflowsStore.setWorkflow(createTestWorkflow({ id: 'w0' }));
workflowsStore.setNodes([
createTestNode({ id: 'n0', name: 'N0', parameters: { p0: 'v0' }, type: SET_NODE_TYPE }),
]);
focusPanelStore = useFocusPanelStore(pinia);
focusPanelStore.toggleFocusPanel();
});
describe('when experimental NDV is enabled', () => {
beforeEach(() => {
experimentalNdvStore = mockedStore(useExperimentalNdvStore);
experimentalNdvStore.isNdvInFocusPanelEnabled = true;
});
it('should render empty state when neither a node nor a parameter is selected', async () => {
const rendered = renderComponent({});
expect(await rendered.findByText('Select a node to edit it here'));
});
it('should render the parameter focus input when a parameter is selected', async () => {
const rendered = renderComponent({});
focusPanelStore.openWithFocusedNodeParameter({
nodeId: 'n0',
parameter: parameter0,
parameterPath: 'parameters.p0',
});
expect(await rendered.findByTestId('focus-parameter')).toBeInTheDocument();
expect(rendered.getByText('N0')).toBeInTheDocument(); // title in header
expect(rendered.getByText('P0')).toBeInTheDocument(); // title in header
expect(rendered.getByDisplayValue('v0')).toBeInTheDocument(); // current value of the parameter
});
it('should render node parameters when a node is selected on canvas', async () => {
const graphNode = createCanvasGraphNode({ id: 'n0' });
const vueFlow = useVueFlow('w0');
const rendered = renderComponent({});
vueFlow.addNodes([graphNode]);
vueFlow.addSelectedNodes([graphNode]);
expect(await rendered.findByTestId('node-parameters')).toBeInTheDocument();
expect(rendered.getByText('N0')).toBeInTheDocument(); // title in header
expect(rendered.getByText('P0')).toBeInTheDocument(); // parameter 0
expect(rendered.getByText('P1')).toBeInTheDocument(); // parameter 1
});
it('should render the parameters when a node is selected on canvas and a parameter is selected', async () => {
const graphNode = createCanvasGraphNode({ id: 'n0' });
const vueFlow = useVueFlow('w0');
const rendered = renderComponent({});
vueFlow.addNodes([graphNode]);
vueFlow.addSelectedNodes([graphNode]);
focusPanelStore.openWithFocusedNodeParameter({
nodeId: 'n0',
parameter: parameter0,
parameterPath: 'parameters.p0',
});
expect(await rendered.findByTestId('focus-parameter')).toBeInTheDocument();
expect(rendered.getByText('N0')).toBeInTheDocument(); // title in header
expect(rendered.getByText('P0')).toBeInTheDocument(); // title in header
expect(rendered.getByDisplayValue('v0')).toBeInTheDocument(); // current value of the parameter
});
});
});

View File

@@ -38,6 +38,7 @@ import { useVueFlow } from '@vue-flow/core';
import ExperimentalFocusPanelHeader from '@/components/canvas/experimental/components/ExperimentalFocusPanelHeader.vue'; import ExperimentalFocusPanelHeader from '@/components/canvas/experimental/components/ExperimentalFocusPanelHeader.vue';
import { useTelemetryContext } from '@/composables/useTelemetryContext'; import { useTelemetryContext } from '@/composables/useTelemetryContext';
import { type ContextMenuAction } from '@/composables/useContextMenuItems'; import { type ContextMenuAction } from '@/composables/useContextMenuItems';
import { type CanvasNode, CanvasNodeRenderType } from '@/types';
defineOptions({ name: 'FocusPanel' }); defineOptions({ name: 'FocusPanel' });
@@ -112,9 +113,11 @@ const node = computed<INodeUi | undefined>(() => {
return resolvedParameter.value?.node; return resolvedParameter.value?.node;
} }
const selected = vueFlow.getSelectedNodes.value[0]?.id; const selected: CanvasNode | undefined = vueFlow.getSelectedNodes.value[0];
return selected ? workflowsStore.allNodes.find((n) => n.id === selected) : undefined; return selected?.data?.render.type === CanvasNodeRenderType.Default
? workflowsStore.allNodes.find((n) => n.id === selected.id)
: undefined;
}); });
const multipleNodesSelected = computed(() => vueFlow.getSelectedNodes.value.length > 1); const multipleNodesSelected = computed(() => vueFlow.getSelectedNodes.value.length > 1);
@@ -209,6 +212,18 @@ const isNodeExecuting = computed(() => workflowsStore.isNodeExecuting(node.value
const selectedNodeIds = computed(() => vueFlow.getSelectedNodes.value.map((n) => n.id)); const selectedNodeIds = computed(() => vueFlow.getSelectedNodes.value.map((n) => n.id));
const emptyTitle = computed(() =>
experimentalNdvStore.isNdvInFocusPanelEnabled
? locale.baseText('nodeView.focusPanel.v2.noParameters.title')
: locale.baseText('nodeView.focusPanel.noParameters.title'),
);
const emptySubtitle = computed(() =>
experimentalNdvStore.isNdvInFocusPanelEnabled
? locale.baseText('nodeView.focusPanel.v2.noParameters.subtitle')
: locale.baseText('nodeView.focusPanel.noParameters.subtitle'),
);
const { resolvedExpression } = useResolvedExpression({ const { resolvedExpression } = useResolvedExpression({
expression, expression,
additionalData: resolvedAdditionalExpressionData, additionalData: resolvedAdditionalExpressionData,
@@ -407,7 +422,16 @@ function onOpenNdv() {
</script> </script>
<template> <template>
<div v-if="focusPanelActive" ref="wrapper" :class="$style.wrapper" @keydown.stop> <div
v-if="focusPanelActive"
ref="wrapper"
data-test-id="focus-panel"
:class="[
$style.wrapper,
{ [$style.isNdvInFocusPanelEnabled]: experimentalNdvStore.isNdvInFocusPanelEnabled },
]"
@keydown.stop
>
<N8nResizeWrapper <N8nResizeWrapper
:width="focusPanelWidth" :width="focusPanelWidth"
:supported-directions="['left']" :supported-directions="['left']"
@@ -427,7 +451,7 @@ function onOpenNdv() {
@open-ndv="onOpenNdv" @open-ndv="onOpenNdv"
@clear-parameter="closeFocusPanel" @clear-parameter="closeFocusPanel"
/> />
<div v-if="resolvedParameter" :class="$style.content"> <div v-if="resolvedParameter" :class="$style.content" data-test-id="focus-parameter">
<div v-if="!experimentalNdvStore.isNdvInFocusPanelEnabled" :class="$style.tabHeader"> <div v-if="!experimentalNdvStore.isNdvInFocusPanelEnabled" :class="$style.tabHeader">
<div :class="$style.tabHeaderText"> <div :class="$style.tabHeaderText">
<N8nText color="text-dark" size="small"> <N8nText color="text-dark" size="small">
@@ -576,37 +600,38 @@ function onOpenNdv() {
v-else-if="node && experimentalNdvStore.isNdvInFocusPanelEnabled" v-else-if="node && experimentalNdvStore.isNdvInFocusPanelEnabled"
:node="node" :node="node"
:node-ids="selectedNodeIds" :node-ids="selectedNodeIds"
:is-read-only="isReadOnly"
@open-ndv="onOpenNdv" @open-ndv="onOpenNdv"
@context-menu-action="(action, nodeIds) => emit('contextMenuAction', action, nodeIds)" @context-menu-action="(action, nodeIds) => emit('contextMenuAction', action, nodeIds)"
/> />
<div v-else :class="[$style.content, $style.emptyContent]"> <div v-else :class="[$style.content, $style.emptyContent]">
<div :class="$style.emptyText"> <div :class="$style.focusParameterWrapper">
<div :class="$style.focusParameterWrapper"> <div :class="$style.iconWrapper">
<div :class="$style.iconWrapper"> <N8nIcon :class="$style.forceHover" icon="panel-right" size="medium" />
<N8nIcon :class="$style.forceHover" icon="panel-right" size="medium" /> <N8nIcon
<N8nIcon :class="$style.pointerIcon"
:class="$style.pointerIcon" icon="mouse-pointer"
icon="mouse-pointer" color="text-dark"
color="text-dark" size="large"
size="large"
/>
</div>
<N8nIcon icon="ellipsis-vertical" size="small" color="text-base" />
<N8nRadioButtons
size="small"
:model-value="'expression'"
:disabled="true"
:options="[
{ label: locale.baseText('parameterInput.fixed'), value: 'fixed' },
{ label: locale.baseText('parameterInput.expression'), value: 'expression' },
]"
/> />
</div> </div>
<N8nIcon icon="ellipsis-vertical" size="small" color="text-base" />
<N8nRadioButtons
size="small"
:model-value="'expression'"
:disabled="true"
:options="[
{ label: locale.baseText('parameterInput.fixed'), value: 'fixed' },
{ label: locale.baseText('parameterInput.expression'), value: 'expression' },
]"
/>
</div>
<div :class="$style.emptyText">
<N8nText color="text-base" size="medium" :bold="true"> <N8nText color="text-base" size="medium" :bold="true">
{{ locale.baseText('nodeView.focusPanel.noParameters.title') }} {{ emptyTitle }}
</N8nText> </N8nText>
<N8nText color="text-base" size="small"> <N8nText color="text-base" size="small">
{{ locale.baseText('nodeView.focusPanel.noParameters.subtitle') }} {{ emptySubtitle }}
</N8nText> </N8nText>
</div> </div>
</div> </div>
@@ -645,35 +670,39 @@ function onOpenNdv() {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
.isNdvInFocusPanelEnabled & {
flex-direction: column-reverse;
}
.emptyText { .emptyText {
margin: 0 var(--spacing-xl); margin: 0 var(--spacing-xl);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-2xs); gap: var(--spacing-2xs);
}
.focusParameterWrapper { .focusParameterWrapper {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--spacing-2xs); gap: var(--spacing-2xs);
margin-bottom: var(--spacing-m); margin-block: var(--spacing-m);
.iconWrapper { .iconWrapper {
position: relative; position: relative;
display: inline-block; display: inline-block;
} }
.pointerIcon { .pointerIcon {
position: absolute; position: absolute;
top: 100%; top: 100%;
left: 50%; left: 50%;
transform: translate(-20%, -30%); transform: translate(-20%, -30%);
pointer-events: none; pointer-events: none;
} }
:global([class*='_disabled_']) { :global([class*='_disabled_']) {
cursor: default !important; cursor: default !important;
}
} }
} }
} }

View File

@@ -103,6 +103,9 @@ defineExpose({
background-color: transparent !important; background-color: transparent !important;
padding: 0 !important; padding: 0 !important;
border: none !important; border: none !important;
/* Override break-all set for el-popper */
word-break: normal;
} }
.dropdown { .dropdown {

View File

@@ -824,7 +824,7 @@ function handleSelectAction(params: INodeParameters) {
gap: var(--spacing-4xs); gap: var(--spacing-4xs);
margin-top: var(--spacing-xl); margin-top: var(--spacing-xl);
font-size: var(--font-size-3xs); font-size: var(--font-size-2xs);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
color: var(--color-text-light); color: var(--color-text-light);
} }

View File

@@ -10,11 +10,17 @@ import type { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import { createEventBus } from '@n8n/utils/event-bus'; import { createEventBus } from '@n8n/utils/event-bus';
import { createMockEnterpriseSettings } from '@/__tests__/mocks'; import {
createTestExpressionLocalResolveContext,
createMockEnterpriseSettings,
createTestNode,
createTestWorkflowObject,
} from '@/__tests__/mocks';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import type { INodeParameterResourceLocator } from 'n8n-workflow'; import { NodeConnectionTypes, type INodeParameterResourceLocator } from 'n8n-workflow';
import type { IWorkflowDb, WorkflowListResource } from '@/Interface'; import type { IWorkflowDb, WorkflowListResource } from '@/Interface';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import { ExpressionLocalResolveContextSymbol } from '@/constants';
function getNdvStateMock(): Partial<ReturnType<typeof useNDVStore>> { function getNdvStateMock(): Partial<ReturnType<typeof useNDVStore>> {
return { return {
@@ -34,6 +40,8 @@ function getNdvStateMock(): Partial<ReturnType<typeof useNDVStore>> {
expressionOutputItemIndex: 0, expressionOutputItemIndex: 0,
isTableHoverOnboarded: false, isTableHoverOnboarded: false,
setHighlightDraggables: vi.fn(), setHighlightDraggables: vi.fn(),
setNDVPanelDataIsEmpty: vi.fn(),
setNDVBranchIndex: vi.fn(),
}; };
} }
@@ -111,6 +119,8 @@ describe('ParameterInput.vue', () => {
expressionOutputItemIndex: 0, expressionOutputItemIndex: 0,
isTableHoverOnboarded: false, isTableHoverOnboarded: false,
setHighlightDraggables: vi.fn(), setHighlightDraggables: vi.fn(),
setNDVPanelDataIsEmpty: vi.fn(),
setNDVBranchIndex: vi.fn(),
}; };
mockNodeTypesState = { mockNodeTypesState = {
allNodeTypes: [], allNodeTypes: [],
@@ -649,4 +659,59 @@ describe('ParameterInput.vue', () => {
}); });
}); });
}); });
describe('data mapper', () => {
const workflow = createTestWorkflowObject({
nodes: [createTestNode({ name: 'n0' }), createTestNode({ name: 'n1' })],
connections: {
n1: {
[NodeConnectionTypes.Main]: [[{ node: 'n0', index: 0, type: NodeConnectionTypes.Main }]],
},
},
});
const ctx = createTestExpressionLocalResolveContext({
workflow,
nodeName: 'n0',
inputNode: { name: 'n1', runIndex: 0, branchIndex: 0 },
});
it('should render mapper', async () => {
const rendered = renderComponent({
global: { provide: { [ExpressionLocalResolveContextSymbol]: ctx } },
props: {
path: 'name',
parameter: { displayName: 'Name', name: 'name', type: 'string' },
modelValue: '',
},
});
expect(rendered.queryByTestId('ndv-input-panel')).toBeInTheDocument();
});
it('should not render mapper if given node property is a node setting', async () => {
const rendered = renderComponent({
global: { provide: { [ExpressionLocalResolveContextSymbol]: ctx } },
props: {
path: 'name',
parameter: { displayName: 'Name', name: 'name', type: 'string', isNodeSetting: true },
modelValue: '',
},
});
expect(rendered.queryByTestId('ndv-input-panel')).not.toBeInTheDocument();
});
it('should not render mapper if given node property has datetime type', async () => {
const rendered = renderComponent({
global: { provide: { [ExpressionLocalResolveContextSymbol]: ctx } },
props: {
path: 'name',
parameter: { displayName: 'Name', name: 'name', type: 'dateTime' },
modelValue: '',
},
});
expect(rendered.queryByTestId('ndv-input-panel')).not.toBeInTheDocument();
});
});
}); });

View File

@@ -81,7 +81,13 @@ import { createEventBus } from '@n8n/utils/event-bus';
import { onClickOutside, useElementSize } from '@vueuse/core'; import { onClickOutside, useElementSize } from '@vueuse/core';
import { captureMessage } from '@sentry/vue'; import { captureMessage } from '@sentry/vue';
import { isCredentialOnlyNodeType } from '@/utils/credentialOnlyNodes'; import { isCredentialOnlyNodeType } from '@/utils/credentialOnlyNodes';
import { hasFocusOnInput, isBlurrableEl, isFocusableEl, isSelectableEl } from '@/utils/typesUtils'; import {
hasFocusOnInput,
isBlurrableEl,
isEmpty,
isFocusableEl,
isSelectableEl,
} from '@/utils/typesUtils';
import { completeExpressionSyntax, shouldConvertToExpression } from '@/utils/expressions'; import { completeExpressionSyntax, shouldConvertToExpression } from '@/utils/expressions';
import CssEditor from './CssEditor/CssEditor.vue'; import CssEditor from './CssEditor/CssEditor.vue';
import { useFocusPanelStore } from '@/stores/focusPanel.store'; import { useFocusPanelStore } from '@/stores/focusPanel.store';
@@ -625,6 +631,14 @@ const shouldCaptureForPosthog = computed(() => node.value?.type === AI_TRANSFORM
const mapperElRef = computed(() => mapperRef.value?.contentRef); const mapperElRef = computed(() => mapperRef.value?.contentRef);
const isMapperAvailable = computed(
() =>
!props.parameter.isNodeSetting &&
(isModelValueExpression.value ||
props.forceShowExpression ||
(isEmpty(props.modelValue) && props.parameter.type !== 'dateTime')),
);
function isRemoteParameterOption(option: INodePropertyOptions) { function isRemoteParameterOption(option: INodePropertyOptions) {
return remoteParameterOptionsKeys.value.includes(option.name); return remoteParameterOptionsKeys.value.includes(option.name);
} }
@@ -811,10 +825,7 @@ async function setFocus() {
} }
isFocused.value = true; isFocused.value = true;
isMapperShown.value = isMapperAvailable.value;
if (isModelValueExpression.value || props.forceShowExpression || props.modelValue === '') {
isMapperShown.value = true;
}
} }
emit('focus'); emit('focus');
@@ -1260,7 +1271,7 @@ onClickOutside(mapperElRef, onClickOutsideMapper);
/> />
<ExperimentalEmbeddedNdvMapper <ExperimentalEmbeddedNdvMapper
v-if="node && expressionLocalResolveCtx?.inputNode" v-if="isMapperAvailable && node && expressionLocalResolveCtx?.inputNode"
ref="mapperRef" ref="mapperRef"
:workflow="expressionLocalResolveCtx?.workflow" :workflow="expressionLocalResolveCtx?.workflow"
:node="node" :node="node"

View File

@@ -28,6 +28,8 @@ import {
updateFromAIOverrideValues, updateFromAIOverrideValues,
} from '../utils/fromAIOverrideUtils'; } from '../utils/fromAIOverrideUtils';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { inject } from 'vue';
import { ExpressionLocalResolveContextSymbol } from '@/constants';
type Props = { type Props = {
parameter: INodeProperties; parameter: INodeProperties;
@@ -72,7 +74,16 @@ const wrapperHovered = ref(false);
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const activeNode = computed(() => ndvStore.activeNode); const expressionLocalResolveCtx = inject(ExpressionLocalResolveContextSymbol, undefined);
const activeNode = computed(() => {
const ctx = expressionLocalResolveCtx?.value;
if (ctx) {
return ctx.workflow.getNode(ctx.nodeName);
}
return ndvStore.activeNode;
});
const fromAIOverride = ref<FromAIOverride | null>(makeOverrideValue(props, activeNode.value)); const fromAIOverride = ref<FromAIOverride | null>(makeOverrideValue(props, activeNode.value));
const canBeContentOverride = computed(() => { const canBeContentOverride = computed(() => {

View File

@@ -142,6 +142,7 @@ type Props = {
disableEdit?: boolean; disableEdit?: boolean;
disablePin?: boolean; disablePin?: boolean;
compact?: boolean; compact?: boolean;
showActionsOnHover?: boolean;
tableHeaderBgColor?: 'base' | 'light'; tableHeaderBgColor?: 'base' | 'light';
disableHoverHighlight?: boolean; disableHoverHighlight?: boolean;
disableSettingsHint?: boolean; disableSettingsHint?: boolean;
@@ -168,6 +169,7 @@ const props = withDefaults(defineProps<Props>(), {
disableHoverHighlight: false, disableHoverHighlight: false,
disableSettingsHint: false, disableSettingsHint: false,
compact: false, compact: false,
showActionsOnHover: false,
tableHeaderBgColor: 'base', tableHeaderBgColor: 'base',
workflowExecution: undefined, workflowExecution: undefined,
disableAiContent: false, disableAiContent: false,
@@ -177,6 +179,7 @@ defineSlots<{
content: {}; content: {};
'callout-message': {}; 'callout-message': {};
header: {}; header: {};
'header-end': (props: InstanceType<typeof RunDataItemCount>['$props']) => unknown;
'input-select': {}; 'input-select': {};
'before-data': {}; 'before-data': {};
'run-info': {}; 'run-info': {};
@@ -1373,7 +1376,11 @@ defineExpose({ enterEditMode });
:class="[ :class="[
'run-data', 'run-data',
$style.container, $style.container,
{ [$style['ndv-v2']]: isNDVV2, [$style.compact]: compact }, {
[$style['ndv-v2']]: isNDVV2,
[$style.compact]: compact,
[$style.showActionsOnHover]: showActionsOnHover,
},
]" ]"
@mouseover="activatePane" @mouseover="activatePane"
> >
@@ -1506,7 +1513,7 @@ defineExpose({ enterEditMode });
</div> </div>
</div> </div>
<RunDataItemCount v-if="props.compact" v-bind="itemsCountProps" /> <slot name="header-end" v-bind="itemsCountProps" />
</div> </div>
<div v-show="!binaryDataDisplayVisible"> <div v-show="!binaryDataDisplayVisible">
@@ -2128,6 +2135,9 @@ defineExpose({ enterEditMode });
.compact & { .compact & {
/* let title text alone decide the height */ /* let title text alone decide the height */
height: 0; height: 0;
}
.showActionsOnHover & {
visibility: hidden; visibility: hidden;
:global(.el-input__prefix) { :global(.el-input__prefix) {
@@ -2135,7 +2145,7 @@ defineExpose({ enterEditMode });
} }
} }
.compact:hover & { .showActionsOnHover:hover & {
visibility: visible; visibility: visible;
} }
} }

View File

@@ -26,7 +26,7 @@ const emit = defineEmits<{
<div class="schema-header-wrapper"> <div class="schema-header-wrapper">
<div class="schema-header" data-test-id="run-data-schema-header"> <div class="schema-header" data-test-id="run-data-schema-header">
<div class="toggle" @click.capture.stop="emit('click:toggle')"> <div class="toggle" @click.capture.stop="emit('click:toggle')">
<N8nIcon icon="chevron-down" :class="{ 'collapse-icon': true, collapsed }" /> <N8nIcon size="medium" icon="chevron-down" :class="{ 'collapse-icon': true, collapsed }" />
</div> </div>
<NodeIcon <NodeIcon
@@ -75,11 +75,13 @@ const emit = defineEmits<{
cursor: pointer; cursor: pointer;
} }
.toggle { .toggle {
width: 30px; padding-left: var(--spacing-5xs);
padding-right: var(--spacing-3xs);
height: 30px; height: 30px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
color: var(--color-text-light);
} }
.collapse-icon { .collapse-icon {
transition: transform 0.2s cubic-bezier(0.19, 1, 0.22, 1); transition: transform 0.2s cubic-bezier(0.19, 1, 0.22, 1);

View File

@@ -158,7 +158,8 @@ exports[`InputPanel > should render 1`] = `
</button> </button>
</div> </div>
</div> </div>
<!--v-if-->
</div> </div>
<div <div
data-v-2e5cd75c="" data-v-2e5cd75c=""

View File

@@ -41,6 +41,7 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1
class="collapse-icon" class="collapse-icon"
data-v-882a318e="" data-v-882a318e=""
icon="chevron-down" icon="chevron-down"
size="medium"
spin="false" spin="false"
/> />
</div> </div>
@@ -285,6 +286,7 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1
class="collapse-icon collapsed" class="collapse-icon collapsed"
data-v-882a318e="" data-v-882a318e=""
icon="chevron-down" icon="chevron-down"
size="medium"
spin="false" spin="false"
/> />
</div> </div>
@@ -327,6 +329,7 @@ exports[`VirtualSchema.vue > renders previous nodes schema for AI tools 1`] = `
class="collapse-icon" class="collapse-icon"
data-v-882a318e="" data-v-882a318e=""
icon="chevron-down" icon="chevron-down"
size="medium"
spin="false" spin="false"
/> />
</div> </div>
@@ -411,6 +414,7 @@ exports[`VirtualSchema.vue > renders schema for empty objects and arrays 1`] = `
class="collapse-icon" class="collapse-icon"
data-v-882a318e="" data-v-882a318e=""
icon="chevron-down" icon="chevron-down"
size="medium"
spin="false" spin="false"
/> />
</div> </div>
@@ -892,6 +896,7 @@ exports[`VirtualSchema.vue > renders schema for empty objects and arrays 1`] = `
class="collapse-icon collapsed" class="collapse-icon collapsed"
data-v-882a318e="" data-v-882a318e=""
icon="chevron-down" icon="chevron-down"
size="medium"
spin="false" spin="false"
/> />
</div> </div>
@@ -1300,6 +1305,7 @@ exports[`VirtualSchema.vue > renders schema with spaces and dots 1`] = `
class="collapse-icon" class="collapse-icon"
data-v-882a318e="" data-v-882a318e=""
icon="chevron-down" icon="chevron-down"
size="medium"
spin="false" spin="false"
/> />
</div> </div>
@@ -1703,6 +1709,7 @@ exports[`VirtualSchema.vue > renders schema with spaces and dots 1`] = `
class="collapse-icon collapsed" class="collapse-icon collapsed"
data-v-882a318e="" data-v-882a318e=""
icon="chevron-down" icon="chevron-down"
size="medium"
spin="false" spin="false"
/> />
</div> </div>
@@ -1764,6 +1771,7 @@ exports[`VirtualSchema.vue > renders schema with spaces and dots 1`] = `
class="collapse-icon collapsed" class="collapse-icon collapsed"
data-v-882a318e="" data-v-882a318e=""
icon="chevron-down" icon="chevron-down"
size="medium"
spin="false" spin="false"
/> />
</div> </div>
@@ -1833,6 +1841,7 @@ exports[`VirtualSchema.vue > renders variables and context section 1`] = `
class="collapse-icon collapsed" class="collapse-icon collapsed"
data-v-882a318e="" data-v-882a318e=""
icon="chevron-down" icon="chevron-down"
size="medium"
spin="false" spin="false"
/> />
</div> </div>
@@ -1900,6 +1909,7 @@ exports[`VirtualSchema.vue > renders variables and context section 1`] = `
class="collapse-icon" class="collapse-icon"
data-v-882a318e="" data-v-882a318e=""
icon="chevron-down" icon="chevron-down"
size="medium"
spin="false" spin="false"
/> />
</div> </div>
@@ -2655,6 +2665,7 @@ exports[`VirtualSchema.vue > should expand all nodes when searching 1`] = `
class="collapse-icon" class="collapse-icon"
data-v-882a318e="" data-v-882a318e=""
icon="chevron-down" icon="chevron-down"
size="medium"
spin="false" spin="false"
/> />
</div> </div>
@@ -2793,6 +2804,7 @@ exports[`VirtualSchema.vue > should expand all nodes when searching 1`] = `
class="collapse-icon" class="collapse-icon"
data-v-882a318e="" data-v-882a318e=""
icon="chevron-down" icon="chevron-down"
size="medium"
spin="false" spin="false"
/> />
</div> </div>

View File

@@ -123,6 +123,7 @@ const props = withDefaults(
executing?: boolean; executing?: boolean;
keyBindings?: boolean; keyBindings?: boolean;
loading?: boolean; loading?: boolean;
suppressInteraction?: boolean;
}>(), }>(),
{ {
id: 'canvas', id: 'canvas',
@@ -134,6 +135,7 @@ const props = withDefaults(
executing: false, executing: false,
keyBindings: true, keyBindings: true,
loading: false, loading: false,
suppressInteraction: false,
}, },
); );
@@ -483,6 +485,7 @@ function onFocusNode(id: string) {
const node = vueFlow.nodeLookup.value.get(id); const node = vueFlow.nodeLookup.value.get(id);
if (node) { if (node) {
addSelectedNodes([node]);
experimentalNdvStore.focusNode(node, { experimentalNdvStore.focusNode(node, {
canvasViewport: viewport.value, canvasViewport: viewport.value,
canvasDimensions: dimensions.value, canvasDimensions: dimensions.value,
@@ -660,11 +663,6 @@ async function onResetZoom() {
await onZoomTo(defaultZoom); await onZoomTo(defaultZoom);
} }
function setReadonly(value: boolean) {
setInteractive(!value);
elementsSelectable.value = true;
}
function onPaneMove({ event }: { event: unknown }) { function onPaneMove({ event }: { event: unknown }) {
// The event object is either D3ZoomEvent or WheelEvent. // The event object is either D3ZoomEvent or WheelEvent.
// Here I'm ignoring D3ZoomEvent because it's not necessarily followed by a moveEnd event. // Here I'm ignoring D3ZoomEvent because it's not necessarily followed by a moveEnd event.
@@ -875,9 +873,16 @@ onNodesInitialized(() => {
initialized.value = true; initialized.value = true;
}); });
watch(() => props.readOnly, setReadonly, { watch(
immediate: true, [() => props.readOnly, () => props.suppressInteraction],
}); ([readOnly, suppressInteraction]) => {
setInteractive(!readOnly && !suppressInteraction);
elementsSelectable.value = !suppressInteraction;
},
{
immediate: true,
},
);
watch([nodesSelectionActive, userSelectionRect], ([isActive, rect]) => watch([nodesSelectionActive, userSelectionRect], ([isActive, rect]) =>
emit('update:has-range-selection', isActive || (rect?.width ?? 0) > 0 || (rect?.height ?? 0) > 0), emit('update:has-range-selection', isActive || (rect?.width ?? 0) > 0 || (rect?.height ?? 0) > 0),

View File

@@ -25,12 +25,14 @@ const props = withDefaults(
eventBus?: EventBus<CanvasEventBusEvents>; eventBus?: EventBus<CanvasEventBusEvents>;
readOnly?: boolean; readOnly?: boolean;
executing?: boolean; executing?: boolean;
suppressInteraction?: boolean;
}>(), }>(),
{ {
id: 'canvas', id: 'canvas',
eventBus: () => createEventBus<CanvasEventBusEvents>(), eventBus: () => createEventBus<CanvasEventBusEvents>(),
fallbackNodes: () => [], fallbackNodes: () => [],
showFallbackNodes: true, showFallbackNodes: true,
suppressInteraction: false,
}, },
); );
@@ -84,6 +86,7 @@ defineExpose({
:event-bus="eventBus" :event-bus="eventBus"
:read-only="readOnly" :read-only="readOnly"
:executing="executing" :executing="executing"
:suppress-interaction="suppressInteraction"
v-bind="$attrs" v-bind="$attrs"
/> />
</div> </div>

View File

@@ -115,6 +115,8 @@ function onFocusNode() {
:class="classes" :class="classes"
@mouseenter="onMouseEnter" @mouseenter="onMouseEnter"
@mouseleave="onMouseLeave" @mouseleave="onMouseLeave"
@mousedown.stop
@click.stop
> >
<div :class="[$style.canvasNodeToolbarItems, itemsClass]"> <div :class="[$style.canvasNodeToolbarItems, itemsClass]">
<N8nTooltip <N8nTooltip
@@ -131,7 +133,7 @@ function onFocusNode() {
icon="node-play" icon="node-play"
:disabled="isExecuting || isDisabled" :disabled="isExecuting || isDisabled"
:title="i18n.baseText('node.testStep')" :title="i18n.baseText('node.testStep')"
@click="executeNode" @click.stop="executeNode"
/> />
</N8nTooltip> </N8nTooltip>
<N8nIconButton <N8nIconButton
@@ -142,7 +144,7 @@ function onFocusNode() {
size="small" size="small"
icon="node-power" icon="node-power"
:title="nodeDisabledTitle" :title="nodeDisabledTitle"
@click="onToggleNode" @click.stop="onToggleNode"
/> />
<N8nIconButton <N8nIconButton
v-if="isDeleteNodeVisible" v-if="isDeleteNodeVisible"
@@ -152,7 +154,7 @@ function onFocusNode() {
text text
icon="node-trash" icon="node-trash"
:title="i18n.baseText('node.delete')" :title="i18n.baseText('node.delete')"
@click="onDeleteNode" @click.stop="onDeleteNode"
/> />
<N8nIconButton <N8nIconButton
v-if="isFocusNodeVisible" v-if="isFocusNodeVisible"
@@ -160,7 +162,7 @@ function onFocusNode() {
size="small" size="small"
text text
icon="crosshair" icon="crosshair"
@click="onFocusNode" @click.stop="onFocusNode"
/> />
<CanvasNodeStickyColorSelector <CanvasNodeStickyColorSelector
v-if="isStickyNoteChangeColorVisible" v-if="isStickyNoteChangeColorVisible"
@@ -173,7 +175,7 @@ function onFocusNode() {
size="small" size="small"
text text
icon="node-ellipsis" icon="node-ellipsis"
@click="onOpenContextMenu" @click.stop="onOpenContextMenu"
/> />
</div> </div>
<CanvasNodeStatusIcons <CanvasNodeStatusIcons
@@ -190,6 +192,7 @@ function onFocusNode() {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
width: 100%; width: 100%;
cursor: default;
&.isExperimentalNdvActive { &.isExperimentalNdvActive {
justify-content: space-between; justify-content: space-between;

View File

@@ -71,8 +71,9 @@ onBeforeUnmount(() => {
:class="$style.option" :class="$style.option"
data-test-id="change-sticky-color" data-test-id="change-sticky-color"
:title="i18n.baseText('node.changeColor')" :title="i18n.baseText('node.changeColor')"
@click.stop
> >
<N8nIcon icon="palette" /> <N8nIcon size="small" icon="palette" />
</div> </div>
</template> </template>
<div :class="$style.content"> <div :class="$style.content">

View File

@@ -9,7 +9,7 @@ const emit = defineEmits<{ openNdv: []; toggleExpand: [] }>();
<template> <template>
<div :class="$style.actions"> <div :class="$style.actions">
<N8nIconButton <N8nIconButton
icon="maximize-2" icon="expand"
type="secondary" type="secondary"
text text
size="mini" size="mini"

View File

@@ -3,11 +3,13 @@ import InputPanel from '@/components/InputPanel.vue';
import { CanvasKey } from '@/constants'; import { CanvasKey } from '@/constants';
import type { INodeUi } from '@/Interface'; import type { INodeUi } from '@/Interface';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { N8nPopover } from '@n8n/design-system'; import { ElPopover } from 'element-plus';
import { useVueFlow } from '@vue-flow/core'; import { useVueFlow } from '@vue-flow/core';
import { watchOnce } from '@vueuse/core'; import { useCanvasStore } from '@/stores/canvas.store';
import { onBeforeUnmount, watch } from 'vue';
import type { Workflow } from 'n8n-workflow'; import type { Workflow } from 'n8n-workflow';
import { computed, inject, ref, useTemplateRef } from 'vue'; import { computed, inject, ref, useTemplateRef } from 'vue';
import { useElementBounding, useElementSize } from '@vueuse/core';
const { node, inputNodeName, visible, virtualRef } = defineProps<{ const { node, inputNodeName, visible, virtualRef } = defineProps<{
workflow: Workflow; workflow: Workflow;
@@ -23,27 +25,52 @@ const vf = useVueFlow();
const canvas = inject(CanvasKey, undefined); const canvas = inject(CanvasKey, undefined);
const isVisible = computed(() => visible && !canvas?.isPaneMoving.value); const isVisible = computed(() => visible && !canvas?.isPaneMoving.value);
const isOnceVisible = ref(isVisible.value); const isOnceVisible = ref(isVisible.value);
const canvasStore = useCanvasStore();
const contentElRef = computed(() => contentRef.value?.$el ?? null);
const contentSize = useElementSize(contentElRef);
const refBounding = useElementBounding(virtualRef);
watchOnce(isVisible, (value) => { watch(
isOnceVisible.value = isOnceVisible.value || value; isVisible,
(value) => {
isOnceVisible.value = isOnceVisible.value || value;
canvasStore.setSuppressInteraction(value);
},
{ immediate: true },
);
onBeforeUnmount(() => {
canvasStore.setSuppressInteraction(false);
}); });
defineExpose({ defineExpose({
contentRef: computed<HTMLElement>(() => contentRef.value?.$el ?? null), contentRef: contentElRef,
}); });
</script> </script>
<template> <template>
<N8nPopover <ElPopover
:visible="isVisible" :visible="isVisible"
placement="left" placement="left-start"
:show-arrow="false" :show-arrow="false"
:popper-class="`${$style.component} ignore-key-press-canvas`" :popper-class="`${$style.component} ignore-key-press-canvas`"
:width="360" :width="360"
:offset="8" :offset="8"
append-to="body" append-to="body"
:popper-options="{ :popper-options="{
modifiers: [{ name: 'flip', enabled: false }], modifiers: [
{ name: 'flip', enabled: false },
{
// Ensures that the popover is re-positioned when the reference element is resized
name: 'custom modifier',
options: {
refX: refBounding.x.value,
refY: refBounding.y.value,
width: contentSize.width.value,
height: contentSize?.height.value,
},
},
],
}" }"
:persistent="isOnceVisible /* works like lazy initialization */" :persistent="isOnceVisible /* works like lazy initialization */"
virtual-triggering virtual-triggering
@@ -68,16 +95,19 @@ defineExpose({
:focused-mappable-input="ndvStore.focusedMappableInput" :focused-mappable-input="ndvStore.focusedMappableInput"
node-not-run-message-variant="simple" node-not-run-message-variant="simple"
/> />
</N8nPopover> </ElPopover>
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.component { .component {
background-color: transparent !important; background-color: transparent !important;
padding: var(--spacing-s) 0 !important; padding: 0 !important;
border: none !important; border: none !important;
box-shadow: none !important; box-shadow: none !important;
margin-top: -2px; margin-top: -2px;
/* Override break-all set for el-popper */
word-break: normal;
} }
.inputPanel { .inputPanel {

View File

@@ -24,7 +24,7 @@ const emit = defineEmits<{
</script> </script>
<template> <template>
<N8nText tag="div" size="small" bold :class="$style.component"> <N8nText tag="header" size="small" bold :class="$style.component">
<NodeIcon :node-type="nodeType" :size="16" /> <NodeIcon :node-type="nodeType" :size="16" />
<div :class="$style.breadcrumbs"> <div :class="$style.breadcrumbs">
<template v-if="parameter"> <template v-if="parameter">
@@ -46,7 +46,7 @@ const emit = defineEmits<{
/> />
<N8nIconButton <N8nIconButton
v-else v-else
icon="maximize-2" icon="expand"
size="small" size="small"
type="tertiary" type="tertiary"
text text
@@ -57,6 +57,7 @@ const emit = defineEmits<{
data-test-id="node-execute-button" data-test-id="node-execute-button"
:node-name="node.name" :node-name="node.name"
:tooltip="`Execute ${node.name}`" :tooltip="`Execute ${node.name}`"
type="secondary"
size="small" size="small"
icon="play" icon="play"
:square="true" :square="true"

View File

@@ -8,7 +8,12 @@ import { computed, provide, ref, watch } from 'vue';
import ExperimentalCanvasNodeSettings from './ExperimentalCanvasNodeSettings.vue'; import ExperimentalCanvasNodeSettings from './ExperimentalCanvasNodeSettings.vue';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
const { node, nodeIds } = defineProps<{ node: INodeUi; nodeIds: string[] }>(); const { node, nodeIds, isReadOnly } = defineProps<{
node: INodeUi;
nodeIds: string[];
isReadOnly?: boolean;
}>();
const emit = defineEmits<{ const emit = defineEmits<{
openNdv: []; openNdv: [];
@@ -54,19 +59,12 @@ provide(ExpressionLocalResolveContextSymbol, expressionResolveCtx);
</li> </li>
</ul> </ul>
</N8nText> </N8nText>
<ExperimentalCanvasNodeSettings v-else-if="node" :key="nodeSettingsViewKey" :node-id="node.id"> <ExperimentalCanvasNodeSettings
<template #actions> v-else-if="node"
<N8nIconButton :key="nodeSettingsViewKey"
icon="maximize-2" :node-id="node.id"
type="secondary" :is-read-only="isReadOnly"
text />
size="mini"
icon-size="large"
aria-label="Expand"
@click="emit('openNdv')"
/>
</template>
</ExperimentalCanvasNodeSettings>
</div> </div>
</template> </template>

View File

@@ -8,6 +8,7 @@ import { useWorkflowsEEStore } from '@/stores/workflows.ee.store';
import { useTagsStore } from '@/stores/tags.store'; import { useTagsStore } from '@/stores/tags.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { import {
createTestExpressionLocalResolveContext,
createTestNode, createTestNode,
createTestTaskData, createTestTaskData,
createTestWorkflow, createTestWorkflow,
@@ -1010,10 +1011,7 @@ describe(resolveParameter, () => {
f0: '={{ $json }}', f0: '={{ $json }}',
f1: '={{ $("n0").item.json }}', f1: '={{ $("n0").item.json }}',
}, },
{ createTestExpressionLocalResolveContext({
localResolve: true,
envVars: {},
additionalKeys: {},
workflow: createTestWorkflowObject(workflowData), workflow: createTestWorkflowObject(workflowData),
execution: createTestWorkflowExecutionResponse({ execution: createTestWorkflowExecutionResponse({
workflowData, workflowData,
@@ -1031,7 +1029,7 @@ describe(resolveParameter, () => {
}), }),
nodeName: 'n1', nodeName: 'n1',
inputNode: { name: 'n0', branchIndex: 0, runIndex: 0 }, inputNode: { name: 'n0', branchIndex: 0, runIndex: 0 },
}, }),
); );
expect(result).toEqual({ expect(result).toEqual({

View File

@@ -10,6 +10,7 @@ import { computed, inject, ref } from 'vue';
import { I18nT } from 'vue-i18n'; import { I18nT } from 'vue-i18n';
import { PopOutWindowKey } from '@/constants'; import { PopOutWindowKey } from '@/constants';
import { isSubNodeLog } from '../logs.utils'; import { isSubNodeLog } from '../logs.utils';
import RunDataItemCount from '@/components/RunDataItemCount.vue';
const { title, logEntry, paneType, collapsingTableColumnName } = defineProps<{ const { title, logEntry, paneType, collapsingTableColumnName } = defineProps<{
title: string; title: string;
@@ -84,6 +85,7 @@ function handleChangeDisplayMode(value: IRunDataDisplayMode) {
:pane-type="paneType" :pane-type="paneType"
:disable-run-index-selection="true" :disable-run-index-selection="true"
:compact="true" :compact="true"
:show-actions-on-hover="true"
:disable-pin="true" :disable-pin="true"
:disable-edit="true" :disable-edit="true"
:disable-hover-highlight="true" :disable-hover-highlight="true"
@@ -102,6 +104,10 @@ function handleChangeDisplayMode(value: IRunDataDisplayMode) {
</N8nText> </N8nText>
</template> </template>
<template #header-end="itemCountProps">
<RunDataItemCount v-bind="itemCountProps" />
</template>
<template #no-output-data> <template #no-output-data>
<N8nText :bold="true" color="text-dark" size="large"> <N8nText :bold="true" color="text-dark" size="large">
{{ locale.baseText('ndv.output.noOutputData.title') }} {{ locale.baseText('ndv.output.noOutputData.title') }}

View File

@@ -9,6 +9,8 @@ export const useCanvasStore = defineStore('canvas', () => {
const loadingService = useLoadingService(); const loadingService = useLoadingService();
const newNodeInsertPosition = ref<XYPosition | null>(null); const newNodeInsertPosition = ref<XYPosition | null>(null);
const suppressInteraction = ref(false);
const nodes = computed<INodeUi[]>(() => workflowStore.allNodes); const nodes = computed<INodeUi[]>(() => workflowStore.allNodes);
const aiNodes = computed<INodeUi[]>(() => const aiNodes = computed<INodeUi[]>(() =>
nodes.value.filter( nodes.value.filter(
@@ -23,14 +25,20 @@ export const useCanvasStore = defineStore('canvas', () => {
hasRangeSelection.value = value; hasRangeSelection.value = value;
} }
function setSuppressInteraction(value: boolean) {
suppressInteraction.value = value;
}
return { return {
newNodeInsertPosition, newNodeInsertPosition,
isLoading: loadingService.isLoading, isLoading: loadingService.isLoading,
aiNodes, aiNodes,
hasRangeSelection: computed(() => hasRangeSelection.value), hasRangeSelection: computed(() => hasRangeSelection.value),
suppressInteraction: computed(() => suppressInteraction.value),
startLoading: loadingService.startLoading, startLoading: loadingService.startLoading,
setLoadingText: loadingService.setLoadingText, setLoadingText: loadingService.setLoadingText,
stopLoading: loadingService.stopLoading, stopLoading: loadingService.stopLoading,
setHasRangeSelection, setHasRangeSelection,
setSuppressInteraction,
}; };
}); });

View File

@@ -35,10 +35,15 @@
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
&:has(+ .cm-completionInfo-left) { &:has(+ .cm-completionInfo-left) {
/* Info box should not cast shadow on the list */
position: relative;
z-index: 1;
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-top-right-radius: var(--border-radius-base); border-top-right-radius: var(--border-radius-base);
border-bottom-right-radius: var(--border-radius-base); border-bottom-right-radius: var(--border-radius-base);
background-color: var(--color-background-xlight);
} }
&:has(+ .cm-completionInfo) { &:has(+ .cm-completionInfo) {
@@ -335,6 +340,8 @@
border-bottom-left-radius: var(--border-radius-base); border-bottom-left-radius: var(--border-radius-base);
border-left: var(--border-base); border-left: var(--border-base);
border-right: none; border-right: none;
background-color: var(--color-infobox-background);
z-index: 0;
} }
&.cm-completionInfo-right { &.cm-completionInfo-right {

View File

@@ -2069,6 +2069,7 @@ onBeforeUnmount(() => {
:read-only="isCanvasReadOnly" :read-only="isCanvasReadOnly"
:executing="isWorkflowRunning" :executing="isWorkflowRunning"
:key-bindings="keyBindingsEnabled" :key-bindings="keyBindingsEnabled"
:suppress-interaction="canvasStore.suppressInteraction"
@update:nodes:position="onUpdateNodesPosition" @update:nodes:position="onUpdateNodesPosition"
@update:node:position="onUpdateNodePosition" @update:node:position="onUpdateNodePosition"
@update:node:activated="onSetNodeActivated" @update:node:activated="onSetNodeActivated"

View File

@@ -5,12 +5,14 @@ import { BasePage } from './BasePage';
import { ROUTES } from '../config/constants'; import { ROUTES } from '../config/constants';
import { resolveFromRoot } from '../utils/path-helper'; import { resolveFromRoot } from '../utils/path-helper';
import { CredentialModal } from './components/CredentialModal'; import { CredentialModal } from './components/CredentialModal';
import { FocusPanel } from './components/FocusPanel';
import { LogsPanel } from './components/LogsPanel'; import { LogsPanel } from './components/LogsPanel';
import { StickyComponent } from './components/StickyComponent'; import { StickyComponent } from './components/StickyComponent';
export class CanvasPage extends BasePage { export class CanvasPage extends BasePage {
readonly sticky = new StickyComponent(this.page); readonly sticky = new StickyComponent(this.page);
readonly logsPanel = new LogsPanel(this.page.getByTestId('logs-panel')); readonly logsPanel = new LogsPanel(this.page.getByTestId('logs-panel'));
readonly focusPanel = new FocusPanel(this.page.getByTestId('focus-panel'));
readonly credentialModal = new CredentialModal(this.page.getByTestId('editCredential-modal')); readonly credentialModal = new CredentialModal(this.page.getByTestId('editCredential-modal'));
saveWorkflowButton(): Locator { saveWorkflowButton(): Locator {

View File

@@ -0,0 +1,24 @@
import type { Locator } from '@playwright/test';
export class FocusPanel {
constructor(private root: Locator) {}
/**
* Accessors
*/
getHeader(): Locator {
return this.root.locator('header');
}
getParameterInputField(path: string): Locator {
return this.root.locator(
`[data-test-id="parameter-input-field"][title="Parameter: \\"${path}\\""]`,
);
}
getMapper(): Locator {
// find from the entire page because the mapper is rendered as portal
return this.root.page().getByRole('tooltip').getByTestId('ndv-input-panel');
}
}

View File

@@ -109,26 +109,31 @@ test.describe('Canvas Actions', () => {
test.describe('Node hover actions', () => { test.describe('Node hover actions', () => {
test('should execute node', async ({ n8n }) => { test('should execute node', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.deselectAll();
await n8n.canvas.executeNode(MANUAL_TRIGGER_NODE_DISPLAY_NAME); await n8n.canvas.executeNode(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
await expect( await expect(
n8n.notifications.getNotificationByTitle('Node executed successfully'), n8n.notifications.getNotificationByTitle('Node executed successfully'),
).toHaveCount(1); ).toHaveCount(1);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1); await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
await expect(n8n.canvas.selectedNodes()).toHaveCount(0);
}); });
test('should disable and enable node', async ({ n8n }) => { test('should disable and enable node', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true }); await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.deselectAll();
const disableButton = n8n.canvas.nodeDisableButton(CODE_NODE_DISPLAY_NAME); const disableButton = n8n.canvas.nodeDisableButton(CODE_NODE_DISPLAY_NAME);
await disableButton.click(); await disableButton.click();
await expect(n8n.canvas.disabledNodes()).toHaveCount(1); await expect(n8n.canvas.disabledNodes()).toHaveCount(1);
await expect(n8n.canvas.selectedNodes()).toHaveCount(0);
await disableButton.click(); await disableButton.click();
await expect(n8n.canvas.disabledNodes()).toHaveCount(0); await expect(n8n.canvas.disabledNodes()).toHaveCount(0);
await expect(n8n.canvas.selectedNodes()).toHaveCount(0);
}); });
test('should delete node', async ({ n8n }) => { test('should delete node', async ({ n8n }) => {

View File

@@ -0,0 +1,40 @@
import { test, expect } from '../../fixtures/base';
import type { TestRequirements } from '../../Types';
test.describe('Focus panel', () => {
test.describe('With experimental NDV in focus panel enabled', () => {
const requirements: TestRequirements = {
storage: {
N8N_EXPERIMENT_OVERRIDES: JSON.stringify({ ndv_in_focus_panel: 'variant' }),
},
};
test('should keep showing selected node when canvas is clicked while mapper popover is shown', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(requirements);
await n8n.start.fromImportedWorkflow('Test_workflow_3.json');
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.deselectAll();
await n8n.canvas.toggleFocusPanelButton().click();
await n8n.canvas.nodeByName('Set').click();
await expect(n8n.canvas.focusPanel.getHeader()).toHaveText('Set');
await n8n.canvas.focusPanel.getParameterInputField('assignments.assignments.0.value').focus();
await expect(n8n.canvas.focusPanel.getMapper()).toBeVisible();
// Assert that mapper is closed but the Set node is still selected and shown in
await n8n.canvas.canvasBody().click({ position: { x: 0, y: 0 } });
await expect(n8n.canvas.focusPanel.getMapper()).not.toBeVisible();
await expect(n8n.canvas.focusPanel.getHeader()).toHaveText('Set');
await expect(n8n.canvas.selectedNodes()).toHaveCount(1);
// Assert that another click on canvas does de-select the Set node
await n8n.canvas.canvasBody().click({ position: { x: 0, y: 0 } });
await expect(n8n.canvas.focusPanel.getHeader()).not.toBeVisible();
await expect(n8n.canvas.selectedNodes()).toHaveCount(0);
});
});
});

View File

@@ -2,9 +2,7 @@ import { test, expect } from '../../fixtures/base';
import type { TestRequirements } from '../../Types'; import type { TestRequirements } from '../../Types';
const requirements: TestRequirements = { const requirements: TestRequirements = {
workflow: { workflow: 'Test_workflow_1.json',
'Test_workflow_1.json': 'Test',
},
storage: { storage: {
N8N_EXPERIMENT_OVERRIDES: JSON.stringify({ ndv_in_focus_panel: 'variant' }), N8N_EXPERIMENT_OVERRIDES: JSON.stringify({ ndv_in_focus_panel: 'variant' }),
}, },