fix: fixed scroll reset issue when interacting with legends (#9065)

* fix: fixed scroll reset issue when interacting with legends

* fix: added test cases to ensure codes execution and req function are attached

* fix: added test cases

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
This commit is contained in:
SagarRajput-7 2025-09-23 17:43:13 +05:30 committed by GitHub
parent eb38dd548a
commit dc8e4365f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 288 additions and 2 deletions

View File

@ -18,6 +18,11 @@ import UPlot from 'uplot';
import { dataMatch, optionsUpdateState } from './utils'; import { dataMatch, optionsUpdateState } from './utils';
// Extended uPlot interface with custom properties
interface ExtendedUPlot extends uPlot {
_legendScrollCleanup?: () => void;
}
export interface UplotProps { export interface UplotProps {
options: uPlot.Options; options: uPlot.Options;
data: uPlot.AlignedData; data: uPlot.AlignedData;
@ -66,6 +71,12 @@ const Uplot = forwardRef<ToggleGraphProps | undefined, UplotProps>(
const destroy = useCallback((chart: uPlot | null) => { const destroy = useCallback((chart: uPlot | null) => {
if (chart) { if (chart) {
// Clean up legend scroll event listener
const extendedChart = chart as ExtendedUPlot;
if (extendedChart._legendScrollCleanup) {
extendedChart._legendScrollCleanup();
}
onDeleteRef.current?.(chart); onDeleteRef.current?.(chart);
chart.destroy(); chart.destroy();
chartRef.current = null; chartRef.current = null;

View File

@ -45,6 +45,13 @@ function UplotPanelWrapper({
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const lineChartRef = useRef<ToggleGraphProps>(); const lineChartRef = useRef<ToggleGraphProps>();
const graphRef = useRef<HTMLDivElement>(null); const graphRef = useRef<HTMLDivElement>(null);
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const [minTimeScale, setMinTimeScale] = useState<number>(); const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>(); const [maxTimeScale, setMaxTimeScale] = useState<number>();
const { currentQuery } = useQueryBuilder(); const { currentQuery } = useQueryBuilder();
@ -227,6 +234,13 @@ function UplotPanelWrapper({
enhancedLegend: true, // Enable enhanced legend enhancedLegend: true, // Enable enhanced legend
legendPosition: widget?.legendPosition, legendPosition: widget?.legendPosition,
query: widget?.query || currentQuery, query: widget?.query || currentQuery,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
}), }),
[ [
queryResponse.data?.payload, queryResponse.data?.payload,

View File

@ -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);
});
});

View File

@ -32,6 +32,12 @@ import getSeries from './utils/getSeriesData';
import { getXAxisScale } from './utils/getXAxisScale'; import { getXAxisScale } from './utils/getXAxisScale';
import { getYAxisScale } from './utils/getYAxisScale'; import { getYAxisScale } from './utils/getYAxisScale';
// Extended uPlot interface with custom properties
interface ExtendedUPlot extends uPlot {
_legendScrollCleanup?: () => void;
_tooltipCleanup?: () => void;
}
export interface GetUPlotChartOptions { export interface GetUPlotChartOptions {
id?: string; id?: string;
apiResponse?: MetricRangePayloadProps; apiResponse?: MetricRangePayloadProps;
@ -72,6 +78,14 @@ export interface GetUPlotChartOptions {
legendPosition?: LegendPosition; legendPosition?: LegendPosition;
enableZoom?: boolean; enableZoom?: boolean;
query?: Query; query?: Query;
legendScrollPosition?: {
scrollTop: number;
scrollLeft: number;
};
setLegendScrollPosition?: (position: {
scrollTop: number;
scrollLeft: number;
}) => void;
} }
/** the function converts series A , series B , series C to /** the function converts series A , series B , series C to
@ -201,6 +215,8 @@ export const getUPlotChartOptions = ({
legendPosition = LegendPosition.BOTTOM, legendPosition = LegendPosition.BOTTOM,
enableZoom, enableZoom,
query, query,
legendScrollPosition,
setLegendScrollPosition,
}: GetUPlotChartOptions): uPlot.Options => { }: GetUPlotChartOptions): uPlot.Options => {
const timeScaleProps = getXAxisScale(minTimeScale, maxTimeScale); const timeScaleProps = getXAxisScale(minTimeScale, maxTimeScale);
@ -455,16 +471,43 @@ export const getUPlotChartOptions = ({
const legend = self.root.querySelector('.u-legend'); const legend = self.root.querySelector('.u-legend');
if (legend) { if (legend) {
const legendElement = legend as HTMLElement;
// Apply enhanced legend styling // Apply enhanced legend styling
if (enhancedLegend) { if (enhancedLegend) {
applyEnhancedLegendStyling( applyEnhancedLegendStyling(
legend as HTMLElement, legendElement,
legendConfig, legendConfig,
legendConfig.requiredRows, legendConfig.requiredRows,
legendPosition, 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 // Global cleanup function for all legend tooltips
const cleanupAllTooltips = (): void => { const cleanupAllTooltips = (): void => {
const existingTooltips = document.querySelectorAll('.legend-tooltip'); const existingTooltips = document.querySelectorAll('.legend-tooltip');
@ -485,7 +528,7 @@ export const getUPlotChartOptions = ({
document?.addEventListener('mousemove', globalCleanupHandler); document?.addEventListener('mousemove', globalCleanupHandler);
// Store cleanup function for potential removal later // Store cleanup function for potential removal later
(self as any)._tooltipCleanup = (): void => { (self as ExtendedUPlot)._tooltipCleanup = (): void => {
cleanupAllTooltips(); cleanupAllTooltips();
document?.removeEventListener('mousemove', globalCleanupHandler); document?.removeEventListener('mousemove', globalCleanupHandler);
}; };