From dd33002e2f4fe96875796b3193c2636d64aae2ac Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Mon, 19 Oct 2020 08:41:06 -0500 Subject: [PATCH] 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. --- .../components/app/ServiceMap/Controls.tsx | 2 +- .../components/app/ServiceMap/Cytoscape.tsx | 2 +- .../ServiceMap/Popover/AnomalyDetection.tsx | 2 +- .../app/ServiceMap/Popover/Contents.tsx | 2 +- .../app/ServiceMap/Popover/index.tsx | 2 +- ...toscapeOptions.ts => cytoscape_options.ts} | 24 +- .../components/app/ServiceMap/index.tsx | 4 +- .../use_cytoscape_event_handlers.test.tsx | 288 +++++++++++++++++- .../use_cytoscape_event_handlers.ts | 68 ++++- 9 files changed, 362 insertions(+), 32 deletions(-) rename x-pack/plugins/apm/public/components/app/ServiceMap/{cytoscapeOptions.ts => cytoscape_options.ts} (95%) diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Controls.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Controls.tsx index baba592e5886e..b4408e20c04d2 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Controls.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Controls.tsx @@ -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}; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index 7b944ed1b6ceb..8a76c5f7bd8f1 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -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); diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx index 3938349050e5e..788e5f25b6310 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx @@ -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, diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx index 197bc94c62603..6dd0c68165732 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx @@ -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; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx index c4272d2869016..7b7e3b46bb317 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx @@ -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 { diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape_options.ts similarity index 95% rename from x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts rename to x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape_options.ts index 136be1c7d947c..e51f53567b5ff 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape_options.ts @@ -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, }); diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx index 1d2e4ada43add..d167b6a9a0565 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -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)} > {serviceName && } diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/use_cytoscape_event_handlers.test.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/use_cytoscape_event_handlers.test.tsx index 4212d866c0853..ab16da1410662 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/use_cytoscape_event_handlers.test.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/use_cytoscape_event_handlers.test.tsx @@ -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(); + }); }); }); diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/use_cytoscape_event_handlers.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/use_cytoscape_event_handlers.ts index e8c6a3165ce93..a9125a13fc6fd 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/use_cytoscape_event_handlers.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/use_cytoscape_event_handlers.ts @@ -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]);