diff --git a/package.json b/package.json index 60d053e86..bef7d5acb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@netdata/charts", - "version": "6.12.14", + "version": "6.12.15", "description": "Netdata frontend SDK and chart utilities", "main": "dist/index.js", "module": "dist/es6/index.js", diff --git a/src/chartLibraries/dygraph/navigation/generic.js b/src/chartLibraries/dygraph/navigation/generic.js index 8130d090e..dff529830 100644 --- a/src/chartLibraries/dygraph/navigation/generic.js +++ b/src/chartLibraries/dygraph/navigation/generic.js @@ -101,7 +101,15 @@ export default chartUI => { let dygraphLastTouchMove = 0 let dygraphLastTouchPageX = 0 + const hasValidTouchTargets = touches => + Array.from(touches).every(touch => typeof touch.target?.getBoundingClientRect === "function") + const touchStart = (event, dygraph, context) => { + if (!hasValidTouchTargets(event.touches)) { + context.initialTouches = null + return + } + Dygraph.defaultInteractionModel.touchstart(event, dygraph, context) context.touchDirections = { x: true, y: false } @@ -111,6 +119,8 @@ export default chartUI => { } const touchMove = (event, dygraph, context) => { + if (!context.initialTouches) return + Dygraph.defaultInteractionModel.touchmove(event, dygraph, context) if (!dygraphLastTouchMove) chartUI.sdk.trigger("panStart", chartUI.chart) @@ -129,7 +139,11 @@ export default chartUI => { lastTouchEndTime = now - Dygraph.defaultInteractionModel.touchend(event, dygraph, context) + if (event.touches.length > 0 && !hasValidTouchTargets(event.touches)) { + context.initialTouches = null + } else { + Dygraph.defaultInteractionModel.touchend(event, dygraph, context) + } if (dygraphLastTouchMove === 0 && dygraphLastTouchPageX !== 0) { chartUI.chart.updateAttribute("clickX", [context.initialTouches?.[0]?.dataX, null]) diff --git a/src/chartLibraries/dygraph/navigation/generic.test.js b/src/chartLibraries/dygraph/navigation/generic.test.js new file mode 100644 index 000000000..7e60e9cdc --- /dev/null +++ b/src/chartLibraries/dygraph/navigation/generic.test.js @@ -0,0 +1,147 @@ +import genericNavigation from "./generic" + +const getHandler = (chartUI, eventName) => + chartUI.on.mock.calls.find(([name]) => name === eventName)[1] + +const makeChartUI = () => { + const chartUI = { + sdk: { + trigger: jest.fn(), + }, + chart: { + getAttribute: jest.fn(), + updateAttributes: jest.fn(), + updateAttribute: jest.fn(), + resetNavigation: jest.fn(), + getPropertiesForSeries: jest.fn(), + }, + trigger: jest.fn(), + on: jest.fn(() => chartUI), + off: jest.fn(() => chartUI), + } + return chartUI +} + +const makeDygraph = () => ({ + toDataXCoord: jest.fn(() => 0), + toDataYCoord: jest.fn(() => 0), + xAxisRange: jest.fn(() => [0, 1]), + yAxisRange: jest.fn(() => [0, 1]), + plotter_: { area: { w: 100, h: 100 } }, + axes_: [{}], + attributes_: { getForAxis: jest.fn(() => false) }, + drawGraph_: jest.fn(), + getFunctionOption: jest.fn(), +}) + +const makeTouchEvent = (touches, changedTouches = []) => ({ + touches, + changedTouches, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), +}) + +describe("generic navigation touch handling", () => { + let chartUI + let dygraph + let context + let touchStart + let touchMove + let touchEnd + + beforeEach(() => { + chartUI = makeChartUI() + genericNavigation(chartUI) + touchStart = getHandler(chartUI, "touchstart") + touchMove = getHandler(chartUI, "touchmove") + touchEnd = getHandler(chartUI, "touchend") + dygraph = makeDygraph() + context = {} + }) + + it("does not throw on touchstart when a touch target has no getBoundingClientRect", () => { + const event = makeTouchEvent([ + { target: document.createTextNode("x"), pageX: 10, pageY: 10, clientX: 10, clientY: 10 }, + ]) + + expect(() => touchStart(event, dygraph, context)).not.toThrow() + }) + + it("does not throw on touchmove after a touchstart that failed to record initialTouches", () => { + const startEvent = makeTouchEvent([ + { target: document.createTextNode("x"), pageX: 10, pageY: 10, clientX: 10, clientY: 10 }, + ]) + try { + touchStart(startEvent, dygraph, context) + } catch {} + + const moveEvent = makeTouchEvent([{ pageX: 20, pageY: 20 }]) + + expect(() => touchMove(moveEvent, dygraph, context)).not.toThrow() + }) + + it("still delegates to dygraph's default interaction model for an Element target", () => { + const element = document.createElement("div") + const startEvent = makeTouchEvent([ + { target: element, pageX: 10, pageY: 10, clientX: 10, clientY: 10 }, + ]) + + touchStart(startEvent, dygraph, context) + + expect(context.initialTouches).toEqual([{ pageX: 10, pageY: 10, dataX: 0, dataY: 0 }]) + + const moveEvent = makeTouchEvent([{ pageX: 15, pageY: 15 }]) + + expect(() => touchMove(moveEvent, dygraph, context)).not.toThrow() + expect(dygraph.drawGraph_).toHaveBeenCalledWith(false) + }) + + it("does not throw on touchmove after a valid one-finger touchstart is followed by an invalid two-finger touchstart", () => { + const element = document.createElement("div") + const oneFingerStart = makeTouchEvent([ + { target: element, pageX: 10, pageY: 10, clientX: 10, clientY: 10 }, + ]) + touchStart(oneFingerStart, dygraph, context) + + const twoFingerStart = makeTouchEvent([ + { target: element, pageX: 10, pageY: 10, clientX: 10, clientY: 10 }, + { target: document.createTextNode("x"), pageX: 20, pageY: 20, clientX: 20, clientY: 20 }, + ]) + touchStart(twoFingerStart, dygraph, context) + + const twoFingerMove = makeTouchEvent([ + { pageX: 12, pageY: 12 }, + { pageX: 22, pageY: 22 }, + ]) + + expect(() => touchMove(twoFingerMove, dygraph, context)).not.toThrow() + }) + + it("does not throw on touchend re-entry when a remaining touch has an invalid target", () => { + const element = document.createElement("div") + const startEvent = makeTouchEvent([ + { target: element, pageX: 10, pageY: 10, clientX: 10, clientY: 10 }, + ]) + touchStart(startEvent, dygraph, context) + + const endEvent = makeTouchEvent([ + { target: document.createTextNode("x"), pageX: 10, pageY: 10 }, + ]) + + expect(() => touchEnd(endEvent, dygraph, context)).not.toThrow() + }) + + it("still delegates the normal touchend path (clickX) when the gesture is valid", () => { + const element = document.createElement("div") + const startEvent = makeTouchEvent([ + { target: element, pageX: 10, pageY: 10, clientX: 10, clientY: 10 }, + ]) + touchStart(startEvent, dygraph, context) + + const endEvent = makeTouchEvent([], [{ screenX: 10, screenY: 10 }]) + + touchEnd(endEvent, dygraph, context) + + expect(chartUI.chart.updateAttribute).toHaveBeenCalledWith("clickX", [0, null]) + }) +})