mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 16:57:08 +02:00
fix(editor): Prevent expression result popover from covering CodeMirror tooltips (#30981)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d7f70b637c
commit
cadba03974
|
|
@ -63,6 +63,25 @@ describe('components', () => {
|
|||
expect(wrapper.emitted()).toBeDefined();
|
||||
});
|
||||
|
||||
it('emits update:open when opened via Cmd+K and when closed via Escape', async () => {
|
||||
const wrapper = render(N8nCommandBar, {
|
||||
props: { items: createSampleItems() },
|
||||
});
|
||||
|
||||
await openCommandBar();
|
||||
|
||||
const openEvents = wrapper.emitted('update:open') ?? [];
|
||||
expect(openEvents.length).toBeGreaterThanOrEqual(1);
|
||||
expect(openEvents[openEvents.length - 1]).toEqual([true]);
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const events = wrapper.emitted('update:open') ?? [];
|
||||
expect(events[events.length - 1]).toEqual([false]);
|
||||
});
|
||||
});
|
||||
|
||||
it('emits inputChange and filters results as user types', async () => {
|
||||
const wrapper = render(N8nCommandBar, {
|
||||
props: { items: createSampleItems() },
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const emit = defineEmits<{
|
|||
const NUM_LOADING_ITEMS_FULL = 8;
|
||||
const NUM_LOADING_ITEMS_PARTIAL = 3;
|
||||
|
||||
const isOpen = ref(false);
|
||||
const isOpen = defineModel<boolean>('open', { default: false });
|
||||
const inputRef = ref<HTMLInputElement>();
|
||||
const selectedIndex = ref(-1);
|
||||
const inputValue = ref('');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { render } from '@testing-library/vue';
|
||||
import { render, waitFor } from '@testing-library/vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
|
|
@ -140,5 +140,34 @@ describe('N8nPopover', () => {
|
|||
|
||||
expect(popover.contains(document.activeElement)).toBe(false);
|
||||
});
|
||||
|
||||
it('should suppress focus restore on close when suppressAutoFocus is true', async () => {
|
||||
const triggerHandle = document.createElement('button');
|
||||
triggerHandle.textContent = 'trigger';
|
||||
document.body.appendChild(triggerHandle);
|
||||
|
||||
const externalInput = document.createElement('input');
|
||||
document.body.appendChild(externalInput);
|
||||
|
||||
const wrapper = render(N8nPopover, {
|
||||
props: { open: true, suppressAutoFocus: true },
|
||||
slots: {
|
||||
trigger: '<button>trigger</button>',
|
||||
content: '<input />',
|
||||
},
|
||||
});
|
||||
await wrapper.findByRole('dialog');
|
||||
|
||||
externalInput.focus();
|
||||
expect(document.activeElement).toBe(externalInput);
|
||||
|
||||
await wrapper.rerender({ open: false, suppressAutoFocus: true });
|
||||
await waitFor(() => expect(wrapper.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
|
||||
expect(document.activeElement).toBe(externalInput);
|
||||
|
||||
triggerHandle.remove();
|
||||
externalInput.remove();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ interface Props
|
|||
*/
|
||||
enableSlideIn?: boolean;
|
||||
/**
|
||||
* Whether to suppress auto-focus behavior when the content includes focusable element
|
||||
* Whether to suppress auto-focus behavior when the popover opens or closes.
|
||||
*/
|
||||
suppressAutoFocus?: boolean;
|
||||
/**
|
||||
|
|
@ -108,6 +108,12 @@ function handleOpenAutoFocus(e: Event) {
|
|||
}
|
||||
}
|
||||
|
||||
function handleCloseAutoFocus(e: Event) {
|
||||
if (props.suppressAutoFocus) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles outside interaction events to prevent Reka UI from interfering
|
||||
* with Element Plus dropdown selections. Element Plus teleports dropdowns
|
||||
|
|
@ -155,6 +161,7 @@ watch(
|
|||
:force-mount="forceMount"
|
||||
:position-strategy="positionStrategy"
|
||||
@open-auto-focus="handleOpenAutoFocus"
|
||||
@close-auto-focus="handleCloseAutoFocus"
|
||||
@pointer-down-outside="handleOutsideInteraction"
|
||||
@interact-outside="handleOutsideInteraction"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -112,7 +112,9 @@ useExposeCssVar('--ask-assistant--floating-button--margin-bottom', askAiFloating
|
|||
</AppLayout>
|
||||
<AppModals />
|
||||
<AppCommandBar />
|
||||
<div :id="CODEMIRROR_TOOLTIP_CONTAINER_ELEMENT_ID" />
|
||||
<template #overlays>
|
||||
<div :id="CODEMIRROR_TOOLTIP_CONTAINER_ELEMENT_ID" />
|
||||
</template>
|
||||
<template #aside>
|
||||
<AppChatPanel v-if="layoutRef" :layout-ref="layoutRef" />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { VIEWS } from '@/app/constants';
|
|||
import { useStyles } from '@/app/composables/useStyles';
|
||||
import { useCommandBar } from '@/features/shared/commandBar/composables/useCommandBar';
|
||||
import { hasPermission } from '@/app/utils/rbac/permissions';
|
||||
import { commandBarEventBus } from '@/features/shared/commandBar/commandBar.eventBus';
|
||||
|
||||
const route = useRoute();
|
||||
const { APP_Z_INDEXES } = useStyles();
|
||||
|
|
@ -29,6 +30,12 @@ watch(showCommandBar, (newVal) => {
|
|||
void initializeCommandBar();
|
||||
}
|
||||
});
|
||||
|
||||
function onCommandBarOpenChange(open: boolean) {
|
||||
if (open) {
|
||||
commandBarEventBus.emit('open');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -41,5 +48,6 @@ watch(showCommandBar, (newVal) => {
|
|||
:z-index="APP_Z_INDEXES.COMMAND_BAR"
|
||||
@input-change="onCommandBarChange"
|
||||
@navigate-to="onCommandBarNavigateTo"
|
||||
@update:open="onCommandBarOpenChange"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -86,6 +86,14 @@ function onFocus() {
|
|||
emit('focus');
|
||||
}
|
||||
|
||||
function onFocusOut(event: FocusEvent) {
|
||||
const nextFocus = event.relatedTarget;
|
||||
if (isEventTargetContainedBy(nextFocus, container)) return;
|
||||
if (isEventTargetContainedBy(nextFocus, outputPopover.value?.contentRef)) return;
|
||||
|
||||
onBlur(event);
|
||||
}
|
||||
|
||||
function onBlur(event?: FocusEvent | KeyboardEvent) {
|
||||
if (
|
||||
event?.target instanceof Element &&
|
||||
|
|
@ -191,7 +199,12 @@ defineExpose({ focus, select });
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" :class="$style['expression-parameter-input']" @keydown.tab="onBlur">
|
||||
<div
|
||||
ref="container"
|
||||
:class="$style['expression-parameter-input']"
|
||||
@keydown.tab="onBlur"
|
||||
@focusout="onFocusOut"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
$style['all-sections'],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
|
||||
export interface CommandBarEventBusEvents {
|
||||
/** Event that the command bar has opened */
|
||||
open: never;
|
||||
}
|
||||
|
||||
export const commandBarEventBus = createEventBus<CommandBarEventBusEvents>();
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
closeCompletion,
|
||||
CompletionContext,
|
||||
completionStatus,
|
||||
type Completion,
|
||||
|
|
@ -21,6 +22,7 @@ import {
|
|||
import type { SyntaxNode } from '@lezer/common';
|
||||
import type { createInfoBoxRenderer } from '../completions/infoBoxRenderer';
|
||||
import { CODEMIRROR_TOOLTIP_CONTAINER_ELEMENT_ID } from '@/app/constants';
|
||||
import { commandBarEventBus } from '@/features/shared/commandBar/commandBar.eventBus';
|
||||
|
||||
const findNearestParentOfType =
|
||||
(type: string) =>
|
||||
|
|
@ -397,6 +399,24 @@ export const closeCursorInfoBox: Command = (view) => {
|
|||
return true;
|
||||
};
|
||||
|
||||
const closeTooltipsOnCommandBarOpen = ViewPlugin.fromClass(
|
||||
class {
|
||||
private readonly listener: () => void;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.listener = () => {
|
||||
closeCompletion(view);
|
||||
closeCursorInfoBox(view);
|
||||
};
|
||||
commandBarEventBus.on('open', this.listener);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
commandBarEventBus.off('open', this.listener);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const infoBoxTooltips = (): Extension[] => {
|
||||
return [
|
||||
tooltips({
|
||||
|
|
@ -405,6 +425,7 @@ export const infoBoxTooltips = (): Extension[] => {
|
|||
cursorInfoBoxTooltip,
|
||||
asyncTooltipLoader,
|
||||
hoverInfoBoxTooltip,
|
||||
closeTooltipsOnCommandBarOpen,
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Escape',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import * as utils from '@/features/shared/editors/plugins/codemirror/completions
|
|||
import * as workflowHelpers from '@/app/composables/useWorkflowHelpers';
|
||||
import { completionStatus } from '@codemirror/autocomplete';
|
||||
import { WORKFLOW_DOCUMENT_FACET } from '@/features/shared/editors/plugins/codemirror/completions/constants';
|
||||
import { commandBarEventBus } from '@/features/shared/commandBar/commandBar.eventBus';
|
||||
|
||||
vi.mock('@codemirror/autocomplete', async (importOriginal) => {
|
||||
const actual = await importOriginal<{}>();
|
||||
|
|
@ -31,6 +32,15 @@ describe('Infobox tooltips', () => {
|
|||
});
|
||||
|
||||
describe('Cursor tooltips', () => {
|
||||
test('should dismiss the cursor info-box when the command bar opens', async () => {
|
||||
const view = await setupEditorWithCursor('{{ $max(|) }}');
|
||||
expect(getCursorTooltips(view).length).toBe(1);
|
||||
|
||||
commandBarEventBus.emit('open');
|
||||
|
||||
expect(getCursorTooltips(view).length).toBe(0);
|
||||
});
|
||||
|
||||
test('should NOT show a tooltip for: {{ $max(1,2) }} foo|', async () => {
|
||||
const tooltips = await cursorTooltips('{{ $max(1,2) }} foo|');
|
||||
expect(tooltips.length).toBe(0);
|
||||
|
|
@ -136,7 +146,7 @@ function infoBoxHeader(infoBox: HTMLElement | undefined) {
|
|||
return infoBox?.querySelector('.autocomplete-info-header');
|
||||
}
|
||||
|
||||
async function cursorTooltips(docWithCursor: string) {
|
||||
async function setupEditorWithCursor(docWithCursor: string) {
|
||||
const cursorPosition = docWithCursor.indexOf('|');
|
||||
|
||||
const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1);
|
||||
|
|
@ -157,12 +167,20 @@ async function cursorTooltips(docWithCursor: string) {
|
|||
view.requestMeasure();
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function getCursorTooltips(view: EditorView) {
|
||||
return view.state
|
||||
.facet(showTooltip)
|
||||
.filter((t): t is Tooltip => !!t)
|
||||
.map((tooltip) => ({ tooltip, view: getTooltip(view, tooltip)?.dom }));
|
||||
}
|
||||
|
||||
async function cursorTooltips(docWithCursor: string) {
|
||||
return getCursorTooltips(await setupEditorWithCursor(docWithCursor));
|
||||
}
|
||||
|
||||
async function hoverTooltip(docWithCursor: string) {
|
||||
const hoverPosition = docWithCursor.indexOf('|');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user