Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions src/components/network-chart/NetworkChartRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>) => {
const pageX = event.pageX;
const pageY = event.pageY;
Expand Down Expand Up @@ -348,6 +358,7 @@ export class NetworkChartRow extends React.PureComponent<
_onMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
const { markerIndex, onLeftClick, onRightClick } = this.props;
if (e.button === 0) {
this.setState({ clickPageX: e.pageX, clickPageY: e.pageY });
if (onLeftClick) {
onLeftClick(markerIndex);
}
Expand Down Expand Up @@ -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 (
<div
// The className below is responsible for the blue hover effect
Expand All @@ -488,19 +510,19 @@ export class NetworkChartRow extends React.PureComponent<
width={width}
timeRange={timeRange}
/>
{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.
<Tooltip mouseX={this.state.pageX} mouseY={this.state.pageY + 5}>
{showTooltip ? (
<Tooltip
mouseX={tooltipX}
mouseY={tooltipY}
className={isSticky ? 'clickable' : undefined}
>
<TooltipMarker
className="tooltipNetwork"
markerIndex={markerIndex}
marker={marker}
threadsKey={this.props.threadsKey}
restrictHeightWidth={true}
// Network Chart doesn't have sticky tooltips yet. But we should convert it
// to false once we implement sticky tooltips for the network chart.
hideFilterButton={true}
hideFilterButton={!isSticky}
/>
</Tooltip>
) : null}
Expand Down
18 changes: 16 additions & 2 deletions src/components/network-chart/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ class NetworkChartImpl extends React.PureComponent<Props> {
};

_onKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
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') ||
Expand Down Expand Up @@ -232,15 +240,21 @@ class NetworkChartImpl extends React.PureComponent<Props> {
};

_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) {
this._onSelectionChange(selectedNetworkMarkerIndex, { source: 'keyboard' });
}

_onSelectionChange = (
selectedNetworkMarkerIndex: MarkerIndex,
selectedNetworkMarkerIndex: MarkerIndex | null,
context: SelectionContext
) => {
const { threadsKey, changeSelectedNetworkMarker } = this.props;
Expand Down
20 changes: 16 additions & 4 deletions src/components/tooltip/Marker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -112,6 +115,7 @@ type StateProps = {

type DispatchProps = {
readonly changeMarkersSearchString: typeof changeMarkersSearchString;
readonly changeNetworkSearchString: typeof changeNetworkSearchString;
};

type Props = ConnectedProps<OwnProps, StateProps, DispatchProps>;
Expand Down Expand Up @@ -493,10 +497,18 @@ class MarkerTooltipContents extends React.PureComponent<Props> {
}

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

/**
Expand Down Expand Up @@ -718,7 +730,7 @@ const ConnectedMarkerTooltipContents = explicitConnect<
categories: getCategories(state),
};
},
mapDispatchToProps: { changeMarkersSearchString },
mapDispatchToProps: { changeMarkersSearchString, changeNetworkSearchString },
component: MarkerTooltipContents,
});

Expand Down
169 changes: 169 additions & 0 deletions src/test/components/NetworkChart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down