Compare commits

...

3 Commits

Author SHA1 Message Date
aks07
fe6e0f1f33 chore(hooks): remove unused useCopyToClipboard hook
No longer imported after CopyButton and the legend moved to
useCopyButton.
2026-07-29 15:47:21 +05:30
aks07
9e708e137d refactor(legend): reuse CopyButton for the copy action
Replace the duplicated inline copy button with the shared CopyButton.
Each button now owns its own copied state, so the per-item id tracking
is no longer needed.
2026-07-29 15:47:06 +05:30
aks07
a7921c727a fix(copy-button): use focus-independent clipboard copy
navigator.clipboard.writeText requires document focus and rejects with
"Document is not focused" in remote-desktop/VDI/multi-monitor setups.
Copy through react-use's useCopyToClipboard (execCommand) via a colocated
useCopyButton hook that also owns the transient "copied" state.

Fixes SIGNOZ-UI-5J4
2026-07-29 15:46:54 +05:30
7 changed files with 131 additions and 175 deletions

View File

@@ -1,62 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
const DEFAULT_COPIED_RESET_MS = 2000;
export interface UseCopyToClipboardOptions {
/** How long (ms) to keep "copied" state before resetting. Default 2000. */
copiedResetMs?: number;
}
export type ID = number | string | null;
export interface UseCopyToClipboardReturn {
/** Copy text to clipboard. Pass an optional id to track which item was copied (e.g. seriesIndex). */
copyToClipboard: (text: string, id?: ID) => void;
/** True when something was just copied and still within the reset threshold. */
isCopied: boolean;
/** The id passed to the last successful copy, or null after reset. Use to show "copied" state for a specific item (e.g. copiedId === item.seriesIndex). */
id: ID;
}
export function useCopyToClipboard(
options: UseCopyToClipboardOptions = {},
): UseCopyToClipboardReturn {
const { copiedResetMs = DEFAULT_COPIED_RESET_MS } = options;
const [state, setState] = useState<{ isCopied: boolean; id: ID }>({
isCopied: false,
id: null,
});
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return (): void => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, []);
const copyToClipboard = useCallback(
(text: string, id?: ID): void => {
// oxlint-disable-next-line signoz/no-navigator-clipboard
navigator.clipboard.writeText(text).then(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setState({ isCopied: true, id: id ?? null });
timeoutRef.current = setTimeout(() => {
setState({ isCopied: false, id: null });
timeoutRef.current = null;
}, copiedResetMs);
});
},
[copiedResetMs],
);
return {
copyToClipboard,
isCopied: state.isCopied,
id: state.id,
};
}

View File

@@ -1,13 +1,11 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { VirtuosoGrid } from 'react-virtuoso';
import { Input } from 'antd';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendItem } from 'lib/uPlotV2/config/types';
import { Check, Copy } from '@signozhq/icons';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { LegendPosition, LegendProps } from '../types';
@@ -33,7 +31,6 @@ export default function Legend({
}: LegendProps): JSX.Element {
const legendContainerRef = useRef<HTMLDivElement | null>(null);
const [legendSearchQuery, setLegendSearchQuery] = useState('');
const { copyToClipboard, id: copiedId } = useCopyToClipboard();
// Search is intrinsic to the right-positioned legend.
const searchEnabled = position === LegendPosition.RIGHT;
@@ -57,17 +54,8 @@ export default function Legend({
return items.filter((item) => item.label?.toLowerCase().includes(query));
}, [searchEnabled, legendSearchQuery, items]);
const handleCopyLegendItem = useCallback(
(e: React.MouseEvent, seriesIndex: number, label: string): void => {
e.stopPropagation();
copyToClipboard(label, seriesIndex);
},
[copyToClipboard],
);
const renderLegendItem = useCallback(
(item: LegendItem): JSX.Element => {
const isCopied = copiedId === item.seriesIndex;
// `color` is uPlot's stroke union (string | fn | gradient); only a string
// is a usable CSS colour for the marker.
const markerColor = typeof item.color === 'string' ? item.color : undefined;
@@ -91,36 +79,18 @@ export default function Legend({
</div>
</TooltipSimple>
{showCopy && (
<TooltipSimple
title={isCopied ? 'Copied' : 'Copy'}
arrow
side="top"
disableHoverableContent
>
<Button
type="button"
size="icon"
variant="ghost"
color="secondary"
className="legend-copy-button"
onClick={(e): void =>
handleCopyLegendItem(e, item.seriesIndex, item.label ?? '')
}
aria-label={`Copy ${item.label}`}
// data-testid (not testId): TooltipSimple's trigger injects
// data-testid:undefined via Radix Slot, and Button spreads
// incoming props after its own testId — so set it as a prop
// that wins the Slot merge and survives the spread.
data-testid="legend-copy"
>
{isCopied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</TooltipSimple>
<CopyButton
value={item.label ?? ''}
size={12}
className="legend-copy-button"
ariaLabel={`Copy ${item.label}`}
testId="legend-copy"
/>
)}
</div>
);
},
[copiedId, focusedSeriesIndex, handleCopyLegendItem, position, showCopy],
[focusedSeriesIndex, position, showCopy],
);
const isEmptyState = useMemo(() => {

View File

@@ -1,11 +1,5 @@
import React from 'react';
import {
fireEvent,
render,
RenderResult,
screen,
within,
} from '@testing-library/react';
import { render, RenderResult, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { LegendItem } from 'lib/uPlotV2/config/types';
@@ -15,9 +9,6 @@ import { useLegendActions } from '../../hooks/useLegendActions';
import UPlotLegend from '../Legend/UPlotLegend';
import { LegendPosition } from '../types';
const mockWriteText = jest.fn().mockResolvedValue(undefined);
let clipboardSpy: jest.SpyInstance | undefined;
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
@@ -49,15 +40,6 @@ const mockUseLegendActions = useLegendActions as jest.MockedFunction<
>;
describe('UPlotLegend', () => {
beforeAll(() => {
// JSDOM does not define navigator.clipboard; add it so we can spy on writeText
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: () => Promise.resolve() },
writable: true,
configurable: true,
});
});
const baseLegendItemsMap = {
0: {
seriesIndex: 0,
@@ -89,11 +71,6 @@ describe('UPlotLegend', () => {
onLegendMouseMove = jest.fn();
onLegendMouseLeave = jest.fn();
onFocusSeries = jest.fn();
mockWriteText.mockClear();
clipboardSpy = jest
.spyOn(navigator.clipboard, 'writeText')
.mockImplementation(mockWriteText);
mockUseLegendsSync.mockReturnValue({
legendItemsMap: baseLegendItemsMap,
@@ -110,7 +87,6 @@ describe('UPlotLegend', () => {
});
afterEach(() => {
clipboardSpy?.mockRestore();
jest.clearAllMocks();
});
@@ -237,47 +213,4 @@ describe('UPlotLegend', () => {
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
});
});
describe('copy action', () => {
it('copies the legend label to clipboard when copy button is clicked', () => {
renderLegend(LegendPosition.RIGHT);
const firstLegendItem = document.querySelector(
'[data-legend-item-id="0"]',
) as HTMLElement;
const copyButton = within(firstLegendItem).getByTestId('legend-copy');
fireEvent.click(copyButton);
expect(mockWriteText).toHaveBeenCalledTimes(1);
expect(mockWriteText).toHaveBeenCalledWith('A');
});
it('copies the correct label when copy is clicked on a different legend item', () => {
renderLegend(LegendPosition.RIGHT);
const thirdLegendItem = document.querySelector(
'[data-legend-item-id="2"]',
) as HTMLElement;
const copyButton = within(thirdLegendItem).getByTestId('legend-copy');
fireEvent.click(copyButton);
expect(mockWriteText).toHaveBeenCalledTimes(1);
expect(mockWriteText).toHaveBeenCalledWith('C');
});
it('does not call onLegendClick when copy button is clicked', () => {
renderLegend(LegendPosition.RIGHT);
const firstLegendItem = document.querySelector(
'[data-legend-item-id="0"]',
) as HTMLElement;
const copyButton = within(firstLegendItem).getByTestId('legend-copy');
fireEvent.click(copyButton);
expect(onLegendClick).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,10 +1,10 @@
import { CSSProperties, useCallback } from 'react';
import { CSSProperties, type MouseEvent, useCallback } from 'react';
import { Check, Copy } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import cx from 'classnames';
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
import styles from './CopyButton.module.scss';
import { useCopyButton } from './useCopyButton';
export interface CopyButtonProps {
/** Text written to the clipboard on click. */
@@ -30,11 +30,15 @@ function CopyButton({
className,
testId,
}: CopyButtonProps): JSX.Element {
const { copyToClipboard, isCopied } = useCopyToClipboard();
const { copyToClipboard, isCopied } = useCopyButton();
const handleClick = useCallback((): void => {
copyToClipboard(value);
}, [copyToClipboard, value]);
const handleClick = useCallback(
(e: MouseEvent<HTMLButtonElement>): void => {
e.stopPropagation();
copyToClipboard(value);
},
[copyToClipboard, value],
);
const stackStyle: CSSProperties = { width: size, height: size };

View File

@@ -0,0 +1,65 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CopyButton from '../CopyButton';
const mockCopy = jest.fn();
// Exercise the real useCopyButton — stub only react-use's underlying copy so the
// click doesn't hit copy-to-clipboard's jsdom fallback (window.prompt).
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
}));
describe('CopyButton', () => {
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
jest.useFakeTimers();
user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
mockCopy.mockClear();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('copies its value on click', async () => {
render(<CopyButton value="hello" testId="copy" />);
await user.click(screen.getByTestId('copy'));
expect(mockCopy).toHaveBeenCalledWith('hello');
});
it('does not trigger parent click handlers (stops propagation)', async () => {
const onParentClick = jest.fn();
render(
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
<div onClick={onParentClick}>
<CopyButton value="x" testId="copy" />
</div>,
);
await user.click(screen.getByTestId('copy'));
expect(onParentClick).not.toHaveBeenCalled();
});
it('shows the copied state after clicking and reverts after the reset window', async () => {
const { container } = render(<CopyButton value="hello" testId="copy" />);
const iconStack = container.querySelector('[data-copied]') as HTMLElement;
expect(iconStack).toHaveAttribute('data-copied', 'false');
await user.click(screen.getByTestId('copy'));
expect(iconStack).toHaveAttribute('data-copied', 'true');
act(() => {
jest.runOnlyPendingTimers();
});
expect(iconStack).toHaveAttribute('data-copied', 'false');
});
});

View File

@@ -0,0 +1,2 @@
/** How long (ms) CopyButton stays in the "copied" state before reverting. */
export const COPIED_RESET_MS = 1000;

View File

@@ -0,0 +1,44 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { COPIED_RESET_MS } from './constants';
export interface UseCopyButtonReturn {
copyToClipboard: (text: string) => void;
isCopied: boolean;
}
export function useCopyButton(): UseCopyButtonReturn {
const [, copy] = useCopyToClipboard();
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return (): void => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, []);
const copyToClipboard = useCallback(
(text: string): void => {
copy(text);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setIsCopied(true);
timeoutRef.current = setTimeout(() => {
setIsCopied(false);
timeoutRef.current = null;
}, COPIED_RESET_MS);
},
[copy],
);
return {
copyToClipboard,
isCopied,
};
}