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);