mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-27 06:45:26 +02:00
fix(editor): NDV in focus panel review batch 2.1 (no-changelog) (#19665)
This commit is contained in:
parent
5a3adf5c97
commit
63dbd65e34
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
: [],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ describe('LogsOverviewPanel', () => {
|
|||
entries: logs,
|
||||
latestNodeInfo: {},
|
||||
execution: aiChatExecutionResponse,
|
||||
isHeaderClickable: true,
|
||||
...props,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ function handleSelectMenuItem(selected: string) {
|
|||
:items="menuItems"
|
||||
:teleported="false /* for pop-out window */"
|
||||
@select="handleSelectMenuItem"
|
||||
@click.stop
|
||||
/>
|
||||
<KeyboardShortcutTooltip
|
||||
v-if="showToggleButton"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user