fix(editor): Fix an issue with overlapping elements in the Assignment component (#18041)

This commit is contained in:
Svetoslav Dekov
2025-08-08 09:34:02 +03:00
committed by GitHub
parent d6bc4abee2
commit c7108f4a06
4 changed files with 88 additions and 42 deletions

View File

@@ -1,11 +1,14 @@
import { computed, nextTick, ref } from 'vue';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { fireEvent } from '@testing-library/vue';
import Assignment from './Assignment.vue'; import Assignment from './Assignment.vue';
import { defaultSettings } from '@/__tests__/defaults'; import { defaultSettings } from '@/__tests__/defaults';
import { STORES } from '@n8n/stores'; import { STORES } from '@n8n/stores';
import merge from 'lodash/merge'; import merge from 'lodash/merge';
import { cleanupAppModals, createAppModals } from '@/__tests__/utils'; import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
import * as useResolvedExpression from '@/composables/useResolvedExpression';
const DEFAULT_SETUP = { const DEFAULT_SETUP = {
pinia: createTestingPinia({ pinia: createTestingPinia({
@@ -69,4 +72,22 @@ describe('Assignment.vue', () => {
// Check if the parameter input hint is not displayed // Check if the parameter input hint is not displayed
expect(() => getByTestId('parameter-input-hint')).toThrow(); expect(() => getByTestId('parameter-input-hint')).toThrow();
}); });
it('should shorten the expression preview hint if options are on the bottom', async () => {
vi.spyOn(useResolvedExpression, 'useResolvedExpression').mockReturnValueOnce({
resolvedExpressionString: ref('foo'),
resolvedExpression: ref(null),
isExpression: computed(() => true),
});
const { getByTestId } = renderComponent();
const previewValue = getByTestId('parameter-expression-preview-value');
expect(previewValue).not.toHaveClass('optionsPadding');
await fireEvent.mouseEnter(getByTestId('assignment-value'));
await nextTick();
expect(previewValue).toHaveClass('optionsPadding');
});
}); });

View File

@@ -25,6 +25,7 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const assignment = ref<AssignmentValue>(props.modelValue); const assignment = ref<AssignmentValue>(props.modelValue);
const valueInputHovered = ref(false);
const emit = defineEmits<{ const emit = defineEmits<{
'update:model-value': [value: AssignmentValue]; 'update:model-value': [value: AssignmentValue];
@@ -113,6 +114,10 @@ const onRemove = (): void => {
const onBlur = (): void => { const onBlur = (): void => {
emit('update:model-value', assignment.value); emit('update:model-value', assignment.value);
}; };
const onValueInputHoverChange = (hovered: boolean): void => {
valueInputHovered.value = hovered;
};
</script> </script>
<template> <template>
@@ -186,11 +191,16 @@ const onBlur = (): void => {
data-test-id="assignment-value" data-test-id="assignment-value"
@update="onAssignmentValueChange" @update="onAssignmentValueChange"
@blur="onBlur" @blur="onBlur"
@hover="onValueInputHoverChange"
/> />
<ParameterInputHint <ParameterInputHint
v-if="resolvedExpressionString" v-if="resolvedExpressionString"
data-test-id="parameter-expression-preview-value" data-test-id="parameter-expression-preview-value"
:class="$style.hint" :class="{
[$style.hint]: true,
[$style.optionsPadding]:
breakpoint !== 'default' && !isReadOnly && valueInputHovered,
}"
:highlight="highlightHint" :highlight="highlightHint"
:hint="hint" :hint="hint"
single-line single-line
@@ -248,6 +258,10 @@ const onBlur = (): void => {
left: 0; left: 0;
right: 0; right: 0;
} }
.optionsPadding {
width: calc(100% - 140px);
}
} }
.iconButton { .iconButton {

View File

@@ -1,4 +1,4 @@
import { renderComponent } from '@/__tests__/render'; import { nextTick } from 'vue';
import type { useNDVStore } from '@/stores/ndv.store'; import type { useNDVStore } from '@/stores/ndv.store';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import type { useNodeTypesStore } from '@/stores/nodeTypes.store'; import type { useNodeTypesStore } from '@/stores/nodeTypes.store';
@@ -6,6 +6,8 @@ import type { useSettingsStore } from '@/stores/settings.store';
import { cleanupAppModals, createAppModals } from '@/__tests__/utils'; import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
import ParameterInputFull from './ParameterInputFull.vue'; import ParameterInputFull from './ParameterInputFull.vue';
import { FROM_AI_AUTO_GENERATED_MARKER } from 'n8n-workflow'; import { FROM_AI_AUTO_GENERATED_MARKER } from 'n8n-workflow';
import { fireEvent } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render';
type Writeable<T> = { -readonly [P in keyof T]: T[P] }; type Writeable<T> = { -readonly [P in keyof T]: T[P] };
@@ -34,6 +36,7 @@ beforeEach(() => {
}; };
mockNodeTypesState = { mockNodeTypesState = {
allNodeTypes: [], allNodeTypes: [],
getNodeType: vi.fn().mockReturnValue({}),
}; };
mockSettingsState = { mockSettingsState = {
settings: { settings: {
@@ -62,6 +65,19 @@ vi.mock('@/stores/settings.store', () => {
}; };
}); });
const renderComponent = createComponentRenderer(ParameterInputFull, {
pinia: createTestingPinia(),
props: {
path: 'myParam',
value: '',
parameter: {
displayName: 'My Param',
name: 'myParam',
type: 'string',
},
},
});
describe('ParameterInputFull.vue', () => { describe('ParameterInputFull.vue', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -73,18 +89,7 @@ describe('ParameterInputFull.vue', () => {
}); });
it('should render basic parameter', async () => { it('should render basic parameter', async () => {
mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({}); const { getByTestId } = renderComponent();
const { getByTestId } = renderComponent(ParameterInputFull, {
pinia: createTestingPinia(),
props: {
path: 'myParam',
parameter: {
displayName: 'My Param',
name: 'myParam',
type: 'string',
},
},
});
expect(getByTestId('parameter-input')).toBeInTheDocument(); expect(getByTestId('parameter-input')).toBeInTheDocument();
}); });
@@ -95,18 +100,7 @@ describe('ParameterInputFull.vue', () => {
subcategories: { AI: ['Tools'] }, subcategories: { AI: ['Tools'] },
}, },
}); });
const { getByTestId } = renderComponent(ParameterInputFull, { const { getByTestId } = renderComponent();
pinia: createTestingPinia(),
props: {
path: 'myParam',
parameter: {
displayName: 'My Param',
name: 'myParam',
type: 'string',
},
value: '',
},
});
expect(getByTestId('parameter-input')).toBeInTheDocument(); expect(getByTestId('parameter-input')).toBeInTheDocument();
expect(getByTestId('from-ai-override-button')).toBeInTheDocument(); expect(getByTestId('from-ai-override-button')).toBeInTheDocument();
}); });
@@ -118,15 +112,8 @@ describe('ParameterInputFull.vue', () => {
subcategories: { AI: ['Tools'] }, subcategories: { AI: ['Tools'] },
}, },
}); });
const { getByTestId } = renderComponent(ParameterInputFull, { const { getByTestId } = renderComponent({
pinia: createTestingPinia(),
props: { props: {
path: 'myParam',
parameter: {
displayName: 'My Param',
name: 'myParam',
type: 'string',
},
value: `={{ value: `={{
'and the air is free' 'and the air is free'
@@ -145,19 +132,27 @@ describe('ParameterInputFull.vue', () => {
subcategories: { AI: ['Tools'] }, subcategories: { AI: ['Tools'] },
}, },
}); });
const { queryByTestId, getByTestId } = renderComponent(ParameterInputFull, { const { queryByTestId, getByTestId } = renderComponent({
pinia: createTestingPinia(),
props: { props: {
path: 'myParam',
value: `={{ ${FROM_AI_AUTO_GENERATED_MARKER} $fromAI('myParam') }}`, value: `={{ ${FROM_AI_AUTO_GENERATED_MARKER} $fromAI('myParam') }}`,
parameter: {
displayName: 'My Param',
name: 'myParam',
type: 'string',
},
}, },
}); });
expect(getByTestId('fromAI-override-field')).toBeInTheDocument(); expect(getByTestId('fromAI-override-field')).toBeInTheDocument();
expect(queryByTestId('override-button')).not.toBeInTheDocument(); expect(queryByTestId('override-button')).not.toBeInTheDocument();
}); });
it('should emit on wrapper hover', async () => {
const { getByTestId, emitted } = renderComponent();
const wrapper = getByTestId('input-label');
await fireEvent.mouseEnter(wrapper);
await nextTick();
expect(emitted().hover).toEqual([[true]]);
await fireEvent.mouseLeave(wrapper);
await nextTick();
expect(emitted().hover).toEqual([[true], [false]]);
});
}); });

View File

@@ -57,6 +57,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{ const emit = defineEmits<{
blur: []; blur: [];
update: [value: IUpdateInformation]; update: [value: IUpdateInformation];
hover: [hovered: boolean];
}>(); }>();
const i18n = useI18n(); const i18n = useI18n();
@@ -66,6 +67,7 @@ const eventBus = ref(createEventBus());
const focused = ref(false); const focused = ref(false);
const menuExpanded = ref(false); const menuExpanded = ref(false);
const forceShowExpression = ref(false); const forceShowExpression = ref(false);
const wrapperHovered = ref(false);
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
@@ -139,6 +141,14 @@ function onMenuExpanded(expanded: boolean) {
menuExpanded.value = expanded; menuExpanded.value = expanded;
} }
function onWrapperMouseEnter() {
wrapperHovered.value = true;
}
function onWrapperMouseLeave() {
wrapperHovered.value = false;
}
function optionSelected(command: string) { function optionSelected(command: string) {
if (isContentOverride.value && command === 'resetValue') { if (isContentOverride.value && command === 'resetValue') {
removeOverride(true); removeOverride(true);
@@ -249,6 +259,10 @@ watch(
}, },
); );
watch(wrapperHovered, (hovered) => {
emit('hover', hovered);
});
const parameterInputWrapper = useTemplateRef('parameterInputWrapper'); const parameterInputWrapper = useTemplateRef('parameterInputWrapper');
const isSingleLineInput: ComputedRef<boolean> = computed( const isSingleLineInput: ComputedRef<boolean> = computed(
() => parameterInputWrapper.value?.isSingleLineInput ?? false, () => parameterInputWrapper.value?.isSingleLineInput ?? false,
@@ -305,6 +319,8 @@ function removeOverride(clearField = false) {
:bold="false" :bold="false"
:size="label.size" :size="label.size"
color="text-dark" color="text-dark"
@mouseenter="onWrapperMouseEnter"
@mouseleave="onWrapperMouseLeave"
> >
<template <template
v-if="showOverrideButton && !isSingleLineInput && optionsPosition === 'top'" v-if="showOverrideButton && !isSingleLineInput && optionsPosition === 'top'"