From a5937296e01ac934b701945ec97302257061e1bf Mon Sep 17 00:00:00 2001 From: Alexandre Fauquette <45398769+alexfauquette@users.noreply.github.com> Date: Tue, 20 Aug 2024 09:36:19 +0200 Subject: [PATCH] [charts] Test pointer events (#14042) --- .../src/BarChart/checkClickEvent.test.tsx | 155 ++++++++++ .../src/LineChart/checkClickEvent.test.tsx | 282 ++++++++++++++++++ .../src/PieChart/checkClickEvent.test.tsx | 74 +++++ .../src/ScatterChart/checkClickEvent.test.tsx | 183 ++++++++++++ packages/x-charts/src/internals/domUtils.ts | 18 +- packages/x-charts/src/internals/index.ts | 1 + .../x-charts/src/tests/firePointerEvent.ts | 41 +++ test/utils/mochaHooks.js | 2 + 8 files changed, 754 insertions(+), 2 deletions(-) create mode 100644 packages/x-charts/src/BarChart/checkClickEvent.test.tsx create mode 100644 packages/x-charts/src/LineChart/checkClickEvent.test.tsx create mode 100644 packages/x-charts/src/PieChart/checkClickEvent.test.tsx create mode 100644 packages/x-charts/src/ScatterChart/checkClickEvent.test.tsx create mode 100644 packages/x-charts/src/tests/firePointerEvent.ts diff --git a/packages/x-charts/src/BarChart/checkClickEvent.test.tsx b/packages/x-charts/src/BarChart/checkClickEvent.test.tsx new file mode 100644 index 000000000000..7804217e7402 --- /dev/null +++ b/packages/x-charts/src/BarChart/checkClickEvent.test.tsx @@ -0,0 +1,155 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer, fireEvent } from '@mui/internal-test-utils'; +import { spy } from 'sinon'; +import { BarChart } from '@mui/x-charts/BarChart'; +import { firePointerEvent } from '../tests/firePointerEvent'; + +const config = { + dataset: [ + { x: 'A', v1: 4, v2: 2 }, + { x: 'B', v1: 1, v2: 1 }, + ], + margin: { top: 0, left: 0, bottom: 0, right: 0 }, + width: 400, + height: 400, +}; + +// Plot as follow to simplify click position +// +// | X +// | X +// | X X +// | X X X X +// ---A---B- + +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + +describe('BarChart - click event', () => { + const { render } = createRenderer(); + + describe('onAxisClick', () => { + it('should provide the right context as second argument', function test() { + if (isJSDOM) { + // can't do Pointer event with JSDom https://github.com/jsdom/jsdom/issues/2527 + this.skip(); + } + const onAxisClick = spy(); + render( +
+ +
, + ); + const svg = document.querySelector('svg')!; + + firePointerEvent(svg, 'pointermove', { + clientX: 198, + clientY: 60, + }); + fireEvent.click(svg); + + expect(onAxisClick.lastCall.args[1]).to.deep.equal({ + dataIndex: 0, + axisValue: 'A', + seriesValues: { s1: 4, s2: 2 }, + }); + + firePointerEvent(svg, 'pointermove', { + clientX: 201, + clientY: 60, + }); + fireEvent.click(svg); + + expect(onAxisClick.lastCall.args[1]).to.deep.equal({ + dataIndex: 1, + axisValue: 'B', + seriesValues: { s1: 1, s2: 1 }, + }); + }); + }); + + describe('onItemClick', () => { + it('should add cursor="pointer" to bar elements', function test() { + render( + {}} + />, + ); + const rectangles = document.querySelectorAll('rect.MuiBarElement-root'); + + expect( + Array.from(rectangles).map((rectangle) => rectangle.getAttribute('cursor')), + ).to.deep.equal(['pointer', 'pointer', 'pointer', 'pointer']); + }); + + it('should provide the right context as second argument', function test() { + if (isJSDOM) { + // can't do Pointer event with JSDom https://github.com/jsdom/jsdom/issues/2527 + this.skip(); + } + const onItemClick = spy(); + render( +
+ +
, + ); + + const rectangles = document.querySelectorAll('rect.MuiBarElement-root'); + + fireEvent.click(rectangles[0]); + expect(onItemClick.lastCall.args[1]).to.deep.equal({ + type: 'bar', + seriesId: 's1', + dataIndex: 0, + }); + + fireEvent.click(rectangles[1]); + expect(onItemClick.lastCall.args[1]).to.deep.equal({ + type: 'bar', + seriesId: 's1', + dataIndex: 1, + }); + + fireEvent.click(rectangles[2]); + expect(onItemClick.lastCall.args[1]).to.deep.equal({ + type: 'bar', + seriesId: 's2', + dataIndex: 0, + }); + }); + }); +}); diff --git a/packages/x-charts/src/LineChart/checkClickEvent.test.tsx b/packages/x-charts/src/LineChart/checkClickEvent.test.tsx new file mode 100644 index 000000000000..8c2e0800f227 --- /dev/null +++ b/packages/x-charts/src/LineChart/checkClickEvent.test.tsx @@ -0,0 +1,282 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer, fireEvent } from '@mui/internal-test-utils'; +import { spy } from 'sinon'; +import { LineChart } from '@mui/x-charts/LineChart'; +import { firePointerEvent } from '../tests/firePointerEvent'; + +const config = { + dataset: [ + { x: 10, v1: 0, v2: 10 }, + { x: 20, v1: 5, v2: 8 }, + { x: 30, v1: 8, v2: 5 }, + { x: 40, v1: 10, v2: 0 }, + ], + margin: { top: 0, left: 0, bottom: 0, right: 0 }, + width: 400, + height: 400, +}; + +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + +describe('LineChart - click event', () => { + const { render } = createRenderer(); + + describe('onAxisClick', () => { + it('should provide the right context as second argument', function test() { + if (isJSDOM) { + // can't do Pointer event with JSDom https://github.com/jsdom/jsdom/issues/2527 + this.skip(); + } + const onAxisClick = spy(); + render( +
+ +
, + ); + const svg = document.querySelector('svg')!; + + firePointerEvent(svg, 'pointermove', { + clientX: 198, + clientY: 60, + }); + fireEvent.click(svg); + + expect(onAxisClick.lastCall.args[1]).to.deep.equal({ + dataIndex: 1, + axisValue: 20, + seriesValues: { s1: 5, s2: 8 }, + }); + + firePointerEvent(svg, 'pointermove', { + clientX: 201, + clientY: 60, + }); + fireEvent.click(svg); + + expect(onAxisClick.lastCall.args[1]).to.deep.equal({ + dataIndex: 2, + axisValue: 30, + seriesValues: { s1: 8, s2: 5 }, + }); + }); + }); + + describe('onMarkClick', () => { + it('should add cursor="pointer" to bar elements', function test() { + render( + {}} + />, + ); + const marks = document.querySelectorAll('path.MuiMarkElement-root'); + + expect(Array.from(marks).map((mark) => mark.getAttribute('cursor'))).to.deep.equal([ + 'pointer', + 'pointer', + 'pointer', + 'pointer', + 'pointer', + 'pointer', + 'pointer', + 'pointer', + ]); + }); + + it('should provide the right context as second argument', function test() { + if (isJSDOM) { + // can't do Pointer event with JSDom https://github.com/jsdom/jsdom/issues/2527 + this.skip(); + } + const onMarkClick = spy(); + render( +
+ +
, + ); + + const marks = document.querySelectorAll('path.MuiMarkElement-root'); + + fireEvent.click(marks[0]); + expect(onMarkClick.lastCall.args[1]).to.deep.equal({ + type: 'line', + seriesId: 's1', + dataIndex: 0, + }); + + fireEvent.click(marks[1]); + expect(onMarkClick.lastCall.args[1]).to.deep.equal({ + type: 'line', + seriesId: 's1', + dataIndex: 1, + }); + + fireEvent.click(marks[4]); + expect(onMarkClick.lastCall.args[1]).to.deep.equal({ + type: 'line', + seriesId: 's2', + dataIndex: 0, + }); + }); + }); + + describe('onAreaClick', () => { + it('should add cursor="pointer" to bar elements', function test() { + render( + {}} + />, + ); + const areas = document.querySelectorAll('path.MuiAreaElement-root'); + + expect(Array.from(areas).map((area) => area.getAttribute('cursor'))).to.deep.equal([ + 'pointer', + 'pointer', + ]); + }); + + it('should provide the right context as second argument', function test() { + if (isJSDOM) { + // can't do Pointer event with JSDom https://github.com/jsdom/jsdom/issues/2527 + this.skip(); + } + const onAreaClick = spy(); + render( +
+ +
, + ); + + const areas = document.querySelectorAll('path.MuiAreaElement-root'); + + fireEvent.click(areas[0]); + expect(onAreaClick.lastCall.args[1]).to.deep.equal({ + type: 'line', + seriesId: 's1', + }); + + fireEvent.click(areas[1]); + expect(onAreaClick.lastCall.args[1]).to.deep.equal({ + type: 'line', + seriesId: 's2', + }); + }); + }); + + describe('onLineClick', () => { + it('should add cursor="pointer" to bar elements', function test() { + render( + {}} + />, + ); + const lines = document.querySelectorAll('path.MuiLineElement-root'); + + expect(Array.from(lines).map((line) => line.getAttribute('cursor'))).to.deep.equal([ + 'pointer', + 'pointer', + ]); + }); + + it('should provide the right context as second argument', function test() { + if (isJSDOM) { + // can't do Pointer event with JSDom https://github.com/jsdom/jsdom/issues/2527 + this.skip(); + } + const onLineClick = spy(); + render( +
+ +
, + ); + + const lines = document.querySelectorAll('path.MuiLineElement-root'); + + fireEvent.click(lines[0]); + expect(onLineClick.lastCall.args[1]).to.deep.equal({ + type: 'line', + seriesId: 's1', + }); + + fireEvent.click(lines[1]); + expect(onLineClick.lastCall.args[1]).to.deep.equal({ + type: 'line', + seriesId: 's2', + }); + }); + }); +}); diff --git a/packages/x-charts/src/PieChart/checkClickEvent.test.tsx b/packages/x-charts/src/PieChart/checkClickEvent.test.tsx new file mode 100644 index 000000000000..bc5f72d36874 --- /dev/null +++ b/packages/x-charts/src/PieChart/checkClickEvent.test.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer, fireEvent } from '@mui/internal-test-utils'; +import { spy } from 'sinon'; +import { PieChart } from '@mui/x-charts/PieChart'; + +const config = { + width: 400, + height: 400, +}; + +describe('PieChart - click event', () => { + const { render } = createRenderer(); + + describe('onItemClick', () => { + it('should add cursor="pointer" to bar elements', function test() { + render( + {}} + />, + ); + const slices = document.querySelectorAll('path.MuiPieArc-root'); + + expect(Array.from(slices).map((slice) => slice.getAttribute('cursor'))).to.deep.equal([ + 'pointer', + 'pointer', + ]); + }); + + it('should provide the right context as second argument', function test() { + const onItemClick = spy(); + render( + , + ); + const slices = document.querySelectorAll('path.MuiPieArc-root'); + + fireEvent.click(slices[0]); + expect(onItemClick.lastCall.args[1]).to.deep.equal({ + type: 'pie', + seriesId: 's1', + dataIndex: 0, + }); + + fireEvent.click(slices[1]); + expect(onItemClick.lastCall.args[1]).to.deep.equal({ + type: 'pie', + seriesId: 's1', + dataIndex: 1, + }); + }); + }); +}); diff --git a/packages/x-charts/src/ScatterChart/checkClickEvent.test.tsx b/packages/x-charts/src/ScatterChart/checkClickEvent.test.tsx new file mode 100644 index 000000000000..08b8148eff60 --- /dev/null +++ b/packages/x-charts/src/ScatterChart/checkClickEvent.test.tsx @@ -0,0 +1,183 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer, fireEvent } from '@mui/internal-test-utils'; +import { spy } from 'sinon'; +import { ScatterChart } from '@mui/x-charts/ScatterChart'; + +const config = { + dataset: [ + { id: 1, x: 0, y: 10 }, + { id: 2, x: 10, y: 10 }, + { id: 3, x: 10, y: 0 }, + { id: 4, x: 0, y: 0 }, + { id: 5, x: 5, y: 5 }, + ], + margin: { top: 0, left: 0, bottom: 0, right: 0 }, + width: 100, + height: 100, +}; + +// Plot on series as a dice 5 +// +// 1...2 +// ..... +// ..5.. +// ..... +// 4...3 + +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + +describe('ScatterChart - click event', () => { + const { render } = createRenderer(); + + describe('onItemClick - using vornoid', () => { + it('should provide the right context as second argument when clicking svg', function test() { + if (isJSDOM) { + // svg.createSVGPoint not supported by JSDom https://github.com/jsdom/jsdom/issues/300 + this.skip(); + } + const onItemClick = spy(); + render( +
+ +
, + ); + const svg = document.querySelector('svg')!; + + fireEvent.click(svg, { + clientX: 10, + clientY: 10, + }); + expect(onItemClick.lastCall.args[1]).to.deep.equal({ + type: 'scatter', + dataIndex: 0, + seriesId: 's1', + }); + + fireEvent.click(svg, { + clientX: 30, + clientY: 30, + }); + expect(onItemClick.lastCall.args[1]).to.deep.equal({ + type: 'scatter', + dataIndex: 4, + seriesId: 's1', + }); + + expect(onItemClick.callCount).to.equal(2); + }); + + it('should provide the right context as second argument when clicking mark', function test() { + if (isJSDOM) { + this.skip(); + } + const onItemClick = spy(); + render( +
+ +
, + ); + const marks = document.querySelectorAll('circle'); + + fireEvent.click(marks[1], { + clientX: 99, + clientY: 2, + }); + + expect(onItemClick.lastCall.args[1]).to.deep.equal({ + type: 'scatter', + dataIndex: 1, + seriesId: 's1', + }); + expect(onItemClick.callCount).to.equal(1); // Make sure voronoid + item click does not duplicate event triggering + }); + }); + + describe('onItemClick - disabling vornoid', () => { + it('should not call onItemClick when clicking the SVG', function test() { + if (isJSDOM) { + this.skip(); + } + const onItemClick = spy(); + render( +
+ +
, + ); + const svg = document.querySelector('svg')!; + + fireEvent.click(svg, { + clientX: 10, + clientY: 10, + }); + expect(onItemClick.callCount).to.equal(0); + }); + + it('should provide the right context as second argument when clicking mark', function test() { + if (isJSDOM) { + this.skip(); + } + const onItemClick = spy(); + render( +
+ +
, + ); + const marks = document.querySelectorAll('circle'); + + fireEvent.click(marks[1], { + clientX: 99, + clientY: 2, + }); + + expect(onItemClick.lastCall.args[1]).to.deep.equal({ + type: 'scatter', + dataIndex: 1, + seriesId: 's1', + }); + expect(onItemClick.callCount).to.equal(1); // Make sure voronoid + item click does not duplicate event triggering + }); + }); +}); diff --git a/packages/x-charts/src/internals/domUtils.ts b/packages/x-charts/src/internals/domUtils.ts index d7aca59b459f..e8a1b2e2d119 100644 --- a/packages/x-charts/src/internals/domUtils.ts +++ b/packages/x-charts/src/internals/domUtils.ts @@ -45,7 +45,7 @@ const STYLE_LIST = [ 'marginTop', 'marginBottom', ]; -const MEASUREMENT_SPAN_ID = 'mui_measurement_span'; +export const MEASUREMENT_SPAN_ID = 'mui_measurement_span'; /** * @@ -97,6 +97,7 @@ export const getStyleString = (style: React.CSSProperties) => '', ); +let domCleanTimeout: NodeJS.Timeout | undefined; /** * * @param text The string to estimate @@ -134,7 +135,6 @@ export const getStringSize = (text: string | number, style: React.CSSProperties return styleKey; }); measurementSpan.textContent = str; - const rect = measurementSpan.getBoundingClientRect(); const result = { width: rect.width, height: rect.height }; @@ -147,8 +147,22 @@ export const getStringSize = (text: string | number, style: React.CSSProperties stringCache.cacheCount += 1; } + if (domCleanTimeout) { + clearTimeout(domCleanTimeout); + } + domCleanTimeout = setTimeout(() => { + // Limit node cleaning to once per render cycle + measurementSpan.textContent = ''; + }, 0); + return result; } catch (e) { return { width: 0, height: 0 }; } }; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export function unstable_cleanupDOM() { + // const measurementSpan = document.getElementById(MEASUREMENT_SPAN_ID); + // measurementSpan?.remove(); +} diff --git a/packages/x-charts/src/internals/index.ts b/packages/x-charts/src/internals/index.ts index f4020f5d0918..d5f2c926fb26 100644 --- a/packages/x-charts/src/internals/index.ts +++ b/packages/x-charts/src/internals/index.ts @@ -21,6 +21,7 @@ export * from './configInit'; export * from './getLabel'; export * from './getSVGPoint'; export * from './isDefined'; +export { unstable_cleanupDOM } from './domUtils'; // contexts diff --git a/packages/x-charts/src/tests/firePointerEvent.ts b/packages/x-charts/src/tests/firePointerEvent.ts new file mode 100644 index 000000000000..ae639a28fcfc --- /dev/null +++ b/packages/x-charts/src/tests/firePointerEvent.ts @@ -0,0 +1,41 @@ +import { fireEvent } from '@mui/internal-test-utils'; + +export function firePointerEvent( + target: Element, + type: 'pointerstart' | 'pointermove' | 'pointerend', + options: Pick, +): void { + const originalGetBoundingClientRect = target.getBoundingClientRect; + target.getBoundingClientRect = () => ({ + x: 0, + y: 0, + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + toJSON() { + return { + x: 0, + y: 0, + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + }; + }, + }); + const event = new window.PointerEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + isPrimary: true, + ...options, + }); + + fireEvent(target, event); + target.getBoundingClientRect = originalGetBoundingClientRect; +} diff --git a/test/utils/mochaHooks.js b/test/utils/mochaHooks.js index b5f113969c06..5e774ee1be5c 100644 --- a/test/utils/mochaHooks.js +++ b/test/utils/mochaHooks.js @@ -2,6 +2,7 @@ import sinon from 'sinon'; import { unstable_resetCleanupTracking as unstable_resetCleanupTrackingDataGrid } from '@mui/x-data-grid'; import { unstable_resetCleanupTracking as unstable_resetCleanupTrackingDataGridPro } from '@mui/x-data-grid-pro'; import { unstable_resetCleanupTracking as unstable_resetCleanupTrackingTreeView } from '@mui/x-tree-view'; +import { unstable_cleanupDOM as unstable_cleanupDOMCharts } from '@mui/x-charts/internals'; import { clearWarningsCache } from '@mui/x-data-grid/internals'; import { generateTestLicenseKey, setupTestLicenseKey } from './testLicense'; @@ -27,6 +28,7 @@ export function createXMochaHooks(coreMochaHooks = {}) { unstable_resetCleanupTrackingDataGrid(); unstable_resetCleanupTrackingDataGridPro(); unstable_resetCleanupTrackingTreeView(); + unstable_cleanupDOMCharts(); // Restore Sinon default sandbox to avoid memory leak // See https://github.com/sinonjs/sinon/issues/1866