diff --git a/frontend/src/components/Uplot/Uplot.tsx b/frontend/src/components/Uplot/Uplot.tsx index 8bb044552a3d..1df0df63db1d 100644 --- a/frontend/src/components/Uplot/Uplot.tsx +++ b/frontend/src/components/Uplot/Uplot.tsx @@ -18,6 +18,11 @@ import UPlot from 'uplot'; import { dataMatch, optionsUpdateState } from './utils'; +// Extended uPlot interface with custom properties +interface ExtendedUPlot extends uPlot { + _legendScrollCleanup?: () => void; +} + export interface UplotProps { options: uPlot.Options; data: uPlot.AlignedData; @@ -66,6 +71,12 @@ const Uplot = forwardRef( const destroy = useCallback((chart: uPlot | null) => { if (chart) { + // Clean up legend scroll event listener + const extendedChart = chart as ExtendedUPlot; + if (extendedChart._legendScrollCleanup) { + extendedChart._legendScrollCleanup(); + } + onDeleteRef.current?.(chart); chart.destroy(); chartRef.current = null; diff --git a/frontend/src/container/PanelWrapper/UplotPanelWrapper.tsx b/frontend/src/container/PanelWrapper/UplotPanelWrapper.tsx index 0f7346fb5593..9385c0a68c76 100644 --- a/frontend/src/container/PanelWrapper/UplotPanelWrapper.tsx +++ b/frontend/src/container/PanelWrapper/UplotPanelWrapper.tsx @@ -45,6 +45,13 @@ function UplotPanelWrapper({ const isDarkMode = useIsDarkMode(); const lineChartRef = useRef(); const graphRef = useRef(null); + const legendScrollPositionRef = useRef<{ + scrollTop: number; + scrollLeft: number; + }>({ + scrollTop: 0, + scrollLeft: 0, + }); const [minTimeScale, setMinTimeScale] = useState(); const [maxTimeScale, setMaxTimeScale] = useState(); const { currentQuery } = useQueryBuilder(); @@ -227,6 +234,13 @@ function UplotPanelWrapper({ enhancedLegend: true, // Enable enhanced legend legendPosition: widget?.legendPosition, query: widget?.query || currentQuery, + legendScrollPosition: legendScrollPositionRef.current, + setLegendScrollPosition: (position: { + scrollTop: number; + scrollLeft: number; + }) => { + legendScrollPositionRef.current = position; + }, }), [ queryResponse.data?.payload, diff --git a/frontend/src/container/PanelWrapper/__tests__/legendScroll.test.ts b/frontend/src/container/PanelWrapper/__tests__/legendScroll.test.ts new file mode 100644 index 000000000000..d0ea1f40e64f --- /dev/null +++ b/frontend/src/container/PanelWrapper/__tests__/legendScroll.test.ts @@ -0,0 +1,218 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions'; +import { LegendPosition } from 'types/api/dashboard/getAll'; + +// Mock uPlot +jest.mock('uplot', () => { + const paths = { + spline: jest.fn(), + bars: jest.fn(), + }; + const uplotMock = jest.fn(() => ({ + paths, + })); + return { + paths, + default: uplotMock, + }; +}); + +// Mock dependencies +jest.mock('container/PanelWrapper/enhancedLegend', () => ({ + calculateEnhancedLegendConfig: jest.fn(() => ({ + minHeight: 46, + maxHeight: 80, + calculatedHeight: 60, + showScrollbar: false, + requiredRows: 2, + })), + applyEnhancedLegendStyling: jest.fn(), +})); + +const mockApiResponse = { + data: { + result: [ + { + metric: { __name__: 'test_metric' }, + queryName: 'test_query', + values: [ + [1640995200, '10'] as [number, string], + [1640995260, '20'] as [number, string], + ], + }, + ], + resultType: 'time_series', + newResult: { + data: { + result: [], + resultType: 'time_series', + }, + }, + }, +}; + +const mockDimensions = { width: 800, height: 400 }; + +const baseOptions = { + id: 'test-widget', + dimensions: mockDimensions, + isDarkMode: false, + apiResponse: mockApiResponse, + enhancedLegend: true, + legendPosition: LegendPosition.BOTTOM, + softMin: null, + softMax: null, +}; + +describe('Legend Scroll Position Preservation', () => { + let originalRequestAnimationFrame: typeof global.requestAnimationFrame; + + beforeEach(() => { + jest.clearAllMocks(); + originalRequestAnimationFrame = global.requestAnimationFrame; + }); + + afterEach(() => { + global.requestAnimationFrame = originalRequestAnimationFrame; + }); + + it('should set up scroll position tracking in ready hook', () => { + const mockSetScrollPosition = jest.fn(); + const options = getUPlotChartOptions({ + ...baseOptions, + setLegendScrollPosition: mockSetScrollPosition, + }); + + // Create mock chart with legend element + const mockChart = { + root: document.createElement('div'), + } as any; + + const legend = document.createElement('div'); + legend.className = 'u-legend'; + mockChart.root.appendChild(legend); + + const addEventListenerSpy = jest.spyOn(legend, 'addEventListener'); + + // Execute ready hook + if (options.hooks?.ready) { + options.hooks.ready.forEach((hook) => hook?.(mockChart)); + } + + // Verify that scroll event listener was added and cleanup function was stored + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'scroll', + expect.any(Function), + ); + expect(mockChart._legendScrollCleanup).toBeDefined(); + }); + + it('should restore scroll position when provided', () => { + const mockScrollPosition = { scrollTop: 50, scrollLeft: 10 }; + const mockSetScrollPosition = jest.fn(); + const options = getUPlotChartOptions({ + ...baseOptions, + legendScrollPosition: mockScrollPosition, + setLegendScrollPosition: mockSetScrollPosition, + }); + + // Create mock chart with legend element + const mockChart = { + root: document.createElement('div'), + } as any; + + const legend = document.createElement('div'); + legend.className = 'u-legend'; + legend.scrollTop = 0; + legend.scrollLeft = 0; + mockChart.root.appendChild(legend); + + // Mock requestAnimationFrame + const mockRequestAnimationFrame = jest.fn((callback) => callback()); + global.requestAnimationFrame = mockRequestAnimationFrame; + + // Execute ready hook + if (options.hooks?.ready) { + options.hooks.ready.forEach((hook) => hook?.(mockChart)); + } + + // Verify that requestAnimationFrame was called to restore scroll position + expect(mockRequestAnimationFrame).toHaveBeenCalledWith(expect.any(Function)); + + // Verify that the legend's scroll position was actually restored + expect(legend.scrollTop).toBe(mockScrollPosition.scrollTop); + expect(legend.scrollLeft).toBe(mockScrollPosition.scrollLeft); + }); + + it('should handle missing scroll position parameters gracefully', () => { + const options = getUPlotChartOptions(baseOptions); + + // Should not throw error and should still create valid options + expect(options.hooks?.ready).toBeDefined(); + }); + + it('should work for both bottom and right legend positions', () => { + const mockSetScrollPosition = jest.fn(); + const mockScrollPosition = { scrollTop: 30, scrollLeft: 15 }; + + // Mock requestAnimationFrame for this test + const mockRequestAnimationFrame = jest.fn((callback) => callback()); + global.requestAnimationFrame = mockRequestAnimationFrame; + + // Test bottom legend position + const bottomOptions = getUPlotChartOptions({ + ...baseOptions, + legendPosition: LegendPosition.BOTTOM, + legendScrollPosition: mockScrollPosition, + setLegendScrollPosition: mockSetScrollPosition, + }); + + // Test right legend position + const rightOptions = getUPlotChartOptions({ + ...baseOptions, + legendPosition: LegendPosition.RIGHT, + legendScrollPosition: mockScrollPosition, + setLegendScrollPosition: mockSetScrollPosition, + }); + + // Both should have ready hooks + expect(bottomOptions.hooks?.ready).toBeDefined(); + expect(rightOptions.hooks?.ready).toBeDefined(); + + // Test bottom legend scroll restoration + const bottomChart = { + root: document.createElement('div'), + } as any; + const bottomLegend = document.createElement('div'); + bottomLegend.className = 'u-legend'; + bottomLegend.scrollTop = 0; + bottomLegend.scrollLeft = 0; + bottomChart.root.appendChild(bottomLegend); + + // Execute bottom legend ready hook + if (bottomOptions.hooks?.ready) { + bottomOptions.hooks.ready.forEach((hook) => hook?.(bottomChart)); + } + + expect(bottomLegend.scrollTop).toBe(mockScrollPosition.scrollTop); + expect(bottomLegend.scrollLeft).toBe(mockScrollPosition.scrollLeft); + + // Test right legend scroll restoration + const rightChart = { + root: document.createElement('div'), + } as any; + const rightLegend = document.createElement('div'); + rightLegend.className = 'u-legend'; + rightLegend.scrollTop = 0; + rightLegend.scrollLeft = 0; + rightChart.root.appendChild(rightLegend); + + // Execute right legend ready hook + if (rightOptions.hooks?.ready) { + rightOptions.hooks.ready.forEach((hook) => hook?.(rightChart)); + } + + expect(rightLegend.scrollTop).toBe(mockScrollPosition.scrollTop); + expect(rightLegend.scrollLeft).toBe(mockScrollPosition.scrollLeft); + }); +}); diff --git a/frontend/src/lib/uPlotLib/getUplotChartOptions.ts b/frontend/src/lib/uPlotLib/getUplotChartOptions.ts index 65ca33e1cd7a..8a8d60bc8213 100644 --- a/frontend/src/lib/uPlotLib/getUplotChartOptions.ts +++ b/frontend/src/lib/uPlotLib/getUplotChartOptions.ts @@ -32,6 +32,12 @@ import getSeries from './utils/getSeriesData'; import { getXAxisScale } from './utils/getXAxisScale'; import { getYAxisScale } from './utils/getYAxisScale'; +// Extended uPlot interface with custom properties +interface ExtendedUPlot extends uPlot { + _legendScrollCleanup?: () => void; + _tooltipCleanup?: () => void; +} + export interface GetUPlotChartOptions { id?: string; apiResponse?: MetricRangePayloadProps; @@ -72,6 +78,14 @@ export interface GetUPlotChartOptions { legendPosition?: LegendPosition; enableZoom?: boolean; query?: Query; + legendScrollPosition?: { + scrollTop: number; + scrollLeft: number; + }; + setLegendScrollPosition?: (position: { + scrollTop: number; + scrollLeft: number; + }) => void; } /** the function converts series A , series B , series C to @@ -201,6 +215,8 @@ export const getUPlotChartOptions = ({ legendPosition = LegendPosition.BOTTOM, enableZoom, query, + legendScrollPosition, + setLegendScrollPosition, }: GetUPlotChartOptions): uPlot.Options => { const timeScaleProps = getXAxisScale(minTimeScale, maxTimeScale); @@ -455,16 +471,43 @@ export const getUPlotChartOptions = ({ const legend = self.root.querySelector('.u-legend'); if (legend) { + const legendElement = legend as HTMLElement; + // Apply enhanced legend styling if (enhancedLegend) { applyEnhancedLegendStyling( - legend as HTMLElement, + legendElement, legendConfig, legendConfig.requiredRows, legendPosition, ); } + // Restore scroll position if available + if (legendScrollPosition && setLegendScrollPosition) { + requestAnimationFrame(() => { + legendElement.scrollTop = legendScrollPosition.scrollTop; + legendElement.scrollLeft = legendScrollPosition.scrollLeft; + }); + } + + // Set up scroll position tracking + if (setLegendScrollPosition) { + const handleScroll = (): void => { + setLegendScrollPosition({ + scrollTop: legendElement.scrollTop, + scrollLeft: legendElement.scrollLeft, + }); + }; + + legendElement.addEventListener('scroll', handleScroll); + + // Store cleanup function + (self as ExtendedUPlot)._legendScrollCleanup = (): void => { + legendElement.removeEventListener('scroll', handleScroll); + }; + } + // Global cleanup function for all legend tooltips const cleanupAllTooltips = (): void => { const existingTooltips = document.querySelectorAll('.legend-tooltip'); @@ -485,7 +528,7 @@ export const getUPlotChartOptions = ({ document?.addEventListener('mousemove', globalCleanupHandler); // Store cleanup function for potential removal later - (self as any)._tooltipCleanup = (): void => { + (self as ExtendedUPlot)._tooltipCleanup = (): void => { cleanupAllTooltips(); document?.removeEventListener('mousemove', globalCleanupHandler); };