diff --git a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.ts b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.ts index f5c873b2ef2..e6c7bc2679d 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.ts +++ b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.ts @@ -244,18 +244,20 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void { }, ]; } - onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { - drillToDetail: drillToDetailFilters, - crossFilter: getCrossFilterDataMask(source), - drillBy: { filters: drillByFilters, groupbyFieldName: 'entity' }, - }); + if (onContextMenu) { + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { + drillToDetail: drillToDetailFilters, + crossFilter: getCrossFilterDataMask(source), + drillBy: { filters: drillByFilters, groupbyFieldName: 'entity' }, + }); + } }; const map = new Datamap({ element, width, height, - data: processedData, + data: mapData, fills: { defaultFill: theme.colorBorder, }, @@ -268,6 +270,7 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void { highlightFillColor: color, highlightBorderWidth: 1, popupTemplate: (geo, d) => + d && `
${d.name}
${formatter( d.m1, )}
`, @@ -298,7 +301,8 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void { .selectAll('.datamaps-subunit') .on('contextmenu', handleContextMenu) .on('click', handleClick) - .on('mouseover', function onMouseOver() { + // Use namespaced events to avoid overriding Datamaps' default tooltip handlers + .on('mouseover.fillPreserve', function onMouseOver() { if (inContextMenu) { return; } @@ -311,7 +315,7 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void { // Store original fill color for restoration element.attr('data-original-fill', originalFill); }) - .on('mouseout', function onMouseOut() { + .on('mouseout.fillPreserve', function onMouseOut() { if (inContextMenu) { return; } 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 index 096c558bc63..5a53aab9e82 100644 --- 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 @@ -77,8 +77,13 @@ const mockSvg = { style: jest.fn().mockReturnThis(), }; +// Store the last Datamap config for assertions +let lastDatamapConfig: Record | null = null; + jest.mock('datamaps/dist/datamaps.all.min', () => jest.fn().mockImplementation(config => { + // Store config for test assertions + lastDatamapConfig = config; // Call the done callback immediately to simulate Datamap initialization if (config.done) { config.done({ @@ -158,9 +163,11 @@ test('sets up mouseover and mouseout handlers on countries', () => { 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'); + // Find mouseover and mouseout handler registrations (namespaced events) + const hasMouseover = onCalls.some( + call => call[0] === 'mouseover.fillPreserve', + ); + const hasMouseout = onCalls.some(call => call[0] === 'mouseout.fillPreserve'); expect(hasMouseover).toBe(true); expect(hasMouseout).toBe(true); @@ -199,9 +206,9 @@ test('stores original fill color on mouseover', () => { jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any); - // Capture the mouseover handler + // Capture the mouseover handler (namespaced event) mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => { - if (event === 'mouseover') { + if (event === 'mouseover.fillPreserve') { mouseoverHandler = handler; } return mockSvg; @@ -254,9 +261,9 @@ test('restores original fill color on mouseout for country with data', () => { jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any); - // Capture the mouseout handler + // Capture the mouseout handler (namespaced event) mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => { - if (event === 'mouseout') { + if (event === 'mouseout.fillPreserve') { mouseoutHandler = handler; } return mockSvg; @@ -310,8 +317,9 @@ test('restores default fill color on mouseout for country with no data', () => { jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any); + // Capture the mouseout handler (namespaced event) mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => { - if (event === 'mouseout') { + if (event === 'mouseout.fillPreserve') { mouseoutHandler = handler; } return mockSvg; @@ -352,11 +360,12 @@ test('does not handle mouse events when inContextMenu is true', () => { jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any); + // Capture namespaced event handlers mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => { - if (event === 'mouseover') { + if (event === 'mouseover.fillPreserve') { mouseoverHandler = handler; } - if (event === 'mouseout') { + if (event === 'mouseout.fillPreserve') { mouseoutHandler = handler; } return mockSvg; @@ -387,3 +396,115 @@ test('does not handle mouse events when inContextMenu is true', () => { expect(fillChangeCalls.length).toBe(0); expect(fillStyleChangeCalls.length).toBe(0); }); + +test('does not throw error when onContextMenu is undefined', () => { + const propsWithoutContextMenu = { + ...baseProps, + onContextMenu: undefined, + }; + + // Should not throw + expect(() => { + WorldMap(container, propsWithoutContextMenu as any); + }).not.toThrow(); +}); + +test('calls onContextMenu when provided and right-click occurs', () => { + const mockOnContextMenu = jest.fn(); + const propsWithContextMenu = { + ...baseProps, + onContextMenu: mockOnContextMenu, + }; + + let contextMenuHandler: ((source: any) => void) | undefined; + + mockSvg.on.mockImplementation((event: string, handler: any) => { + if (event === 'contextmenu') { + contextMenuHandler = handler; + } + return mockSvg; + }); + + // Mock d3.event + (d3 as any).event = { + preventDefault: jest.fn(), + clientX: 100, + clientY: 200, + }; + + WorldMap(container, propsWithContextMenu); + + expect(contextMenuHandler).toBeDefined(); + contextMenuHandler!({ country: 'USA' }); + + expect(mockOnContextMenu).toHaveBeenCalledWith(100, 200, expect.any(Object)); +}); + +test('initializes Datamap with keyed object data for tooltip support', () => { + WorldMap(container, baseProps); + + // Verify data is an object (not an array) keyed by country codes + expect(Array.isArray(lastDatamapConfig?.data)).toBe(false); + expect(typeof lastDatamapConfig?.data).toBe('object'); + + const data = lastDatamapConfig?.data as Record; + + // Verify the data is keyed by country code + expect(data).toHaveProperty('USA'); + expect(data).toHaveProperty('CAN'); + + // Verify the keyed data contains the expected properties for tooltips + expect(data.USA).toMatchObject({ + country: 'USA', + name: 'United States', + m1: 100, + m2: 200, + }); + expect(data.CAN).toMatchObject({ + country: 'CAN', + name: 'Canada', + m1: 50, + m2: 100, + }); +}); + +test('popupTemplate returns tooltip HTML when country data exists', () => { + WorldMap(container, baseProps); + + const geographyConfig = lastDatamapConfig?.geographyConfig as Record< + string, + unknown + >; + const popupTemplate = geographyConfig?.popupTemplate as ( + geo: unknown, + d: unknown, + ) => string; + + const mockGeo = { properties: { name: 'United States' } }; + const mockCountryData = { name: 'United States', m1: 100 }; + + const tooltipHtml = popupTemplate(mockGeo, mockCountryData); + + expect(tooltipHtml).toContain('United States'); + expect(tooltipHtml).toContain('hoverinfo'); +}); + +test('popupTemplate handles null/undefined country data gracefully', () => { + WorldMap(container, baseProps); + + const geographyConfig = lastDatamapConfig?.geographyConfig as Record< + string, + unknown + >; + const popupTemplate = geographyConfig?.popupTemplate as ( + geo: unknown, + d: unknown, + ) => string | undefined; + + const mockGeo = { properties: { name: 'Antarctica' } }; + + // When hovering over a country with no data, 'd' will be undefined + const tooltipHtml = popupTemplate(mockGeo, undefined); + + expect(tooltipHtml).toBeFalsy(); +}); diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx index a0a6f702a4a..77da178b246 100644 --- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx @@ -205,7 +205,11 @@ const SliceHeader = forwardRef( const sqlRowCount = countFromSecondQuery != null ? countFromSecondQuery - : Number(firstQueryResponse?.sql_rowcount ?? 0); + : Number( + firstQueryResponse?.sql_rowcount ?? + firstQueryResponse?.rowcount ?? + 0, + ); const canExplore = !editMode && supersetCanExplore; const showRowLimitWarning = diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx index fa015ff4991..0dda657a02d 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx @@ -499,6 +499,8 @@ const Chart = (props: ChartProps) => { } else if ((queriesResponse?.[0] as JsonObject)?.sql_rowcount != null) { actualRowCount = (queriesResponse![0] as JsonObject) .sql_rowcount as number; + } else if ((queriesResponse?.[0] as JsonObject)?.rowcount != null) { + actualRowCount = (queriesResponse![0] as JsonObject).rowcount as number; } else { actualRowCount = (exportFormData as JsonObject)?.row_limit as | number diff --git a/superset-frontend/src/explore/components/ChartPills.tsx b/superset-frontend/src/explore/components/ChartPills.tsx index a3d26de0a57..7794cf147d5 100644 --- a/superset-frontend/src/explore/components/ChartPills.tsx +++ b/superset-frontend/src/explore/components/ChartPills.tsx @@ -77,7 +77,11 @@ export const ChartPills = forwardRef( const actualRowCount = isTableChart && countFromSecondQuery != null ? countFromSecondQuery - : Number(firstQueryResponse?.sql_rowcount ?? 0); + : Number( + firstQueryResponse?.sql_rowcount ?? + firstQueryResponse?.rowcount ?? + 0, + ); return (
diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx index 1fbf54ede75..db491c134ba 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx @@ -311,6 +311,8 @@ export const useExploreAdditionalActionsMenu = ( actualRowCount = queriesResponse[1].data[0].rowcount; } else if (queriesResponse && queriesResponse[0]?.sql_rowcount != null) { actualRowCount = queriesResponse[0].sql_rowcount; + } else if (queriesResponse && queriesResponse[0]?.rowcount != null) { + actualRowCount = queriesResponse[0].rowcount; } else { actualRowCount = latestQueryFormData?.row_limit; }