mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(editor): NDV in focus panel experiment feedback (no-changelog) (#19304)
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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 you’d 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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
134
packages/frontend/editor-ui/src/components/FocusPanel.test.ts
Normal file
134
packages/frontend/editor-ui/src/components/FocusPanel.test.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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=""
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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') }}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
24
packages/testing/playwright/pages/components/FocusPanel.ts
Normal file
24
packages/testing/playwright/pages/components/FocusPanel.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }) => {
|
||||||
|
|||||||
40
packages/testing/playwright/tests/ui/54-focus-panel.spec.ts
Normal file
40
packages/testing/playwright/tests/ui/54-focus-panel.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' }),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user