This is an automated email from the ASF dual-hosted git repository.
enzomartellucci pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 9ef87e75d5 fix(ace-editor-popover): main AntD popover closes when
clicking autocomplete suggestions in Ace Editor (#35986)
9ef87e75d5 is described below
commit 9ef87e75d506cb02f1d24a62daac8dfb20073193
Author: Enzo Martellucci <[email protected]>
AuthorDate: Fri Nov 14 16:35:15 2025 +0100
fix(ace-editor-popover): main AntD popover closes when clicking
autocomplete suggestions in Ace Editor (#35986)
---
.../AsyncAceEditor/AsyncAceEditor.test.tsx | 252 +++++++++++++++++++++
.../src/components/AsyncAceEditor/index.tsx | 81 ++++++-
2 files changed, 330 insertions(+), 3 deletions(-)
diff --git
a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/AsyncAceEditor.test.tsx
b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/AsyncAceEditor.test.tsx
index 151b1db1a9..51c78d0b9c 100644
---
a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/AsyncAceEditor.test.tsx
+++
b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/AsyncAceEditor.test.tsx
@@ -16,7 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { createRef } from 'react';
import { render, screen, waitFor } from '@superset-ui/core/spec';
+import type AceEditor from 'react-ace';
import {
AsyncAceEditor,
SQLEditor,
@@ -99,3 +101,253 @@ test('renders a custom placeholder', () => {
expect(screen.getByRole('paragraph')).toBeInTheDocument();
});
+
+test('registers afterExec event listener for command handling', async () => {
+ const ref = createRef<AceEditor>();
+ const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
+
+ await waitFor(() => {
+ expect(container.querySelector(selector)).toBeInTheDocument();
+ });
+
+ const editorInstance = ref.current?.editor;
+ expect(editorInstance).toBeDefined();
+
+ if (!editorInstance) return;
+
+ // Verify the commands object has the 'on' method (confirms event listener
capability)
+ expect(editorInstance.commands).toHaveProperty('on');
+ expect(typeof editorInstance.commands.on).toBe('function');
+});
+
+test('moves autocomplete popup to parent container when triggered', async ()
=> {
+ const ref = createRef<AceEditor>();
+ const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
+
+ await waitFor(() => {
+ expect(container.querySelector(selector)).toBeInTheDocument();
+ });
+
+ const editorInstance = ref.current?.editor;
+ expect(editorInstance).toBeDefined();
+
+ if (!editorInstance) return;
+
+ // Create a mock autocomplete popup in the editor container
+ const mockAutocompletePopup = document.createElement('div');
+ mockAutocompletePopup.className = 'ace_autocomplete';
+ editorInstance.container?.appendChild(mockAutocompletePopup);
+
+ const parentContainer =
+ editorInstance.container?.closest('#ace-editor') ??
+ editorInstance.container?.parentElement;
+
+ // Manually trigger the afterExec event with insertstring command using _emit
+ type CommandManagerWithEmit = typeof editorInstance.commands & {
+ _emit: (event: string, data: unknown) => void;
+ };
+ (editorInstance.commands as CommandManagerWithEmit)._emit('afterExec', {
+ command: { name: 'insertstring' },
+ args: ['SELECT'],
+ });
+
+ await waitFor(() => {
+ // Check that the popup has the data attribute set
+ expect(mockAutocompletePopup.dataset.aceAutocomplete).toBe('true');
+ // Check that the popup is in the parent container
+ expect(parentContainer?.contains(mockAutocompletePopup)).toBe(true);
+ });
+});
+
+test('moves autocomplete popup on startAutocomplete command event', async ()
=> {
+ const ref = createRef<AceEditor>();
+ const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
+
+ await waitFor(() => {
+ expect(container.querySelector(selector)).toBeInTheDocument();
+ });
+
+ const editorInstance = ref.current?.editor;
+ expect(editorInstance).toBeDefined();
+
+ if (!editorInstance) return;
+
+ // Create a mock autocomplete popup
+ const mockAutocompletePopup = document.createElement('div');
+ mockAutocompletePopup.className = 'ace_autocomplete';
+ editorInstance.container?.appendChild(mockAutocompletePopup);
+
+ const parentContainer =
+ editorInstance.container?.closest('#ace-editor') ??
+ editorInstance.container?.parentElement;
+
+ // Manually trigger the afterExec event with startAutocomplete command
+ type CommandManagerWithEmit = typeof editorInstance.commands & {
+ _emit: (event: string, data: unknown) => void;
+ };
+ (editorInstance.commands as CommandManagerWithEmit)._emit('afterExec', {
+ command: { name: 'startAutocomplete' },
+ });
+
+ await waitFor(() => {
+ // Check that the popup has the data attribute set
+ expect(mockAutocompletePopup.dataset.aceAutocomplete).toBe('true');
+ // Check that the popup is in the parent container
+ expect(parentContainer?.contains(mockAutocompletePopup)).toBe(true);
+ });
+});
+
+test('does not move autocomplete popup on unrelated commands', async () => {
+ const ref = createRef<AceEditor>();
+ const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
+
+ await waitFor(() => {
+ expect(container.querySelector(selector)).toBeInTheDocument();
+ });
+
+ const editorInstance = ref.current?.editor;
+ expect(editorInstance).toBeDefined();
+
+ if (!editorInstance) return;
+
+ // Create a mock autocomplete popup in the body
+ const mockAutocompletePopup = document.createElement('div');
+ mockAutocompletePopup.className = 'ace_autocomplete';
+ document.body.appendChild(mockAutocompletePopup);
+
+ const originalParent = mockAutocompletePopup.parentElement;
+
+ // Simulate an unrelated command (e.g., 'selectall')
+ editorInstance.commands.exec('selectall', editorInstance, {});
+
+ // Wait a bit to ensure no movement happens
+ await new Promise(resolve => {
+ setTimeout(resolve, 100);
+ });
+
+ // The popup should remain in its original location
+ expect(mockAutocompletePopup.parentElement).toBe(originalParent);
+
+ // Cleanup
+ document.body.removeChild(mockAutocompletePopup);
+});
+
+test('revalidates cached autocomplete popup when detached from DOM', async ()
=> {
+ const ref = createRef<AceEditor>();
+ const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
+
+ await waitFor(() => {
+ expect(container.querySelector(selector)).toBeInTheDocument();
+ });
+
+ const editorInstance = ref.current?.editor;
+ expect(editorInstance).toBeDefined();
+
+ if (!editorInstance) return;
+
+ // Create first autocomplete popup
+ const firstPopup = document.createElement('div');
+ firstPopup.className = 'ace_autocomplete';
+ editorInstance.container?.appendChild(firstPopup);
+
+ // Trigger command to cache the first popup
+ editorInstance.commands.exec('insertstring', editorInstance, 'SELECT');
+
+ await waitFor(() => {
+ expect(firstPopup.dataset.aceAutocomplete).toBe('true');
+ });
+
+ // Remove the first popup from DOM (simulating ACE editor replacing it)
+ firstPopup.remove();
+
+ // Create a new autocomplete popup
+ const secondPopup = document.createElement('div');
+ secondPopup.className = 'ace_autocomplete';
+ editorInstance.container?.appendChild(secondPopup);
+
+ // Trigger command again - should find and move the new popup
+ editorInstance.commands.exec('insertstring', editorInstance, ' ');
+
+ await waitFor(() => {
+ expect(secondPopup.dataset.aceAutocomplete).toBe('true');
+ const parentContainer =
+ editorInstance.container?.closest('#ace-editor') ??
+ editorInstance.container?.parentElement;
+ expect(parentContainer?.contains(secondPopup)).toBe(true);
+ });
+});
+
+test('cleans up event listeners on unmount', async () => {
+ const ref = createRef<AceEditor>();
+ const { container, unmount } = render(
+ <SQLEditor ref={ref as React.Ref<never>} />,
+ );
+ await waitFor(() => {
+ expect(container.querySelector(selector)).toBeInTheDocument();
+ });
+
+ const editorInstance = ref.current?.editor;
+ expect(editorInstance).toBeDefined();
+
+ if (!editorInstance) return;
+
+ // Spy on the commands.off method
+ const offSpy = jest.spyOn(editorInstance.commands, 'off');
+
+ // Unmount the component
+ unmount();
+
+ // Verify that the event listener was removed
+ expect(offSpy).toHaveBeenCalledWith('afterExec', expect.any(Function));
+
+ offSpy.mockRestore();
+});
+
+test('does not move autocomplete popup if target container is document.body',
async () => {
+ const ref = createRef<AceEditor>();
+ const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
+
+ await waitFor(() => {
+ expect(container.querySelector(selector)).toBeInTheDocument();
+ });
+
+ const editorInstance = ref.current?.editor;
+ expect(editorInstance).toBeDefined();
+
+ if (!editorInstance) return;
+
+ // Create a mock autocomplete popup
+ const mockAutocompletePopup = document.createElement('div');
+ mockAutocompletePopup.className = 'ace_autocomplete';
+ document.body.appendChild(mockAutocompletePopup);
+
+ // Mock the closest method to return null (simulating no #ace-editor parent)
+ const originalClosest = editorInstance.container?.closest;
+ if (editorInstance.container) {
+ editorInstance.container.closest = jest.fn(() => null);
+ }
+
+ // Mock parentElement to be document.body
+ Object.defineProperty(editorInstance.container, 'parentElement', {
+ value: document.body,
+ configurable: true,
+ });
+
+ const initialParent = mockAutocompletePopup.parentElement;
+
+ // Trigger command
+ editorInstance.commands.exec('insertstring', editorInstance, 'SELECT');
+
+ await new Promise(resolve => {
+ setTimeout(resolve, 100);
+ });
+
+ // The popup should NOT be moved because target container is document.body
+ expect(mockAutocompletePopup.parentElement).toBe(initialParent);
+
+ // Cleanup
+ if (editorInstance.container && originalClosest) {
+ editorInstance.container.closest = originalClosest;
+ }
+ document.body.removeChild(mockAutocompletePopup);
+});
diff --git
a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx
b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx
index 7609ab7527..34609a2a1c 100644
---
a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx
+++
b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx
@@ -26,6 +26,7 @@ import type {
} from 'brace';
import type AceEditor from 'react-ace';
import type { IAceEditorProps } from 'react-ace';
+import type { Ace } from 'ace-builds';
import {
AsyncEsmComponent,
@@ -207,6 +208,68 @@ export function AsyncAceEditor(
}
}, [keywords, setCompleters]);
+ // Move autocomplete popup to the nearest parent container with
data-ace-container
+ useEffect(() => {
+ const editorInstance = (ref as React.RefObject<AceEditor>)?.current
+ ?.editor;
+ if (!editorInstance) return;
+
+ const editorContainer = editorInstance.container;
+ if (!editorContainer) return;
+
+ // Cache DOM elements to avoid repeated queries on every command
execution
+ let cachedAutocompletePopup: HTMLElement | null = null;
+ let cachedTargetContainer: Element | null = null;
+
+ const moveAutocompleteToContainer = () => {
+ // Revalidate cached popup if missing or detached from DOM
+ if (
+ !cachedAutocompletePopup ||
+ !document.body.contains(cachedAutocompletePopup)
+ ) {
+ cachedAutocompletePopup =
+ editorContainer.querySelector<HTMLElement>(
+ '.ace_autocomplete',
+ ) ?? document.querySelector<HTMLElement>('.ace_autocomplete');
+ }
+
+ // Revalidate cached container if missing or detached
+ if (
+ !cachedTargetContainer ||
+ !document.body.contains(cachedTargetContainer)
+ ) {
+ cachedTargetContainer =
+ editorContainer.closest('#ace-editor') ??
+ editorContainer.parentElement;
+ }
+
+ if (
+ cachedAutocompletePopup &&
+ cachedTargetContainer &&
+ cachedTargetContainer !== document.body
+ ) {
+ cachedTargetContainer.appendChild(cachedAutocompletePopup);
+ cachedAutocompletePopup.dataset.aceAutocomplete = 'true';
+ }
+ };
+
+ const handleAfterExec = (e: Ace.Operation) => {
+ const name: string | undefined = e?.command?.name;
+ if (name === 'insertstring' || name === 'startAutocomplete') {
+ moveAutocompleteToContainer();
+ }
+ };
+
+ const { commands } = editorInstance;
+ commands.on('afterExec', handleAfterExec);
+
+ return () => {
+ commands.off('afterExec', handleAfterExec);
+ cachedAutocompletePopup = null;
+ cachedTargetContainer = null;
+ };
+ }, [ref]);
+
return (
<>
<Global
@@ -288,14 +351,24 @@ export function AsyncAceEditor(
border: 1px solid ${token.colorBorderSecondary};
box-shadow: ${token.boxShadow};
border-radius: ${token.borderRadius}px;
+ padding: ${token.paddingXS}px ${token.paddingXS}px;
+ }
+
+ .ace_tooltip.ace_doc-tooltip {
+ display: flex !important;
}
- & .tooltip-detail {
+ &&& .tooltip-detail {
+ display: flex;
+ justify-content: center;
+ flex-direction: row;
+ gap: ${token.paddingXXS}px;
+ align-items: center;
background-color: ${token.colorBgContainer};
white-space: pre-wrap;
word-break: break-all;
- min-width: ${token.sizeXXL * 5}px;
max-width: ${token.sizeXXL * 10}px;
+ font-size: ${token.fontSize}px;
& .tooltip-detail-head {
background-color: ${token.colorBgElevated};
@@ -318,7 +391,9 @@ export function AsyncAceEditor(
& .tooltip-detail-head,
& .tooltip-detail-body {
- padding: ${token.padding}px ${token.paddingLG}px;
+ background-color: ${token.colorBgLayout};
+ padding: 0px ${token.paddingXXS}px;
+ border: 1px ${token.colorSplit} solid;
}
& .tooltip-detail-footer {