fix(editor): NDV in focus panel review batch 2.1 (no-changelog) (#19665)

This commit is contained in:
Suguru Inoue 2025-09-26 12:39:12 +02:00 committed by GitHub
parent 5a3adf5c97
commit 63dbd65e34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 370 additions and 114 deletions

View File

@ -9,13 +9,16 @@ import type {
INode,
INodeProperties,
} from 'n8n-workflow';
import { computed, reactive, watch } from 'vue';
import { computed, inject, reactive, useTemplateRef, watch } from 'vue';
import DropArea from '../DropArea/DropArea.vue';
import ParameterOptions from '../ParameterOptions.vue';
import Assignment from './Assignment.vue';
import { inputDataToAssignments, typeFromExpression } from './utils';
import { propertyNameFromExpression } from '@/utils/mappingUtils';
import Draggable from 'vuedraggable';
import ExperimentalEmbeddedNdvMapper from '@/components/canvas/experimental/components/ExperimentalEmbeddedNdvMapper.vue';
import { ExpressionLocalResolveContextSymbol } from '@/constants';
import { useExperimentalNdvStore } from '@/components/canvas/experimental/experimentalNdv.store';
interface Props {
parameter: INodeProperties;
@ -38,6 +41,8 @@ const emit = defineEmits<{
}>();
const i18n = useI18n();
const expressionLocalResolveCtx = inject(ExpressionLocalResolveContextSymbol, undefined);
const dropAreaContainer = useTemplateRef('dropArea');
const state = reactive<{ paramValue: AssignmentCollectionValue }>({
paramValue: {
@ -50,6 +55,7 @@ const state = reactive<{ paramValue: AssignmentCollectionValue }>({
});
const ndvStore = useNDVStore();
const experimentalNdvStore = useExperimentalNdvStore();
const { callDebounced } = useDebounce();
const issues = computed(() => {
@ -147,6 +153,21 @@ function optionSelected(action: string) {
/>
</template>
</n8n-input-label>
<ExperimentalEmbeddedNdvMapper
v-if="
experimentalNdvStore.isNdvInFocusPanelEnabled &&
dropAreaContainer?.$el &&
node &&
expressionLocalResolveCtx?.inputNode
"
:workflow="expressionLocalResolveCtx.workflow"
:node="node"
:input-node-name="expressionLocalResolveCtx.inputNode.name"
:reference="dropAreaContainer?.$el"
visible-on-hover
/>
<div :class="$style.content">
<div :class="$style.assignments">
<Draggable
@ -178,7 +199,7 @@ function optionSelected(action: string) {
data-test-id="assignment-collection-drop-area"
@click="addAssignment"
>
<DropArea :sticky-offset="empty ? [-4, 32] : [92, 0]" @drop="dropAssignment">
<DropArea ref="dropArea" :sticky-offset="empty ? [-4, 32] : [92, 0]" @drop="dropAssignment">
<template #default="{ active, droppable }">
<div :class="{ [$style.active]: active, [$style.droppable]: droppable }">
<div v-if="droppable" :class="$style.dropArea">

View File

@ -18,6 +18,7 @@ import type { IDataObject } from 'n8n-workflow';
import { createEventBus, type EventBus } from '@n8n/utils/event-bus';
import { CanvasKey } from '@/constants';
import { useIsInExperimentalNdv } from '@/components/canvas/experimental/composables/useIsInExperimentalNdv';
import { isEventTargetContainedBy } from '@/utils/htmlUtils';
const isFocused = ref(false);
const segments = ref<Segment[]>([]);
@ -89,7 +90,7 @@ function onBlur(event?: FocusEvent | KeyboardEvent) {
return; // prevent blur on resizing
}
if (event?.target instanceof Element && outputPopover.value?.contentRef?.contains(event.target)) {
if (isEventTargetContainedBy(event?.target, outputPopover.value?.contentRef)) {
return;
}

View File

@ -106,7 +106,7 @@ describe('FocusPanel', () => {
vueFlow.addSelectedNodes([graphNode]);
expect(await rendered.findByTestId('node-parameters')).toBeInTheDocument();
expect(rendered.getByText('N0')).toBeInTheDocument(); // title in header
expect(rendered.getAllByText('N0')).not.toHaveLength(0); // title in header
expect(rendered.getByText('P0')).toBeInTheDocument(); // parameter 0
expect(rendered.getByText('P1')).toBeInTheDocument(); // parameter 1
});

View File

@ -21,6 +21,7 @@ import {
type CodeNodeEditorLanguage,
type EditorType,
HTML_NODE_TYPE,
type INodeProperties,
isResourceLocatorValue,
} from 'n8n-workflow';
import { useEnvironmentsStore } from '@/stores/environments.ee.store';
@ -39,6 +40,7 @@ import ExperimentalFocusPanelHeader from '@/components/canvas/experimental/compo
import { useTelemetryContext } from '@/composables/useTelemetryContext';
import { type ContextMenuAction } from '@/composables/useContextMenuItems';
import { type CanvasNode, CanvasNodeRenderType } from '@/types';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
defineOptions({ name: 'FocusPanel' });
@ -70,6 +72,7 @@ const ndvStore = useNDVStore();
const deviceSupport = useDeviceSupport();
const vueFlow = useVueFlow(workflowsStore.workflowId);
const activeElement = useActiveElement();
const { renameNode } = useCanvasOperations();
useTelemetryContext({ view_shown: 'focus_panel' });
@ -144,9 +147,9 @@ const hasNodeRun = computed(() => {
);
});
function getTypeOption<T>(optionName: string): T | undefined {
function getTypeOption<T extends keyof NonNullable<INodeProperties['typeOptions']>>(optionName: T) {
return resolvedParameter.value
? getParameterTypeOption<T>(resolvedParameter.value.parameter, optionName)
? getParameterTypeOption(resolvedParameter.value.parameter, optionName)
: undefined;
}
@ -165,7 +168,7 @@ const editorLanguage = computed<CodeNodeEditorLanguage>(() => {
return getTypeOption('editorLanguage') ?? 'javaScript';
});
const editorRows = computed(() => getTypeOption<number>('rows'));
const editorRows = computed(() => getTypeOption('rows'));
const isToolNode = computed(() =>
resolvedParameter.value ? nodeTypesStore.isToolNode(resolvedParameter.value?.node.type) : false,
@ -419,6 +422,12 @@ function onOpenNdv() {
ndvStore.setActiveNodeName(node.value.name, 'focus_panel');
}
}
function onRenameNode(value: string) {
if (node.value) {
void renameNode(node.value.name, value);
}
}
</script>
<template>
@ -428,6 +437,7 @@ function onOpenNdv() {
data-test-id="focus-panel"
:class="[
$style.wrapper,
'ignore-key-press-canvas',
{ [$style.isNdvInFocusPanelEnabled]: experimentalNdvStore.isNdvInFocusPanelEnabled },
]"
@keydown.stop
@ -447,9 +457,11 @@ function onOpenNdv() {
:node="node"
:parameter="resolvedParameter?.parameter"
:is-executable="isExecutable"
:read-only="isCanvasReadOnly"
@execute="onExecute"
@open-ndv="onOpenNdv"
@clear-parameter="closeFocusPanel"
@rename-node="onRenameNode"
/>
<div v-if="resolvedParameter" :class="$style.content" data-test-id="focus-parameter">
<div v-if="!experimentalNdvStore.isNdvInFocusPanelEnabled" :class="$style.tabHeader">

View File

@ -53,6 +53,7 @@ export type Props = {
focusedMappableInput: string;
isMappingOnboarded: boolean;
nodeNotRunMessageVariant?: 'default' | 'simple';
truncateLimit?: number;
};
const props = withDefaults(defineProps<Props>(), {
@ -424,6 +425,7 @@ function handleChangeCollapsingColumn(columnName: string | null) {
:disable-ai-content="true"
:collapsing-table-column-name="collapsingColumnName"
:compact="compact"
:truncate-limit="truncateLimit"
:disable-display-mode-selection="disableDisplayModeSelection"
@activate-pane="activatePane"
@item-hover="onItemHover"

View File

@ -22,6 +22,7 @@ import { NodeConnectionTypes, type INodeParameterResourceLocator } from 'n8n-wor
import type { IWorkflowDb, WorkflowListResource } from '@/Interface';
import { mock } from 'vitest-mock-extended';
import { ExpressionLocalResolveContextSymbol } from '@/constants';
import { nextTick } from 'vue';
function getNdvStateMock(): Partial<ReturnType<typeof useNDVStore>> {
return {
@ -686,6 +687,7 @@ describe('ParameterInput.vue', () => {
},
});
await nextTick();
await fireEvent.focusIn(rendered.container.querySelector('.parameter-input')!);
expect(rendered.queryByTestId('ndv-input-panel')).toBeInTheDocument();
@ -701,6 +703,7 @@ describe('ParameterInput.vue', () => {
},
});
await nextTick();
await fireEvent.focusIn(rendered.container.querySelector('.parameter-input')!);
expect(rendered.queryByTestId('ndv-input-panel')).toBeInTheDocument();
@ -716,6 +719,7 @@ describe('ParameterInput.vue', () => {
},
});
await nextTick();
await fireEvent.focusIn(rendered.container.querySelector('.parameter-input')!);
expect(rendered.queryByTestId('ndv-input-panel')).toBeInTheDocument();
@ -731,6 +735,7 @@ describe('ParameterInput.vue', () => {
},
});
await nextTick();
await fireEvent.focusIn(rendered.container.querySelector('.parameter-input')!);
expect(rendered.queryByTestId('ndv-input-panel')).not.toBeInTheDocument();
@ -746,6 +751,7 @@ describe('ParameterInput.vue', () => {
},
});
await nextTick();
await fireEvent.focusIn(rendered.container.querySelector('.parameter-input')!);
expect(rendered.queryByTestId('ndv-input-panel')).not.toBeInTheDocument();

View File

@ -13,7 +13,6 @@ import type {
CodeExecutionMode,
EditorType,
IDataObject,
ILoadOptions,
INodeParameterResourceLocator,
INodeParameters,
INodeProperties,
@ -78,7 +77,7 @@ import { useUIStore } from '@/stores/ui.store';
import { N8nIcon, N8nInput, N8nInputNumber, N8nOption, N8nSelect } from '@n8n/design-system';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import { onClickOutside, useElementSize } from '@vueuse/core';
import { useElementSize } from '@vueuse/core';
import { captureMessage } from '@sentry/vue';
import { isCredentialOnlyNodeType } from '@/utils/credentialOnlyNodes';
import {
@ -167,7 +166,6 @@ const expressionLocalResolveCtx = inject(ExpressionLocalResolveContextSymbol, un
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
const inputField = ref<InstanceType<typeof N8nInput | typeof N8nSelect> | HTMLElement>();
const wrapper = ref<HTMLDivElement>();
const mapperRef = ref<InstanceType<typeof ExperimentalEmbeddedNdvMapper>>();
const nodeName = ref('');
const codeEditDialogVisible = ref(false);
@ -209,7 +207,6 @@ const dateTimePickerOptions = ref({
],
});
const isFocused = ref(false);
const isMapperShown = ref(false);
const contextNode = expressionLocalResolveCtx?.value?.workflow.getNode(
expressionLocalResolveCtx.value.nodeName,
@ -225,8 +222,8 @@ const shortPath = computed<string>(() => {
return short.join('.');
});
function getTypeOption<T>(optionName: string): T {
return getParameterTypeOption<T>(props.parameter, optionName);
function getTypeOption<T extends keyof NonNullable<INodeProperties['typeOptions']>>(optionName: T) {
return getParameterTypeOption(props.parameter, optionName);
}
const isModelValueExpression = computed(() => isValueExpression(props.parameter, props.modelValue));
@ -275,13 +272,13 @@ const modelValueExpressionEdit = computed<NodeParameterValueType>(() => {
: props.modelValue;
});
const editorRows = computed(() => getTypeOption<number>('rows'));
const editorRows = computed(() => getTypeOption('rows'));
const editorType = computed<EditorType | 'json' | 'code' | 'cssEditor' | undefined>(() => {
return getTypeOption<EditorType>('editor');
return getTypeOption('editor');
});
const editorIsReadOnly = computed<boolean>(() => {
return getTypeOption<boolean>('editorIsReadOnly') ?? false;
return getTypeOption('editorIsReadOnly') ?? false;
});
const editorLanguage = computed<CodeNodeLanguageOption>(() => {
@ -289,7 +286,7 @@ const editorLanguage = computed<CodeNodeLanguageOption>(() => {
if (node.value?.parameters?.language === 'pythonNative') return 'pythonNative';
return getTypeOption<CodeNodeLanguageOption>('editorLanguage') ?? 'javaScript';
return getTypeOption('editorLanguage') ?? 'javaScript';
});
const codeEditorMode = computed<CodeExecutionMode>(() => {
@ -389,7 +386,7 @@ const expressionDisplayValue = computed(() => {
});
const dependentParametersValues = computed<string | null>(() => {
const loadOptionsDependsOn = getTypeOption<string[] | undefined>('loadOptionsDependsOn');
const loadOptionsDependsOn = getTypeOption('loadOptionsDependsOn');
if (loadOptionsDependsOn === undefined) {
return null;
@ -629,8 +626,6 @@ const showDragnDropTip = computed(
const shouldCaptureForPosthog = computed(() => node.value?.type === AI_TRANSFORM_NODE_TYPE);
const mapperElRef = computed(() => mapperRef.value?.contentRef);
const isMapperAvailable = computed(
() =>
!props.parameter.isNodeSetting &&
@ -701,8 +696,8 @@ async function loadRemoteParameterOptions() {
props.parameter,
currentNodeParameters,
) as INodeParameters;
const loadOptionsMethod = getTypeOption<string | undefined>('loadOptionsMethod');
const loadOptions = getTypeOption<ILoadOptions | undefined>('loadOptions');
const loadOptionsMethod = getTypeOption('loadOptionsMethod');
const loadOptions = getTypeOption('loadOptions');
const options = await nodeTypesStore.getNodeParameterOptions({
nodeTypeAndVersion: {
@ -971,26 +966,6 @@ function onBlur() {
isFocused.value = false;
}
function onFocusIn() {
if (isMapperAvailable.value) {
isMapperShown.value = true;
}
}
function onFocusOutOrOutsideClickMapper(event: FocusEvent | MouseEvent) {
if (
!(event?.target instanceof Node && wrapper.value?.contains(event.target)) &&
!(event?.target instanceof Node && mapperElRef.value?.contains(event.target)) &&
!(
'relatedTarget' in event &&
event.relatedTarget instanceof Node &&
mapperElRef.value?.contains(event.relatedTarget)
)
) {
isMapperShown.value = false;
}
}
function onPaste(event: ClipboardEvent) {
const pastedText = event.clipboardData?.getData('text');
const input = event.target;
@ -1050,7 +1025,6 @@ async function optionSelected(command: string) {
case 'removeExpression':
isFocused.value = false;
isMapperShown.value = false;
valueChanged(
parseFromExpression(
props.modelValue,
@ -1249,8 +1223,6 @@ onUpdated(async () => {
}
}
});
onClickOutside(mapperElRef, onFocusOutOrOutsideClickMapper);
</script>
<template>
@ -1274,13 +1246,11 @@ onClickOutside(mapperElRef, onFocusOutOrOutsideClickMapper);
/>
<ExperimentalEmbeddedNdvMapper
v-if="isMapperAvailable && node && expressionLocalResolveCtx?.inputNode"
ref="mapperRef"
:workflow="expressionLocalResolveCtx?.workflow"
v-if="wrapper && isMapperAvailable && node && expressionLocalResolveCtx?.inputNode"
:workflow="expressionLocalResolveCtx.workflow"
:node="node"
:input-node-name="expressionLocalResolveCtx?.inputNode?.name"
:visible="isMapperShown"
:virtual-ref="wrapper"
:input-node-name="expressionLocalResolveCtx.inputNode.name"
:reference="wrapper"
/>
<div
@ -1293,8 +1263,6 @@ onClickOutside(mapperElRef, onFocusOutOrOutsideClickMapper);
]"
:style="parameterInputWrapperStyle"
:data-parameter-path="path"
@focusin="onFocusIn"
@focusout="onFocusOutOrOutsideClickMapper"
>
<ResourceLocator
v-if="parameter.type === 'resourceLocator'"

View File

@ -392,10 +392,7 @@ function shouldShowOptions(parameter: INodeProperties): boolean {
}
function getDependentParametersValues(parameter: INodeProperties): string | null {
const loadOptionsDependsOn = getParameterTypeOption<string[] | undefined>(
parameter,
'loadOptionsDependsOn',
);
const loadOptionsDependsOn = getParameterTypeOption(parameter, 'loadOptionsDependsOn');
if (loadOptionsDependsOn === undefined) {
return null;

View File

@ -11,6 +11,7 @@ import { useNDVStore } from '@/stores/ndv.store';
import { AI_TRANSFORM_NODE_TYPE } from '@/constants';
import { getParameterTypeOption } from '@/utils/nodeSettingsUtils';
import { useIsInExperimentalNdv } from '@/components/canvas/experimental/composables/useIsInExperimentalNdv';
import { useExperimentalNdvStore } from '@/components/canvas/experimental/experimentalNdv.store';
interface Props {
parameter: INodeProperties;
@ -46,22 +47,29 @@ const ndvStore = useNDVStore();
const activeNode = computed(() => ndvStore.activeNode);
const isDefault = computed(() => props.parameter.default === props.value);
const isValueAnExpression = computed(() => isValueExpression(props.parameter, props.value));
const isHtmlEditor = computed(
() => getParameterTypeOption(props.parameter, 'editor') === 'htmlEditor',
);
const editor = computed(() => getParameterTypeOption(props.parameter, 'editor'));
const shouldShowExpressionSelector = computed(
() => !props.parameter.noDataExpression && props.showExpressionSelector && !props.isReadOnly,
);
const isInEmbeddedNdv = useIsInExperimentalNdv();
const experimentalNdvStore = useExperimentalNdvStore();
const canBeOpenedInFocusPanel = computed(
() =>
!props.parameter.isNodeSetting &&
!props.isReadOnly &&
!props.isContentOverridden &&
(activeNode.value || isInEmbeddedNdv.value) && // checking that it's inside ndv
(props.parameter.type === 'string' || props.parameter.type === 'json'),
);
const canBeOpenedInFocusPanel = computed(() => {
if (props.parameter.isNodeSetting || props.isReadOnly || props.isContentOverridden) {
return false;
}
if (!activeNode.value && !isInEmbeddedNdv.value) {
// The current parameter is focused parameter in focus panel
return false;
}
if (experimentalNdvStore.isNdvInFocusPanelEnabled) {
return (props.parameter.typeOptions?.rows ?? 1) > 1 || editor.value !== undefined;
}
return props.parameter.type === 'string' || props.parameter.type === 'json';
});
const shouldShowOptions = computed(() => {
if (props.isReadOnly) {
@ -100,7 +108,7 @@ const actions = computed(() => {
return props.customActions;
}
if (isHtmlEditor.value && !isValueAnExpression.value) {
if (editor.value === 'htmlEditor' && !isValueAnExpression.value) {
return [
{
label: i18n.baseText('parameterInput.formatHtml'),

View File

@ -150,6 +150,7 @@ type Props = {
disableSettingsHint?: boolean;
disableAiContent?: boolean;
collapsingTableColumnName: string | null;
truncateLimit?: number;
};
const props = withDefaults(defineProps<Props>(), {
@ -1925,6 +1926,7 @@ defineExpose({ enterEditMode });
:search="search"
:class="$style.schema"
:compact="props.compact"
:truncate-limit="props.truncateLimit"
@clear:search="onSearchClear"
/>
</Suspense>

View File

@ -59,6 +59,7 @@ type Props = {
search?: string;
compact?: boolean;
outputIndex?: number;
truncateLimit?: number;
};
const props = withDefaults(defineProps<Props>(), {
@ -71,6 +72,7 @@ const props = withDefaults(defineProps<Props>(), {
mappingEnabled: false,
compact: false,
outputIndex: undefined,
truncateLimit: 600,
});
const telemetry = useTelemetry();
@ -210,7 +212,12 @@ const contextItems = computed(() => {
return [];
}
const flatSchema = flattenSchema({ schema, depth: 1, isDataEmpty: false });
const flatSchema = flattenSchema({
schema,
depth: 1,
isDataEmpty: false,
truncateLimit: props.truncateLimit,
});
const fields: Renders[] = flatSchema.flatMap((renderItem) => {
const isVars =
renderItem.type === 'item' && renderItem.depth === 1 && renderItem.title === '$vars';
@ -334,7 +341,7 @@ const nodeAdditionalInfo = (node: INodeUi) => {
};
const flattenedNodes = computed(() =>
flattenMultipleSchemas(nodesSchemas.value, nodeAdditionalInfo),
flattenMultipleSchemas(nodesSchemas.value, nodeAdditionalInfo, props.truncateLimit),
);
const flattenNodeSchema = computed(() =>
@ -344,6 +351,7 @@ const flattenNodeSchema = computed(() =>
depth: 0,
level: -1,
isDataEmpty: props.data.length === 0,
truncateLimit: props.truncateLimit,
})
: [],
);

View File

@ -0,0 +1,101 @@
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { render, waitFor } from '@testing-library/vue';
import { flushPromises } from '@vue/test-utils';
import { NodeConnectionTypes } from 'n8n-workflow';
import { nextTick } from 'vue';
import ExperimentalEmbeddedNdvMapper from './ExperimentalEmbeddedNdvMapper.vue';
import { useExperimentalNdvStore } from '../experimentalNdv.store';
describe('ExperimentalEmbeddedNdvMapper', () => {
const node = createTestNode({ name: 'n1' });
const workflow = createTestWorkflowObject({
nodes: [node],
connections: {
n0: {
[NodeConnectionTypes.Main]: [[{ index: 0, node: 'n0', type: NodeConnectionTypes.Main }]],
},
},
});
it('should open the popover on hover if visibleOnHover is true', async () => {
const reference = document.createElement('div');
const rendered = render(ExperimentalEmbeddedNdvMapper, {
global: { plugins: [createTestingPinia({ stubActions: false })] },
props: {
workflow,
node,
inputNodeName: 'n0',
reference,
visibleOnHover: true,
},
});
await nextTick();
await userEvent.hover(reference);
expect(rendered.queryByTestId('ndv-input-panel')).toBeInTheDocument();
});
it('should not open the popover on hover if visibleOnHover is false', async () => {
const reference = document.createElement('div');
const rendered = render(ExperimentalEmbeddedNdvMapper, {
global: { plugins: [createTestingPinia({ stubActions: false })] },
props: {
workflow,
node,
inputNodeName: 'n0',
reference,
visibleOnHover: false,
},
});
await nextTick();
await userEvent.hover(reference);
expect(rendered.queryByTestId('ndv-input-panel')).not.toBeInTheDocument();
});
it('should not open the popover if a mapper is already opened elsewhere', async () => {
const pinia = createTestingPinia({ stubActions: false });
const store = useExperimentalNdvStore(pinia);
const reference = document.createElement('div');
const rendered = render(ExperimentalEmbeddedNdvMapper, {
global: { plugins: [pinia] },
props: {
workflow,
node,
inputNodeName: 'n0',
reference,
visibleOnHover: true,
},
});
store.setMapperOpen(true);
await nextTick();
await userEvent.hover(reference);
expect(rendered.queryByTestId('ndv-input-panel')).not.toBeInTheDocument();
});
it('should close the popover on mouse leave if the popover is opened by hover', async () => {
const reference = document.createElement('div');
const rendered = render(ExperimentalEmbeddedNdvMapper, {
global: { plugins: [createTestingPinia({ stubActions: false })] },
props: {
workflow,
node,
inputNodeName: 'n0',
reference,
visibleOnHover: true,
},
});
await nextTick();
await userEvent.hover(reference);
expect(await rendered.findByTestId('ndv-input-panel')).toBeInTheDocument();
await userEvent.unhover(reference);
await flushPromises();
await waitFor(() => expect(rendered.queryByTestId('ndv-input-panel')).not.toBeInTheDocument());
});
});

View File

@ -1,35 +1,88 @@
<script setup lang="ts">
import InputPanel from '@/components/InputPanel.vue';
import { CanvasKey } from '@/constants';
import type { INodeUi } from '@/Interface';
import { useNDVStore } from '@/stores/ndv.store';
import { onBeforeUnmount, watch } from 'vue';
import { onBeforeUnmount, ref, watch } from 'vue';
import type { Workflow } from 'n8n-workflow';
import { computed, inject, useTemplateRef } from 'vue';
import { computed, useTemplateRef } from 'vue';
import { N8nPopoverReka } from '@n8n/design-system';
import { useStyles } from '@/composables/useStyles';
import {
onClickOutside,
useElementHover,
type UseElementHoverOptions,
useEventListener,
} from '@vueuse/core';
import { useExperimentalNdvStore } from '@/components/canvas/experimental/experimentalNdv.store';
import { isEventTargetContainedBy } from '@/utils/htmlUtils';
const { node, inputNodeName, visible, virtualRef } = defineProps<{
type MapperState = { isOpen: true; closeOnMouseLeave: boolean } | { isOpen: false };
const hoverOptions: UseElementHoverOptions = {
delayLeave: 200, // should be a positive value, otherwise user cannot click on the mapper
};
const {
node,
inputNodeName,
reference,
visibleOnHover = false,
} = defineProps<{
workflow: Workflow;
node: INodeUi;
inputNodeName: string;
visible: boolean;
virtualRef: HTMLElement | undefined;
visibleOnHover?: boolean;
reference: HTMLElement;
}>();
const state = ref<MapperState>({ isOpen: false });
const contentRef = useTemplateRef('content');
const ndvStore = useNDVStore();
const experimentalNdvStore = useExperimentalNdvStore();
const canvas = inject(CanvasKey, undefined);
const isVisible = computed(() => visible && !canvas?.isPaneMoving.value);
const contentElRef = computed(() => contentRef.value?.$el ?? null);
const contentElRef = computed<HTMLElement | null>(() => contentRef.value?.$el ?? null);
const { APP_Z_INDEXES } = useStyles();
const isReferenceHovered = useElementHover(visibleOnHover ? reference : null, hoverOptions);
const isMapperHovered = useElementHover(visibleOnHover ? contentElRef : null, hoverOptions);
const isHovered = computed(() => isReferenceHovered.value || isMapperHovered.value);
function handleFocusIn() {
if (experimentalNdvStore.isMapperOpen) {
// Skip if there's already a mapper opened
return;
}
state.value = { isOpen: true, closeOnMouseLeave: false };
}
function handleReferenceFocusOut(event: FocusEvent | MouseEvent) {
if (
isEventTargetContainedBy(event.target, reference) ||
isEventTargetContainedBy(event.target, contentElRef) ||
isEventTargetContainedBy(event.relatedTarget, contentElRef)
) {
// Skip when focus moves between the mapper and its reference element
return;
}
state.value = { isOpen: false };
}
watch(isHovered, (hovered) => {
if (
!visibleOnHover ||
(state.value.isOpen && !state.value.closeOnMouseLeave) ||
experimentalNdvStore.isMapperOpen
) {
return;
}
state.value = hovered ? { isOpen: true, closeOnMouseLeave: true } : { isOpen: false };
});
watch(
isVisible,
state,
(value) => {
experimentalNdvStore.setMapperOpen(value);
experimentalNdvStore.setMapperOpen(value.isOpen && !value.closeOnMouseLeave);
},
{ immediate: true },
);
@ -38,28 +91,31 @@ onBeforeUnmount(() => {
experimentalNdvStore.setMapperOpen(false);
});
defineExpose({
contentRef: contentElRef,
});
useEventListener(reference, 'focusin', handleFocusIn);
useEventListener(reference, 'focusout', handleReferenceFocusOut);
useEventListener(contentElRef, 'focusin', handleFocusIn);
onClickOutside(contentElRef, handleReferenceFocusOut);
</script>
<template>
<N8nPopoverReka
:open="isVisible"
:open="state.isOpen"
side="left"
:side-flip="false"
align="start"
width="360px"
:max-height="`calc(100vh - var(--spacing-s) * 2)`"
:reference="virtualRef"
:reference="reference"
:suppress-auto-focus="true"
:z-index="APP_Z_INDEXES.NDV + 1"
content-class="ignore-key-press-canvas ignore-key-press-node-creator"
>
<template #content>
<InputPanel
ref="content"
:tabindex="-1"
:class="[$style.inputPanel, 'ignore-key-press-canvas']"
:class="$style.inputPanel"
:workflow-object="workflow"
:run-index="0"
compact
@ -71,6 +127,7 @@ defineExpose({
:is-mapping-onboarded="ndvStore.isMappingOnboarded"
:focused-mappable-input="ndvStore.focusedMappableInput"
node-not-run-message-variant="simple"
:truncate-limit="60"
search-shortcut="ctrl+f"
/>
</template>

View File

@ -3,7 +3,8 @@ import NodeExecuteButton from '@/components/NodeExecuteButton.vue';
import NodeIcon from '@/components/NodeIcon.vue';
import { type INodeUi } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { N8nIconButton, N8nText } from '@n8n/design-system';
import { N8nIconButton, N8nInlineTextEdit, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { type INodeProperties } from 'n8n-workflow';
import { computed } from 'vue';
@ -11,16 +12,19 @@ const { node, parameter, isExecutable } = defineProps<{
node: INodeUi;
parameter?: INodeProperties;
isExecutable: boolean;
readOnly: boolean;
}>();
const nodeTypesStore = useNodeTypesStore();
const nodeType = computed(() => nodeTypesStore.getNodeType(node.type, node.typeVersion));
const emit = defineEmits<{
execute: [];
openNdv: [];
clearParameter: [];
renameNode: [newName: string];
}>();
const nodeTypesStore = useNodeTypesStore();
const i18n = useI18n();
const nodeType = computed(() => nodeTypesStore.getNodeType(node.type, node.typeVersion));
</script>
<template>
@ -34,7 +38,15 @@ const emit = defineEmits<{
<N8nText size="small" color="text-light">/</N8nText>
{{ parameter.displayName }}
</template>
<template v-else>{{ node.name }}</template>
<N8nInlineTextEdit
v-else
:model-value="node.name"
:min-width="0"
:max-width="500"
:placeholder="i18n.baseText('ndv.title.rename.placeholder')"
:read-only="readOnly"
@update:model-value="emit('renameNode', $event)"
/>
</div>
<N8nIconButton
v-if="parameter"

View File

@ -55,6 +55,7 @@ import debounce from 'lodash/debounce';
import { ignoreUpdateAnnotation } from '../utils/forceParse';
import type { TargetNodeParameterContext } from '@/Interface';
import type { CodeNodeLanguageOption } from '@/components/CodeNodeEditor/CodeNodeEditor.vue';
import { isEventTargetContainedBy } from '@/utils/htmlUtils';
export type CodeEditorLanguageParamsMap = {
json: {};
@ -197,7 +198,7 @@ export const useCodeEditor = <L extends CodeNodeLanguageOption>({
}
function blurOnClickOutside(event: MouseEvent) {
if (event.target && !dragging.value && !editor.value?.dom.contains(event.target as Node)) {
if (!dragging.value && !isEventTargetContainedBy(event.target, editor.value?.dom)) {
blur();
}
dragging.value = false;

View File

@ -818,6 +818,7 @@ describe('useFlattenSchema', () => {
useFlattenSchema().flattenSchema({
schema,
isDataEmpty: false,
truncateLimit: 600,
}).length,
).toBe(3);
});
@ -841,12 +842,14 @@ describe('useFlattenSchema', () => {
expressionPrefix: '$("First Node")',
depth: 1,
isDataEmpty: false,
truncateLimit: 600,
});
const node2Schema = flattenSchema({
schema,
expressionPrefix: '$("Second Node")',
depth: 1,
isDataEmpty: false,
truncateLimit: 600,
});
expect(node1Schema[0].id).not.toBe(node2Schema[0].id);
@ -866,6 +869,7 @@ describe('useFlattenSchema', () => {
}),
],
vi.fn(),
600,
);
expect(result).toHaveLength(2);
expect(result[0]).toEqual(expect.objectContaining({ type: 'header', title: 'Test Node' }));
@ -886,6 +890,7 @@ describe('useFlattenSchema', () => {
}),
],
vi.fn(),
600,
);
expect(result).toHaveLength(2);
expect(result[0]).toEqual(expect.objectContaining({ type: 'header', title: 'Test Node' }));
@ -907,6 +912,7 @@ describe('useFlattenSchema', () => {
}),
],
vi.fn(),
600,
);
expect(result).toHaveLength(2);
expect(result[0]).toEqual(expect.objectContaining({ type: 'header', title: 'Test Node' }));
@ -928,6 +934,7 @@ describe('useFlattenSchema', () => {
}),
],
vi.fn(),
600,
);
expect(result).toHaveLength(2);
expect(result[0]).toEqual(expect.objectContaining({ type: 'header', title: 'Test Node' }));
@ -983,6 +990,7 @@ describe('useFlattenSchema', () => {
}),
],
vi.fn(),
600,
);
expect(result).toHaveLength(10);
expect(result.filter((item) => item.type === 'header')).toHaveLength(2);

View File

@ -358,6 +358,7 @@ export const useFlattenSchema = () => {
prefix = '',
level = 0,
preview,
truncateLimit,
}: {
isDataEmpty: boolean;
schema: Schema;
@ -368,6 +369,7 @@ export const useFlattenSchema = () => {
prefix?: string;
level?: number;
preview?: boolean;
truncateLimit: number;
}): Renders[] => {
// only show empty item for the first level
if (isEmptySchema(schema) && level < 0) {
@ -416,6 +418,7 @@ export const useFlattenSchema = () => {
prefix: itemPrefix,
level: level + 1,
preview,
truncateLimit,
});
})
.flat(),
@ -428,7 +431,7 @@ export const useFlattenSchema = () => {
expression,
level,
depth,
value: shorten(schema.value, 600, 0),
value: shorten(schema.value, truncateLimit, 0),
id,
icon: getIconBySchemaType(schema.type),
collapsable: false,
@ -446,6 +449,7 @@ export const useFlattenSchema = () => {
const flattenMultipleSchemas = (
nodes: SchemaNode[],
additionalInfo: (node: INodeUi) => string,
truncateLimit: number,
) => {
return nodes.reduce<Renders[]>((acc, item) => {
acc.push({
@ -485,6 +489,7 @@ export const useFlattenSchema = () => {
nodeType: item.node.type,
nodeName: item.node.name,
preview: item.preview,
truncateLimit,
expressionPrefix: getNodeParentExpression({
nodeName: item.node.name,
distanceFromActive: item.depth,

View File

@ -43,6 +43,7 @@ import { useAutocompleteTelemetry } from './useAutocompleteTelemetry';
import { ignoreUpdateAnnotation } from '../utils/forceParse';
import { TARGET_NODE_PARAMETER_FACET } from '@/plugins/codemirror/completions/constants';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { isEventTargetContainedBy } from '@/utils/htmlUtils';
export const useExpressionEditor = ({
editorRef,
@ -197,7 +198,7 @@ export const useExpressionEditor = ({
}
function blurOnClickOutside(event: MouseEvent) {
if (event.target && !dragging.value && !editor.value?.dom.contains(event.target as Node)) {
if (!dragging.value && !isEventTargetContainedBy(event.target, editor.value?.dom)) {
blur();
}
dragging.value = false;

View File

@ -11,6 +11,7 @@ import { getNodeViewTab } from '@/utils/nodeViewUtils';
import type { RouteLocationNormalizedLoaded } from 'vue-router';
import { useTelemetry } from './useTelemetry';
import { useDebounce } from '@/composables/useDebounce';
import { shouldIgnoreCanvasShortcut } from '@/utils/canvasUtils';
const UNDO_REDO_DEBOUNCE_INTERVAL = 100;
const ELEMENT_UI_OVERLAY_SELECTOR = '.el-overlay';
@ -124,7 +125,14 @@ export function useHistoryHelper(activeRoute: RouteLocationNormalizedLoaded) {
const isAnyModalOpen = uiStore.isAnyModalOpen || isMessageDialogOpen();
const undoKeysPressed = isCtrlKeyPressed(event) && event.key.toLowerCase() === 'z';
if (event.repeat || currentNodeViewTab !== MAIN_HEADER_TABS.WORKFLOW) return;
if (
event.repeat ||
currentNodeViewTab !== MAIN_HEADER_TABS.WORKFLOW ||
(event.target instanceof HTMLElement && shouldIgnoreCanvasShortcut(event.target))
) {
return;
}
if (isNDVOpen || isAnyModalOpen) {
if (isNDVOpen && undoKeysPressed && !event.shiftKey) {
trackUndoAttempt();

View File

@ -20,6 +20,7 @@ interface Props {
showCloseButton?: boolean;
isOpen?: boolean;
isReadOnly?: boolean;
isHeaderClickable: boolean;
}
const props = withDefaults(defineProps<Props>(), {
@ -173,6 +174,7 @@ async function copySessionId() {
<LogsPanelHeader
data-test-id="chat-header"
:title="locale.baseText('chat.window.title')"
:is-clickable="isHeaderClickable"
@click="emit('clickHeader')"
>
<template #actions>

View File

@ -94,6 +94,7 @@ describe('LogDetailsPanel', () => {
panels: LOG_DETAILS_PANEL_STATE.BOTH,
collapsingInputTableColumnName: null,
collapsingOutputTableColumnName: null,
isHeaderClickable: true,
});
const header = within(rendered.getByTestId('log-details-header'));
@ -117,6 +118,7 @@ describe('LogDetailsPanel', () => {
panels: LOG_DETAILS_PANEL_STATE.BOTH,
collapsingInputTableColumnName: null,
collapsingOutputTableColumnName: null,
isHeaderClickable: true,
});
const inputPanel = within(rendered.getByTestId('log-details-input'));
@ -133,6 +135,7 @@ describe('LogDetailsPanel', () => {
panels: LOG_DETAILS_PANEL_STATE.BOTH,
collapsingInputTableColumnName: null,
collapsingOutputTableColumnName: null,
isHeaderClickable: true,
});
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
@ -150,6 +153,7 @@ describe('LogDetailsPanel', () => {
panels: LOG_DETAILS_PANEL_STATE.BOTH,
collapsingInputTableColumnName: null,
collapsingOutputTableColumnName: null,
isHeaderClickable: true,
});
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
@ -178,6 +182,7 @@ describe('LogDetailsPanel', () => {
panels: LOG_DETAILS_PANEL_STATE.BOTH,
collapsingInputTableColumnName: null,
collapsingOutputTableColumnName: null,
isHeaderClickable: true,
});
expect(
@ -217,6 +222,7 @@ describe('LogDetailsPanel', () => {
panels: LOG_DETAILS_PANEL_STATE.BOTH,
collapsingInputTableColumnName: null,
collapsingOutputTableColumnName: null,
isHeaderClickable: true,
};
const rendered = render({ ...props, logEntry: logB });

View File

@ -30,6 +30,7 @@ const {
panels,
collapsingInputTableColumnName,
collapsingOutputTableColumnName,
isHeaderClickable,
} = defineProps<{
isOpen: boolean;
logEntry: LogEntry;
@ -38,6 +39,7 @@ const {
panels: LogDetailsPanelState;
collapsingInputTableColumnName: string | null;
collapsingOutputTableColumnName: string | null;
isHeaderClickable: boolean;
}>();
const emit = defineEmits<{
@ -94,6 +96,7 @@ function handleResizeEnd() {
<LogsPanelHeader
data-test-id="log-details-header"
:class="$style.header"
:is-clickable="isHeaderClickable"
@click="emit('clickHeader')"
>
<template #title>

View File

@ -32,6 +32,7 @@ describe('LogsOverviewPanel', () => {
entries: logs,
latestNodeInfo: {},
execution: aiChatExecutionResponse,
isHeaderClickable: true,
...props,
};

View File

@ -23,6 +23,7 @@ const {
entries,
flatLogEntries,
latestNodeInfo,
isHeaderClickable,
} = defineProps<{
isOpen: boolean;
selected?: LogEntry;
@ -32,6 +33,7 @@ const {
entries: LogEntry[];
flatLogEntries: LogEntry[];
latestNodeInfo: Record<string, LatestNodeInfo>;
isHeaderClickable: boolean;
}>();
const emit = defineEmits<{
@ -136,6 +138,7 @@ watch(
<LogsPanelHeader
:title="locale.baseText('logs.overview.header.title')"
data-test-id="logs-overview-header"
:is-clickable="isHeaderClickable"
@click="emit('clickHeader')"
>
<template #actions>

View File

@ -179,7 +179,7 @@ describe('LogsPanel', () => {
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
});
it('opens collapsed panel when clicked', async () => {
it('toggles panel when header is clicked', async () => {
workflowsStore.setWorkflow(aiChatWorkflow);
const rendered = render();
@ -187,6 +187,12 @@ describe('LogsPanel', () => {
await fireEvent.click(await rendered.findByTestId('logs-overview-header'));
expect(await rendered.findByTestId('logs-overview-empty')).toBeInTheDocument();
await fireEvent.click(await rendered.findByTestId('logs-overview-header'));
await waitFor(() =>
expect(rendered.queryByTestId('logs-overview-empty')).not.toBeInTheDocument(),
);
});
it('should toggle panel when chevron icon button in the overview panel is clicked', async () => {

View File

@ -186,11 +186,12 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
:past-chat-messages="previousChatMessages"
:show-close-button="false"
:is-new-logs-enabled="true"
:is-header-clickable="!isPoppedOut"
@close="onToggleOpen"
@refresh-session="refreshSession"
@display-execution="displayExecution"
@send-message="sendMessage"
@click-header="onToggleOpen(true)"
@click-header="onToggleOpen"
/>
</N8nResizeWrapper>
<div ref="logsContainer" :class="$style.logsContainer">
@ -214,7 +215,8 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
:entries="entries"
:latest-node-info="latestNodeNameById"
:flat-log-entries="flatLogEntries"
@click-header="onToggleOpen(true)"
:is-header-clickable="!isPoppedOut"
@click-header="onToggleOpen"
@select="select"
@clear-execution-data="resetExecutionData"
@toggle-expanded="toggleExpanded"
@ -238,7 +240,8 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
:panels="logsStore.detailsState"
:collapsing-input-table-column-name="inputCollapsingColumnName"
:collapsing-output-table-column-name="outputCollapsingColumnName"
@click-header="onToggleOpen(true)"
:is-header-clickable="!isPoppedOut"
@click-header="onToggleOpen"
@toggle-input-open="logsStore.toggleInputOpen"
@toggle-output-open="logsStore.toggleOutputOpen"
@collapsing-input-table-column-changed="handleChangeInputTableColumnCollapsing"

View File

@ -73,6 +73,7 @@ function handleSelectMenuItem(selected: string) {
:items="menuItems"
:teleported="false /* for pop-out window */"
@select="handleSelectMenuItem"
@click.stop
/>
<KeyboardShortcutTooltip
v-if="showToggleButton"

View File

@ -1,15 +1,21 @@
<script setup lang="ts">
import { N8nText } from '@n8n/design-system';
const { title } = defineProps<{ title?: string }>();
const { title, isClickable } = defineProps<{ title?: string; isClickable: boolean }>();
defineSlots<{ actions: {}; title?: {} }>();
const emit = defineEmits<{ click: [] }>();
function handleClick() {
if (isClickable) {
emit('click');
}
}
</script>
<template>
<header :class="$style.container" @click="emit('click')">
<header :class="[$style.container, { [$style.clickable]: isClickable }]" @click="handleClick">
<N8nText :class="$style.title" :bold="true" size="small">
<slot name="title">{{ title }}</slot>
</N8nText>
@ -32,8 +38,7 @@ const emit = defineEmits<{ click: [] }>();
align-items: center;
line-height: var(--font-line-height-compact);
&:last-child {
/** Panel collapsed */
&.clickable {
cursor: pointer;
}

View File

@ -1,5 +1,6 @@
import xss, { escapeAttrValue } from 'xss';
import { ALLOWED_HTML_ATTRIBUTES, ALLOWED_HTML_TAGS } from '@/constants';
import { toValue, type MaybeRef } from 'vue';
/*
Constants and utility functions that help in HTML, CSS and DOM manipulation
@ -96,3 +97,10 @@ export function getScrollbarWidth() {
return scrollbarWidth;
}
export function isEventTargetContainedBy(
eventTarget: EventTarget | null | undefined,
maybeContainer: MaybeRef<HTMLElement | null | undefined>,
): boolean {
return !!(eventTarget instanceof Node && toValue(maybeContainer)?.contains(eventTarget));
}

View File

@ -342,11 +342,11 @@ export function updateParameterByPath(
return parameterPath;
}
export function getParameterTypeOption<T = string | number | boolean | undefined>(
export function getParameterTypeOption<T extends keyof NonNullable<INodeProperties['typeOptions']>>(
parameter: INodeProperties,
optionName: string,
): T {
return parameter.typeOptions?.[optionName] as T;
optionName: T,
): NonNullable<INodeProperties['typeOptions']>[T] | undefined {
return parameter.typeOptions?.[optionName];
}
export function isResourceLocatorParameterType(type: NodePropertyTypes) {

View File

@ -7,8 +7,8 @@ export class FocusPanel {
* Accessors
*/
getHeader(): Locator {
return this.root.locator('header');
getHeaderNodeName(): Locator {
return this.root.locator('header').getByTestId('inline-edit-preview');
}
getParameterInputField(path: string): Locator {

View File

@ -19,7 +19,7 @@ test.describe('Focus panel', () => {
await n8n.canvas.deselectAll();
await n8n.canvas.toggleFocusPanelButton().click();
await n8n.canvas.nodeByName('Set').click();
await expect(n8n.canvas.focusPanel.getHeader()).toHaveText('Set');
await expect(n8n.canvas.focusPanel.getHeaderNodeName()).toHaveText('Set');
await n8n.canvas.focusPanel.getParameterInputField('assignments.assignments.0.value').focus();
await expect(n8n.canvas.focusPanel.getMapper()).toBeVisible();
@ -27,13 +27,13 @@ test.describe('Focus panel', () => {
await n8n.canvas.canvasBody().click({ position: { x: 0, y: 0 } });
await expect(n8n.canvas.focusPanel.getMapper()).toBeHidden();
await expect(n8n.canvas.focusPanel.getHeader()).toHaveText('Set');
await expect(n8n.canvas.focusPanel.getHeaderNodeName()).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()).toBeHidden();
await expect(n8n.canvas.focusPanel.getHeaderNodeName()).toBeHidden();
await expect(n8n.canvas.selectedNodes()).toHaveCount(0);
});
});