From 1cc967aca944765b53bfb2bd54722960793dbcfb Mon Sep 17 00:00:00 2001 From: fatadel Date: Wed, 4 Mar 2026 16:18:12 +0400 Subject: [PATCH] Make network markers in the network panel sticky on click When clicking a marker in the Network panel, its tooltip is now persisted, matching the Marker Chart behavior. This enables interaction with tooltip content like copying text or clicking the filter button. The existing behavior of displaying the tooltip on hover is kept as well. Because of that, it should now be easier to compare two markers. - Add click coordinate tracking to NetworkChartRow state for sticky tooltip positioning - Show filter button only in sticky (clicked) tooltips - Toggle selection off when re-clicking the same row - Dismiss sticky tooltip on Escape key - Add 7 new tests covering the sticky tooltip related behavior --- .../network-chart/NetworkChartRow.tsx | 38 +++- src/components/network-chart/index.tsx | 18 +- src/components/tooltip/Marker.tsx | 20 ++- src/test/components/NetworkChart.test.tsx | 169 ++++++++++++++++++ 4 files changed, 231 insertions(+), 14 deletions(-) diff --git a/src/components/network-chart/NetworkChartRow.tsx b/src/components/network-chart/NetworkChartRow.tsx index 6d844477ee..13aa390c56 100644 --- a/src/components/network-chart/NetworkChartRow.tsx +++ b/src/components/network-chart/NetworkChartRow.tsx @@ -308,18 +308,28 @@ type State = { pageX: CssPixels; pageY: CssPixels; hovered: boolean | null; + clickPageX: CssPixels | null; + clickPageY: CssPixels | null; }; export class NetworkChartRow extends React.PureComponent< NetworkChartRowProps, State > { - override state = { + override state: State = { pageX: 0, pageY: 0, hovered: false, + clickPageX: null, + clickPageY: null, }; + override componentDidUpdate(prevProps: NetworkChartRowProps) { + if (prevProps.isSelected && !this.props.isSelected) { + this.setState({ clickPageX: null, clickPageY: null }); + } + } + _hoverIn = (event: React.MouseEvent) => { const pageX = event.pageX; const pageY = event.pageY; @@ -348,6 +358,7 @@ export class NetworkChartRow extends React.PureComponent< _onMouseDown = (e: React.MouseEvent) => { const { markerIndex, onLeftClick, onRightClick } = this.props; if (e.button === 0) { + this.setState({ clickPageX: e.pageX, clickPageY: e.pageY }); if (onLeftClick) { onLeftClick(markerIndex); } @@ -467,6 +478,17 @@ export class NetworkChartRow extends React.PureComponent< } ); + const clickX = this.state.clickPageX; + const clickY = this.state.clickPageY; + + const isSticky = isSelected && clickX !== null && clickY !== null; + const showTooltip = + shouldDisplayTooltips() && (this.state.hovered || isSticky); + + // When sticky, use the click coordinates; otherwise use the current mouse position. + const tooltipX = isSticky ? clickX : this.state.pageX; + const tooltipY = isSticky ? clickY : this.state.pageY; + return (
- {shouldDisplayTooltips() && this.state.hovered ? ( - // This magic value "5" avoids the tooltip of being too close of the - // row, especially when we mouseEnter the row from the top edge. - + {showTooltip ? ( + ) : null} diff --git a/src/components/network-chart/index.tsx b/src/components/network-chart/index.tsx index 07ba63baec..3449c18415 100644 --- a/src/components/network-chart/index.tsx +++ b/src/components/network-chart/index.tsx @@ -143,6 +143,14 @@ class NetworkChartImpl extends React.PureComponent { }; _onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + const { threadsKey, changeSelectedNetworkMarker } = this.props; + event.stopPropagation(); + event.preventDefault(); + changeSelectedNetworkMarker(threadsKey, null, { source: 'keyboard' }); + return; + } + const hasModifier = event.ctrlKey || event.altKey; const isNavigationKey = event.key.startsWith('Arrow') || @@ -232,7 +240,13 @@ class NetworkChartImpl extends React.PureComponent { }; _onLeftClick = (selectedNetworkMarkerIndex: MarkerIndex) => { - this._onSelectionChange(selectedNetworkMarkerIndex, { source: 'pointer' }); + if (this.props.selectedNetworkMarkerIndex === selectedNetworkMarkerIndex) { + this._onSelectionChange(null, { source: 'pointer' }); + } else { + this._onSelectionChange(selectedNetworkMarkerIndex, { + source: 'pointer', + }); + } }; _selectWithKeyboard(selectedNetworkMarkerIndex: MarkerIndex) { @@ -240,7 +254,7 @@ class NetworkChartImpl extends React.PureComponent { } _onSelectionChange = ( - selectedNetworkMarkerIndex: MarkerIndex, + selectedNetworkMarkerIndex: MarkerIndex | null, context: SelectionContext ) => { const { threadsKey, changeSelectedNetworkMarker } = this.props; diff --git a/src/components/tooltip/Marker.tsx b/src/components/tooltip/Marker.tsx index 9d8ea59bf0..613df41c92 100644 --- a/src/components/tooltip/Marker.tsx +++ b/src/components/tooltip/Marker.tsx @@ -22,7 +22,10 @@ import { getProcessIdToNameMap, getThreadSelectorsFromThreadsKey, } from 'firefox-profiler/selectors'; -import { changeMarkersSearchString } from 'firefox-profiler/actions/profile-view'; +import { + changeMarkersSearchString, + changeNetworkSearchString, +} from 'firefox-profiler/actions/profile-view'; import { TooltipNetworkMarkerPhases, @@ -112,6 +115,7 @@ type StateProps = { type DispatchProps = { readonly changeMarkersSearchString: typeof changeMarkersSearchString; + readonly changeNetworkSearchString: typeof changeNetworkSearchString; }; type Props = ConnectedProps; @@ -493,10 +497,18 @@ class MarkerTooltipContents extends React.PureComponent { } _onFilterButtonClick = () => { - const { markerIndex, getMarkerSearchTerm, changeMarkersSearchString } = - this.props; + const { + marker, + markerIndex, + getMarkerSearchTerm, + changeMarkersSearchString, + changeNetworkSearchString, + } = this.props; const searchTerm = getMarkerSearchTerm(markerIndex); changeMarkersSearchString(searchTerm); + if (marker.data && marker.data.type === 'Network') { + changeNetworkSearchString(searchTerm); + } }; /** @@ -718,7 +730,7 @@ const ConnectedMarkerTooltipContents = explicitConnect< categories: getCategories(state), }; }, - mapDispatchToProps: { changeMarkersSearchString }, + mapDispatchToProps: { changeMarkersSearchString, changeNetworkSearchString }, component: MarkerTooltipContents, }); diff --git a/src/test/components/NetworkChart.test.tsx b/src/test/components/NetworkChart.test.tsx index ffc6d3512b..198645c54a 100644 --- a/src/test/components/NetworkChart.test.tsx +++ b/src/test/components/NetworkChart.test.tsx @@ -683,6 +683,175 @@ describe('Network Chart/tooltip behavior', () => { }); }); +describe('Network Chart/sticky tooltip behavior', () => { + beforeEach(addRootOverlayElement); + afterEach(removeRootOverlayElement); + + function setupForStickyTooltip(uris: string[] = ['https://mozilla.org/1']) { + const markers: TestDefinedMarker[] = []; + uris.forEach((uri, i) => { + markers.push( + ...getNetworkMarkers({ + uri, + id: i, + startTime: 10 + i * 10, + endTime: 19 + i * 10, + }) + ); + }); + + const result = setupWithPayload(markers); + const { container } = result; + + function rowItems(): HTMLElement[] { + return Array.from( + container.querySelectorAll('.networkChartRowItem') + ) as HTMLElement[]; + } + + return { ...result, rowItems }; + } + + it('persists tooltip when clicking a row (sticky)', () => { + const { rowItem, getByTestId, getAllByTestId } = setupForStickyTooltip(); + const row = rowItem(); + + // Hover to show tooltip + fireEvent(row, getMouseEvent('mouseover', { pageX: 25, pageY: 25 })); + expect(getByTestId('tooltip')).toBeInTheDocument(); + + // Click to make sticky + fireFullClick(row, { pageX: 25, pageY: 25 }); + + // Mouse out — tooltip should still be present + fireEvent(row, getMouseEvent('mouseout', { pageX: 25, pageY: 25 })); + expect(getAllByTestId('tooltip').length).toBeGreaterThanOrEqual(1); + + // Verify the tooltip has the clickable class + const tooltips = getAllByTestId('tooltip'); + const hasClickable = tooltips.some((t) => + t.classList.contains('clickable') + ); + expect(hasClickable).toBe(true); + }); + + it('dismisses sticky tooltip when clicking the same row again', () => { + const { rowItem, getByTestId, queryByTestId } = setupForStickyTooltip(); + const row = rowItem(); + + // Click to make sticky + fireFullClick(row, { pageX: 25, pageY: 25 }); + fireEvent(row, getMouseEvent('mouseout', { pageX: 25, pageY: 25 })); + expect(getByTestId('tooltip')).toBeInTheDocument(); + + // Click again to dismiss + fireFullClick(row, { pageX: 25, pageY: 25 }); + fireEvent(row, getMouseEvent('mouseout', { pageX: 25, pageY: 25 })); + expect(queryByTestId('tooltip')).not.toBeInTheDocument(); + }); + + it('moves sticky tooltip when clicking a different row', () => { + const { rowItems, queryAllByTestId } = setupForStickyTooltip([ + 'https://mozilla.org/1', + 'https://mozilla.org/2', + ]); + const rows = rowItems(); + expect(rows.length).toBe(2); + + // Click first row to make sticky + fireFullClick(rows[0], { pageX: 25, pageY: 25 }); + fireEvent(rows[0], getMouseEvent('mouseout', { pageX: 25, pageY: 25 })); + + let tooltips = queryAllByTestId('tooltip'); + expect(tooltips.length).toBe(1); + expect(tooltips[0]).toHaveClass('clickable'); + + // Click second row — first row tooltip should go, second should appear + fireFullClick(rows[1], { pageX: 25, pageY: 50 }); + fireEvent(rows[1], getMouseEvent('mouseout', { pageX: 25, pageY: 50 })); + + tooltips = queryAllByTestId('tooltip'); + expect(tooltips.length).toBe(1); + expect(tooltips[0]).toHaveClass('clickable'); + }); + + it('dismisses sticky tooltip on Escape key', () => { + const { rowItem, container, getByTestId, queryByTestId } = + setupForStickyTooltip(); + const row = rowItem(); + + // Click to make sticky + fireFullClick(row, { pageX: 25, pageY: 25 }); + fireEvent(row, getMouseEvent('mouseout', { pageX: 25, pageY: 25 })); + expect(getByTestId('tooltip')).toBeInTheDocument(); + + // Press Escape + const treeViewBody = ensureExists( + container.querySelector('.treeViewBody'), + `Couldn't find the tree view body` + ); + fireEvent.keyDown(treeViewBody, { key: 'Escape' }); + expect(queryByTestId('tooltip')).not.toBeInTheDocument(); + }); + + it('shows filter button only in sticky tooltip', () => { + const { rowItem } = setupForStickyTooltip(); + const row = rowItem(); + + // Hover-only tooltip should hide filter button + fireEvent(row, getMouseEvent('mouseover', { pageX: 25, pageY: 25 })); + expect( + document.querySelector('.tooltipTitleFilterButton') + ).not.toBeInTheDocument(); + + // Click to make sticky — filter button should appear + fireFullClick(row, { pageX: 25, pageY: 25 }); + expect( + document.querySelector('.tooltipTitleFilterButton') + ).toBeInTheDocument(); + }); + + it('filters network panel when clicking filter button in sticky tooltip', () => { + const { rowItems } = setupForStickyTooltip([ + 'https://mozilla.org/1', + 'https://example.com/2', + ]); + const rows = rowItems(); + + // Click first row to make sticky + fireFullClick(rows[0], { pageX: 25, pageY: 25 }); + + // Click the filter button + const filterButton = ensureExists( + document.querySelector('.tooltipTitleFilterButton'), + `Couldn't find the filter button` + ) as HTMLElement; + fireFullClick(filterButton); + + // Network search string should be set, filtering down the rows + const rowsAfter = rowItems(); + expect(rowsAfter.length).toBeLessThan(rows.length); + }); + + it('hover tooltip on another row works alongside sticky tooltip', () => { + const { rowItems, queryAllByTestId } = setupForStickyTooltip([ + 'https://mozilla.org/1', + 'https://mozilla.org/2', + ]); + const rows = rowItems(); + + // Click row 1 to make sticky + fireFullClick(rows[0], { pageX: 25, pageY: 25 }); + fireEvent(rows[0], getMouseEvent('mouseout', { pageX: 25, pageY: 25 })); + + // Hover row 2 — both tooltips should be visible + fireEvent(rows[1], getMouseEvent('mouseover', { pageX: 25, pageY: 50 })); + + const tooltips = queryAllByTestId('tooltip'); + expect(tooltips.length).toBe(2); + }); +}); + describe('calltree/ProfileCallTreeView navigation keys', () => { beforeEach(addRootOverlayElement); afterEach(removeRootOverlayElement);