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"]
+  }
+}

Reply via email to