Skip to content

Commit

Permalink
feat: add basic context menu (#460)
Browse files Browse the repository at this point in the history
* feat: add basic context menu with 'Reset View' button


also increase BAR_HEIGHT to make it easier to click
  • Loading branch information
eh-am committed Oct 13, 2021
1 parent 224ac5c commit 3df5d9d
Show file tree
Hide file tree
Showing 14 changed files with 213 additions and 12 deletions.
42 changes: 35 additions & 7 deletions cypress/integration/basic.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
// copied from the source code
// TODO move those definitions to a different shareable file
const PX_PER_LEVEL = 18;
const GAP = 0.5;
const BAR_HEIGHT = PX_PER_LEVEL - GAP;
import { BAR_HEIGHT } from '../../webapp/javascript/components/FlameGraph/FlameGraphComponent/constants';

/// <reference types="cypress" />
describe('basic test', () => {
Expand Down Expand Up @@ -204,15 +200,15 @@ describe('basic test', () => {
});
});

it('validates "Reset View" works', () => {
it('validates "Reset View" button works', () => {
cy.intercept('**/render*', {
fixture: 'simple-golang-app-cpu.json',
}).as('render');

cy.visit('/');

cy.findByTestId('reset-view').should('not.be.visible');
cy.findByTestId('flamegraph-canvas').click(0, BAR_HEIGHT);
cy.findByTestId('flamegraph-canvas').click(0, BAR_HEIGHT * 2);
cy.findByTestId('reset-view').should('be.visible');
cy.findByTestId('reset-view').click();
cy.findByTestId('reset-view').should('not.be.visible');
Expand Down Expand Up @@ -360,4 +356,36 @@ describe('basic test', () => {
cy.findByTestId('flamegraph-highlight').should('be.visible');
});
});

describe('contextmenu', () => {
it("it works when 'clear view' is clicked", () => {
cy.intercept('**/render*', {
fixture: 'simple-golang-app-cpu.json',
times: 1,
}).as('render');

cy.visit('/');

// until we focus on a specific, it should not be enabled
cy.findByTestId('flamegraph-canvas').rightclick();
cy.findByRole('menuitem')
.contains('Reset View')
.should('have.attr', 'aria-disabled', 'true');

// click on the second item
cy.findByTestId('flamegraph-canvas').click(0, BAR_HEIGHT * 2);
cy.findByTestId('flamegraph-canvas').rightclick();
cy.findByRole('menuitem')
.contains('Reset View')
.should('not.have.attr', 'aria-disabled');
cy.findByRole('menuitem').contains('Reset View').click();
// TODO assert that it was indeed reset?

// should be disabled again
cy.findByTestId('flamegraph-canvas').rightclick();
cy.findByRole('menuitem')
.contains('Reset View')
.should('have.attr', 'aria-disabled', 'true');
});
});
});
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/basic.ts/pyroscope.server.cpu-flamegraph.snap.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/e2e.ts/e2e-comparison-diff-flamegraph.snap.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/e2e.ts/e2e-comparison-flamegraph-left.snap.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/e2e.ts/e2e-comparison-flamegraph-right.snap.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/e2e.ts/e2e-single-flamegraph.snap.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/* eslint-disable react/jsx-props-no-spreading */
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MenuItem } from '@szhsin/react-menu';
import userEvent from '@testing-library/user-event';

import ContextMenu, { ContextMenuProps } from './ContextMenu';

const { queryByRole, queryAllByRole } = screen;

function TestCanvas(props: Omit<ContextMenuProps, 'canvasRef'>) {
const canvasRef = React.useRef();

return (
<>
<canvas data-testid="canvas" ref={canvasRef} />
<ContextMenu data-testid="contextmenu" canvasRef={canvasRef} {...props} />
</>
);
}

describe('ContextMenu', () => {
it('works', () => {
let hasBeenClicked = false;

const xyToMenuItems = () => {
return [
<MenuItem
key="test"
onClick={() => {
hasBeenClicked = true;
}}
>
Test
</MenuItem>,
] as unknown[] as typeof MenuItem[]; // nasty conversion
};

render(<TestCanvas xyToMenuItems={xyToMenuItems} />);

expect(queryByRole('menu')).not.toBeInTheDocument();

// trigger a right click
userEvent.click(screen.getByTestId('canvas'), { buttons: 2 });

expect(queryByRole('menu')).toBeVisible();
expect(queryAllByRole('menuitem')).toHaveLength(1);

userEvent.click(queryByRole('menuitem'));
expect(hasBeenClicked).toBe(true);
});

it('shows different items depending on the clicked node', () => {
const xyToMenuItems = jest.fn();

render(<TestCanvas xyToMenuItems={xyToMenuItems} />);

expect(queryByRole('menu')).not.toBeInTheDocument();

// trigger a right click
xyToMenuItems.mockReturnValueOnce([<MenuItem key="1">1</MenuItem>]);
userEvent.click(screen.getByTestId('canvas'), { buttons: 2 });
expect(queryAllByRole('menuitem')).toHaveLength(1);

xyToMenuItems.mockReturnValueOnce([
<MenuItem key="1">1</MenuItem>,
<MenuItem key="2">2</MenuItem>,
]);
userEvent.click(screen.getByTestId('canvas'), { buttons: 2 });
expect(queryAllByRole('menuitem')).toHaveLength(2);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React from 'react';
import {
ControlledMenu,
useMenuState,
MenuItem,
SubMenu,
} from '@szhsin/react-menu';

// even though the library support many different types
// we only support these
type SupportedItems = typeof MenuItem | typeof SubMenu;

type xyToMenuItems = (x: number, y: number) => SupportedItems[];

export interface ContextMenuProps {
canvasRef: React.RefObject<HTMLCanvasElement>;

// The menu should be built dynamically
// Based on the cell's contents
xyToMenuItems: xyToMenuItems;
}

export default function ContextMenu(props: ContextMenuProps) {
const { toggleMenu, openMenu, closeMenu, ...menuProps } = useMenuState(false);
const [anchorPoint, setAnchorPoint] = React.useState({ x: 0, y: 0 });
const { canvasRef } = props;
const [menuItems, setMenuItems] = React.useState<SupportedItems[]>([]);

const onContextMenu = (e: MouseEvent) => {
e.preventDefault();

const items = props.xyToMenuItems(e.clientX, e.clientY);
setMenuItems(items);

// TODO
// if the menu becomes too large, it may overflow to outside the screen
const x = e.clientX;
const y = e.clientY + 20;

setAnchorPoint({ x, y });
openMenu();
};

React.useEffect(() => {
closeMenu();

// use closure to "cache" the current canvas reference
// so that when cleaning up, it points to a valid canvas
// (otherwise it would be null)
const canvasEl = canvasRef.current;
if (!canvasEl) {
return () => {};
}

// watch for mouse events on the bar
canvasEl.addEventListener('contextmenu', onContextMenu);

return () => {
canvasEl.removeEventListener('contextmenu', onContextMenu);
};
}, []);
return (
<ControlledMenu
menuItemFocus={menuProps.menuItemFocus}
isMounted={menuProps.isMounted}
isOpen={menuProps.isOpen}
anchorPoint={anchorPoint}
onClose={closeMenu}
>
{menuItems}
</ControlledMenu>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const PX_PER_LEVEL = 22;
export const COLLAPSE_THRESHOLD = 5;
export const LABEL_THRESHOLD = 20;
export const GAP = 0.5;
export const BAR_HEIGHT = PX_PER_LEVEL - GAP;
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ THIS SOFTWARE.
/* eslint-disable no-restricted-syntax */
import React from 'react';
import clsx from 'clsx';
import { MenuItem, SubMenu } from '@szhsin/react-menu';
import {
getFormatter,
ratioToPercent,
Expand All @@ -47,6 +48,14 @@ import { fitToCanvasRect } from '../../../util/fitMode';
import DiffLegend from './DiffLegend';
import Tooltip from './Tooltip';
import Highlight from './Highlight';
import ContextMenu from './ContextMenu';
import {
PX_PER_LEVEL,
COLLAPSE_THRESHOLD,
LABEL_THRESHOLD,
GAP,
BAR_HEIGHT,
} from './constants';

const formatSingle = {
format: 'single',
Expand Down Expand Up @@ -106,12 +115,7 @@ export function parseFlamebearerFormat(format) {
return formatDouble;
}

const PX_PER_LEVEL = 18;
const COLLAPSE_THRESHOLD = 5;
const LABEL_THRESHOLD = 20;
const HIGHLIGHT_NODE_COLOR = '#48CE73'; // green
const GAP = 0.5;
export const BAR_HEIGHT = PX_PER_LEVEL - GAP;

const unitsToFlamegraphTitle = {
objects: 'amount of objects in RAM per function',
Expand Down Expand Up @@ -510,6 +514,18 @@ class FlameGraph extends React.Component {
return true;
};

xyToContextMenuItems = (x, y) => {
const isFocused = this.selectedLevel !== 0;

// Depending on what item we clicked
// The menu items will be completely different
return [
<MenuItem key="reset" disabled={!isFocused} onClick={this.reset}>
Reset View
</MenuItem>,
];
};

updateZoom(i, j) {
const ff = this.props.format;
if (!Number.isNaN(i) && !Number.isNaN(j)) {
Expand Down Expand Up @@ -620,6 +636,13 @@ class FlameGraph extends React.Component {
/>
</div>
</div>

<ContextMenu
canvasRef={this.canvasRef}
items={this.contextMenuItems}
xyToMenuItems={this.xyToContextMenuItems}
/>

<Tooltip
format={this.props.format.format}
canvasRef={this.canvasRef}
Expand Down

0 comments on commit 3df5d9d

Please sign in to comment.