Skip to content

Commit 074425f

Browse files
FriedhelmWSopensearch-changeset-bot[bot]
authored andcommitted
Fix chatbot flyout cannot be resized beyond the window size (opensearch-project#9735)
* Fix chatbot flyout resize button displays out of window Signed-off-by: Owen Wang <[email protected]> * Changeset file for PR opensearch-project#9735 created/updated * Add debouncing and add check when initial load chatbot size Signed-off-by: Owen Wang <[email protected]> --------- Signed-off-by: Owen Wang <[email protected]> Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com>
1 parent e462bb0 commit 074425f

File tree

5 files changed

+223
-6
lines changed

5 files changed

+223
-6
lines changed

changelogs/fragments/9735.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
fix:
2+
- Chatbot flyout cannot be resized beyond the window size ([#9735](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/9735))

src/core/public/overlays/sidecar/components/resizable_button.test.tsx

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,18 @@
55

66
import React from 'react';
77

8-
import { ResizableButton, MIN_SIDECAR_SIZE } from './resizable_button';
9-
import { shallow } from 'enzyme';
8+
import { ResizableButton } from './resizable_button';
9+
import { mount, shallow } from 'enzyme';
1010
import { SIDECAR_DOCKED_MODE } from '../sidecar_service';
11+
import { act } from 'react-dom/test-utils';
12+
import { waitFor } from '@testing-library/dom';
13+
import { MIN_SIDECAR_SIZE } from '../helper';
14+
15+
const originalAddEventListener = window.addEventListener;
16+
17+
const restoreWindowEvents = () => {
18+
window.addEventListener = originalAddEventListener;
19+
};
1120

1221
const storeWindowEvents = () => {
1322
const map: Record<string, Function> = {};
@@ -47,6 +56,17 @@ test('it should be vertical when docked mode is takeover', () => {
4756
test('it should emit onResize with new flyout size when docked right and drag and horizontal', () => {
4857
const windowEvents = storeWindowEvents();
4958

59+
Object.defineProperty(window, 'innerHeight', {
60+
writable: true,
61+
configurable: true,
62+
value: 2000,
63+
});
64+
Object.defineProperty(window, 'innerWidth', {
65+
writable: true,
66+
configurable: true,
67+
value: 2000,
68+
});
69+
5070
const onResize = jest.fn();
5171
const newProps = { ...props, onResize };
5272
const component = shallow(<ResizableButton {...newProps} />);
@@ -63,6 +83,17 @@ test('it should emit onResize with new flyout size when docked right and drag an
6383
test('it should emit onResize with new flyout size when docked left and drag and horizontal', () => {
6484
const windowEvents = storeWindowEvents();
6585

86+
Object.defineProperty(window, 'innerHeight', {
87+
writable: true,
88+
configurable: true,
89+
value: 2000,
90+
});
91+
Object.defineProperty(window, 'innerWidth', {
92+
writable: true,
93+
configurable: true,
94+
value: 2000,
95+
});
96+
6697
const onResize = jest.fn();
6798
const newProps = { ...props, onResize, dockedMode: SIDECAR_DOCKED_MODE.LEFT };
6899
const component = shallow(<ResizableButton {...newProps} />);
@@ -79,6 +110,17 @@ test('it should emit onResize with new flyout size when docked left and drag and
79110
test('it should emit onResize with new flyout size when drag and vertical', () => {
80111
const windowEvents = storeWindowEvents();
81112

113+
Object.defineProperty(window, 'innerHeight', {
114+
writable: true,
115+
configurable: true,
116+
value: 2000,
117+
});
118+
Object.defineProperty(window, 'innerWidth', {
119+
writable: true,
120+
configurable: true,
121+
value: 2000,
122+
});
123+
82124
const onResize = jest.fn();
83125
const newProps = { ...props, onResize, dockedMode: SIDECAR_DOCKED_MODE.TAKEOVER };
84126
const component = shallow(<ResizableButton {...newProps} />);
@@ -94,6 +136,7 @@ test('it should emit onResize with new flyout size when drag and vertical', () =
94136

95137
test('it should emit onResize with min size when drag if new size is below the minimum', () => {
96138
const windowEvents = storeWindowEvents();
139+
97140
const onResize = jest.fn();
98141
const newProps = { ...props, onResize };
99142
const component = shallow(<ResizableButton {...newProps} />);
@@ -106,3 +149,111 @@ test('it should emit onResize with min size when drag if new size is below the m
106149
expect(onResize).toHaveBeenCalledWith(newSize);
107150
expect(component).toMatchSnapshot();
108151
});
152+
153+
test('it should not call onResize when new size exceeds window bounds', () => {
154+
const windowEvents = storeWindowEvents();
155+
const onResize = jest.fn();
156+
157+
// Mock window dimensions
158+
Object.defineProperty(window, 'innerHeight', {
159+
writable: true,
160+
configurable: true,
161+
value: 1000,
162+
});
163+
Object.defineProperty(window, 'innerWidth', {
164+
writable: true,
165+
configurable: true,
166+
value: 1200,
167+
});
168+
169+
// Test takeover mode with size exceeding window height
170+
const takeoverProps = {
171+
...props,
172+
onResize,
173+
dockedMode: SIDECAR_DOCKED_MODE.TAKEOVER,
174+
};
175+
const takeoverComponent = shallow(<ResizableButton {...takeoverProps} />);
176+
const takeoverResizer = takeoverComponent.find(`[data-test-subj~="resizableButton"]`).first();
177+
178+
takeoverResizer.simulate('mousedown', { clientY: 0, pageX: 0, pageY: 0 });
179+
let mouseMoveEvent = new MouseEvent('mousemove', {
180+
clientY: -2000,
181+
pageX: 0,
182+
pageY: 0,
183+
} as any);
184+
windowEvents?.mousemove(mouseMoveEvent); // Exceeds window.innerHeight
185+
windowEvents?.mouseup();
186+
187+
expect(onResize).not.toHaveBeenCalled();
188+
189+
// Test right mode with size exceeding window width
190+
const rightProps = {
191+
...props,
192+
onResize,
193+
dockedMode: SIDECAR_DOCKED_MODE.RIGHT,
194+
};
195+
const rightComponent = shallow(<ResizableButton {...rightProps} />);
196+
const rightResizer = rightComponent.find(`[data-test-subj~="resizableButton"]`).first();
197+
198+
rightResizer.simulate('mousedown', { clientX: 0, pageX: 0, pageY: 0 });
199+
mouseMoveEvent = new MouseEvent('mousemove', { clientX: -2000, pageX: 0, pageY: 0 } as any);
200+
windowEvents?.mousemove(mouseMoveEvent); // Exceeds window.innerWidth
201+
windowEvents?.mouseup();
202+
203+
expect(onResize).not.toHaveBeenCalled();
204+
});
205+
206+
test('it should handle window resize events correctly for different docked modes', async () => {
207+
restoreWindowEvents();
208+
const onResize = jest.fn();
209+
210+
Object.defineProperty(window, 'innerHeight', {
211+
writable: true,
212+
configurable: true,
213+
value: 1000,
214+
});
215+
Object.defineProperty(window, 'innerWidth', {
216+
writable: true,
217+
configurable: true,
218+
value: 1200,
219+
});
220+
221+
const takeoverProps = {
222+
...props,
223+
onResize,
224+
dockedMode: SIDECAR_DOCKED_MODE.TAKEOVER,
225+
flyoutSize: 800,
226+
};
227+
let component = mount(<ResizableButton {...takeoverProps} />);
228+
229+
await act(async () => {
230+
Object.defineProperty(window, 'innerHeight', { value: 600 });
231+
window.dispatchEvent(new Event('resize'));
232+
});
233+
234+
await waitFor(() => {
235+
// wait for debounce
236+
expect(onResize).toHaveBeenCalledWith(600);
237+
});
238+
onResize.mockClear();
239+
240+
const rightProps = {
241+
...props,
242+
onResize,
243+
dockedMode: SIDECAR_DOCKED_MODE.RIGHT,
244+
flyoutSize: 1000,
245+
};
246+
component = mount(<ResizableButton {...rightProps} />);
247+
248+
await act(async () => {
249+
Object.defineProperty(window, 'innerWidth', { value: 800 });
250+
window.dispatchEvent(new Event('resize'));
251+
});
252+
253+
await waitFor(() => {
254+
// wait for debounce
255+
expect(onResize).toHaveBeenCalledWith(800);
256+
});
257+
258+
component.unmount();
259+
});

src/core/public/overlays/sidecar/components/resizable_button.tsx

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,20 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import React, { useCallback, useRef } from 'react';
6+
import React, { useCallback, useEffect, useRef } from 'react';
77
import classNames from 'classnames';
88
import './resizable_button.scss';
9-
import { getPosition } from '../helper';
9+
import { getPosition, calculateNewPaddingSize, MIN_SIDECAR_SIZE } from '../helper';
1010
import { ISidecarConfig, SIDECAR_DOCKED_MODE } from '../sidecar_service';
11+
import { debounce } from '../../../../../core/public/utils';
1112

1213
interface Props {
1314
onResize: (size: number) => void;
1415
flyoutSize: number;
1516
dockedMode: ISidecarConfig['dockedMode'] | undefined;
1617
}
1718

18-
export const MIN_SIDECAR_SIZE = 350;
19+
const RESIZE_DEBOUNCE_DELAY = 50;
1920

2021
export const ResizableButton = ({ dockedMode, onResize, flyoutSize }: Props) => {
2122
const isHorizontal = dockedMode !== SIDECAR_DOCKED_MODE.TAKEOVER;
@@ -29,6 +30,29 @@ export const ResizableButton = ({ dockedMode, onResize, flyoutSize }: Props) =>
2930
const initialFlyoutSize = useRef(flyoutSize);
3031
const setFocus = (e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.focus();
3132

33+
const currentFlyoutSize = useRef(flyoutSize);
34+
currentFlyoutSize.current = flyoutSize;
35+
36+
useEffect(() => {
37+
const debouncedResize = debounce(() => {
38+
const currentSize = currentFlyoutSize.current;
39+
40+
if (currentSize > MIN_SIDECAR_SIZE) {
41+
// Make sure flyout never below min size even if the window goes below the size
42+
const newPaddingSize = calculateNewPaddingSize(dockedMode, currentSize);
43+
if (currentSize > newPaddingSize) {
44+
onResize(newPaddingSize);
45+
}
46+
}
47+
}, RESIZE_DEBOUNCE_DELAY);
48+
49+
window.addEventListener('resize', debouncedResize);
50+
51+
return () => {
52+
window.removeEventListener('resize', debouncedResize);
53+
};
54+
}, [dockedMode, onResize]);
55+
3256
const onMouseDown = useCallback(
3357
(event: React.MouseEvent | React.TouchEvent) => {
3458
const onMouseUp = () => {
@@ -39,6 +63,17 @@ export const ResizableButton = ({ dockedMode, onResize, flyoutSize }: Props) =>
3963
window.removeEventListener('touchend', onMouseUp);
4064
};
4165
const onMouseMove = (e: MouseEvent | TouchEvent) => {
66+
if (
67+
e instanceof MouseEvent &&
68+
(e.clientX < 0 ||
69+
e.clientX > window.innerWidth ||
70+
e.clientY < 0 ||
71+
e.clientY > window.innerHeight)
72+
) {
73+
// Stop resize calculation if the user mouse move out of window
74+
return;
75+
}
76+
4277
let offset;
4378
if (dockedMode === SIDECAR_DOCKED_MODE.LEFT) {
4479
offset = getPosition(e, isHorizontal) - initialMouseXorY.current;

src/core/public/overlays/sidecar/helper.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ function isMouseEvent(
1111
return typeof event === 'object' && 'pageX' in event && 'pageY' in event;
1212
}
1313

14+
export const MIN_SIDECAR_SIZE = 350;
15+
1416
export const getPosition = (
1517
event: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent,
1618
isHorizontal: boolean
@@ -44,3 +46,23 @@ export const getSidecarLeftNavStyle = (config: ISidecarConfig | undefined) => {
4446
}
4547
return {};
4648
};
49+
50+
export const calculateNewPaddingSize = (
51+
dockedMode: SIDECAR_DOCKED_MODE | undefined,
52+
currentSize: number
53+
): number => {
54+
let paddingSize = currentSize;
55+
// Make sure flyout never below min size even if the window goes below the size
56+
if (dockedMode === SIDECAR_DOCKED_MODE.TAKEOVER && currentSize > window.innerHeight) {
57+
// Automatically reduce the height in full screen mode when resize the window
58+
paddingSize = window.innerHeight;
59+
} else if (
60+
(dockedMode === SIDECAR_DOCKED_MODE.LEFT || dockedMode === SIDECAR_DOCKED_MODE.RIGHT) &&
61+
currentSize > window.innerWidth
62+
) {
63+
// Automatically reduce the width in left or right docked mode when resize the window
64+
paddingSize = window.innerWidth;
65+
}
66+
// Make sure the padding size never goes below minimum size
67+
return Math.max(paddingSize, MIN_SIDECAR_SIZE);
68+
};

src/core/public/overlays/sidecar/sidecar_service.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { I18nStart } from '../../i18n';
1212
import { MountPoint } from '../../types';
1313
import { OverlayRef } from '../types';
1414
import { Sidecar } from './components/sidecar';
15+
import { calculateNewPaddingSize } from './helper';
1516
/**
1617
* A SidecarRef is a reference to an opened sidecar panel. It offers methods to
1718
* close the sidecar panel again. If you open a sidecar panel you should make
@@ -133,7 +134,13 @@ export class SidecarService {
133134
// init
134135
(!sidecarConfig$.value && config.dockedMode && config.paddingSize)
135136
) {
136-
sidecarConfig$.next({ ...sidecarConfig$.value, ...config } as ISidecarConfig);
137+
sidecarConfig$.next({
138+
...sidecarConfig$.value,
139+
...config,
140+
...(config.paddingSize
141+
? { paddingSize: calculateNewPaddingSize(config.dockedMode, config.paddingSize) }
142+
: {}),
143+
} as ISidecarConfig);
137144
}
138145
};
139146

0 commit comments

Comments
 (0)