mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-17 15:36:48 +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';
|
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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 { 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);
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user