Compare commits

...

4 Commits

Author SHA1 Message Date
Abhi kumar
b0d7ed56e3 Merge branch 'main' into fix/memory-leak 2026-02-25 19:17:25 +05:30
Abhi Kumar
4f0eff50af fix: fixed tooltip plugin test 2026-02-25 19:11:08 +05:30
Abhi Kumar
2102ab6df3 chore: throttled legendcolor change 2026-02-25 18:37:54 +05:30
Abhi Kumar
fefa60bfd3 fix: creating tooltip plugin container only once 2026-02-25 17:03:19 +05:30
5 changed files with 201 additions and 26 deletions

View File

@@ -0,0 +1,131 @@
import { ColorPickerProps } from 'antd';
import { Color } from 'antd/es/color-picker';
import { render, screen, userEvent } from 'tests/test-utils';
import LegendColors from './LegendColors';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
__esModule: true,
useQueryBuilder: (): { currentQuery: unknown } => ({
currentQuery: {
builder: {
queryData: [
{
queryName: 'A',
legend: '{service.name}',
},
],
},
},
}),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
return {
...actual,
ColorPicker: ({ onChange, children }: ColorPickerProps): JSX.Element => (
<button
type="button"
data-testid="legend-color-picker"
onClick={(): void =>
onChange!({ toHexString: (): string => '#ffffff' } as Color, '#ffffff')
}
>
{children}
</button>
),
};
});
describe('LegendColors', () => {
it('renders legend colors panel and items', async () => {
const user = userEvent.setup();
render(
<LegendColors
customLegendColors={{}}
setCustomLegendColors={jest.fn()}
queryResponse={undefined}
/>,
);
expect(screen.getByText('Legend Colors')).toBeInTheDocument();
// Expand the collapse to reveal legend items
await user.click(
screen.getByRole('tab', {
name: /Legend Colors/i,
}),
);
expect(screen.getByText('{service.name}')).toBeInTheDocument();
});
it('calls setCustomLegendColors when color is changed', async () => {
const user = userEvent.setup();
const setCustomLegendColors = jest.fn();
render(
<LegendColors
customLegendColors={{}}
setCustomLegendColors={setCustomLegendColors}
queryResponse={undefined}
/>,
);
// Expand to render the mocked ColorPicker button
await user.click(
screen.getByRole('tab', {
name: /Legend Colors/i,
}),
);
const colorTrigger = screen.getByTestId('legend-color-picker');
await user.click(colorTrigger);
expect(setCustomLegendColors).toHaveBeenCalled();
});
it('throttles rapid color changes', async () => {
jest.useFakeTimers();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const setCustomLegendColors = jest.fn();
render(
<LegendColors
customLegendColors={{}}
setCustomLegendColors={setCustomLegendColors}
queryResponse={undefined}
/>,
);
// Expand panel to render the mocked ColorPicker button
await user.click(
screen.getByRole('tab', {
name: /Legend Colors/i,
}),
);
const colorTrigger = screen.getByTestId('legend-color-picker');
// Fire multiple rapid changes
await user.click(colorTrigger);
await user.click(colorTrigger);
await user.click(colorTrigger);
await user.click(colorTrigger);
// Flush pending throttled calls
jest.advanceTimersByTime(500);
// Throttling should ensure we don't invoke the setter once per click
expect(setCustomLegendColors).toHaveBeenCalledTimes(2);
jest.useRealTimers();
});
});

View File

@@ -14,6 +14,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import throttle from 'lodash-es/throttle';
import { Palette } from 'lucide-react';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
@@ -95,13 +96,24 @@ function LegendColors({
);
};
// Handle color change
const handleColorChange = (label: string, color: string): void => {
setCustomLegendColors((prev) => ({
...prev,
[label]: color,
}));
};
// Handle color change (throttled to avoid excessive updates)
const handleColorChange = useMemo(
() =>
throttle((label: string, color: string): void => {
setCustomLegendColors((prev) => ({
...prev,
[label]: color,
}));
}, 200), // 200ms is a good compromise between responsiveness and performance
[setCustomLegendColors],
);
// Clean up throttled handler on unmount
useEffect(() => {
return (): void => {
handleColorChange.cancel();
};
}, [handleColorChange]);
// Reset to default color
const resetToDefault = (label: string): void => {

View File

@@ -1,14 +1,22 @@
.tooltip-plugin-container {
top: 0;
left: 0;
width: 100%;
z-index: 1070;
white-space: pre;
border-radius: 4px;
position: fixed;
overflow: auto;
transform: translate(-1000px, -1000px); // hide the tooltip initially
opacity: 0;
pointer-events: none;
&.pinned {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
&.visible {
opacity: 1;
pointer-events: all;
}
}

View File

@@ -339,8 +339,9 @@ export default function TooltipPlugin({
return;
}
const layout = layoutRef.current;
layout.observer.disconnect();
if (containerRef.current) {
layout.observer.disconnect();
layout.observer.observe(containerRef.current);
const { width, height } = containerRef.current.getBoundingClientRect();
layout.width = width;
@@ -351,24 +352,28 @@ export default function TooltipPlugin({
}
}, [isHovering, plot]);
if (!plot || !isHovering) {
if (!plot) {
return null;
}
return createPortal(
<div
className={cx('tooltip-plugin-container', { pinned: isPinned })}
className={cx('tooltip-plugin-container', {
pinned: isPinned,
visible: isHovering,
})}
style={{
...style,
maxWidth: `${maxWidth}px`,
maxHeight: `${maxHeight}px`,
width: '100%',
}}
aria-live="polite"
aria-atomic="true"
aria-hidden={!isHovering}
ref={containerRef}
data-testid="tooltip-plugin-container"
>
{contents}
{isHovering ? contents : null}
</div>,
portalRoot.current,
);

View File

@@ -187,9 +187,7 @@ describe('TooltipPlugin', () => {
canPinTooltip: true,
});
const container = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement;
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.classList.contains('pinned')).toBe(false);
act(() => {
@@ -197,11 +195,9 @@ describe('TooltipPlugin', () => {
});
return waitFor(() => {
const updated = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement | null;
expect(updated).not.toBeNull();
expect(updated?.classList.contains('pinned')).toBe(true);
const updated = screen.getByTestId('tooltip-plugin-container');
expect(updated).toBeInTheDocument();
expect(updated.classList.contains('pinned')).toBe(true);
});
});
@@ -249,7 +245,13 @@ describe('TooltipPlugin', () => {
await user.click(button);
await waitFor(() => {
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
const container = screen.getByTestId('tooltip-plugin-container');
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
expect(container.textContent).toBe('');
});
});
@@ -292,12 +294,16 @@ describe('TooltipPlugin', () => {
jest.runAllTimers();
});
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
const container = screen.getByTestId('tooltip-plugin-container');
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
jest.useRealTimers();
});
it('unpins the tooltip on outside mousedown', () => {
it('unpins the tooltip on outside mousedown', async () => {
jest.useFakeTimers();
const config = createConfigMock();
@@ -335,12 +341,19 @@ describe('TooltipPlugin', () => {
jest.runAllTimers();
});
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
await waitFor(() => {
const container = screen.getByTestId('tooltip-plugin-container');
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
});
jest.useRealTimers();
});
it('unpins the tooltip on outside keydown', () => {
it('unpins the tooltip on outside keydown', async () => {
jest.useFakeTimers();
const config = createConfigMock();
@@ -380,7 +393,13 @@ describe('TooltipPlugin', () => {
jest.runAllTimers();
});
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
await waitFor(() => {
const container = screen.getByTestId('tooltip-plugin-container');
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
});
jest.useRealTimers();
});