Set service map cursors (#80920)

* Set service map cursors

* "pointer" when mousing over a node
* "default" when mousing out
* "grabbing" while dragging

Sets the cursor style on the container, not on the individual element.

Since the node can both be clicked (to open a popover) and dragged, I left the cursor on hover as "pointer" to indicate the clickability.

Fixes #64283.
This commit is contained in:
Nathan L Smith 2020-10-19 08:41:06 -05:00 committed by GitHub
parent f4c596dbe7
commit dd33002e2f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 362 additions and 32 deletions

View file

@ -14,7 +14,7 @@ import { useUrlParams } from '../../../hooks/useUrlParams';
import { getAPMHref } from '../../shared/Links/apm/APMLink';
import { APMQueryParams } from '../../shared/Links/url_helpers';
import { CytoscapeContext } from './Cytoscape';
import { getAnimationOptions, getNodeHeight } from './cytoscapeOptions';
import { getAnimationOptions, getNodeHeight } from './cytoscape_options';
const ControlsContainer = styled('div')`
left: ${({ theme }) => theme.eui.gutterTypes.gutterMedium};

View file

@ -17,7 +17,7 @@ import React, {
useState,
} from 'react';
import { useTheme } from '../../../hooks/useTheme';
import { getCytoscapeOptions } from './cytoscapeOptions';
import { getCytoscapeOptions } from './cytoscape_options';
import { useCytoscapeEventHandlers } from './use_cytoscape_event_handlers';
cytoscape.use(dagre);

View file

@ -22,7 +22,7 @@ import { useTheme } from '../../../../hooks/useTheme';
import { fontSize, px } from '../../../../style/variables';
import { asInteger, asDuration } from '../../../../../common/utils/formatters';
import { MLJobLink } from '../../../shared/Links/MachineLearningLinks/MLJobLink';
import { popoverWidth } from '../cytoscapeOptions';
import { popoverWidth } from '../cytoscape_options';
import { TRANSACTION_REQUEST } from '../../../../../common/transaction_types';
import {
getSeverity,

View file

@ -15,7 +15,7 @@ import React, { MouseEvent } from 'react';
import { Buttons } from './Buttons';
import { Info } from './Info';
import { ServiceStatsFetcher } from './ServiceStatsFetcher';
import { popoverWidth } from '../cytoscapeOptions';
import { popoverWidth } from '../cytoscape_options';
interface ContentsProps {
isService: boolean;

View file

@ -18,7 +18,7 @@ import cytoscape from 'cytoscape';
import { useTheme } from '../../../../hooks/useTheme';
import { SERVICE_NAME } from '../../../../../common/elasticsearch_fieldnames';
import { CytoscapeContext } from '../Cytoscape';
import { getAnimationOptions } from '../cytoscapeOptions';
import { getAnimationOptions } from '../cytoscape_options';
import { Contents } from './Contents';
interface PopoverProps {

View file

@ -5,17 +5,18 @@
*/
import cytoscape from 'cytoscape';
import { CSSProperties } from 'react';
import {
getServiceHealthStatusColor,
ServiceHealthStatus,
} from '../../../../common/service_health_status';
import { EuiTheme } from '../../../../../observability/public';
import { ServiceAnomalyStats } from '../../../../common/anomaly_detection';
import {
SERVICE_NAME,
SPAN_DESTINATION_SERVICE_RESOURCE,
} from '../../../../common/elasticsearch_fieldnames';
import { EuiTheme } from '../../../../../observability/public';
import {
getServiceHealthStatusColor,
ServiceHealthStatus,
} from '../../../../common/service_health_status';
import { FETCH_STATUS } from '../../../hooks/useFetcher';
import { defaultIcon, iconForNode } from './icons';
import { ServiceAnomalyStats } from '../../../../common/anomaly_detection';
export const popoverWidth = 280;
@ -104,6 +105,11 @@ function isService(el: cytoscape.NodeSingular) {
const getStyle = (theme: EuiTheme): cytoscape.Stylesheet[] => {
const lineColor = theme.eui.euiColorMediumShade;
return [
{
selector: 'core',
// @ts-expect-error DefinitelyTyped does not recognize 'active-bg-opacity'
style: { 'active-bg-opacity': 0 },
},
{
selector: 'node',
style: {
@ -226,7 +232,10 @@ const getStyle = (theme: EuiTheme): cytoscape.Stylesheet[] => {
// The CSS styles for the div containing the cytoscape element. Makes a
// background grid of dots.
export const getCytoscapeDivStyle = (theme: EuiTheme): CSSProperties => ({
export const getCytoscapeDivStyle = (
theme: EuiTheme,
status: FETCH_STATUS
): CSSProperties => ({
background: `linear-gradient(
90deg,
${theme.eui.euiPageBackgroundColor}
@ -242,6 +251,7 @@ linear-gradient(
center,
${theme.eui.euiColorLightShade}`,
backgroundSize: `${theme.eui.euiSizeL} ${theme.eui.euiSizeL}`,
cursor: `${status === FETCH_STATUS.LOADING ? 'wait' : 'grab'}`,
margin: `-${theme.eui.gutterTypes.gutterLarge}`,
marginTop: 0,
});

View file

@ -19,7 +19,7 @@ import { callApmApi } from '../../../services/rest/createCallApmApi';
import { LicensePrompt } from '../../shared/LicensePrompt';
import { Controls } from './Controls';
import { Cytoscape } from './Cytoscape';
import { getCytoscapeDivStyle } from './cytoscapeOptions';
import { getCytoscapeDivStyle } from './cytoscape_options';
import { EmptyBanner } from './EmptyBanner';
import { EmptyPrompt } from './empty_prompt';
import { Popover } from './Popover';
@ -121,7 +121,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) {
elements={data.elements}
height={height}
serviceName={serviceName}
style={getCytoscapeDivStyle(theme)}
style={getCytoscapeDivStyle(theme, status)}
>
<Controls />
{serviceName && <EmptyBanner />}

View file

@ -6,9 +6,12 @@
import { renderHook } from '@testing-library/react-hooks';
import cytoscape from 'cytoscape';
import { EuiTheme } from '../../../../../observability/public';
import { useCytoscapeEventHandlers } from './use_cytoscape_event_handlers';
import dagre from 'cytoscape-dagre';
import { EuiTheme, useUiTracker } from '../../../../../observability/public';
import { useCytoscapeEventHandlers } from './use_cytoscape_event_handlers';
import lodash from 'lodash';
jest.mock('../../../../../observability/public');
cytoscape.use(dagre);
@ -25,14 +28,109 @@ describe('useCytoscapeEventHandlers', () => {
});
});
describe('when data is received', () => {
describe('with a service name', () => {
it('sets the primary class', () => {
const cy = cytoscape({
elements: [{ data: { id: 'test' } }],
});
// Mock the chain that leads to layout run
jest.spyOn(cy, 'elements').mockReturnValueOnce(({
difference: () =>
(({
layout: () =>
(({ run: () => {} } as unknown) as cytoscape.Layouts),
} as unknown) as cytoscape.CollectionReturnValue),
} as unknown) as cytoscape.CollectionReturnValue);
renderHook(() =>
useCytoscapeEventHandlers({ serviceName: 'test', cy, theme })
);
cy.trigger('custom:data');
expect(cy.getElementById('test').hasClass('primary')).toEqual(true);
});
});
it('runs the layout', () => {
const cy = cytoscape({
elements: [{ data: { id: 'test' } }],
});
const run = jest.fn();
// Mock the chain that leads to layout run
jest.spyOn(cy, 'elements').mockReturnValueOnce(({
difference: () =>
(({
layout: () => (({ run } as unknown) as cytoscape.Layouts),
} as unknown) as cytoscape.CollectionReturnValue),
} as unknown) as cytoscape.CollectionReturnValue);
renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
cy.trigger('custom:data');
expect(run).toHaveBeenCalled();
});
});
describe('when layoutstop is triggered', () => {
it('applies cubic bézier styles', () => {
const cy = cytoscape({
elements: [
{ data: { id: 'test', source: 'a', target: 'b' } },
{ data: { id: 'a' } },
{ data: { id: 'b' } },
],
});
const edge = cy.getElementById('test');
const style = jest.spyOn(edge, 'style');
renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
cy.trigger('layoutstop');
expect(style).toHaveBeenCalledWith('control-point-distances', [-0, 0]);
});
});
describe('when an element is dragged', () => {
it('sets the hasBeenDragged data', () => {
const cy = cytoscape({ elements: [{ data: { id: 'test' } }] });
const node = cy.getElementById('test');
renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
cy.getElementById('test').trigger('drag');
node.trigger('drag');
expect(cy.getElementById('test').data('hasBeenDragged')).toEqual(true);
expect(node.data('hasBeenDragged')).toEqual(true);
});
describe('when it has already been dragged', () => {
it('keeps hasBeenDragged as true', () => {
const cy = cytoscape({
elements: [{ data: { hasBeenDragged: true, id: 'test' } }],
});
const node = cy.getElementById('test');
renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
node.trigger('drag');
expect(node.data('hasBeenDragged')).toEqual(true);
});
});
});
describe('when a drag ends', () => {
it('changes the cursor to pointer', () => {
const cy = cytoscape({ elements: [{ data: { id: 'test' } }] });
const container = ({
style: { cursor: 'grabbing' },
} as unknown) as HTMLElement;
jest.spyOn(cy, 'container').mockReturnValueOnce(container);
renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
cy.getElementById('test').trigger('dragfree');
expect(container.style.cursor).toEqual('pointer');
});
});
@ -48,6 +146,36 @@ describe('useCytoscapeEventHandlers', () => {
expect(node.hasClass('hover')).toEqual(true);
});
it('sets the cursor to pointer', () => {
const cy = cytoscape({ elements: [{ data: { id: 'test' } }] });
const container = ({
style: { cursor: 'default' },
} as unknown) as HTMLElement;
jest.spyOn(cy, 'container').mockReturnValueOnce(container);
renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
cy.getElementById('test').trigger('mouseover');
expect(container.style.cursor).toEqual('pointer');
});
it('tracks an event', () => {
const cy = cytoscape({ elements: [{ data: { id: 'test' } }] });
const trackApmEvent = jest.fn();
(useUiTracker as jest.Mock).mockReturnValueOnce(trackApmEvent);
jest.spyOn(lodash, 'debounce').mockImplementationOnce((fn: any) => {
fn();
return fn;
});
renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
cy.getElementById('test').trigger('mouseover');
expect(trackApmEvent).toHaveBeenCalledWith({
metric: 'service_map_node_or_edge_hover',
});
});
});
describe('when a node is un-hovered', () => {
@ -62,5 +190,157 @@ describe('useCytoscapeEventHandlers', () => {
expect(node.hasClass('hover')).toEqual(false);
});
it('sets the cursor to the default', () => {
const cy = cytoscape({ elements: [{ data: { id: 'test' } }] });
const container = ({
style: { cursor: 'pointer' },
} as unknown) as HTMLElement;
jest.spyOn(cy, 'container').mockReturnValueOnce(container);
renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
cy.getElementById('test').trigger('mouseout');
expect(container.style.cursor).toEqual('grab');
});
});
describe('when an edge is hovered', () => {
it('does not set the cursor to pointer', () => {
const cy = cytoscape({
elements: [
{ data: { id: 'test', source: 'a', target: 'b' } },
{ data: { id: 'a' } },
{ data: { id: 'b' } },
],
});
const container = ({
style: { cursor: 'default' },
} as unknown) as HTMLElement;
jest.spyOn(cy, 'container').mockReturnValueOnce(container);
renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
cy.getElementById('test').trigger('mouseover');
expect(container.style.cursor).toEqual('default');
});
});
describe('when a node is selected', () => {
it('tracks an event', () => {
const cy = cytoscape({ elements: [{ data: { id: 'test' } }] });
const trackApmEvent = jest.fn();
(useUiTracker as jest.Mock).mockReturnValueOnce(trackApmEvent);
jest.spyOn(lodash, 'debounce').mockImplementationOnce((fn: any) => {
fn();
return fn;
});
renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
cy.getElementById('test').trigger('select');
expect(trackApmEvent).toHaveBeenCalledWith({
metric: 'service_map_node_select',
});
});
});
describe('when a node is unselected', () => {
it('resets connected edge styles', () => {
const cy = cytoscape({
elements: [
{ data: { id: 'test' } },
{ data: { id: 'edge', source: 'test', target: 'test2' } },
{ data: { id: 'test2' } },
],
});
renderHook(() =>
useCytoscapeEventHandlers({
serviceName: 'test',
cy,
theme,
})
);
cy.getElementById('test').trigger('unselect');
expect(cy.getElementById('edge').hasClass('highlight')).toEqual(true);
});
});
describe('when a tap starts', () => {
it('sets the cursor to grabbing', () => {
const cy = cytoscape({});
const container = ({
style: { cursor: 'grab' },
} as unknown) as HTMLElement;
jest.spyOn(cy, 'container').mockReturnValueOnce(container);
renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
cy.trigger('tapstart');
expect(container.style.cursor).toEqual('grabbing');
});
describe('when the target is a node', () => {
it('does not change the cursor', () => {
const cy = cytoscape({ elements: [{ data: { id: 'test' } }] });
const container = ({
style: { cursor: 'grab' },
} as unknown) as HTMLElement;
jest.spyOn(cy, 'container').mockReturnValueOnce(container);
renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
cy.getElementById('test').trigger('tapstart');
expect(container.style.cursor).toEqual('grab');
});
});
});
describe('when a tap ends', () => {
it('sets the cursor to the default', () => {
const cy = cytoscape({});
const container = ({
style: { cursor: 'grabbing' },
} as unknown) as HTMLElement;
jest.spyOn(cy, 'container').mockReturnValueOnce(container);
renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
cy.trigger('tapend');
expect(container.style.cursor).toEqual('grab');
});
describe('when the target is a node', () => {
it('does not change the cursor', () => {
const cy = cytoscape({ elements: [{ data: { id: 'test' } }] });
const container = ({
style: { cursor: 'pointer' },
} as unknown) as HTMLElement;
jest.spyOn(cy, 'container').mockReturnValueOnce(container);
renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
cy.getElementById('test').trigger('tapend');
expect(container.style.cursor).toEqual('pointer');
});
});
});
describe('when debug is enabled', () => {
it('logs a debug message', () => {
const cy = cytoscape({ elements: [{ data: { id: 'test' } }] });
(useUiTracker as jest.Mock).mockReturnValueOnce(() => {});
jest.spyOn(Storage.prototype, 'getItem').mockReturnValueOnce('true');
const debug = jest
.spyOn(window.console, 'debug')
.mockReturnValueOnce(undefined);
renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
cy.getElementById('test').trigger('select');
expect(debug).toHaveBeenCalled();
});
});
});

View file

@ -8,7 +8,7 @@ import cytoscape from 'cytoscape';
import { debounce } from 'lodash';
import { useEffect } from 'react';
import { EuiTheme, useUiTracker } from '../../../../../observability/public';
import { getAnimationOptions, getNodeHeight } from './cytoscapeOptions';
import { getAnimationOptions, getNodeHeight } from './cytoscape_options';
/*
* @notice
@ -66,6 +66,24 @@ function getLayoutOptions({
};
}
function setCursor(cursor: string, event: cytoscape.EventObjectCore) {
const container = event.cy.container();
if (container) {
container.style.cursor = cursor;
}
}
function resetConnectedEdgeStyle(
cytoscapeInstance: cytoscape.Core,
node?: cytoscape.NodeSingular
) {
cytoscapeInstance.edges().removeClass('highlight');
if (node) {
node.connectedEdges().addClass('highlight');
}
}
export function useCytoscapeEventHandlers({
cy,
serviceName,
@ -80,16 +98,6 @@ export function useCytoscapeEventHandlers({
useEffect(() => {
const nodeHeight = getNodeHeight(theme);
const resetConnectedEdgeStyle = (
cytoscapeInstance: cytoscape.Core,
node?: cytoscape.NodeSingular
) => {
cytoscapeInstance.edges().removeClass('highlight');
if (node) {
node.connectedEdges().addClass('highlight');
}
};
const dataHandler: cytoscape.EventHandler = (event, fit) => {
if (serviceName) {
const node = event.cy.getElementById(serviceName);
@ -123,11 +131,17 @@ export function useCytoscapeEventHandlers({
);
const mouseoverHandler: cytoscape.EventHandler = (event) => {
if (event.target.isNode()) {
setCursor('pointer', event);
}
trackNodeEdgeHover();
event.target.addClass('hover');
event.target.connectedEdges().addClass('nodeHover');
};
const mouseoutHandler: cytoscape.EventHandler = (event) => {
setCursor('grab', event);
event.target.removeClass('hover');
event.target.connectedEdges().removeClass('nodeHover');
};
@ -148,17 +162,37 @@ export function useCytoscapeEventHandlers({
console.debug('cytoscape:', event);
}
};
const dragHandler: cytoscape.EventHandler = (event) => {
setCursor('grabbing', event);
applyCubicBezierStyles(event.target.connectedEdges());
if (!event.target.data('hasBeenDragged')) {
event.target.data('hasBeenDragged', true);
}
};
const dragfreeHandler: cytoscape.EventHandler = (event) => {
setCursor('pointer', event);
};
const tapstartHandler: cytoscape.EventHandler = (event) => {
// Onle set cursot to "grabbing" if the target doesn't have an "isNode"
// property (meaning it's the canvas) or if "isNode" is false (meaning
// it's an edge.)
if (!event.target.isNode || !event.target.isNode()) {
setCursor('grabbing', event);
}
};
const tapendHandler: cytoscape.EventHandler = (event) => {
if (!event.target.isNode || !event.target.isNode()) {
setCursor('grab', event);
}
};
if (cy) {
cy.on('custom:data drag layoutstop select unselect', debugHandler);
cy.on(
'custom:data drag dragfree layoutstop select tapstart tapend unselect',
debugHandler
);
cy.on('custom:data', dataHandler);
cy.on('layoutstop', layoutstopHandler);
cy.on('mouseover', 'edge, node', mouseoverHandler);
@ -166,12 +200,15 @@ export function useCytoscapeEventHandlers({
cy.on('select', 'node', selectHandler);
cy.on('unselect', 'node', unselectHandler);
cy.on('drag', 'node', dragHandler);
cy.on('dragfree', 'node', dragfreeHandler);
cy.on('tapstart', tapstartHandler);
cy.on('tapend', tapendHandler);
}
return () => {
if (cy) {
cy.removeListener(
'custom:data drag layoutstop select unselect',
'custom:data drag dragfree layoutstop select tapstart tapend unselect',
undefined,
debugHandler
);
@ -182,6 +219,9 @@ export function useCytoscapeEventHandlers({
cy.removeListener('select', 'node', selectHandler);
cy.removeListener('unselect', 'node', unselectHandler);
cy.removeListener('drag', 'node', dragHandler);
cy.removeListener('dragfree', 'node', dragfreeHandler);
cy.removeListener('tapstart', undefined, tapstartHandler);
cy.removeListener('tapend', undefined, tapendHandler);
}
};
}, [cy, serviceName, trackApmEvent, theme]);