This is an automated email from the ASF dual-hosted git repository.
justinpark pushed a commit to branch 5.0-extensions
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/5.0-extensions by this push:
new f3b21ecaa27 fix(world-map): reset hover highlight on mouse out (#37716)
f3b21ecaa27 is described below
commit f3b21ecaa27cca5a8ad8ae2c3c9a15f172e47cef
Author: JUST.in DO IT <[email protected]>
AuthorDate: Fri Feb 6 10:27:57 2026 -0800
fix(world-map): reset hover highlight on mouse out (#37716)
Co-authored-by: Arunodoy18 <[email protected]>
(cherry picked from commit a04571fa20f300784168699c8ca7b6adb2efc9a4)
---
.../legacy-plugin-chart-world-map/src/WorldMap.js | 27 +-
.../test/WorldMap.test.ts | 335 +++++++++++++++++++++
.../test/tsconfig.json | 8 +
3 files changed, 369 insertions(+), 1 deletion(-)
diff --git
a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.js
b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.js
index 03ea4ea9c78..a696fb750ec 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.js
+++ b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.js
@@ -243,7 +243,32 @@ function WorldMap(element, props) {
datamap.svg
.selectAll('.datamaps-subunit')
.on('contextmenu', handleContextMenu)
- .on('click', handleClick);
+ .on('click', handleClick)
+ .on('mouseover', function onMouseOver() {
+ if (inContextMenu) {
+ return;
+ }
+ const element = d3.select(this);
+ const classes = element.attr('class') || '';
+ const countryId = classes.split(' ')[1];
+ const countryData = mapData[countryId];
+ const originalFill =
+ (countryData && countryData.fillColor) || theme.colorBorder;
+ // Store original fill color for restoration
+ element.attr('data-original-fill', originalFill);
+ })
+ .on('mouseout', function onMouseOut() {
+ if (inContextMenu) {
+ return;
+ }
+ const element = d3.select(this);
+ const originalFill = element.attr('data-original-fill');
+ // Restore the original fill color (data-based or default no-data
color)
+ if (originalFill) {
+ element.style('fill', originalFill);
+ element.attr('data-original-fill', null);
+ }
+ });
},
});
diff --git
a/superset-frontend/plugins/legacy-plugin-chart-world-map/test/WorldMap.test.ts
b/superset-frontend/plugins/legacy-plugin-chart-world-map/test/WorldMap.test.ts
new file mode 100644
index 00000000000..b7439406589
--- /dev/null
+++
b/superset-frontend/plugins/legacy-plugin-chart-world-map/test/WorldMap.test.ts
@@ -0,0 +1,335 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import d3 from 'd3';
+import WorldMap from '../src/WorldMap';
+
+type MouseEventHandler = (this: HTMLElement) => void;
+
+interface MockD3Selection {
+ attr: jest.Mock;
+ style: jest.Mock;
+ classed: jest.Mock;
+ selectAll: jest.Mock;
+}
+
+// Mock Datamap
+const mockBubbles = jest.fn();
+const mockUpdateChoropleth = jest.fn();
+const mockSvg = {
+ selectAll: jest.fn().mockReturnThis(),
+ on: jest.fn().mockReturnThis(),
+ attr: jest.fn().mockReturnThis(),
+ style: jest.fn().mockReturnThis(),
+};
+
+jest.mock('datamaps/dist/datamaps.all.min', () => {
+ return jest.fn().mockImplementation(config => {
+ // Call the done callback immediately to simulate Datamap initialization
+ if (config.done) {
+ config.done({
+ svg: mockSvg,
+ });
+ }
+ return {
+ bubbles: mockBubbles,
+ updateChoropleth: mockUpdateChoropleth,
+ svg: mockSvg,
+ };
+ });
+});
+
+let container: HTMLElement;
+const mockFormatter = jest.fn(val => String(val));
+
+const baseProps = {
+ data: [
+ { country: 'USA', name: 'United States', m1: 100, m2: 200, code: 'US' },
+ { country: 'CAN', name: 'Canada', m1: 50, m2: 100, code: 'CA' },
+ ],
+ width: 600,
+ height: 400,
+ maxBubbleSize: 25,
+ showBubbles: false,
+ linearColorScheme: 'schemeRdYlBu',
+ color: '#61B0B7',
+ colorBy: 'country',
+ colorScheme: 'supersetColors',
+ sliceId: 123,
+ theme: {
+ colorBorder: '#e0e0e0',
+ colorSplit: '#333',
+ colorIcon: '#000',
+ colorTextSecondary: '#666',
+ },
+ countryFieldtype: 'code',
+ entity: 'country',
+ onContextMenu: jest.fn(),
+ setDataMask: jest.fn(),
+ inContextMenu: false,
+ filterState: { selectedValues: [] },
+ emitCrossFilters: false,
+ formatter: mockFormatter,
+};
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ container = document.createElement('div');
+ document.body.appendChild(container);
+});
+
+afterEach(() => {
+ document.body.removeChild(container);
+});
+
+test('sets up mouseover and mouseout handlers on countries', () => {
+ WorldMap(container, baseProps);
+
+ expect(mockSvg.selectAll).toHaveBeenCalledWith('.datamaps-subunit');
+ const onCalls = mockSvg.on.mock.calls;
+
+ // Find mouseover and mouseout handler registrations
+ const hasMouseover = onCalls.some(call => call[0] === 'mouseover');
+ const hasMouseout = onCalls.some(call => call[0] === 'mouseout');
+
+ expect(hasMouseover).toBe(true);
+ expect(hasMouseout).toBe(true);
+});
+
+test('stores original fill color on mouseover', () => {
+ // Create a mock DOM element with d3 selection capabilities
+ const mockElement = document.createElement('path');
+ mockElement.setAttribute('class', 'datamaps-subunit USA');
+ mockElement.style.fill = 'rgb(100, 150, 200)';
+ container.appendChild(mockElement);
+
+ let mouseoverHandler: MouseEventHandler | null = null;
+
+ // Mock d3.select to return the mock element
+ const mockD3Selection: MockD3Selection = {
+ attr: jest.fn((attrName: string, value?: string) => {
+ if (value !== undefined) {
+ mockElement.setAttribute(attrName, value);
+ } else {
+ return mockElement.getAttribute(attrName);
+ }
+ return mockD3Selection;
+ }),
+ style: jest.fn((styleName: string, value?: string) => {
+ if (value !== undefined) {
+ mockElement.style[styleName as any] = value;
+ } else {
+ return mockElement.style[styleName as any];
+ }
+ return mockD3Selection;
+ }),
+ classed: jest.fn().mockReturnThis(),
+ selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
+ };
+
+ jest.spyOn(d3, 'select').mockReturnValue(mockD3Selection as any);
+
+ // Capture the mouseover handler
+ mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) =>
{
+ if (event === 'mouseover') {
+ mouseoverHandler = handler;
+ }
+ return mockSvg;
+ });
+
+ WorldMap(container, baseProps);
+
+ // Simulate mouseover
+ if (mouseoverHandler) {
+ (mouseoverHandler as MouseEventHandler).call(mockElement);
+ }
+
+ // Verify that data-original-fill attribute was set
+ expect(mockD3Selection.attr).toHaveBeenCalledWith(
+ 'data-original-fill',
+ expect.any(String),
+ );
+});
+
+test('restores original fill color on mouseout for country with data', () => {
+ const mockElement = document.createElement('path');
+ mockElement.setAttribute('class', 'datamaps-subunit USA');
+ mockElement.style.fill = 'rgb(100, 150, 200)';
+ mockElement.setAttribute('data-original-fill', 'rgb(100, 150, 200)');
+ container.appendChild(mockElement);
+
+ let mouseoutHandler: MouseEventHandler | null = null;
+
+ const mockD3Selection: MockD3Selection = {
+ attr: jest.fn((attrName: string, value?: string | null) => {
+ if (value !== undefined) {
+ if (value === null) {
+ mockElement.removeAttribute(attrName);
+ } else {
+ mockElement.setAttribute(attrName, value);
+ }
+ return mockD3Selection;
+ }
+ return mockElement.getAttribute(attrName);
+ }),
+ style: jest.fn((styleName: string, value?: string) => {
+ if (value !== undefined) {
+ mockElement.style[styleName as any] = value;
+ }
+ return mockElement.style[styleName as any] || mockD3Selection;
+ }),
+ classed: jest.fn().mockReturnThis(),
+ selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
+ };
+
+ jest.spyOn(d3, 'select').mockReturnValue(mockD3Selection as any);
+
+ // Capture the mouseout handler
+ mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) =>
{
+ if (event === 'mouseout') {
+ mouseoutHandler = handler;
+ }
+ return mockSvg;
+ });
+
+ WorldMap(container, baseProps);
+
+ // Simulate mouseout
+ if (mouseoutHandler) {
+ (mouseoutHandler as MouseEventHandler).call(mockElement);
+ }
+
+ // Verify that original fill was restored
+ expect(mockD3Selection.style).toHaveBeenCalledWith(
+ 'fill',
+ 'rgb(100, 150, 200)',
+ );
+ expect(mockD3Selection.attr).toHaveBeenCalledWith('data-original-fill',
null);
+});
+
+test('restores default fill color on mouseout for country with no data', () =>
{
+ const mockElement = document.createElement('path');
+ mockElement.setAttribute('class', 'datamaps-subunit XXX');
+ mockElement.style.fill = '#e0e0e0'; // Default border color
+ mockElement.setAttribute('data-original-fill', '#e0e0e0');
+ container.appendChild(mockElement);
+
+ let mouseoutHandler: MouseEventHandler | null = null;
+
+ const mockD3Selection: MockD3Selection = {
+ attr: jest.fn((attrName: string, value?: string | null) => {
+ if (value !== undefined) {
+ if (value === null) {
+ mockElement.removeAttribute(attrName);
+ } else {
+ mockElement.setAttribute(attrName, value);
+ }
+ return mockD3Selection;
+ }
+ return mockElement.getAttribute(attrName);
+ }),
+ style: jest.fn((styleName: string, value?: string) => {
+ if (value !== undefined) {
+ mockElement.style[styleName as any] = value;
+ }
+ return mockElement.style[styleName as any] || mockD3Selection;
+ }),
+ classed: jest.fn().mockReturnThis(),
+ selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
+ };
+
+ jest.spyOn(d3, 'select').mockReturnValue(mockD3Selection as any);
+
+ mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) =>
{
+ if (event === 'mouseout') {
+ mouseoutHandler = handler;
+ }
+ return mockSvg;
+ });
+
+ WorldMap(container, baseProps);
+
+ // Simulate mouseout
+ if (mouseoutHandler) {
+ (mouseoutHandler as MouseEventHandler).call(mockElement);
+ }
+
+ // Verify that default fill was restored (no-data color)
+ expect(mockD3Selection.style).toHaveBeenCalledWith('fill', '#e0e0e0');
+ expect(mockD3Selection.attr).toHaveBeenCalledWith('data-original-fill',
null);
+});
+
+test('does not handle mouse events when inContextMenu is true', () => {
+ const propsWithContextMenu = {
+ ...baseProps,
+ inContextMenu: true,
+ };
+
+ const mockElement = document.createElement('path');
+ mockElement.setAttribute('class', 'datamaps-subunit USA');
+ mockElement.style.fill = 'rgb(100, 150, 200)';
+ container.appendChild(mockElement);
+
+ let mouseoverHandler: MouseEventHandler | null = null;
+ let mouseoutHandler: MouseEventHandler | null = null;
+
+ const mockD3Selection: MockD3Selection = {
+ attr: jest.fn(() => mockD3Selection),
+ style: jest.fn(() => mockD3Selection),
+ classed: jest.fn().mockReturnThis(),
+ selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
+ };
+
+ jest.spyOn(d3, 'select').mockReturnValue(mockD3Selection as any);
+
+ mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) =>
{
+ if (event === 'mouseover') {
+ mouseoverHandler = handler;
+ }
+ if (event === 'mouseout') {
+ mouseoutHandler = handler;
+ }
+ return mockSvg;
+ });
+
+ WorldMap(container, propsWithContextMenu);
+
+ // Simulate mouseover and mouseout
+ if (mouseoverHandler) {
+ (mouseoverHandler as MouseEventHandler).call(mockElement);
+ }
+ if (mouseoutHandler) {
+ (mouseoutHandler as MouseEventHandler).call(mockElement);
+ }
+
+ // When inContextMenu is true, handlers should exit early without modifying
anything
+ // We verify this by checking that attr and style weren't called to change
fill
+ const attrCalls = mockD3Selection.attr.mock.calls;
+ const fillChangeCalls = attrCalls.filter(
+ (call: [string, unknown]) =>
+ call[0] === 'data-original-fill' && call[1] !== undefined,
+ );
+ const styleCalls = mockD3Selection.style.mock.calls;
+ const fillStyleChangeCalls = styleCalls.filter(
+ (call: [string, unknown]) => call[0] === 'fill' && call[1] !== undefined,
+ );
+ // The handlers should return early, so no state changes
+ expect(fillChangeCalls.length).toBe(0);
+ expect(fillStyleChangeCalls.length).toBe(0);
+});
diff --git
a/superset-frontend/plugins/legacy-plugin-chart-world-map/test/tsconfig.json
b/superset-frontend/plugins/legacy-plugin-chart-world-map/test/tsconfig.json
new file mode 100644
index 00000000000..4c9211140ce
--- /dev/null
+++ b/superset-frontend/plugins/legacy-plugin-chart-world-map/test/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../../tsconfig.options.json",
+ "include": ["**/*"],
+ "compilerOptions": {
+ "esModuleInterop": true,
+ "types": ["jest", "node"]
+ }
+}