From 62a03364254a4b59dff59e08c2e4abf77ee0f075 Mon Sep 17 00:00:00 2001 From: Elizabeth Thompson Date: Tue, 21 May 2024 09:52:32 -0700 Subject: [PATCH] fix: add listener to repaint on visibility change for canvas (#28568) --- superset-frontend/package-lock.json | 65 +++++++++ superset-frontend/package.json | 1 + .../src/dashboard/components/Dashboard.jsx | 23 +++ .../dashboard/components/Dashboard.test.jsx | 133 ++++++++++++++++++ 4 files changed, 222 insertions(+) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index deaa3748f..7ac405a55 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -250,6 +250,7 @@ "ignore-styles": "^5.0.1", "imports-loader": "^3.1.1", "jest": "^26.6.3", + "jest-canvas-mock": "^2.5.2", "jest-environment-enzyme": "^7.1.2", "jest-enzyme": "^7.1.2", "jest-websocket-mock": "^2.2.0", @@ -31171,6 +31172,12 @@ "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", "integrity": "sha1-xtJnJjKi5cg+AT5oZKQs6N79IK4=" }, + "node_modules/cssfontparser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz", + "integrity": "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==", + "dev": true + }, "node_modules/cssnano": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.0.3.tgz", @@ -41175,6 +41182,16 @@ "node": ">= 10.14.2" } }, + "node_modules/jest-canvas-mock": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.5.2.tgz", + "integrity": "sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A==", + "dev": true, + "dependencies": { + "cssfontparser": "^1.2.1", + "moo-color": "^1.0.2" + } + }, "node_modules/jest-changed-files": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-26.6.2.tgz", @@ -52539,6 +52556,21 @@ "resolved": "https://registry.npmjs.org/moo/-/moo-0.4.3.tgz", "integrity": "sha512-gFD2xGCl8YFgGHsqJ9NKRVdwlioeW3mI1iqfLNYQOv0+6JRwG58Zk9DIGQgyIaffSYaO1xsKnMaYzzNr1KyIAw==" }, + "node_modules/moo-color": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", + "integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==", + "dev": true, + "dependencies": { + "color-name": "^1.1.4" + } + }, + "node_modules/moo-color/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, "node_modules/mousetrap": { "version": "1.6.5", "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.5.tgz", @@ -96422,6 +96454,12 @@ "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", "integrity": "sha1-xtJnJjKi5cg+AT5oZKQs6N79IK4=" }, + "cssfontparser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz", + "integrity": "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==", + "dev": true + }, "cssnano": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.0.3.tgz", @@ -104272,6 +104310,16 @@ } } }, + "jest-canvas-mock": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.5.2.tgz", + "integrity": "sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A==", + "dev": true, + "requires": { + "cssfontparser": "^1.2.1", + "moo-color": "^1.0.2" + } + }, "jest-changed-files": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-26.6.2.tgz", @@ -112794,6 +112842,23 @@ "resolved": "https://registry.npmjs.org/moo/-/moo-0.4.3.tgz", "integrity": "sha512-gFD2xGCl8YFgGHsqJ9NKRVdwlioeW3mI1iqfLNYQOv0+6JRwG58Zk9DIGQgyIaffSYaO1xsKnMaYzzNr1KyIAw==" }, + "moo-color": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", + "integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==", + "dev": true, + "requires": { + "color-name": "^1.1.4" + }, + "dependencies": { + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + } + } + }, "mousetrap": { "version": "1.6.5", "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.5.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index c526a9b06..70ca63de7 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -316,6 +316,7 @@ "ignore-styles": "^5.0.1", "imports-loader": "^3.1.1", "jest": "^26.6.3", + "jest-canvas-mock": "^2.5.2", "jest-environment-enzyme": "^7.1.2", "jest-enzyme": "^7.1.2", "jest-websocket-mock": "^2.2.0", diff --git a/superset-frontend/src/dashboard/components/Dashboard.jsx b/superset-frontend/src/dashboard/components/Dashboard.jsx index 038ab148b..9e4807513 100644 --- a/superset-frontend/src/dashboard/components/Dashboard.jsx +++ b/superset-frontend/src/dashboard/components/Dashboard.jsx @@ -90,6 +90,8 @@ class Dashboard extends React.PureComponent { this.appliedFilters = props.activeFilters ?? {}; this.appliedOwnDataCharts = props.ownDataCharts ?? {}; this.onVisibilityChange = this.onVisibilityChange.bind(this); + this.handleVisibilityChange = this.handleVisibilityChange.bind(this); + this.repaintCanvas = this.repaintCanvas.bind(this); } componentDidMount() { @@ -192,6 +194,24 @@ class Dashboard extends React.PureComponent { this.props.actions.clearDataMaskState(); } + repaintCanvas(canvas, ctx, imageBitmap) { + // Clear the canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Draw the copied content + ctx.drawImage(imageBitmap, 0, 0); + } + + handleVisibilityChange() { + this.canvases.forEach(canvas => { + const ctx = canvas.getContext('2d'); + createImageBitmap(canvas).then(imageBitmap => { + // Call the repaintCanvas function with canvas, ctx, and imageBitmap + this.repaintCanvas(canvas, ctx, imageBitmap); + }); + }); + } + onVisibilityChange() { if (document.visibilityState === 'hidden') { // from visible to hidden @@ -199,6 +219,7 @@ class Dashboard extends React.PureComponent { start_offset: Logger.getTimestamp(), ts: new Date().getTime(), }; + this.canvases = document.querySelectorAll('canvas'); } else if (document.visibilityState === 'visible') { // from hidden to visible const logStart = this.visibilityEventData.start_offset; @@ -206,6 +227,8 @@ class Dashboard extends React.PureComponent { ...this.visibilityEventData, duration: Logger.getTimestamp() - logStart, }); + // for chrome to ensure that the canvas doesn't disappear + this.handleVisibilityChange(); } } diff --git a/superset-frontend/src/dashboard/components/Dashboard.test.jsx b/superset-frontend/src/dashboard/components/Dashboard.test.jsx index d75bda27d..bfb8eec07 100644 --- a/superset-frontend/src/dashboard/components/Dashboard.test.jsx +++ b/superset-frontend/src/dashboard/components/Dashboard.test.jsx @@ -19,6 +19,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import sinon from 'sinon'; +import 'jest-canvas-mock'; import Dashboard from 'src/dashboard/components/Dashboard'; import { CHART_TYPE } from 'src/dashboard/util/componentTypes'; @@ -38,6 +39,7 @@ import { dashboardLayout } from 'spec/fixtures/mockDashboardLayout'; import dashboardState from 'spec/fixtures/mockDashboardState'; import { sliceEntitiesForChart as sliceEntities } from 'spec/fixtures/mockSliceEntities'; import { getAllActiveFilters } from 'src/dashboard/util/activeAllDashboardFilters'; +import { Logger, LOG_ACTIONS_HIDE_BROWSER_TAB } from '../../logger/LogUtils'; describe('Dashboard', () => { const props = { @@ -245,5 +247,136 @@ describe('Dashboard', () => { expect(refreshSpy.callCount).toBe(1); expect(refreshSpy.getCall(0).args[0]).toEqual([]); }); + + // The canvas is cleared using the clearRect method. + it('should clear the canvas using clearRect method', () => { + // Arrange + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const imageBitmap = new ImageBitmap(100, 100); + + // Act + wrapper.instance().repaintCanvas(canvas, ctx, imageBitmap); + + // Assert + expect(ctx.clearRect).toHaveBeenCalledWith( + 0, + 0, + canvas.width, + canvas.height, + ); + }); + + // The canvas width and height are 0. + it('should recreate the canvas with the same dimensions', () => { + // Arrange + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const imageBitmap = new ImageBitmap(100, 100); + + // Act + wrapper.instance().repaintCanvas(canvas, ctx, imageBitmap); + + // Assert + const { width, height } = ctx.canvas; + expect(canvas.width).toBe(width); + expect(canvas.height).toBe(height); + }); + + // When the document visibility state changes to 'hidden', the method sets the 'visibilityEventData' object with a 'start_offset' and 'ts' properties, and queries all canvas elements on the page and stores them in the 'canvases' property. + it('should set visibilityEventData and canvases when document visibility state changes to "hidden"', () => { + // Initialize the class object with props + const props = { + activeFilters: {}, + ownDataCharts: {}, + actions: { + logEvent: jest.fn(), + }, + layout: {}, + dashboardInfo: {}, + dashboardState: { + editMode: false, + isPublished: false, + hasUnsavedChanges: false, + }, + chartConfiguration: {}, + }; + + const DATE_TO_USE = new Date('2020'); + const OrigDate = Date; + global.Date = jest.fn(() => DATE_TO_USE); + global.Date.UTC = OrigDate.UTC; + global.Date.parse = OrigDate.parse; + global.Date.now = OrigDate.now; + + // Your test code here + + const dashboard = new Dashboard(props); + + // Mock the return value of document.visibilityState + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); + // mock Logger.getTimestamp() to return a fixed value + jest.spyOn(Logger, 'getTimestamp').mockReturnValue(1234567890); + + // Invoke the method + dashboard.onVisibilityChange(); + + // Assert that visibilityEventData is set correctly + expect(dashboard.visibilityEventData).toEqual({ + start_offset: 1234567890, + ts: DATE_TO_USE.getTime(), + }); + + // Assert that canvases are queried correctly + expect(dashboard.canvases).toEqual(expect.any(NodeList)); + + // Restore the original implementation of document.visibilityState + jest.restoreAllMocks(); + // After your test + global.Date = OrigDate; + }); + + // When the document visibility state changes to 'visible', the method logs an event and calls the 'handleVisibilityChange' method. + it('should log an event and call handleVisibilityChange when document visibility state changes to "visible"', () => { + // Initialize the class object + const dashboard = new Dashboard({ activeFilters: {} }); + + // Mock the props and actions + dashboard.props = { + actions: { + logEvent: jest.fn(), + }, + }; + + // Mock the visibilityEventData + dashboard.visibilityEventData = { + start_offset: 123, + ts: 456, + }; + + // Mock the handleVisibilityChange method + dashboard.handleVisibilityChange = jest.fn(); + + // Mock the document.visibilityState property + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); + + // Invoke the method + dashboard.onVisibilityChange(); + + // Assert that logEvent is called with the correct arguments + expect(dashboard.props.actions.logEvent).toHaveBeenCalledWith( + LOG_ACTIONS_HIDE_BROWSER_TAB, + { + ...dashboard.visibilityEventData, + duration: expect.any(Number), + }, + ); + + // Assert that handleVisibilityChange is called + expect(dashboard.handleVisibilityChange).toHaveBeenCalled(); + + // Restore the original implementation of document.visibilityState + jest.restoreAllMocks(); + }); }); });