Skip to content

Commit 677b35e

Browse files
Fixes #26424: prevent table text selection turn white & anchor table actions to active cell (#27225)
* fix: selection fixed & menu works in mid of table Signed-off-by: hassaansaleem28 <iamhassaans@gmail.com> * fix: run make ui-checkstyle-src DONE Signed-off-by: hassaansaleem28 <iamhassaans@gmail.com> * copilot suggestion & refactor Signed-off-by: hassaansaleem28 <iamhassaans@gmail.com> * fix: text Color on selection Signed-off-by: hassaansaleem28 <iamhassaans@gmail.com> * add copiot suggestion Signed-off-by: hassaansaleem28 <iamhassaans@gmail.com> * Update openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/TableMenu/TableMenu.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/TableMenu/TableMenu.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/TableMenu/TableMenu.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/TableMenu/TableMenu.test.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor & run lint tests Signed-off-by: hassaansaleem28 <iamhassaans@gmail.com> * fix(ui): resolve merge conflict markers in table menu test * fix(ui): use TS5-safe closest typing in table menu * run lints tests Signed-off-by: hassaansaleem28 <iamhassaans@gmail.com> * chore(ui): drop unintended ui index.html change from branch * chore(ui): sync index.html CSP/GTM handling with upstream main --------- Signed-off-by: hassaansaleem28 <iamhassaans@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent c9770bf commit 677b35e

3 files changed

Lines changed: 367 additions & 8 deletions

File tree

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/*
2+
* Copyright 2024 Collate.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
* Unless required by applicable law or agreed to in writing, software
8+
* distributed under the License is distributed on an "AS IS" BASIS,
9+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
* See the License for the specific language governing permissions and
11+
* limitations under the License.
12+
*/
13+
import { render } from '@testing-library/react';
14+
import { Editor } from '@tiptap/react';
15+
import tippy from 'tippy.js';
16+
import TableMenu from './TableMenu';
17+
18+
jest.mock('tippy.js', () => {
19+
return {
20+
__esModule: true,
21+
default: jest.fn(),
22+
};
23+
});
24+
25+
const mockSetProps = jest.fn();
26+
const mockShow = jest.fn();
27+
const mockHide = jest.fn();
28+
const mockDestroy = jest.fn();
29+
const mockRun = jest.fn();
30+
31+
const createRect = (rect: Partial<DOMRect>): DOMRect => {
32+
return {
33+
x: 0,
34+
y: 0,
35+
width: 0,
36+
height: 0,
37+
top: 0,
38+
right: 0,
39+
bottom: 0,
40+
left: 0,
41+
toJSON: jest.fn(),
42+
...rect,
43+
} as DOMRect;
44+
};
45+
46+
const mockBoundingClientRect = (element: Element, rect: DOMRect) => {
47+
Object.defineProperty(element, 'getBoundingClientRect', {
48+
configurable: true,
49+
value: () => rect,
50+
});
51+
};
52+
53+
const createChainMethods = () => {
54+
return {
55+
addRowAfter: jest.fn().mockReturnValue({ run: mockRun }),
56+
addColumnAfter: jest.fn().mockReturnValue({ run: mockRun }),
57+
deleteRow: jest.fn().mockReturnValue({ run: mockRun }),
58+
deleteColumn: jest.fn().mockReturnValue({ run: mockRun }),
59+
deleteTable: jest.fn().mockReturnValue({ run: mockRun }),
60+
};
61+
};
62+
63+
const mockChain = jest.fn().mockImplementation(() => {
64+
return {
65+
focus: jest.fn().mockImplementation(() => createChainMethods()),
66+
};
67+
});
68+
69+
const mockEditor = {
70+
view: {
71+
dom: document.createElement('div'),
72+
},
73+
isEditable: true,
74+
chain: mockChain,
75+
} as unknown as Editor;
76+
77+
const renderTableMenu = () => {
78+
const rendered = render(<TableMenu editor={mockEditor} />);
79+
80+
const safeUnmount = () => {
81+
const calls = (tippy as unknown as jest.Mock).mock.calls;
82+
const latestCall = calls[calls.length - 1];
83+
const tippyOptions = latestCall?.[1] as
84+
| {
85+
content?: HTMLElement;
86+
}
87+
| undefined;
88+
const menuContent = tippyOptions?.content;
89+
90+
if (menuContent && !menuContent.isConnected) {
91+
rendered.container.appendChild(menuContent);
92+
}
93+
94+
rendered.unmount();
95+
};
96+
97+
return {
98+
...rendered,
99+
safeUnmount,
100+
};
101+
};
102+
103+
describe('TableMenu', () => {
104+
beforeEach(() => {
105+
jest.clearAllMocks();
106+
(tippy as unknown as jest.Mock).mockReturnValue({
107+
setProps: mockSetProps,
108+
show: mockShow,
109+
hide: mockHide,
110+
destroy: mockDestroy,
111+
});
112+
});
113+
114+
it('anchors menu to clicked table cell instead of full table bounds', () => {
115+
const { safeUnmount } = renderTableMenu();
116+
117+
const tableWrapper = document.createElement('div');
118+
tableWrapper.className = 'tableWrapper';
119+
120+
try {
121+
const table = document.createElement('table');
122+
const row = document.createElement('tr');
123+
const cell = document.createElement('td');
124+
const content = document.createElement('span');
125+
126+
row.appendChild(cell);
127+
cell.appendChild(content);
128+
table.appendChild(row);
129+
tableWrapper.appendChild(table);
130+
document.body.appendChild(tableWrapper);
131+
132+
const wrapperRect = createRect({
133+
x: 20,
134+
y: 10,
135+
top: 10,
136+
left: 20,
137+
right: 620,
138+
bottom: 410,
139+
width: 600,
140+
height: 400,
141+
});
142+
143+
const cellRect = createRect({
144+
x: 280,
145+
y: 180,
146+
top: 180,
147+
left: 280,
148+
right: 460,
149+
bottom: 212,
150+
width: 180,
151+
height: 32,
152+
});
153+
154+
mockBoundingClientRect(tableWrapper, wrapperRect);
155+
mockBoundingClientRect(cell, cellRect);
156+
157+
content.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
158+
159+
expect(mockSetProps).toHaveBeenCalledTimes(1);
160+
expect(mockShow).toHaveBeenCalledTimes(1);
161+
162+
const tippyProps = mockSetProps.mock.calls[0][0] as {
163+
getReferenceClientRect: () => DOMRect;
164+
};
165+
166+
expect(tippyProps.getReferenceClientRect()).toEqual(cellRect);
167+
expect(tippyProps.getReferenceClientRect()).not.toEqual(wrapperRect);
168+
} finally {
169+
tableWrapper.remove();
170+
safeUnmount();
171+
}
172+
});
173+
174+
it('anchors to selected-cells bounding area when click target is table wrapper', () => {
175+
const { safeUnmount } = renderTableMenu();
176+
177+
const tableWrapper = document.createElement('div');
178+
tableWrapper.className = 'tableWrapper';
179+
180+
try {
181+
const firstSelectedCell = document.createElement('td');
182+
firstSelectedCell.className = 'selectedCell';
183+
184+
const secondSelectedCell = document.createElement('td');
185+
secondSelectedCell.className = 'selectedCell';
186+
187+
tableWrapper.appendChild(firstSelectedCell);
188+
tableWrapper.appendChild(secondSelectedCell);
189+
document.body.appendChild(tableWrapper);
190+
191+
const firstRect = createRect({
192+
top: 100,
193+
left: 200,
194+
right: 250,
195+
bottom: 140,
196+
width: 50,
197+
height: 40,
198+
});
199+
200+
const secondRect = createRect({
201+
top: 130,
202+
left: 260,
203+
right: 330,
204+
bottom: 170,
205+
width: 70,
206+
height: 40,
207+
});
208+
209+
mockBoundingClientRect(firstSelectedCell, firstRect);
210+
mockBoundingClientRect(secondSelectedCell, secondRect);
211+
212+
tableWrapper.dispatchEvent(
213+
new MouseEvent('mousedown', { bubbles: true })
214+
);
215+
216+
const tippyProps = mockSetProps.mock.calls[0][0] as {
217+
getReferenceClientRect: () => DOMRect;
218+
};
219+
const rect = tippyProps.getReferenceClientRect();
220+
221+
expect(rect.top).toBe(100);
222+
expect(rect.left).toBe(200);
223+
expect(rect.right).toBe(330);
224+
expect(rect.bottom).toBe(170);
225+
expect(rect.width).toBe(130);
226+
expect(rect.height).toBe(70);
227+
} finally {
228+
tableWrapper.remove();
229+
safeUnmount();
230+
}
231+
});
232+
233+
it('falls back to table wrapper bounds when no selected cells exist', () => {
234+
const { safeUnmount } = renderTableMenu();
235+
236+
const tableWrapper = document.createElement('div');
237+
tableWrapper.className = 'tableWrapper';
238+
239+
try {
240+
document.body.appendChild(tableWrapper);
241+
242+
const wrapperRect = createRect({
243+
x: 32,
244+
y: 48,
245+
top: 48,
246+
left: 32,
247+
right: 672,
248+
bottom: 448,
249+
width: 640,
250+
height: 400,
251+
});
252+
253+
mockBoundingClientRect(tableWrapper, wrapperRect);
254+
255+
tableWrapper.dispatchEvent(
256+
new MouseEvent('mousedown', { bubbles: true })
257+
);
258+
259+
const tippyProps = mockSetProps.mock.calls[0][0] as {
260+
getReferenceClientRect: () => DOMRect;
261+
};
262+
263+
expect(tippyProps.getReferenceClientRect()).toEqual(wrapperRect);
264+
} finally {
265+
tableWrapper.remove();
266+
safeUnmount();
267+
}
268+
});
269+
});

openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/TableMenu/TableMenu.tsx

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,98 @@ interface TableMenuProps {
2424
editor: Editor;
2525
}
2626

27+
const TABLE_WRAPPER_SELECTOR = '.tableWrapper';
28+
const TABLE_CELL_SELECTOR = 'td, th';
29+
const SELECTED_TABLE_CELL_SELECTOR = 'td.selectedCell, th.selectedCell';
30+
31+
const buildRect = (
32+
top: number,
33+
left: number,
34+
right: number,
35+
bottom: number
36+
): DOMRect => {
37+
return {
38+
x: left,
39+
y: top,
40+
top,
41+
left,
42+
right,
43+
bottom,
44+
width: right - left,
45+
height: bottom - top,
46+
toJSON: () => ({
47+
x: left,
48+
y: top,
49+
top,
50+
left,
51+
right,
52+
bottom,
53+
width: right - left,
54+
height: bottom - top,
55+
}),
56+
} as DOMRect;
57+
};
58+
59+
const getSelectedCellsRect = (tableWrapper: Element): DOMRect | null => {
60+
const selectedCells = tableWrapper.querySelectorAll<HTMLElement>(
61+
SELECTED_TABLE_CELL_SELECTOR
62+
);
63+
64+
if (!selectedCells.length) {
65+
return null;
66+
}
67+
68+
let top = Number.POSITIVE_INFINITY;
69+
let left = Number.POSITIVE_INFINITY;
70+
let right = Number.NEGATIVE_INFINITY;
71+
let bottom = Number.NEGATIVE_INFINITY;
72+
73+
selectedCells.forEach((cell) => {
74+
const rect = cell.getBoundingClientRect();
75+
76+
top = Math.min(top, rect.top);
77+
left = Math.min(left, rect.left);
78+
right = Math.max(right, rect.right);
79+
bottom = Math.max(bottom, rect.bottom);
80+
});
81+
82+
return buildRect(top, left, right, bottom);
83+
};
84+
2785
const TableMenu = (props: TableMenuProps) => {
2886
const { editor } = props;
2987
const { view, isEditable } = editor;
3088
const menuRef = useRef<HTMLDivElement>(null);
3189
const tableMenuPopup = useRef<Instance | null>(null);
3290

3391
const handleMouseDown = useCallback((event: MouseEvent) => {
34-
const target = event.target as HTMLElement;
35-
const table = target?.closest('.tableWrapper');
92+
const { target } = event;
3693

37-
if (table?.contains(target)) {
38-
tableMenuPopup.current?.setProps({
39-
getReferenceClientRect: () => table.getBoundingClientRect(),
40-
});
94+
if (!(target instanceof Element)) {
95+
return;
96+
}
97+
98+
const tableWrapper = target.closest(
99+
TABLE_WRAPPER_SELECTOR
100+
) as HTMLElement | null;
41101

42-
tableMenuPopup.current?.show();
102+
if (!tableWrapper) {
103+
return;
43104
}
105+
106+
const tableCell = target.closest(TABLE_CELL_SELECTOR) as HTMLElement | null;
107+
108+
const getReferenceClientRect = tableCell
109+
? () => tableCell.getBoundingClientRect()
110+
: () =>
111+
getSelectedCellsRect(tableWrapper) ??
112+
tableWrapper.getBoundingClientRect();
113+
114+
tableMenuPopup.current?.setProps({
115+
getReferenceClientRect,
116+
});
117+
118+
tableMenuPopup.current?.show();
44119
}, []);
45120

46121
useEffect(() => {
@@ -70,12 +145,16 @@ const TableMenu = (props: TableMenuProps) => {
70145
}, [isEditable]);
71146

72147
useEffect(() => {
148+
if (!isEditable) {
149+
return;
150+
}
151+
73152
document.addEventListener('mousedown', handleMouseDown);
74153

75154
return () => {
76155
document.removeEventListener('mousedown', handleMouseDown);
77156
};
78-
}, [handleMouseDown]);
157+
}, [handleMouseDown, isEditable]);
79158

80159
return (
81160
<div className="table-menu" ref={menuRef}>

0 commit comments

Comments
 (0)