Skip to content

Commit 9ef87e7

Browse files
authored
fix(ace-editor-popover): main AntD popover closes when clicking autocomplete suggestions in Ace Editor (#35986)
1 parent f8933c2 commit 9ef87e7

File tree

2 files changed

+330
-3
lines changed

2 files changed

+330
-3
lines changed

superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/AsyncAceEditor.test.tsx

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
* specific language governing permissions and limitations
1717
* under the License.
1818
*/
19+
import { createRef } from 'react';
1920
import { render, screen, waitFor } from '@superset-ui/core/spec';
21+
import type AceEditor from 'react-ace';
2022
import {
2123
AsyncAceEditor,
2224
SQLEditor,
@@ -99,3 +101,253 @@ test('renders a custom placeholder', () => {
99101

100102
expect(screen.getByRole('paragraph')).toBeInTheDocument();
101103
});
104+
105+
test('registers afterExec event listener for command handling', async () => {
106+
const ref = createRef<AceEditor>();
107+
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
108+
109+
await waitFor(() => {
110+
expect(container.querySelector(selector)).toBeInTheDocument();
111+
});
112+
113+
const editorInstance = ref.current?.editor;
114+
expect(editorInstance).toBeDefined();
115+
116+
if (!editorInstance) return;
117+
118+
// Verify the commands object has the 'on' method (confirms event listener capability)
119+
expect(editorInstance.commands).toHaveProperty('on');
120+
expect(typeof editorInstance.commands.on).toBe('function');
121+
});
122+
123+
test('moves autocomplete popup to parent container when triggered', async () => {
124+
const ref = createRef<AceEditor>();
125+
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
126+
127+
await waitFor(() => {
128+
expect(container.querySelector(selector)).toBeInTheDocument();
129+
});
130+
131+
const editorInstance = ref.current?.editor;
132+
expect(editorInstance).toBeDefined();
133+
134+
if (!editorInstance) return;
135+
136+
// Create a mock autocomplete popup in the editor container
137+
const mockAutocompletePopup = document.createElement('div');
138+
mockAutocompletePopup.className = 'ace_autocomplete';
139+
editorInstance.container?.appendChild(mockAutocompletePopup);
140+
141+
const parentContainer =
142+
editorInstance.container?.closest('#ace-editor') ??
143+
editorInstance.container?.parentElement;
144+
145+
// Manually trigger the afterExec event with insertstring command using _emit
146+
type CommandManagerWithEmit = typeof editorInstance.commands & {
147+
_emit: (event: string, data: unknown) => void;
148+
};
149+
(editorInstance.commands as CommandManagerWithEmit)._emit('afterExec', {
150+
command: { name: 'insertstring' },
151+
args: ['SELECT'],
152+
});
153+
154+
await waitFor(() => {
155+
// Check that the popup has the data attribute set
156+
expect(mockAutocompletePopup.dataset.aceAutocomplete).toBe('true');
157+
// Check that the popup is in the parent container
158+
expect(parentContainer?.contains(mockAutocompletePopup)).toBe(true);
159+
});
160+
});
161+
162+
test('moves autocomplete popup on startAutocomplete command event', async () => {
163+
const ref = createRef<AceEditor>();
164+
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
165+
166+
await waitFor(() => {
167+
expect(container.querySelector(selector)).toBeInTheDocument();
168+
});
169+
170+
const editorInstance = ref.current?.editor;
171+
expect(editorInstance).toBeDefined();
172+
173+
if (!editorInstance) return;
174+
175+
// Create a mock autocomplete popup
176+
const mockAutocompletePopup = document.createElement('div');
177+
mockAutocompletePopup.className = 'ace_autocomplete';
178+
editorInstance.container?.appendChild(mockAutocompletePopup);
179+
180+
const parentContainer =
181+
editorInstance.container?.closest('#ace-editor') ??
182+
editorInstance.container?.parentElement;
183+
184+
// Manually trigger the afterExec event with startAutocomplete command
185+
type CommandManagerWithEmit = typeof editorInstance.commands & {
186+
_emit: (event: string, data: unknown) => void;
187+
};
188+
(editorInstance.commands as CommandManagerWithEmit)._emit('afterExec', {
189+
command: { name: 'startAutocomplete' },
190+
});
191+
192+
await waitFor(() => {
193+
// Check that the popup has the data attribute set
194+
expect(mockAutocompletePopup.dataset.aceAutocomplete).toBe('true');
195+
// Check that the popup is in the parent container
196+
expect(parentContainer?.contains(mockAutocompletePopup)).toBe(true);
197+
});
198+
});
199+
200+
test('does not move autocomplete popup on unrelated commands', async () => {
201+
const ref = createRef<AceEditor>();
202+
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
203+
204+
await waitFor(() => {
205+
expect(container.querySelector(selector)).toBeInTheDocument();
206+
});
207+
208+
const editorInstance = ref.current?.editor;
209+
expect(editorInstance).toBeDefined();
210+
211+
if (!editorInstance) return;
212+
213+
// Create a mock autocomplete popup in the body
214+
const mockAutocompletePopup = document.createElement('div');
215+
mockAutocompletePopup.className = 'ace_autocomplete';
216+
document.body.appendChild(mockAutocompletePopup);
217+
218+
const originalParent = mockAutocompletePopup.parentElement;
219+
220+
// Simulate an unrelated command (e.g., 'selectall')
221+
editorInstance.commands.exec('selectall', editorInstance, {});
222+
223+
// Wait a bit to ensure no movement happens
224+
await new Promise(resolve => {
225+
setTimeout(resolve, 100);
226+
});
227+
228+
// The popup should remain in its original location
229+
expect(mockAutocompletePopup.parentElement).toBe(originalParent);
230+
231+
// Cleanup
232+
document.body.removeChild(mockAutocompletePopup);
233+
});
234+
235+
test('revalidates cached autocomplete popup when detached from DOM', async () => {
236+
const ref = createRef<AceEditor>();
237+
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
238+
239+
await waitFor(() => {
240+
expect(container.querySelector(selector)).toBeInTheDocument();
241+
});
242+
243+
const editorInstance = ref.current?.editor;
244+
expect(editorInstance).toBeDefined();
245+
246+
if (!editorInstance) return;
247+
248+
// Create first autocomplete popup
249+
const firstPopup = document.createElement('div');
250+
firstPopup.className = 'ace_autocomplete';
251+
editorInstance.container?.appendChild(firstPopup);
252+
253+
// Trigger command to cache the first popup
254+
editorInstance.commands.exec('insertstring', editorInstance, 'SELECT');
255+
256+
await waitFor(() => {
257+
expect(firstPopup.dataset.aceAutocomplete).toBe('true');
258+
});
259+
260+
// Remove the first popup from DOM (simulating ACE editor replacing it)
261+
firstPopup.remove();
262+
263+
// Create a new autocomplete popup
264+
const secondPopup = document.createElement('div');
265+
secondPopup.className = 'ace_autocomplete';
266+
editorInstance.container?.appendChild(secondPopup);
267+
268+
// Trigger command again - should find and move the new popup
269+
editorInstance.commands.exec('insertstring', editorInstance, ' ');
270+
271+
await waitFor(() => {
272+
expect(secondPopup.dataset.aceAutocomplete).toBe('true');
273+
const parentContainer =
274+
editorInstance.container?.closest('#ace-editor') ??
275+
editorInstance.container?.parentElement;
276+
expect(parentContainer?.contains(secondPopup)).toBe(true);
277+
});
278+
});
279+
280+
test('cleans up event listeners on unmount', async () => {
281+
const ref = createRef<AceEditor>();
282+
const { container, unmount } = render(
283+
<SQLEditor ref={ref as React.Ref<never>} />,
284+
);
285+
await waitFor(() => {
286+
expect(container.querySelector(selector)).toBeInTheDocument();
287+
});
288+
289+
const editorInstance = ref.current?.editor;
290+
expect(editorInstance).toBeDefined();
291+
292+
if (!editorInstance) return;
293+
294+
// Spy on the commands.off method
295+
const offSpy = jest.spyOn(editorInstance.commands, 'off');
296+
297+
// Unmount the component
298+
unmount();
299+
300+
// Verify that the event listener was removed
301+
expect(offSpy).toHaveBeenCalledWith('afterExec', expect.any(Function));
302+
303+
offSpy.mockRestore();
304+
});
305+
306+
test('does not move autocomplete popup if target container is document.body', async () => {
307+
const ref = createRef<AceEditor>();
308+
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
309+
310+
await waitFor(() => {
311+
expect(container.querySelector(selector)).toBeInTheDocument();
312+
});
313+
314+
const editorInstance = ref.current?.editor;
315+
expect(editorInstance).toBeDefined();
316+
317+
if (!editorInstance) return;
318+
319+
// Create a mock autocomplete popup
320+
const mockAutocompletePopup = document.createElement('div');
321+
mockAutocompletePopup.className = 'ace_autocomplete';
322+
document.body.appendChild(mockAutocompletePopup);
323+
324+
// Mock the closest method to return null (simulating no #ace-editor parent)
325+
const originalClosest = editorInstance.container?.closest;
326+
if (editorInstance.container) {
327+
editorInstance.container.closest = jest.fn(() => null);
328+
}
329+
330+
// Mock parentElement to be document.body
331+
Object.defineProperty(editorInstance.container, 'parentElement', {
332+
value: document.body,
333+
configurable: true,
334+
});
335+
336+
const initialParent = mockAutocompletePopup.parentElement;
337+
338+
// Trigger command
339+
editorInstance.commands.exec('insertstring', editorInstance, 'SELECT');
340+
341+
await new Promise(resolve => {
342+
setTimeout(resolve, 100);
343+
});
344+
345+
// The popup should NOT be moved because target container is document.body
346+
expect(mockAutocompletePopup.parentElement).toBe(initialParent);
347+
348+
// Cleanup
349+
if (editorInstance.container && originalClosest) {
350+
editorInstance.container.closest = originalClosest;
351+
}
352+
document.body.removeChild(mockAutocompletePopup);
353+
});

superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type {
2626
} from 'brace';
2727
import type AceEditor from 'react-ace';
2828
import type { IAceEditorProps } from 'react-ace';
29+
import type { Ace } from 'ace-builds';
2930

3031
import {
3132
AsyncEsmComponent,
@@ -207,6 +208,68 @@ export function AsyncAceEditor(
207208
}
208209
}, [keywords, setCompleters]);
209210

211+
// Move autocomplete popup to the nearest parent container with data-ace-container
212+
useEffect(() => {
213+
const editorInstance = (ref as React.RefObject<AceEditor>)?.current
214+
?.editor;
215+
if (!editorInstance) return;
216+
217+
const editorContainer = editorInstance.container;
218+
if (!editorContainer) return;
219+
220+
// Cache DOM elements to avoid repeated queries on every command execution
221+
let cachedAutocompletePopup: HTMLElement | null = null;
222+
let cachedTargetContainer: Element | null = null;
223+
224+
const moveAutocompleteToContainer = () => {
225+
// Revalidate cached popup if missing or detached from DOM
226+
if (
227+
!cachedAutocompletePopup ||
228+
!document.body.contains(cachedAutocompletePopup)
229+
) {
230+
cachedAutocompletePopup =
231+
editorContainer.querySelector<HTMLElement>(
232+
'.ace_autocomplete',
233+
) ?? document.querySelector<HTMLElement>('.ace_autocomplete');
234+
}
235+
236+
// Revalidate cached container if missing or detached
237+
if (
238+
!cachedTargetContainer ||
239+
!document.body.contains(cachedTargetContainer)
240+
) {
241+
cachedTargetContainer =
242+
editorContainer.closest('#ace-editor') ??
243+
editorContainer.parentElement;
244+
}
245+
246+
if (
247+
cachedAutocompletePopup &&
248+
cachedTargetContainer &&
249+
cachedTargetContainer !== document.body
250+
) {
251+
cachedTargetContainer.appendChild(cachedAutocompletePopup);
252+
cachedAutocompletePopup.dataset.aceAutocomplete = 'true';
253+
}
254+
};
255+
256+
const handleAfterExec = (e: Ace.Operation) => {
257+
const name: string | undefined = e?.command?.name;
258+
if (name === 'insertstring' || name === 'startAutocomplete') {
259+
moveAutocompleteToContainer();
260+
}
261+
};
262+
263+
const { commands } = editorInstance;
264+
commands.on('afterExec', handleAfterExec);
265+
266+
return () => {
267+
commands.off('afterExec', handleAfterExec);
268+
cachedAutocompletePopup = null;
269+
cachedTargetContainer = null;
270+
};
271+
}, [ref]);
272+
210273
return (
211274
<>
212275
<Global
@@ -288,14 +351,24 @@ export function AsyncAceEditor(
288351
border: 1px solid ${token.colorBorderSecondary};
289352
box-shadow: ${token.boxShadow};
290353
border-radius: ${token.borderRadius}px;
354+
padding: ${token.paddingXS}px ${token.paddingXS}px;
355+
}
356+
357+
.ace_tooltip.ace_doc-tooltip {
358+
display: flex !important;
291359
}
292360
293-
& .tooltip-detail {
361+
&&& .tooltip-detail {
362+
display: flex;
363+
justify-content: center;
364+
flex-direction: row;
365+
gap: ${token.paddingXXS}px;
366+
align-items: center;
294367
background-color: ${token.colorBgContainer};
295368
white-space: pre-wrap;
296369
word-break: break-all;
297-
min-width: ${token.sizeXXL * 5}px;
298370
max-width: ${token.sizeXXL * 10}px;
371+
font-size: ${token.fontSize}px;
299372
300373
& .tooltip-detail-head {
301374
background-color: ${token.colorBgElevated};
@@ -318,7 +391,9 @@ export function AsyncAceEditor(
318391
319392
& .tooltip-detail-head,
320393
& .tooltip-detail-body {
321-
padding: ${token.padding}px ${token.paddingLG}px;
394+
background-color: ${token.colorBgLayout};
395+
padding: 0px ${token.paddingXXS}px;
396+
border: 1px ${token.colorSplit} solid;
322397
}
323398
324399
& .tooltip-detail-footer {

0 commit comments

Comments
 (0)