fix(ui): make icon CopyButton use the shared clipboard helper

The icon variant of CopyButton called navigator.clipboard directly and returned early when it was missing. The Clipboard API only exists in secure contexts, so on a proxy served over plain HTTP the copy icons in the virtual key and log detail panels did nothing and showed no toast

Route both variants through copyToClipboard, which already falls back to execCommand and shows the toast. The model detail page uses the same helper, which is why it kept working. The checkmark still only appears when the copy succeeds

Fixes #48418
This commit is contained in:
Vivek Raj 2026-10-26 23:23:50 +05:30
parent e0982d6248
commit ba746967c8
2 changed files with 13 additions and 46 deletions

View file

@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ComponentProps } from "react";
import { copyToClipboard } from "@/utils/dataUtils";
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import { renderWithProviders, screen } from "../../../tests/test-utils";
import CopyButton from "./CopyButton";
vi.mock("@/utils/dataUtils", () => ({ copyToClipboard: vi.fn() }));
@ -28,55 +28,35 @@ describe("CopyButton", () => {
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("shows the confirmation checkmark only after a successful write", async () => {
it("does not show the checkmark when copying fails", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
vi.mocked(copyToClipboard).mockResolvedValue(false);
renderWithProviders(<CopyButton value="secret-value" label="Copy value" />);
const button = screen.getByRole("button", { name: "Copy value" });
expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Copy value" }));
await user.click(button);
expect(writeText).toHaveBeenCalledWith("secret-value");
expect(await screen.findByRole("img", { name: "Copied icon" })).toBeInTheDocument();
});
it("does not show the checkmark when the clipboard write is rejected", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockRejectedValue(new Error("permission denied"));
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
renderWithProviders(<CopyButton value="secret-value" label="Copy value" />);
const button = screen.getByRole("button", { name: "Copy value" });
await user.click(button);
await waitFor(() => expect(writeText).toHaveBeenCalledWith("secret-value"));
expect(copyToClipboard).toHaveBeenCalledWith("secret-value", "Copied");
expect(screen.queryByRole("img", { name: "Copied icon" })).not.toBeInTheDocument();
expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument();
});
it("does not show the checkmark when the clipboard API is unavailable", async () => {
it("still copies when the clipboard API is unavailable, as on a plain HTTP origin", async () => {
const user = userEvent.setup();
Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
vi.mocked(copyToClipboard).mockResolvedValue(true);
renderWithProviders(<CopyButton value="secret-value" label="Copy value" />);
const button = screen.getByRole("button", { name: "Copy value" });
await user.click(button);
await user.click(screen.getByRole("button", { name: "Copy value" }));
expect(screen.queryByRole("img", { name: "Copied icon" })).not.toBeInTheDocument();
expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument();
expect(copyToClipboard).not.toHaveBeenCalled();
expect(copyToClipboard).toHaveBeenCalledWith("secret-value", "Copied");
expect(await screen.findByRole("img", { name: "Copied icon" })).toBeInTheDocument();
});
it("keeps the default icon styling, custom classes, title and 1200ms confirmation", async () => {
vi.useFakeTimers();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
vi.mocked(copyToClipboard).mockResolvedValue(true);
renderWithProviders(
<CopyButton value="request-id" label="Copy request ID" className="shrink-0" iconClassName="size-3" />,
);
@ -87,8 +67,7 @@ describe("CopyButton", () => {
expect(screen.getByRole("img", { name: "Copy icon" })).toHaveClass("size-3");
await act(async () => fireEvent.click(button));
expect(writeText).toHaveBeenCalledWith("request-id");
expect(copyToClipboard).not.toHaveBeenCalled();
expect(copyToClipboard).toHaveBeenCalledWith("request-id", "Copied");
act(() => vi.advanceTimersByTime(1199));
expect(screen.getByRole("img", { name: "Copied icon" })).toBeInTheDocument();
act(() => vi.advanceTimersByTime(1));

View file

@ -53,19 +53,7 @@ const CopyButton: React.FC<CopyButtonProps> = ({
if (value == null || (!value && !isAction)) return null;
const handleCopy = async () => {
if (isAction) {
setCopied(await copyToClipboard(value, copiedLabel));
return;
}
if (!navigator.clipboard) return;
try {
await navigator.clipboard.writeText(value);
setCopied(true);
} catch {
setCopied(false);
}
};
const handleCopy = async () => setCopied(await copyToClipboard(value, copiedLabel));
const button = (
<Button