Skip to content

Commit aecec57

Browse files
albertoblazclaude
andauthored
[Security Solution][Cloud Security] Fix Graph view removing scrollbar from flyout (#266726)
## Summary Closes #264432. Removes inner scrollbar in visualization section. This is achieved by replacing the static math with a new hook that measures the live DOM and re-measures via `ResizeObserver`. Both the document details and entity details flyouts drop their height plumbing. ### Screenshots Both entity and document flyout render Graph without scrollbars in the visualization, as expected. <img width="1439" height="1477" alt="Screenshot 2026-04-30 at 17 05 04" src="https://github.com/user-attachments/assets/8e6ebb0f-cc0a-45c1-b494-21b1d71fcd87" /> <img width="1420" height="1478" alt="Screenshot 2026-04-30 at 17 05 33" src="https://github.com/user-attachments/assets/aa023844-ed85-4b3b-8fb9-356647a2c68b" /> ### Checklist - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [x] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) - [x] Review the [backport guidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing) and apply applicable `backport:*` labels. ### Identify risks Risk of not rendering Graph properly affecting its wrapping elements. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6122f15 commit aecec57

4 files changed

Lines changed: 240 additions & 28 deletions

File tree

x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left/graph_view_tab.tsx

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,6 @@ import type { FC } from 'react';
99
import React, { memo } from 'react';
1010
import { GraphVisualization } from '../../../../shared/components/graph_visualization';
1111

12-
const EXPANDABLE_FLYOUT_LEFT_SECTION_HEADER_HEIGHT = 72;
13-
const VISUALIZE_WRAPPER_PADDING = 16;
14-
const VISUALIZE_BUTTON_GROUP_HEIGHT = 32;
15-
const EUI_SPACER_HEIGHT = 16;
16-
1712
export interface GraphViewTabProps {
1813
/** Entity Store v2 entity ID (`entity.id`) to center the graph on */
1914
entityId: string;
@@ -26,20 +21,7 @@ export interface GraphViewTabProps {
2621
* Renders the full graph investigation view centered on the given entity.
2722
*/
2823
export const GraphViewTab: FC<GraphViewTabProps> = memo(({ entityId, scopeId }) => {
29-
const height =
30-
window.innerHeight -
31-
EXPANDABLE_FLYOUT_LEFT_SECTION_HEADER_HEIGHT -
32-
2 * VISUALIZE_WRAPPER_PADDING -
33-
VISUALIZE_BUTTON_GROUP_HEIGHT -
34-
EUI_SPACER_HEIGHT;
35-
return (
36-
<GraphVisualization
37-
mode="entity"
38-
entityId={entityId}
39-
scopeId={scopeId}
40-
height={`${height}px`}
41-
/>
42-
);
24+
return <GraphVisualization mode="entity" entityId={entityId} scopeId={scopeId} />;
4325
});
4426

4527
GraphViewTab.displayName = 'GraphViewTab';

x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/graph_visualization.tsx

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* 2.0.
66
*/
77

8-
import React, { memo, useCallback } from 'react';
8+
import React, { memo, useCallback, useRef } from 'react';
99
import { css } from '@emotion/react';
1010
import { EuiLoadingSpinner } from '@elastic/eui';
1111
import type { Filter, Query, TimeRange } from '@kbn/es-query';
@@ -23,6 +23,7 @@ import {
2323
import { type NodeDocumentDataModel } from '@kbn/cloud-security-posture-common/types/graph/v1';
2424
import { DOCUMENT_TYPE_ENTITY } from '@kbn/cloud-security-posture-common/schema/graph/v1';
2525
import { isEntityNodeEnriched } from '@kbn/cloud-security-posture-graph/src/components/utils';
26+
import { useFlyoutBodyAvailableHeight } from './use_flyout_body_available_height';
2627
import { PageScope } from '../../../data_view_manager/constants';
2728
import { useDataView } from '../../../data_view_manager/hooks/use_data_view';
2829
import { useGetScopedSourcererDataView } from '../../../sourcerer/components/use_get_sourcerer_data_view';
@@ -73,12 +74,7 @@ interface EntityGraphVisualizationProps {
7374
entityId: string;
7475
}
7576

76-
export type GraphVisualizationProps = (
77-
| EventGraphVisualizationProps
78-
| EntityGraphVisualizationProps
79-
) & {
80-
height?: number | string;
81-
};
77+
export type GraphVisualizationProps = EventGraphVisualizationProps | EntityGraphVisualizationProps;
8278

8379
/**
8480
* Full-screen graph investigation view for use in left-panel flyout tabs.
@@ -89,6 +85,9 @@ export type GraphVisualizationProps = (
8985
export const GraphVisualization: React.FC<GraphVisualizationProps> = memo((props) => {
9086
const { scopeId } = props;
9187

88+
const wrapperRef = useRef<HTMLDivElement>(null);
89+
const height = useFlyoutBodyAvailableHeight(wrapperRef);
90+
9291
const {
9392
application: { capabilities },
9493
} = useKibana().services;
@@ -297,10 +296,10 @@ export const GraphVisualization: React.FC<GraphVisualizationProps> = memo((props
297296

298297
return (
299298
<div
299+
ref={wrapperRef}
300300
data-test-subj={GRAPH_VISUALIZATION_TEST_ID}
301301
css={css`
302-
height: ${props.height ?? 'calc(100vh - 250px)'};
303-
min-height: 400px;
302+
height: ${height}px;
304303
width: 100%;
305304
`}
306305
>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React, { useRef } from 'react';
9+
import { act, render } from '@testing-library/react';
10+
import { useFlyoutBodyAvailableHeight } from './use_flyout_body_available_height';
11+
12+
class MockResizeObserver {
13+
static instances: MockResizeObserver[] = [];
14+
callback: ResizeObserverCallback;
15+
16+
constructor(callback: ResizeObserverCallback) {
17+
this.callback = callback;
18+
MockResizeObserver.instances.push(this);
19+
}
20+
observe() {}
21+
unobserve() {}
22+
disconnect() {}
23+
trigger() {
24+
this.callback([], this as unknown as ResizeObserver);
25+
}
26+
}
27+
28+
const Probe = ({ onHeight }: { onHeight: (h: number) => void }) => {
29+
const ref = useRef<HTMLDivElement>(null);
30+
const height = useFlyoutBodyAvailableHeight(ref);
31+
onHeight(height);
32+
return <div ref={ref} data-test-subj="probe" />;
33+
};
34+
35+
const renderInsideFlyoutBody = ({
36+
flyoutBottom,
37+
wrapperTop,
38+
panelPaddingBottom = '16px',
39+
}: {
40+
flyoutBottom: number;
41+
wrapperTop: number;
42+
panelPaddingBottom?: string;
43+
}) => {
44+
const heights: number[] = [];
45+
46+
// Override prototype methods so the layout effect (which fires during the
47+
// first render) sees the mocked values immediately.
48+
const originalGetRect = Element.prototype.getBoundingClientRect;
49+
Element.prototype.getBoundingClientRect = function () {
50+
if (this.classList.contains('euiFlyoutBody__overflow')) {
51+
return { bottom: flyoutBottom } as DOMRect;
52+
}
53+
if ((this as HTMLElement).dataset?.testSubj === 'probe') {
54+
return { top: wrapperTop } as DOMRect;
55+
}
56+
return originalGetRect.call(this);
57+
};
58+
59+
const originalGetComputedStyle = window.getComputedStyle;
60+
window.getComputedStyle = ((el: Element) => {
61+
if (el.classList.contains('euiPanel')) {
62+
return { paddingBottom: panelPaddingBottom } as CSSStyleDeclaration;
63+
}
64+
return originalGetComputedStyle(el);
65+
}) as typeof window.getComputedStyle;
66+
67+
const utils = render(
68+
<div className="euiFlyoutBody__overflow">
69+
<div className="euiPanel">
70+
<Probe onHeight={(h) => heights.push(h)} />
71+
</div>
72+
</div>
73+
);
74+
75+
const restore = () => {
76+
Element.prototype.getBoundingClientRect = originalGetRect;
77+
window.getComputedStyle = originalGetComputedStyle;
78+
};
79+
80+
return { heights, restore, ...utils };
81+
};
82+
83+
describe('useFlyoutBodyAvailableHeight', () => {
84+
const originalResizeObserver = window.ResizeObserver;
85+
86+
beforeEach(() => {
87+
MockResizeObserver.instances = [];
88+
window.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
89+
});
90+
91+
afterEach(() => {
92+
window.ResizeObserver = originalResizeObserver;
93+
});
94+
95+
it('returns flyoutBottom - wrapperTop - panel paddingBottom', () => {
96+
const { heights, restore } = renderInsideFlyoutBody({
97+
flyoutBottom: 1500,
98+
wrapperTop: 100,
99+
panelPaddingBottom: '16px',
100+
});
101+
expect(heights.at(-1)).toBe(1500 - 100 - 16);
102+
restore();
103+
});
104+
105+
it('handles a zero-padding panel', () => {
106+
const { heights, restore } = renderInsideFlyoutBody({
107+
flyoutBottom: 1000,
108+
wrapperTop: 50,
109+
panelPaddingBottom: '0px',
110+
});
111+
expect(heights.at(-1)).toBe(950);
112+
restore();
113+
});
114+
115+
it('clamps negative results to 0', () => {
116+
const { heights, restore } = renderInsideFlyoutBody({
117+
flyoutBottom: 100,
118+
wrapperTop: 200,
119+
panelPaddingBottom: '16px',
120+
});
121+
expect(heights.at(-1)).toBe(0);
122+
restore();
123+
});
124+
125+
it('re-measures when ResizeObserver fires', () => {
126+
let currentFlyoutBottom = 1500;
127+
const heights: number[] = [];
128+
129+
const originalGetRect = Element.prototype.getBoundingClientRect;
130+
Element.prototype.getBoundingClientRect = function () {
131+
if (this.classList.contains('euiFlyoutBody__overflow')) {
132+
return { bottom: currentFlyoutBottom } as DOMRect;
133+
}
134+
if ((this as HTMLElement).dataset?.testSubj === 'probe') {
135+
return { top: 100 } as DOMRect;
136+
}
137+
return originalGetRect.call(this);
138+
};
139+
140+
const originalGetComputedStyle = window.getComputedStyle;
141+
window.getComputedStyle = ((el: Element) => {
142+
if (el.classList.contains('euiPanel')) {
143+
return { paddingBottom: '16px' } as CSSStyleDeclaration;
144+
}
145+
return originalGetComputedStyle(el);
146+
}) as typeof window.getComputedStyle;
147+
148+
render(
149+
<div className="euiFlyoutBody__overflow">
150+
<div className="euiPanel">
151+
<Probe onHeight={(h) => heights.push(h)} />
152+
</div>
153+
</div>
154+
);
155+
156+
expect(heights.at(-1)).toBe(1500 - 100 - 16);
157+
158+
currentFlyoutBottom = 2000;
159+
act(() => {
160+
MockResizeObserver.instances[0].trigger();
161+
});
162+
163+
expect(heights.at(-1)).toBe(2000 - 100 - 16);
164+
165+
Element.prototype.getBoundingClientRect = originalGetRect;
166+
window.getComputedStyle = originalGetComputedStyle;
167+
});
168+
169+
it('returns 0 when not rendered inside an EuiFlyoutBody', () => {
170+
const heights: number[] = [];
171+
render(
172+
<div>
173+
<Probe onHeight={(h) => heights.push(h)} />
174+
</div>
175+
);
176+
expect(heights.at(-1)).toBe(0);
177+
});
178+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import type { RefObject } from 'react';
9+
import { useLayoutEffect, useState } from 'react';
10+
11+
const FLYOUT_BODY_SELECTOR = '.euiFlyoutBody__overflow';
12+
const PANEL_SELECTOR = '.euiPanel';
13+
14+
/**
15+
* Measures the available vertical space inside the surrounding `EuiFlyoutBody`
16+
* for the element pointed at by `ref`, in pixels.
17+
*
18+
* The returned height fills the area from the top of `ref.current` down to the
19+
* bottom of `.euiFlyoutBody__overflow`, minus the bottom padding of the nearest
20+
* enclosing `.euiPanel` (so the panel's `paddingMedium` doesn't push content
21+
* past the flyout body and trigger a redundant scrollbar).
22+
*
23+
* Re-measures on viewport resize via `ResizeObserver`. Returns `0` until the
24+
* element is mounted inside an `EuiFlyoutBody`.
25+
*/
26+
export const useFlyoutBodyAvailableHeight = (ref: RefObject<HTMLElement | null>): number => {
27+
const [height, setHeight] = useState(0);
28+
29+
useLayoutEffect(() => {
30+
const el = ref.current;
31+
if (!el) return;
32+
33+
const measure = () => {
34+
if (!el.isConnected) return;
35+
const flyoutBody = el.closest(FLYOUT_BODY_SELECTOR);
36+
if (!flyoutBody) return;
37+
const panel = el.closest(PANEL_SELECTOR);
38+
const paddingBottom = panel ? parseFloat(getComputedStyle(panel).paddingBottom) || 0 : 0;
39+
const flyoutBottom = flyoutBody.getBoundingClientRect().bottom;
40+
const wrapperTop = el.getBoundingClientRect().top;
41+
const next = Math.max(0, flyoutBottom - wrapperTop - paddingBottom);
42+
setHeight((prev) => (prev === next ? prev : next));
43+
};
44+
45+
measure();
46+
47+
const observer = new ResizeObserver(measure);
48+
observer.observe(document.body);
49+
return () => observer.disconnect();
50+
}, [ref]);
51+
52+
return height;
53+
};

0 commit comments

Comments
 (0)