mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-17 07:26:20 +00:00
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:
parent
eb38dd548a
commit
dc8e4365f5
@ -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<ToggleGraphProps | undefined, UplotProps>(
|
||||
|
||||
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;
|
||||
|
||||
@ -45,6 +45,13 @@ function UplotPanelWrapper({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const lineChartRef = useRef<ToggleGraphProps>();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user