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

View File

@@ -1533,6 +1533,8 @@
"nodeView.focusPanel.noExecutionData": "Execute previous node for autocomplete",
"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.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.itLooksLikeYouHaveBeenEditingSomething": "It looks like you made some edits. If you leave before saving, your changes will be lost.",
"nodeView.loadingTemplate": "Loading template",

View File

@@ -30,6 +30,7 @@ import {
import type { IExecutionResponse, INodeUi, IWorkflowDb } from '@/Interface';
import { CanvasNodeRenderType } from '@/types';
import type { FrontendSettings } from '@n8n/api-types';
import type { ExpressionLocalResolveContext } from '@/types/expressions';
export const mockNode = ({
id = uuid(),
@@ -69,22 +70,7 @@ export const mockNodeTypeDescription = ({
description,
webhooks,
eventTriggerDescription,
}: {
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'];
} = {}) =>
}: Partial<INodeTypeDescription> = {}) =>
mock<INodeTypeDescription>({
name,
icon,
@@ -294,3 +280,21 @@ export function createTestWorkflowExecutionResponse(
...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 { useTelemetryContext } from '@/composables/useTelemetryContext';
import { type ContextMenuAction } from '@/composables/useContextMenuItems';
import { type CanvasNode, CanvasNodeRenderType } from '@/types';
defineOptions({ name: 'FocusPanel' });
@@ -112,9 +113,11 @@ const node = computed<INodeUi | undefined>(() => {
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);
@@ -209,6 +212,18 @@ const isNodeExecuting = computed(() => workflowsStore.isNodeExecuting(node.value
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({
expression,
additionalData: resolvedAdditionalExpressionData,
@@ -407,7 +422,16 @@ function onOpenNdv() {
</script>
<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
:width="focusPanelWidth"
:supported-directions="['left']"
@@ -427,7 +451,7 @@ function onOpenNdv() {
@open-ndv="onOpenNdv"
@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 :class="$style.tabHeaderText">
<N8nText color="text-dark" size="small">
@@ -576,37 +600,38 @@ function onOpenNdv() {
v-else-if="node && experimentalNdvStore.isNdvInFocusPanelEnabled"
:node="node"
:node-ids="selectedNodeIds"
:is-read-only="isReadOnly"
@open-ndv="onOpenNdv"
@context-menu-action="(action, nodeIds) => emit('contextMenuAction', action, nodeIds)"
/>
<div v-else :class="[$style.content, $style.emptyContent]">
<div :class="$style.emptyText">
<div :class="$style.focusParameterWrapper">
<div :class="$style.iconWrapper">
<N8nIcon :class="$style.forceHover" icon="panel-right" size="medium" />
<N8nIcon
:class="$style.pointerIcon"
icon="mouse-pointer"
color="text-dark"
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 :class="$style.focusParameterWrapper">
<div :class="$style.iconWrapper">
<N8nIcon :class="$style.forceHover" icon="panel-right" size="medium" />
<N8nIcon
:class="$style.pointerIcon"
icon="mouse-pointer"
color="text-dark"
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 :class="$style.emptyText">
<N8nText color="text-base" size="medium" :bold="true">
{{ locale.baseText('nodeView.focusPanel.noParameters.title') }}
{{ emptyTitle }}
</N8nText>
<N8nText color="text-base" size="small">
{{ locale.baseText('nodeView.focusPanel.noParameters.subtitle') }}
{{ emptySubtitle }}
</N8nText>
</div>
</div>
@@ -645,35 +670,39 @@ function onOpenNdv() {
justify-content: center;
align-items: center;
.isNdvInFocusPanelEnabled & {
flex-direction: column-reverse;
}
.emptyText {
margin: 0 var(--spacing-xl);
display: flex;
flex-direction: column;
gap: var(--spacing-2xs);
}
.focusParameterWrapper {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-2xs);
margin-bottom: var(--spacing-m);
.focusParameterWrapper {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-2xs);
margin-block: var(--spacing-m);
.iconWrapper {
position: relative;
display: inline-block;
}
.iconWrapper {
position: relative;
display: inline-block;
}
.pointerIcon {
position: absolute;
top: 100%;
left: 50%;
transform: translate(-20%, -30%);
pointer-events: none;
}
.pointerIcon {
position: absolute;
top: 100%;
left: 50%;
transform: translate(-20%, -30%);
pointer-events: none;
}
:global([class*='_disabled_']) {
cursor: default !important;
}
:global([class*='_disabled_']) {
cursor: default !important;
}
}
}

View File

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

View File

@@ -824,7 +824,7 @@ function handleSelectAction(params: INodeParameters) {
gap: var(--spacing-4xs);
margin-top: var(--spacing-xl);
font-size: var(--font-size-3xs);
font-size: var(--font-size-2xs);
font-weight: var(--font-weight-bold);
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 { mockedStore } from '@/__tests__/utils';
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 type { INodeParameterResourceLocator } from 'n8n-workflow';
import { NodeConnectionTypes, type INodeParameterResourceLocator } from 'n8n-workflow';
import type { IWorkflowDb, WorkflowListResource } from '@/Interface';
import { mock } from 'vitest-mock-extended';
import { ExpressionLocalResolveContextSymbol } from '@/constants';
function getNdvStateMock(): Partial<ReturnType<typeof useNDVStore>> {
return {
@@ -34,6 +40,8 @@ function getNdvStateMock(): Partial<ReturnType<typeof useNDVStore>> {
expressionOutputItemIndex: 0,
isTableHoverOnboarded: false,
setHighlightDraggables: vi.fn(),
setNDVPanelDataIsEmpty: vi.fn(),
setNDVBranchIndex: vi.fn(),
};
}
@@ -111,6 +119,8 @@ describe('ParameterInput.vue', () => {
expressionOutputItemIndex: 0,
isTableHoverOnboarded: false,
setHighlightDraggables: vi.fn(),
setNDVPanelDataIsEmpty: vi.fn(),
setNDVBranchIndex: vi.fn(),
};
mockNodeTypesState = {
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 { captureMessage } from '@sentry/vue';
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 CssEditor from './CssEditor/CssEditor.vue';
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 isMapperAvailable = computed(
() =>
!props.parameter.isNodeSetting &&
(isModelValueExpression.value ||
props.forceShowExpression ||
(isEmpty(props.modelValue) && props.parameter.type !== 'dateTime')),
);
function isRemoteParameterOption(option: INodePropertyOptions) {
return remoteParameterOptionsKeys.value.includes(option.name);
}
@@ -811,10 +825,7 @@ async function setFocus() {
}
isFocused.value = true;
if (isModelValueExpression.value || props.forceShowExpression || props.modelValue === '') {
isMapperShown.value = true;
}
isMapperShown.value = isMapperAvailable.value;
}
emit('focus');
@@ -1260,7 +1271,7 @@ onClickOutside(mapperElRef, onClickOutsideMapper);
/>
<ExperimentalEmbeddedNdvMapper
v-if="node && expressionLocalResolveCtx?.inputNode"
v-if="isMapperAvailable && node && expressionLocalResolveCtx?.inputNode"
ref="mapperRef"
:workflow="expressionLocalResolveCtx?.workflow"
:node="node"

View File

@@ -28,6 +28,8 @@ import {
updateFromAIOverrideValues,
} from '../utils/fromAIOverrideUtils';
import { useTelemetry } from '@/composables/useTelemetry';
import { inject } from 'vue';
import { ExpressionLocalResolveContextSymbol } from '@/constants';
type Props = {
parameter: INodeProperties;
@@ -72,7 +74,16 @@ const wrapperHovered = ref(false);
const ndvStore = useNDVStore();
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 canBeContentOverride = computed(() => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -123,6 +123,7 @@ const props = withDefaults(
executing?: boolean;
keyBindings?: boolean;
loading?: boolean;
suppressInteraction?: boolean;
}>(),
{
id: 'canvas',
@@ -134,6 +135,7 @@ const props = withDefaults(
executing: false,
keyBindings: true,
loading: false,
suppressInteraction: false,
},
);
@@ -483,6 +485,7 @@ function onFocusNode(id: string) {
const node = vueFlow.nodeLookup.value.get(id);
if (node) {
addSelectedNodes([node]);
experimentalNdvStore.focusNode(node, {
canvasViewport: viewport.value,
canvasDimensions: dimensions.value,
@@ -660,11 +663,6 @@ async function onResetZoom() {
await onZoomTo(defaultZoom);
}
function setReadonly(value: boolean) {
setInteractive(!value);
elementsSelectable.value = true;
}
function onPaneMove({ event }: { event: unknown }) {
// The event object is either D3ZoomEvent or WheelEvent.
// Here I'm ignoring D3ZoomEvent because it's not necessarily followed by a moveEnd event.
@@ -875,9 +873,16 @@ onNodesInitialized(() => {
initialized.value = true;
});
watch(() => props.readOnly, setReadonly, {
immediate: true,
});
watch(
[() => props.readOnly, () => props.suppressInteraction],
([readOnly, suppressInteraction]) => {
setInteractive(!readOnly && !suppressInteraction);
elementsSelectable.value = !suppressInteraction;
},
{
immediate: true,
},
);
watch([nodesSelectionActive, userSelectionRect], ([isActive, rect]) =>
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>;
readOnly?: boolean;
executing?: boolean;
suppressInteraction?: boolean;
}>(),
{
id: 'canvas',
eventBus: () => createEventBus<CanvasEventBusEvents>(),
fallbackNodes: () => [],
showFallbackNodes: true,
suppressInteraction: false,
},
);
@@ -84,6 +86,7 @@ defineExpose({
:event-bus="eventBus"
:read-only="readOnly"
:executing="executing"
:suppress-interaction="suppressInteraction"
v-bind="$attrs"
/>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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